Repository navigation
Expand file tree
/
Copy pathstdlib.ts
More file actions
518 lines (496 loc) · 24.6 KB
/
Copy pathstdlib.ts
File metadata and controls
518 lines (496 loc) · 24.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
/**
* ObjectStack standard CEL function library.
*
* Registered into the per-evaluation `Environment` by the CEL engine. All
* functions are pure given a pinned `now` and the pinned data this module is
* handed — that determinism is what makes `objectstack build` artifacts
* byte-stable across runs. ⛔ Nothing registered here may be handed a resolver,
* a lazy getter or any other callback into the host: a function that reaches
* outside its arguments and the data pinned at registration makes the same
* source evaluate to different values on two runs of the same build.
*
* Function naming intentionally avoids the `os.` prefix because cel-js binds
* dotted names to receiver types. Instead, the `os` namespace in CEL holds
* *data* (`os.user`, `os.org`, `os.env`) supplied by the caller's
* {@link EvalContext}. The one RECEIVER-form function this library registers —
* `current_user.can(object, verb)` — uses that same binding rather than
* fighting it: see {@link registerPermissionPredicate}.
*/
import type { Environment } from '@marcbachmann/cel-js';
import type { EvalContext, EvalPermissions } from './types';
import { createEvalUser, type EvalUser } from '@objectstack/spec';
// The verb vocabulary lives on the SECURITY subpath, where the permission
// contract it is seeded from lives — not on the root barrel, which exports a
// curated authoring surface and no domain tables.
import {
objectPermissionGrants,
resolveObjectPermissionVerb,
OBJECT_PERMISSION_VERB_NAMES,
} from '@objectstack/spec/security';
/**
* Calendar-day parts (y/m/d) of an instant *as seen in a timezone*
* (ADR-0053 Phase 2). Uses `Intl.DateTimeFormat` so DST transitions are
* handled correctly — never hand-rolled offset math. An unknown zone throws,
* which the caller treats as a fall-through to UTC.
*/
function partsInTz(d: Date, tz: string): { y: number; m: number; day: number } {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit',
}).formatToParts(d);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
return { y: get('year'), m: get('month'), day: get('day') };
}
/**
* The calendar day of an instant *in a reference timezone*, expressed as a
* UTC-midnight `Date` (ADR-0053 Phase 2, decision D1). This is the one
* representation consistent with how `Field.date` strings hydrate (UTC
* midnight), how the SQL driver normalizes date filters, and how Phase 1
* stores dates — so `record.date == today()` compares cleanly. Falls back to
* the UTC calendar day for `UTC` or an invalid zone.
*/
function calendarDayUtc(d: Date, tz: string): Date {
if (tz && tz !== 'UTC') {
try {
const { y, m, day } = partsInTz(d, tz);
return new Date(Date.UTC(y, m - 1, day));
} catch {
// unknown zone → fall through to UTC
}
}
return startOfDayUtc(d);
}
/** Truncate a Date to start-of-day in UTC. */
function startOfDayUtc(d: Date): Date {
const out = new Date(d.getTime());
out.setUTCHours(0, 0, 0, 0);
return out;
}
/** Coerce a CEL value (Date | ISO string | epoch number) to a Date. */
function toDate(v: unknown): Date {
if (v instanceof Date) return v;
if (typeof v === 'number' || typeof v === 'bigint') return new Date(Number(v));
return new Date(String(v));
}
/** One UTC day in milliseconds. */
const MS_PER_DAY = 86_400_000;
/**
* The range cel-js's own `timestamp()` accepts, 0001-01-01T00:00:00Z through
* 9999-12-31T23:59:59.999Z. Inside it `toISOString()` has one fixed shape,
* `YYYY-MM-DDTHH:mm:ss.sssZ`; outside it the year expands (`+010000-…`).
*/
const MIN_TIMESTAMP_MS = -62_135_596_800_000;
const MAX_TIMESTAMP_MS = 253_402_300_799_999;
/**
* The ISO text of a timestamp: `toISOString()`, the UTC calendar, three-digit
* milliseconds and a `Z`. These are the bytes the retired flow template
* dialect wrote for `{NOW()}`, and their first ten characters are what it
* wrote for `{TODAY()}` (the 17.x `resolveToken`, deleted in #19939).
*
* An invalid or out-of-range instant is refused rather than rendered. The
* refusal is a throw, which the engine reports as a runtime error.
*/
function isoTimestampText(fn: string, d: Date): string {
const ms = d.getTime();
if (!(ms >= MIN_TIMESTAMP_MS && ms <= MAX_TIMESTAMP_MS)) {
throw new Error(
`${fn}(t): \`t\` is not a renderable timestamp (${Number.isNaN(ms) ? 'an invalid date' : 'outside 0001-01-01 … 9999-12-31'}). ` +
`Pass a valid timestamp, e.g. ${fn}(${fn === 'isoDate' ? 'today()' : 'now()'}), or ISO text through date(…).`,
);
}
return d.toISOString();
}
/** Add `n` days to a Date in UTC; returns a new Date. */
function addDaysUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
out.setUTCDate(out.getUTCDate() + n);
return out;
}
/**
* Add `n` calendar months to a Date in UTC; returns a new Date. Clamps the day
* to the target month's last day so `addMonths(date('2026-01-31'), 1)` yields
* Feb 28, never an overflow into March — matching how authors expect a
* "next service date = last + N months" rule to behave.
*/
function addMonthsUtc(d: Date, n: number): Date {
const out = new Date(d.getTime());
const day = out.getUTCDate();
out.setUTCDate(1); // avoid roll-over while shifting the month
out.setUTCMonth(out.getUTCMonth() + n);
const lastDay = new Date(Date.UTC(out.getUTCFullYear(), out.getUTCMonth() + 1, 0)).getUTCDate();
out.setUTCDate(Math.min(day, lastDay));
return out;
}
/**
* What `current_user.can(object, verb)` is answered from: the acting subject
* this evaluation bound, and that subject's effective object permissions.
*
* Both halves are DATA, pinned when the environment is built. `subject` is the
* very object {@link buildScope} mounted under `current_user` (and its `user` /
* `ctx.user` / `os.user` aliases), carried here so the binding can tell a call
* ON the acting subject from a call on something else that happens to sit to
* the left of a dot.
*/
export interface PermissionBinding {
/**
* The canonical `EvalUser` this evaluation bound, or `undefined` when the
* evaluation carries no user at all (a parse-time environment, a system
* write). Compared by IDENTITY, never by shape.
*/
readonly subject: unknown;
/** The subject's effective object permissions, or `undefined` when none were passed. */
readonly permissions: EvalPermissions | undefined;
}
/**
* Register `can` — the permission predicate — as a RECEIVER method, so the one
* authored spelling is `current_user.can(object, verb)`.
*
* ## Receiver-only, deliberately
*
* cel-js binds dotted names to receiver types, and a bare `can(object, verb)`
* is NOT registered: it keeps faulting (`found no matching overload for
* 'can(dyn, dyn)'`), which is the right answer — a bare call names no subject,
* and a permission question with no subject has no meaning. The name exists in
* the environment either way, so the publish gate's function-existence verdict
* (`firstUnknownFunctionCall`) reads a bare call as a call-FORM fault rather
* than an existence one, exactly as it already does for `split`.
*
* ## Every refusal is LOUD — there is no quiet answer
*
* The binding throws, and the engine reports the throw as
* `{ ok: false, error: { kind: 'runtime' } }`, for each of:
*
* - **no permission data in the context.** ⛔ Never `true` (fail-open: an
* action shown to someone who cannot use it, and worse, a section of data
* revealed), ⛔ never a silent `false` (fail-shut: every gated element
* disappears for everyone, indistinguishable from a correct denial, and the
* author is told nothing). A context that was never given the data cannot
* tell "denied" from "nobody passed it", so it says so.
* - **a receiver that is not the acting subject.** `record.can(…)` reads as a
* question about the record and would silently be answered about the user.
* - **a verb outside the vocabulary.** `current_user.can('crm_lead', 'approve')`
* is an author asking about a capability this platform does not model; the
* refusal names the whole accepted vocabulary.
* - **a non-string object or verb.**
*
* The ONE quiet answer is a real one: an object the effective map does not
* mention is an object with no grant, and answers `false` — the same answer an
* all-`false` entry gives, because they mean the same thing.
*/
export function registerPermissionPredicate(
env: Environment,
binding: PermissionBinding | undefined,
): Environment {
return env.registerFunction(
'dyn.can(dyn, dyn): bool',
(receiver: unknown, object: unknown, verb: unknown): boolean => {
if (typeof object !== 'string' || object.length === 0) {
throw new Error(
'can(object, verb): `object` must be an object NAME (a non-empty string), e.g. ' +
"current_user.can('crm_lead', 'edit').",
);
}
if (typeof verb !== 'string' || verb.length === 0) {
throw new Error(
'can(object, verb): `verb` must be one of ' +
`${OBJECT_PERMISSION_VERB_NAMES.join(', ')} (a non-empty string).`,
);
}
const target = resolveObjectPermissionVerb(verb);
if (!target) {
throw new Error(
`can(object, verb): \`${verb}\` is not a permission verb. The accepted verbs are ` +
`${OBJECT_PERMISSION_VERB_NAMES.join(', ')}. A capability outside that list is not ` +
'modelled as an object permission, so no answer about it would mean anything.',
);
}
if (binding?.subject === undefined || receiver !== binding.subject) {
throw new Error(
'can(object, verb) answers about the ACTING SUBJECT and must be called on it: write ' +
"current_user.can('<object>', '<verb>') (the `user` / `ctx.user` / `os.user` aliases " +
'are the same object and work too). Calling it on anything else would answer a ' +
'question about the current user while reading as a question about the receiver.',
);
}
if (binding.permissions === undefined) {
throw new Error(
`can('${object}', '${verb}') cannot be answered: this evaluation context carries no ` +
'permission data. Pass `permissions` on the EvalContext — the `objects` map of the ' +
'published /auth/me/permissions response, object name -> EffectiveObjectPermission. ' +
'Refusing loudly is deliberate: answering `true` would show what the subject may not ' +
'have, and answering `false` would hide it from everyone with no way to tell that ' +
'apart from a real denial.',
);
}
const entry = Object.prototype.hasOwnProperty.call(binding.permissions, object)
? binding.permissions[object]
: undefined;
return objectPermissionGrants(entry, target);
},
);
}
/**
* Register the ObjectStack standard library into a CEL environment.
*
* The `now` resolver is closed over so each call uses the pinned
* `EvalContext.now` (or wall-clock fallback); `permissionBinding` is pinned the
* same way and for the same reason — see {@link PermissionBinding}.
* Implementations are kept tiny and dependency-free — they're the contract
* surface for AI authors and must stay legible.
*
* `can` is registered UNCONDITIONALLY, binding or not: whether a name exists in
* this environment is a fact about the platform, not about one call site's
* data. A conditional registration would make the publish gate's answer depend
* on which context happened to build the environment, so an authored predicate
* could pass the gate and be unknown at runtime — the very split this binding
* exists to close.
*/
export function registerStdLib(
env: Environment,
now: () => Date,
timezone = 'UTC',
permissionBinding?: PermissionBinding,
): Environment {
registerPermissionPredicate(env, permissionBinding);
// `today()` / `daysFromNow()` / `daysAgo()` are calendar-day functions: they
// resolve to the reference-tz calendar day expressed as a UTC-midnight Date
// (ADR-0053 Phase 2 D1), never an instant carrying wall-clock time. For a
// genuine sub-day offset use `now() + duration("Nh")`.
return env
.registerFunction('now(): google.protobuf.Timestamp', () => now())
.registerFunction(
'today(): google.protobuf.Timestamp',
() => calendarDayUtc(now(), timezone),
)
.registerFunction(
'daysFromNow(int): google.protobuf.Timestamp',
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), Number(n)),
)
.registerFunction(
'daysAgo(int): google.protobuf.Timestamp',
(n: bigint | number) => addDaysUtc(calendarDayUtc(now(), timezone), -Number(n)),
)
// Returns true when `value` is null, undefined, empty string, or empty list.
// Matches the intent of legacy `ISBLANK()` while staying CEL-idiomatic.
.registerFunction(
'isBlank(dyn): bool',
(value: unknown) => {
if (value === null || value === undefined) return true;
if (typeof value === 'string') return value.length === 0;
if (Array.isArray(value)) return value.length === 0;
return false;
},
)
// Returns `value` when not null/undefined, otherwise the `fallback`.
// Use this to safely concatenate optional string fields:
// coalesce(record.salutation, '') + ' ' + coalesce(record.first_name, '')
.registerFunction(
'coalesce(dyn, dyn): dyn',
(value: unknown, fallback: unknown) =>
(value === null || value === undefined) ? fallback : value,
)
// Trim leading/trailing ASCII whitespace from a string. Returns '' for
// null/undefined so it composes cleanly with `coalesce`.
.registerFunction(
'trim(dyn): string',
(value: unknown) => {
if (value === null || value === undefined) return '';
return String(value).trim();
},
)
// Join a list of values with `sep`, dropping null/undefined/empty entries
// first. Designed for display-name formulas like:
// joinNonEmpty([record.salutation, record.first_name, record.last_name], ' ')
// which produces 'Alice Martinez' (no leading/trailing/internal extra
// spaces) when `salutation` is null.
.registerFunction(
'joinNonEmpty(list, string): string',
(list: unknown, sep: unknown) => {
const arr = Array.isArray(list) ? list : [];
const separator = typeof sep === 'string' ? sep : ' ';
const parts: string[] = [];
for (const item of arr) {
if (item === null || item === undefined) continue;
const s = String(item).trim();
if (s.length > 0) parts.push(s);
}
return parts.join(separator);
},
)
// ── Dates ────────────────────────────────────────────────────────────
// Whole days from `a` to `b` (negative if `b` is before `a`). The common
// shape is `daysBetween(today(), record.due)` for "days remaining". Args are
// coerced (Date | ISO string | epoch) so a `Field.date` that arrives as a
// string still works without the caller hydrating it.
.registerFunction(
'daysBetween(dyn, dyn): int',
(a: unknown, b: unknown) =>
BigInt(Math.round((toDate(b).getTime() - toDate(a).getTime()) / MS_PER_DAY)),
)
// Shift an arbitrary date by a (possibly negative) number of days/months.
// Unlike `daysFromNow`, these operate on a *given* date — the shape behind
// "next service date = last service + cycle". Args are coerced (Date | ISO
// string | epoch) so a `Field.date` arriving as a string works directly.
// `addMonths` clamps to the target month's last day (Jan 31 +1mo → Feb 28).
// `n` is typed `dyn` (not `int`): a record number field arrives as cel-js
// `double`, so an `int` overload would fault `no such overload` (#1928).
// We coerce defensively with `Number(...)`.
.registerFunction(
'addDays(dyn, dyn): google.protobuf.Timestamp',
(d: unknown, n: unknown) => addDaysUtc(toDate(d), Math.trunc(Number(n))),
)
.registerFunction(
'addMonths(dyn, dyn): google.protobuf.Timestamp',
(d: unknown, n: unknown) => addMonthsUtc(toDate(d), Math.trunc(Number(n))),
)
// Parse an ISO date / date-time string to a Timestamp. `date` and `datetime`
// are aliases — both accept either form (the field's own type decides the
// intent); kept distinct because authors reach for whichever reads clearer.
.registerFunction('date(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
.registerFunction('datetime(dyn): google.protobuf.Timestamp', (s: unknown) => toDate(s))
// The string form of a timestamp, the reverse of `date` / `datetime`:
// `isoDate(t)` is `YYYY-MM-DD` and `isoDatetime(t)` is
// `YYYY-MM-DDTHH:mm:ss.sssZ`, both on the UTC calendar. They write the
// bytes the retired flow template dialect wrote for `{TODAY()}` and `{NOW()}`.
// `isoDate(today())` is the reference-timezone day, because `today()` is
// that day at UTC midnight (ADR-0053 D1).
//
// Two named functions, not a `string(timestamp)` overload. A `string()`
// overload answers one shape for one type, so it cannot spell the date
// shape. CEL also defines `string(timestamp)` as RFC 3339 text that drops
// zero fractions, so matching the template's `.000` would make `string()`
// a dialect. That name stays refused for a timestamp, which a test pins.
//
// The parameter is a timestamp, never `dyn`. Text, a number or `null` is
// refused: at build when the argument's type is known, at run otherwise.
// Coercing like `toDate` would parse non-ISO text in the host's local
// zone and could render a different day.
.registerFunction(
'isoDate(google.protobuf.Timestamp): string',
(d: Date) => isoTimestampText('isoDate', d).slice(0, 10),
)
.registerFunction(
'isoDatetime(google.protobuf.Timestamp): string',
(d: Date) => isoTimestampText('isoDatetime', d),
)
// ── Numbers ──────────────────────────────────────────────────────────
.registerFunction('abs(dyn): double', (x: unknown) => Math.abs(Number(x)))
.registerFunction('round(dyn): int', (x: unknown) => BigInt(Math.round(Number(x))))
// `floor`/`ceil` round toward −∞ / +∞ (NOT toward zero like integer division),
// so `floor(-1.2) == -2`, `ceil(-1.2) == -1`. Registered because they are
// universally-expected math primitives that authors (and LLMs) reach for by
// reflex — omitting them makes `floor(...)` fault and the formula silently
// null (#3306). Return `int` to match `round`, coercing the arg with
// `Number(...)` (a record number arrives as cel-js `double`).
.registerFunction('floor(dyn): int', (x: unknown) => BigInt(Math.floor(Number(x))))
.registerFunction('ceil(dyn): int', (x: unknown) => BigInt(Math.ceil(Number(x))))
// min/max return the smaller/larger operand verbatim (type preserved) rather
// than a coerced copy, so `min(record.a, record.b)` keeps int-ness when both
// are ints. Comparison is numeric.
.registerFunction('min(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) <= Number(b) ? a : b))
.registerFunction('max(dyn, dyn): dyn', (a: unknown, b: unknown) => (Number(a) >= Number(b) ? a : b))
// ── Strings ──────────────────────────────────────────────────────────
// Free-function forms of the common string ops. CEL also exposes some as
// receiver methods (`s.contains(x)`), but the authoring catalog advertises
// the bare-call form, so register it to match what authors are told to use.
.registerFunction('upper(dyn): string', (s: unknown) => String(s ?? '').toUpperCase())
.registerFunction('lower(dyn): string', (s: unknown) => String(s ?? '').toLowerCase())
.registerFunction('contains(dyn, dyn): bool', (s: unknown, sub: unknown) => String(s ?? '').includes(String(sub ?? '')))
.registerFunction('startsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').startsWith(String(p ?? '')))
.registerFunction('endsWith(dyn, dyn): bool', (s: unknown, p: unknown) => String(s ?? '').endsWith(String(p ?? '')))
.registerFunction('matches(dyn, dyn): bool', (s: unknown, re: unknown) => new RegExp(String(re ?? '')).test(String(s ?? '')))
// ── Collections ──────────────────────────────────────────────────────
// `len` mirrors CEL's built-in `size()` for strings/lists/maps; `isEmpty` is
// the inverse-of-non-empty companion to `isBlank` (true for null, '', []).
.registerFunction('len(dyn): int', (v: unknown) => BigInt(lengthOf(v)))
.registerFunction(
'isEmpty(dyn): bool',
(v: unknown) => v === null || v === undefined || lengthOf(v) === 0,
);
}
/** Length of a string / list / map (0 for scalars and null). */
function lengthOf(v: unknown): number {
if (v === null || v === undefined) return 0;
if (typeof v === 'string' || Array.isArray(v)) return v.length;
if (typeof v === 'object') return Object.keys(v as Record<string, unknown>).length;
return 0;
}
/**
* Register mixed `double <op> int` / `int <op> double` arithmetic overloads.
*
* cel-js types a record field number as `double` and a bare integer literal as
* `int`, and ships overloads only for matching pairs (`double op double`,
* `int op int`). So a formula as ordinary as `record.amount / 100` or
* `record.price * 2` faults at runtime (`no such overload: dyn<double> / int`);
* the engine catches the fault and the formula silently evaluates to `null`
* (#1928). Authors then have to know the cel-js quirk and write `/ 100.0`.
*
* We close the gap by registering the missing mixed overloads. The result is
* always computed as a JS `double`, matching CEL's promotion rule for mixed
* numeric arithmetic. Pure `int op int` is untouched, so integer division
* (`7 / 2 == 3`) keeps its semantics — these overloads only fire when the two
* operands are genuinely a `double` and an `int`.
*/
export function registerNumericCoercions(env: Environment): Environment {
const ops: Record<string, (a: number, b: number) => number> = {
'+': (a, b) => a + b,
'-': (a, b) => a - b,
'*': (a, b) => a * b,
'/': (a, b) => a / b,
'%': (a, b) => a % b,
};
for (const [op, fn] of Object.entries(ops)) {
const impl = (a: unknown, b: unknown) => fn(Number(a), Number(b));
env.registerOperator(`double ${op} int`, impl);
env.registerOperator(`int ${op} double`, impl);
}
return env;
}
/**
* Normalize the loosely-typed EvalContext user into the canonical EvalUser
* (ADR-0068, renamed by ADR-0090 D3): `positions` is the one canonical
* membership array. One-step rename — no legacy `roles`/`role` aliases parse.
*/
function toEvalUser(u: NonNullable<EvalContext['user']>): EvalUser {
const positions = Array.isArray(u.positions) ? (u.positions as string[]) : [];
const canonical = createEvalUser({
id: u.id,
name: typeof u.name === 'string' ? u.name : undefined,
email: typeof u.email === 'string' ? u.email : undefined,
positions,
organizationId:
typeof u.organizationId === 'string' || u.organizationId === null
? (u.organizationId as string | null)
: undefined,
});
return canonical;
}
/**
* Build the variable scope for a single evaluation. Absent fields are simply
* not bound — CEL macros (`has(record.foo)`) handle missing-key safely.
*/
export function buildScope(ctx: EvalContext): Record<string, unknown> {
const scope: Record<string, unknown> = {};
if (ctx.record !== undefined) scope.record = ctx.record;
if (ctx.previous !== undefined) scope.previous = ctx.previous;
if (ctx.input !== undefined) scope.input = ctx.input;
// Namespaced data — written as `os.user.id`, `os.env`, etc. in CEL.
const os: Record<string, unknown> = {};
if (ctx.user !== undefined) {
// ADR-0068: one canonical EvalUser under every alias (`current_user`,
// `user`, `ctx.user`, `os.user`) — the SAME object, so a predicate
// evaluates identically wherever it is authored.
const currentUser = toEvalUser(ctx.user);
scope.current_user = currentUser;
scope.user = currentUser;
scope.ctx = {
...(typeof scope.ctx === 'object' && scope.ctx !== null ? scope.ctx : {}),
user: currentUser,
};
os.user = currentUser;
}
if (ctx.org !== undefined) os.org = ctx.org;
if (ctx.env !== undefined) os.env = ctx.env;
if (Object.keys(os).length > 0) scope.os = os;
if (ctx.extra !== undefined) Object.assign(scope, ctx.extra);
return scope;
}