From dcfc2bd3f4fe8a12126222b5375382a91b16ade6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:30:22 +0000 Subject: [PATCH 1/3] =?UTF-8?q?docs(spec,approvals):=20ApprovalEscalation.?= =?UTF-8?q?timeoutHours=20names=20its=20clock=20=E2=80=94=20calendar=20(wa?= =?UTF-8?q?ll-clock)=20hours?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruled A-half of the business-hours question, consumption-side: the unit is carried by the declaration's own contract text (the timeoutHours describe, which gen:schema emits to the JSON schema and build-docs to the reference page), the one runtime site that turns the number into a deadline (slaDueAt) says the same in its JSDoc and is pinned by a wall-clock test through the real code path (Friday 17:00 + 4 h is due Friday 21:00; a 168-hour deadline spans the weekend; a DST transition changes nothing because the arithmetic is elapsed time), and the handwritten approvals page carries one sentence. No key is added, renamed or defaulted differently; plugin-approvals has no logic change. The system-context census page is re-anchored mechanically for the line shift the JSDoc introduced. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .../approval-escalation-calendar-clock.md | 16 ++ content/docs/automation/approvals.mdx | 4 + content/docs/permissions/system-context.mdx | 2 +- .../docs/references/automation/approval.mdx | 4 +- ...pproval-service-sla-calendar-clock.test.ts | 204 ++++++++++++++++++ .../plugin-approvals/src/approval-service.ts | 15 +- packages/spec/src/automation/approval.zod.ts | 12 +- 7 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 .changeset/approval-escalation-calendar-clock.md create mode 100644 packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts diff --git a/.changeset/approval-escalation-calendar-clock.md b/.changeset/approval-escalation-calendar-clock.md new file mode 100644 index 0000000000..6eeb9924da --- /dev/null +++ b/.changeset/approval-escalation-calendar-clock.md @@ -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). diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index af603b221c..3e85ba17e5 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -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. **Escalation needs the job service.** The plugin sweeps pending requests on an diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 05b8b9b450..97f8cd0d27 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -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` | diff --git a/content/docs/references/automation/approval.mdx b/content/docs/references/automation/approval.mdx index 7946a3989e..b318759db5 100644 --- a/content/docs/references/automation/approval.mdx +++ b/content/docs/references/automation/approval.mdx @@ -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 | @@ -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 | diff --git a/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts b/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts new file mode 100644 index 0000000000..0134166438 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts @@ -0,0 +1,204 @@ +// 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 = {}; + 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) { + return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where)); + }, + 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) { + 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); + }); +}); diff --git a/packages/plugins/plugin-approvals/src/approval-service.ts b/packages/plugins/plugin-approvals/src/approval-service.ts index fd8a406788..7d44eb9e69 100644 --- a/packages/plugins/plugin-approvals/src/approval-service.ts +++ b/packages/plugins/plugin-approvals/src/approval-service.ts @@ -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; diff --git a/packages/spec/src/automation/approval.zod.ts b/packages/spec/src/automation/approval.zod.ts index 2818df6db1..b829fcec4f 100644 --- a/packages/spec/src/automation/approval.zod.ts +++ b/packages/spec/src/automation/approval.zod.ts @@ -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 From c1e4ca8db4cdcad5a5d55fce1bcab67f522fbbe3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:35:47 +0000 Subject: [PATCH 2/3] test(approvals): the wall-clock pin's find double honours the caller's limit The read-and-append engine double answered a `limit: 1` read with every row; `check:objectql-double-limit` names that shape limit-blind. The bound is now applied by presence, after the filter. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- .../src/approval-service-sla-calendar-clock.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts b/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts index 0134166438..f6ac3ac593 100644 --- a/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-service-sla-calendar-clock.test.ts @@ -63,7 +63,11 @@ function makeEngine() { return { _tables: tables, async find(object: string, options?: any) { - return ensure(object).filter((r) => matches(r, options?.filter ?? options?.where)); + 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; }, From fb5c8a4a3dad3363f9583f447ecc67a36fdebb6f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 09:36:47 +0000 Subject: [PATCH 3/3] chore(docs): re-anchor the system-context census after merging origin/main The merge brought main's own re-anchoring of the same page beside this branch's; the census is regenerated from the merged tree so every elevation-read anchor resolves again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --- content/docs/permissions/system-context.mdx | 24 ++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 97f8cd0d27..effe83fd6f 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11049` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11217` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9827` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11128` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11296` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:9895` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9864`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5761` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3605`, `:3615`, `:3642` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:9943`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5762` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3606`, `:3616`, `:3643` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:98` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6459` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11810` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11739` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6460` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:11889` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:11818` | ### 3. Sharing (`plugin-sharing`) @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3412` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14159` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3413` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14238` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:685` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9810`–`9827` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:9878`–`9895` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1516` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:286` |