Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/approval-escalation-calendar-clock.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
---
"@objectstack/spec": patch
---

docs(spec): `ApprovalEscalation.timeoutHours` names its clock — calendar (wall-clock) hours

The `timeoutHours` describe text now states that the hours are calendar
(wall-clock) hours: nights, weekends and holidays count, because the platform
ships no business-hours calendar, so a request opened at 17:00 on a Friday with
`timeoutHours: 4` escalates at 21:00 that same Friday. The sentence is published
contract text — it is what `gen:schema` emits to the JSON schema `description`
and what the reference page carries — so the unit is part of the declaration an
author reads at authoring time rather than prose beside it. No key is added,
renamed or defaulted differently; the approvals service's arithmetic is
unchanged and is now pinned by a wall-clock test (Friday 17:00 + 4 h, a 168-hour
deadline across a weekend, a DST transition).
4 changes: 4 additions & 0 deletions content/docs/automation/approvals.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -630,6 +630,10 @@ that request's drawer directly instead of a generic list.
and pick an `action` — `notify` (default), `reassign`, `auto_approve`, or
`auto_reject`. Auto decisions run through the normal decide path, so the flow
resumes exactly as if a human had clicked. Every escalation writes an audit row.
`timeoutHours` counts **calendar (wall-clock) hours** — nights, weekends and
holidays included, because the platform ships no business-hours calendar — so a
request opened at 17:00 on a Friday with `timeoutHours: 4` escalates at 21:00
that same Friday.

<Callout type="warn">
**Escalation needs the job service.** The plugin sweeps pending requests on an
Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -145,7 +145,7 @@ The largest single consumer — **20 of the 109 sites**.
|:--|:---|:---|:---|:---|
| 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` |
| 41 | Delegation write guard bypassed | plugin-approvals | Get: service / seed / import may write delegation rows naming another delegator | `lifecycle-hooks.ts:570` |
| 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` |
| 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` |
| 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` |
| 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` |
| 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` |
Expand Down
4 changes: 2 additions & 2 deletions content/docs/references/automation/approval.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -38,7 +38,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand DownExpand Up@@ -102,7 +102,7 @@ const result = ApprovalDecision.parse(data);
| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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 |
| **timeoutHours** | `number` | ✅ | Hours before escalation triggers |
| **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 |
| **action** | `Enum<'reassign' \| 'auto_approve' \| 'auto_reject' \| 'notify'>` | optional (default: `"notify"`) | Action on escalation timeout |
| **escalateTo** | `string` | optional | User id or position machine name to escalate to |
| **notifySubmitter** | `boolean` | optional (default: `true`) | Notify the original submitter on escalation |
Expand Down
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* `ApprovalEscalation.timeoutHours` is CALENDAR (wall-clock) hours — pinned
* through the real code path, not restated.
*
* The declaration's `describe` text on `ApprovalEscalationSchema` says the
* clock out loud; `slaDueAt` in `approval-service.ts` is the one runtime site
* that turns the declared number into a deadline; the escalation sweep compares
* that deadline against the injected clock. This file drives all three through
* `openNodeRequest` → `getRequest` → `runEscalations`, so the sentence in the
* schema and the arithmetic in the service cannot drift apart without a red
* here.
*
* Timezone assumption, stated: NONE is required. Every timestamp the service
* reads or writes is an ISO-8601 UTC string (`toISOString()` / `Date.parse` of
* a `Z`-suffixed literal) and the deadline is `created_at` plus elapsed
* milliseconds, so the assertions hold under any `TZ` the runner sets — they
* are written against UTC instants and never call a local-time accessor. The
* DST cases document what the SAME instants read as on a wall clock in
* America/New_York, to make the elapsed-time-versus-local-time distinction
* visible where a reader would otherwise infer it.
*/

import { describe, it, expect } from 'vitest';
import { ApprovalService } from './approval-service.js';

interface Row { [k: string]: any }

/**
* Read-and-append engine double: `find` + `insert` only.
*
* The three paths under test dispatch nothing else — `openNodeRequest` finds
* and inserts, `getRequest` finds, and the `notify` escalation arm finds and
* inserts the audit action. No `update` / `delete` member exists on purpose:
* `check:engine-double-contract` pins those write verbs to the real engine's
* dispatch, and a double that does not declare them has nothing to pin.
*/
function makeEngine() {
const tables: Record<string, Row[]> = {};
const ensure = (n: string) => (tables[n] ??= []);
const matches = (row: Row, filter: any): boolean => {
if (!filter || typeof filter !== 'object') return true;
for (const [k, v] of Object.entries(filter)) {
if (k === '$or') {
if (!(v as any[]).some((sub) => matches(row, sub))) return false;
continue;
}
if (k.startsWith('$')) throw new Error(`fake engine: unsupported filter operator ${k}`);
const rv = row[k];
if (v != null && typeof v === 'object' && '$in' in (v as any)) {
if (!(v as any).$in.includes(rv)) return false;
continue;
}
if (v != null && typeof v === 'object' && '$ne' in (v as any)) {
if (rv === (v as any).$ne) return false;
continue;
}
if (rv !== v) return false;
}
return true;
};
return {
_tables: tables,
async find(object: string, options?: any) {
const rows = ensure(object).filter((r) => matches(r, options?.filter ?? options?.where));
// Honour the caller's bound by presence, after the filter — the shape
// `check:objectql-double-limit` pins, so a `limit: 1` read cannot be
// silently answered with every row.
return typeof options?.limit === 'number' ? rows.slice(0, options.limit) : rows;
},
async insert(object: string, data: Row) { ensure(object).push({ ...data }); return { ...data }; },
async count(object: string) { return ensure(object).length; },
registerHook() { /* no-op */ },
unregisterHooksByPackage() { /* no-op */ },
};
}

const HOUR = 3_600_000;
const SYS = { isSystem: true, positions: [], permissions: [] } as any;
const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;

/** A node whose only escalation dependency is the clock: `notify`, no reassign. */
function input(nodeId: string, timeoutHours: number) {
return {
object: 'opportunity',
recordId: 'opp1',
runId: 'run_1',
nodeId,
flowName: 'deal_approval',
config: {
approvers: [{ type: 'user' as const, value: 'u9' }],
behavior: 'first_response' as const,
lockRecord: false,
escalation: { timeoutHours, action: 'notify' as const, escalateTo: 'boss', notifySubmitter: false },
},
record: { id: 'opp1', amount: 100 },
};
}

/**
* Open a node request and return the PENDING row. `openNodeRequest` can also
* answer with an auto outcome (an empty approver slate under
* `onEmptyApprovers: 'auto_approve'`), which carries no `id` and no SLA — the
* arm this file is not about, so it is refused loudly rather than narrowed
* away with a cast.
*/
async function openPending(svc: ApprovalService, nodeInput: ReturnType<typeof input>) {
const opened = await svc.openNodeRequest(nodeInput, CTX);
if (!('id' in opened)) throw new Error('expected a pending approval request, got an auto outcome');
return opened;
}

/** A service whose clock is set by the test, in UTC instants. */
function serviceAt(iso: string) {
let nowMs = Date.parse(iso);
const engine = makeEngine();
const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(nowMs) } });
return { svc, engine, setNow: (at: string) => { nowMs = Date.parse(at); } };
}

const utcDay = (iso: string) => new Date(iso).getUTCDay(); // 0 = Sunday … 5 = Friday, 6 = Saturday

// 2026-01-16 is a Friday; the calendar claims below are about the dates they name.
const FRIDAY_1700 = '2026-01-16T17:00:00.000Z';
const MONDAY_0900 = '2026-01-19T09:00:00.000Z';

describe('ApprovalEscalation.timeoutHours is calendar (wall-clock) hours', () => {
it('the fixture dates are the weekdays the assertions name', () => {
expect(utcDay(FRIDAY_1700)).toBe(5);
expect(utcDay(MONDAY_0900)).toBe(1);
});

it('Friday 17:00 + timeoutHours 4 is due Friday 21:00 — the same evening, not the next business day', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_4h', 4));

const row = await svc.getRequest(req.id, SYS);
expect(row?.created_at).toBe(FRIDAY_1700);
expect(row?.sla_due_at).toBe('2026-01-16T21:00:00.000Z');
expect(utcDay(row!.sla_due_at!)).toBe(5);
// A business-hours reading would put this deadline on Monday at the
// earliest; the wall clock puts it before Monday's first working hour.
expect(Date.parse(row!.sla_due_at!)).toBeLessThan(Date.parse(MONDAY_0900));

// The sweep reads the same deadline: one millisecond early is not overdue,
// the deadline instant itself is — on Friday night, with nobody at work.
setNow('2026-01-16T20:59:59.999Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow('2026-01-16T21:00:00.000Z');
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });

const actions = await svc.listActions(req.id, SYS);
expect(actions.at(-1)).toMatchObject({ action: 'escalate', actor_id: 'system:sla' });
});

it('a 168-hour deadline spans the weekend: due the next Friday at the same hour, 7 × 24 elapsed hours', async () => {
const { svc, setNow } = serviceAt(FRIDAY_1700);
const req = await openPending(svc, input('sla_168h', 168));

const row = await svc.getRequest(req.id, SYS);
const due = row!.sla_due_at!;
expect(due).toBe('2026-01-23T17:00:00.000Z');
expect(utcDay(due)).toBe(5);
expect(Date.parse(due) - Date.parse(FRIDAY_1700)).toBe(168 * HOUR);

// Saturday and Sunday sit inside the window and are not skipped: the
// deadline is not 168 working hours later (that would be four weeks out).
const saturday = '2026-01-17T12:00:00.000Z';
const sunday = '2026-01-18T12:00:00.000Z';
expect(utcDay(saturday)).toBe(6);
expect(utcDay(sunday)).toBe(0);
for (const weekendInstant of [saturday, sunday]) {
expect(Date.parse(weekendInstant)).toBeGreaterThan(Date.parse(FRIDAY_1700));
expect(Date.parse(weekendInstant)).toBeLessThan(Date.parse(due));
}

setNow(MONDAY_0900);
expect(await svc.runEscalations()).toMatchObject({ escalated: 0 });
setNow(due);
expect(await svc.runEscalations()).toMatchObject({ escalated: 1 });
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (spring forward)', async () => {
// 2026-03-08T05:00:00Z is 00:00 EST in America/New_York; at 02:00 local the
// clocks jump to 03:00 EDT. Four ELAPSED hours later is 09:00Z = 05:00 EDT —
// five o'clock on the local wall, four hours of real time. The service adds
// elapsed milliseconds, so the deadline is the 09:00Z instant on every host.
const created = '2026-03-08T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_spring', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-03-08T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});

it('a DST transition changes nothing: elapsed hours, not local wall-clock hours (fall back)', async () => {
// 2026-11-01T05:00:00Z is 01:00 EDT in America/New_York; at 02:00 EDT the
// clocks go back to 01:00 EST. Four ELAPSED hours later is 09:00Z = 04:00
// EST — three o'clock-hours on the local wall, four hours of real time.
const created = '2026-11-01T05:00:00.000Z';
const { svc } = serviceAt(created);
const req = await openPending(svc, input('sla_dst_fall', 4));
const row = await svc.getRequest(req.id, SYS);
expect(row?.sla_due_at).toBe('2026-11-01T09:00:00.000Z');
expect(Date.parse(row!.sla_due_at!) - Date.parse(created)).toBe(4 * HOUR);
});
});
15 changes: 14 additions & 1 deletion packages/plugins/plugin-approvals/src/approval-service.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -607,7 +607,20 @@ function rowFromRequest(row: any): ApprovalRequestRow {
} as any;
}

/** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
/**
* `created_at + escalation.timeoutHours`, when the node declares an SLA.
*
* Calendar (wall-clock) hours, by construction: the hours are added as elapsed
* milliseconds, so the deadline does not skip nights, weekends or holidays —
* the platform ships no business-hours calendar to count against. This is the
* one runtime site that turns the declared number into a deadline; the sweep
* below and the `sla_due_at` read projection both go through it, and
* `approval-service-sla-calendar-clock.test.ts` pins the clock (a request
* opened Friday 17:00 with `timeoutHours: 4` is due Friday 21:00; a 168-hour
* deadline spans the weekend; a DST transition changes nothing, because the
* arithmetic is elapsed time, not local calendar time). The same sentence
* lives in the declaration's `describe` text on `ApprovalEscalationSchema`.
*/
function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
const hours = cfg?.escalation?.timeoutHours;
if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
Expand Down
12 changes: 11 additions & 1 deletion packages/spec/src/automation/approval.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -620,7 +620,17 @@ export const ApprovalEscalationSchema = lazySchema(() => strictObject(
// surface have always meant. Declared in DEFAULT_CHANGES_BY_MAJOR (17) and
// the `approval-escalation-enabled-default-flip` semantic migration entry.
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'),
timeoutHours: z.number().min(1).describe('Hours before escalation triggers'),
/**
* Wall-clock SLA. The approvals service adds `timeoutHours` to the request's
* `created_at` as elapsed milliseconds (hours × 3_600_000), so the deadline
* does not skip nights, weekends or holidays — the platform ships no
* business-hours calendar to count against. The clock is named in the
* declaration's own contract text rather than in prose beside it, so the
* number cannot be read as working hours at authoring time. No `clock` key
* exists because only one clock exists: a key with a single legal value would
* be declared-but-inert (ADR-0049).
*/
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'),
action: z.enum(['reassign', 'auto_approve', 'auto_reject', 'notify']).default('notify')
.describe('Action on escalation timeout'),
// Escalation hands the request to a position (the common case — e.g. an
Expand Down
Loading