@ultimat3/policy 0.0.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 developerz.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -14,7 +14,7 @@ export const publishPost = action({
14
14
 
15
15
  ## Shape
16
16
 
17
- A policy is a pure `(input, actor, ctx) => PolicyDecision`.
17
+ A policy is a pure `(input, actor, row, ctx) => PolicyDecision`.
18
18
 
19
19
  ```ts
20
20
  type PolicyDecision =
@@ -26,6 +26,35 @@ type PolicyDecision =
26
26
  to an agent: `actor lacks post:publish` and `post:publish predicate returned false`
27
27
  are different problems with different fixes.
28
28
 
29
+ ## One predicate signature, every surface
30
+
31
+ ```ts
32
+ interface PolicyArgs<I = unknown, R = unknown> {
33
+ input: I;
34
+ actor: Actor | null;
35
+ row: R | null; // required — `null` means "this rule decides on input alone"
36
+ ctx?: Ctx;
37
+ }
38
+ ```
39
+
40
+ A predicate is written once and is correct in an HTTP route, a job, an MCP tool and a
41
+ live query's per-row gate:
42
+
43
+ ```ts
44
+ // decides on input alone — `row` is null
45
+ can<{ orgId: string }>('post:create', ({ actor, input }) => actor?.orgId === input.orgId);
46
+
47
+ // decides about a row the surface already loaded
48
+ can<{ postId: string }, Post>('post:publish', ({ actor, row }) => row?.authorId === actor?.id);
49
+ ```
50
+
51
+ `row` is required and nullable, not optional. An optional field is how the two shapes
52
+ drifted apart the first time: the realtime row gate nested the row inside `input`, so a
53
+ row rule and an input rule received different objects and nothing caught it.
54
+
55
+ Callers have it easier — `EvaluateArgs.row` **is** optional, and `evaluate()` normalises a
56
+ missing row to `null`. A surface that has no row passes `{ input, actor, ctx }` unchanged.
57
+
29
58
  ## Combinators
30
59
 
31
60
  | Builder | Behaviour |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/policy",
3
- "version": "0.0.1",
3
+ "version": "1.0.0",
4
4
  "description": "The one authz rule, evaluated identically in every surface",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,6 +30,6 @@
30
30
  "test": "bun test"
31
31
  },
32
32
  "dependencies": {
33
- "@ultimat3/core": "^0.0.1"
33
+ "@ultimat3/core": "1.0.0"
34
34
  }
35
35
  }
package/src/define.ts ADDED
@@ -0,0 +1,71 @@
1
+ // `definePolicy` — the authoring form apps use. Sugar over `can()`, deliberately synchronous.
2
+ //
3
+ // Why sync, when an async `check` would let a rule fetch what it needs and no surface could
4
+ // forget to load it: a live query evaluates policy PER SUBSCRIBER on every change event. That
5
+ // is the design — it is why two actors watching one query see different rows. An async check
6
+ // that reads a row turns one insert into one round-trip per subscriber, so a hot feed with
7
+ // 10k watchers costs 10k reads per write and the fanout the realtime tier is built on stops
8
+ // working. HTTP and jobs would tolerate async fine; live queries have the least slack, so they
9
+ // set the constraint.
10
+ //
11
+ // The cost is real and belongs in the open: the caller loads the row first and passes it in.
12
+ // That is more boilerplate than `check: async () => db.posts.find(...)`, and it is the same
13
+ // trade as cursor-only pagination — the more ergonomic option is correct right up until
14
+ // concurrency arrives.
15
+
16
+ import type { KnownPermission } from './permissions';
17
+ import type { Policy, PolicyArgs, PolicyDecision } from './policy';
18
+ import { can, denied } from './policy';
19
+
20
+ export interface DefinePolicyInput<I, R = unknown> {
21
+ /**
22
+ * Message key rendered when the rule denies. A key rather than a sentence so the denial is
23
+ * translatable and so two surfaces cannot word the same refusal differently.
24
+ */
25
+ readonly deny: string;
26
+ /**
27
+ * Pure decision over the actor, the already-loaded input and the already-loaded row.
28
+ * Returning `false` denies with `deny`; return a `PolicyDecision` for a more specific reason.
29
+ *
30
+ * Must not perform I/O — see the file header. A rule that needs a row reads `args.row`,
31
+ * which the surface loaded and passed in; `row` is `null` when the rule decides on input
32
+ * alone. Never reach for a row through `input` — that is the drift this signature ended.
33
+ */
34
+ readonly check?: (args: PolicyArgs<I, R>) => boolean | PolicyDecision;
35
+ }
36
+
37
+ /**
38
+ * Decides on input alone — `row` stays `null`:
39
+ *
40
+ * ```ts
41
+ * export const postCreate = definePolicy<{ orgId: string }>('post:create', {
42
+ * deny: 'errors.policyDenied',
43
+ * check: ({ actor, input }) => actor?.orgId === input.orgId,
44
+ * });
45
+ * ```
46
+ *
47
+ * Decides about a row the surface already loaded — second type argument, read `row`:
48
+ *
49
+ * ```ts
50
+ * export const postPublish = definePolicy<{ postId: string }, Post>('post:publish', {
51
+ * deny: 'errors.policyDenied',
52
+ * check: ({ actor, row }) => row !== null && row.authorId === actor?.id,
53
+ * });
54
+ * ```
55
+ *
56
+ * Identical in behaviour to `can(permission, predicate)` — the same `Policy` object, so the
57
+ * same instance is evaluated by HTTP, live queries, jobs, MCP and the admin UI.
58
+ */
59
+ export const definePolicy = <I = unknown, R = unknown>(
60
+ permission: KnownPermission,
61
+ input: DefinePolicyInput<I, R>,
62
+ ): Policy<I, R> => {
63
+ const { deny, check } = input;
64
+ if (check === undefined) return can<I, R>(permission);
65
+ return can<I, R>(permission, (args) => {
66
+ const outcome = check(args);
67
+ if (outcome === true) return true;
68
+ if (outcome === false) return denied(deny);
69
+ return outcome;
70
+ });
71
+ };
package/src/errors.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // The policy layer's stable error codes. `X_POLICY_MISSING` is deliberately a build
2
2
  // error rather than a runtime default: an action with no policy is not "public", it
3
3
  // is unfinished.
4
- import { UltimateError } from '@ultimat3/core';
4
+ import { registerErrorCodes, UltimateError } from '@ultimat3/core';
5
5
 
6
6
  export const POLICY_ERROR_CODES = [
7
7
  'X_FORBIDDEN',
@@ -17,7 +17,17 @@ export const POLICY_ERROR_TITLES: Readonly<Record<PolicyErrorCode, string>> = {
17
17
  X_PERMISSION_UNKNOWN: 'permission string is not in the permission set',
18
18
  };
19
19
 
20
+ // This package OWNS X_FORBIDDEN — http, auth, ai, realtime and every other surface adapter throw
21
+ // it and none of them declare a title for it. One authz code, one title, so every surface renders
22
+ // the same string. Registered unconditionally: a second package claiming one of these codes is a
23
+ // bug the registry must surface as X_ERROR_CODE_DUPLICATE, not absorb into a silent first-wins.
24
+ registerErrorCodes(
25
+ Object.fromEntries(Object.entries(POLICY_ERROR_TITLES).map(([code, title]) => [code, { title }])),
26
+ );
27
+
20
28
  export class PolicyError extends UltimateError {
29
+ override readonly name = 'PolicyError';
30
+
21
31
  constructor(init: { code: PolicyErrorCode; cause: string; fix: string }) {
22
32
  super({
23
33
  code: init.code,
@@ -25,7 +35,6 @@ export class PolicyError extends UltimateError {
25
35
  fix: init.fix,
26
36
  docs: `https://ultimate.dev/errors/${init.code}`,
27
37
  });
28
- this.name = 'PolicyError';
29
38
  }
30
39
  }
31
40
 
package/src/evaluate.ts CHANGED
@@ -5,9 +5,16 @@ import type { Ctx } from '@ultimat3/core';
5
5
  import type { Policy, PolicyDecision, TraceEntry } from './policy';
6
6
  import type { Actor } from './roles';
7
7
 
8
- export interface EvaluateArgs<I> {
8
+ /**
9
+ * What a *caller* supplies. `row` is optional here and required in `PolicyArgs`: a surface
10
+ * that decides on input alone should not have to write `row: null`, but the predicate it
11
+ * reaches must still see the field. `evaluate()` is the one place that gap is closed.
12
+ */
13
+ export interface EvaluateArgs<I, R = unknown> {
9
14
  readonly input: I;
10
15
  readonly actor: Actor | null;
16
+ /** The already-loaded row for a row-level rule. Omitted means "this rule has no row". */
17
+ readonly row?: R;
11
18
  readonly ctx?: Ctx;
12
19
  }
13
20
 
@@ -21,12 +28,19 @@ export interface PolicyEvaluation {
21
28
  readonly label: string;
22
29
  }
23
30
 
24
- export const evaluate = <I>(policy: Policy<I>, args: EvaluateArgs<I>): PolicyEvaluation => {
31
+ export const evaluate = <I, R = unknown>(
32
+ policy: Policy<I, R>,
33
+ args: EvaluateArgs<I, R>,
34
+ ): PolicyEvaluation => {
25
35
  const trace: TraceEntry[] = [];
26
36
  const decision = policy.run(
27
37
  {
28
38
  input: args.input,
29
39
  actor: args.actor,
40
+ // Normalising here is what keeps `row` a required field of `PolicyArgs`: an absent row
41
+ // and an explicit `null` reach the predicate as the same value, so no rule needs to
42
+ // handle both.
43
+ row: args.row ?? null,
30
44
  ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
31
45
  },
32
46
  (entry) => trace.push(entry),
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
+ export type { DefinePolicyInput } from './define';
2
+ export { definePolicy } from './define';
1
3
  // The public surface of @ultimat3/policy. Explicit, never `export *`.
2
4
 
3
5
  export type { PolicyErrorCode } from './errors';
package/src/policy.ts CHANGED
@@ -1,4 +1,4 @@
1
- // A policy is a pure function of (input, actor, ctx). Purity is what lets the same
1
+ // A policy is a pure function of (input, actor, row, ctx). Purity is what lets the same
2
2
  // object be evaluated in an HTTP request, a job, a live query and an MCP tool without
3
3
  // any of them re-implementing the rule — one authz system, never two.
4
4
  import type { Ctx } from '@ultimat3/core';
@@ -17,13 +17,29 @@ export const denied = (reason: string, code = 'X_FORBIDDEN'): PolicyDecision =>
17
17
  code,
18
18
  });
19
19
 
20
- export interface PolicyArgs<I> {
20
+ /**
21
+ * The one shape every predicate sees, on every surface. An input-level rule reads `input`,
22
+ * a row-level rule reads `row`, and neither has to know which surface called it.
23
+ */
24
+ export interface PolicyArgs<I = unknown, R = unknown> {
21
25
  readonly input: I;
22
26
  readonly actor: Actor | null;
27
+ /**
28
+ * The already-loaded row a row-level rule decides about; `null` when the rule decides on
29
+ * input alone.
30
+ *
31
+ * Required and nullable rather than optional, deliberately. An optional `row?: R` lets the
32
+ * two shapes drift apart again: a surface that forgets to pass it still typechecks, so a
33
+ * row rule reading `args.row` silently sees `undefined` and denies (or worse, allows) for
34
+ * the wrong reason. `R | null` forces every caller to say which case it is, and makes
35
+ * "no row here" a value the predicate can branch on instead of an absence it must guess at.
36
+ * This is the field that used to be smuggled inside `input` by the realtime row gate.
37
+ */
38
+ readonly row: R | null;
23
39
  readonly ctx?: Ctx;
24
40
  }
25
41
 
26
- export type PolicyPredicate<I> = (args: PolicyArgs<I>) => boolean | PolicyDecision;
42
+ export type PolicyPredicate<I, R = unknown> = (args: PolicyArgs<I, R>) => boolean | PolicyDecision;
27
43
 
28
44
  export type PolicyKind = 'permission' | 'allow' | 'deny' | 'and' | 'or' | 'not';
29
45
 
@@ -38,13 +54,14 @@ export interface TraceEntry {
38
54
 
39
55
  export type Recorder = (entry: TraceEntry) => void;
40
56
 
41
- export interface Policy<I = unknown> {
57
+ /** `R` is defaulted so `Policy<Input>` keeps meaning "decides on input, any row or none". */
58
+ export interface Policy<I = unknown, R = unknown> {
42
59
  readonly kind: PolicyKind;
43
60
  /** Stable, human-readable, safe to log: shown in traces and denial reasons. */
44
61
  readonly label: string;
45
62
  readonly permissions: readonly Permission[];
46
- readonly children: readonly Policy<I>[];
47
- run(args: PolicyArgs<I>, record?: Recorder, depth?: number): PolicyDecision;
63
+ readonly children: readonly Policy<I, R>[];
64
+ run(args: PolicyArgs<I, R>, record?: Recorder, depth?: number): PolicyDecision;
48
65
  }
49
66
 
50
67
  const record = (
@@ -76,10 +93,10 @@ const asDecision = (result: boolean | PolicyDecision, label: string): PolicyDeci
76
93
  * predicate second, so a denial reason distinguishes "you may never do this" from
77
94
  * "you may, but not to this row" — an agent can act on the difference.
78
95
  */
79
- export const can = <I = unknown>(
96
+ export const can = <I = unknown, R = unknown>(
80
97
  permission: KnownPermission,
81
- predicate?: PolicyPredicate<I>,
82
- ): Policy<I> => {
98
+ predicate?: PolicyPredicate<I, R>,
99
+ ): Policy<I, R> => {
83
100
  assertPermission(permission);
84
101
  const label = permission;
85
102
  return {
@@ -101,7 +118,7 @@ export const can = <I = unknown>(
101
118
  };
102
119
 
103
120
  /** Explicitly public. Saying so is required; forgetting a policy is a build error. */
104
- export const allow = <I = unknown>(label = 'allow'): Policy<I> => ({
121
+ export const allow = <I = unknown, R = unknown>(label = 'allow'): Policy<I, R> => ({
105
122
  kind: 'allow',
106
123
  label,
107
124
  permissions: [],
@@ -111,7 +128,10 @@ export const allow = <I = unknown>(label = 'allow'): Policy<I> => ({
111
128
  },
112
129
  });
113
130
 
114
- export const deny = <I = unknown>(reason: string, code = 'X_FORBIDDEN'): Policy<I> => ({
131
+ export const deny = <I = unknown, R = unknown>(
132
+ reason: string,
133
+ code = 'X_FORBIDDEN',
134
+ ): Policy<I, R> => ({
115
135
  kind: 'deny',
116
136
  label: `deny(${reason})`,
117
137
  permissions: [],
@@ -121,12 +141,14 @@ export const deny = <I = unknown>(reason: string, code = 'X_FORBIDDEN'): Policy<
121
141
  },
122
142
  });
123
143
 
124
- const combined = <I>(
144
+ // Combinators hand `args` to every child untouched — `row` included. A clause that rewrote
145
+ // the args would be the second authz shape all over again.
146
+ const combined = <I, R>(
125
147
  kind: PolicyKind,
126
148
  label: string,
127
- children: readonly Policy<I>[],
128
- decide: (args: PolicyArgs<I>, recorder: Recorder | undefined, depth: number) => PolicyDecision,
129
- ): Policy<I> => ({
149
+ children: readonly Policy<I, R>[],
150
+ decide: (args: PolicyArgs<I, R>, recorder: Recorder | undefined, depth: number) => PolicyDecision,
151
+ ): Policy<I, R> => ({
130
152
  kind,
131
153
  label,
132
154
  permissions: children.flatMap((child) => child.permissions),
@@ -137,7 +159,7 @@ const combined = <I>(
137
159
  });
138
160
 
139
161
  /** First denial wins, and its reason is the reason — short-circuit, left to right. */
140
- export const and = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
162
+ export const and = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> =>
141
163
  combined(
142
164
  'and',
143
165
  `and(${policies.map((policy) => policy.label).join(', ')})`,
@@ -152,7 +174,7 @@ export const and = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
152
174
  );
153
175
 
154
176
  /** First allowance wins; if none allow, the LAST denial is reported. */
155
- export const or = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
177
+ export const or = <I, R = unknown>(...policies: readonly Policy<I, R>[]): Policy<I, R> =>
156
178
  combined(
157
179
  'or',
158
180
  `or(${policies.map((policy) => policy.label).join(', ')})`,
@@ -168,7 +190,7 @@ export const or = <I>(...policies: readonly Policy<I>[]): Policy<I> =>
168
190
  },
169
191
  );
170
192
 
171
- export const not = <I>(policy: Policy<I>): Policy<I> =>
193
+ export const not = <I, R = unknown>(policy: Policy<I, R>): Policy<I, R> =>
172
194
  combined('not', `not(${policy.label})`, [policy], (args, recorder, depth) => {
173
195
  const decision = policy.run(args, recorder, depth);
174
196
  return decision.allowed ? denied(`not(${policy.label}) — inner clause allowed`) : ALLOWED;
package/src/surfaces.ts CHANGED
@@ -51,9 +51,9 @@ const reason = (evaluation: PolicyEvaluation): string => reasonOf(evaluation.dec
51
51
 
52
52
  const code = (evaluation: PolicyEvaluation): string => codeOf(evaluation.decision) ?? 'X_FORBIDDEN';
53
53
 
54
- export const enforceHttp = <I>(
55
- policy: Policy<I>,
56
- args: EvaluateArgs<I>,
54
+ export const enforceHttp = <I, R = unknown>(
55
+ policy: Policy<I, R>,
56
+ args: EvaluateArgs<I, R>,
57
57
  ): HttpDenial | undefined => {
58
58
  const evaluation = evaluate(policy, args);
59
59
  if (evaluation.allowed) return undefined;
@@ -69,16 +69,19 @@ export const enforceHttp = <I>(
69
69
  };
70
70
  };
71
71
 
72
- export const enforceLive = <I>(
73
- policy: Policy<I>,
74
- args: EvaluateArgs<I>,
72
+ export const enforceLive = <I, R = unknown>(
73
+ policy: Policy<I, R>,
74
+ args: EvaluateArgs<I, R>,
75
75
  ): LiveDenial | undefined => {
76
76
  const evaluation = evaluate(policy, args);
77
77
  if (evaluation.allowed) return undefined;
78
78
  return { surface: 'live', close: 4403, code: code(evaluation), reason: reason(evaluation) };
79
79
  };
80
80
 
81
- export const enforceJob = <I>(policy: Policy<I>, args: EvaluateArgs<I>): JobDenial | undefined => {
81
+ export const enforceJob = <I, R = unknown>(
82
+ policy: Policy<I, R>,
83
+ args: EvaluateArgs<I, R>,
84
+ ): JobDenial | undefined => {
82
85
  const evaluation = evaluate(policy, args);
83
86
  if (evaluation.allowed) return undefined;
84
87
  return {
@@ -90,7 +93,10 @@ export const enforceJob = <I>(policy: Policy<I>, args: EvaluateArgs<I>): JobDeni
90
93
  };
91
94
  };
92
95
 
93
- export const enforceMcp = <I>(policy: Policy<I>, args: EvaluateArgs<I>): McpDenial | undefined => {
96
+ export const enforceMcp = <I, R = unknown>(
97
+ policy: Policy<I, R>,
98
+ args: EvaluateArgs<I, R>,
99
+ ): McpDenial | undefined => {
94
100
  const evaluation = evaluate(policy, args);
95
101
  if (evaluation.allowed) return undefined;
96
102
  return {
@@ -103,7 +109,7 @@ export const enforceMcp = <I>(policy: Policy<I>, args: EvaluateArgs<I>): McpDeni
103
109
 
104
110
  export type SurfaceDenial = HttpDenial | LiveDenial | JobDenial | McpDenial;
105
111
 
106
- type Adapter = <I>(policy: Policy<I>, args: EvaluateArgs<I>) => SurfaceDenial | undefined;
112
+ type Adapter = <I, R>(policy: Policy<I, R>, args: EvaluateArgs<I, R>) => SurfaceDenial | undefined;
107
113
 
108
114
  const adapters: Readonly<Record<Surface, Adapter>> = {
109
115
  http: enforceHttp,
@@ -113,14 +119,17 @@ const adapters: Readonly<Record<Surface, Adapter>> = {
113
119
  };
114
120
 
115
121
  /** Dispatcher for code that is generic over surfaces (the action projector). */
116
- export const enforce = <I>(
122
+ export const enforce = <I, R = unknown>(
117
123
  surface: Surface,
118
- policy: Policy<I>,
119
- args: EvaluateArgs<I>,
124
+ policy: Policy<I, R>,
125
+ args: EvaluateArgs<I, R>,
120
126
  ): SurfaceDenial | undefined => adapters[surface](policy, args);
121
127
 
122
128
  /** For call sites that would rather throw than branch. Same decision, same reason. */
123
- export const assertAllowed = <I>(policy: Policy<I>, args: EvaluateArgs<I>): PolicyEvaluation => {
129
+ export const assertAllowed = <I, R = unknown>(
130
+ policy: Policy<I, R>,
131
+ args: EvaluateArgs<I, R>,
132
+ ): PolicyEvaluation => {
124
133
  const evaluation = evaluate(policy, args);
125
134
  if (!evaluation.allowed) throw forbidden(policy.label, reason(evaluation));
126
135
  return evaluation;
package/src/test-kit.ts CHANGED
@@ -27,15 +27,22 @@ export interface PolicyMatrix {
27
27
  toTable(): string;
28
28
  }
29
29
 
30
- export interface MatrixArgs<I> extends Omit<EvaluateArgs<I>, 'actor'> {
30
+ /** Everything `evaluate` takes except the actor — `row` included, so a row rule is testable. */
31
+ export interface MatrixArgs<I, R = unknown> extends Omit<EvaluateArgs<I, R>, 'actor'> {
31
32
  readonly actors: readonly NamedActor[];
32
33
  }
33
34
 
34
- export const policyMatrix = <I>(policy: Policy<I>, args: MatrixArgs<I>): PolicyMatrix => {
35
+ export const policyMatrix = <I, R = unknown>(
36
+ policy: Policy<I, R>,
37
+ args: MatrixArgs<I, R>,
38
+ ): PolicyMatrix => {
35
39
  const rows = args.actors.map((entry): MatrixRow => {
40
+ // Every field but the actor is forwarded verbatim: a matrix that dropped `row` would
41
+ // report a row rule as denying everyone, and the table would lie.
36
42
  const evaluation = evaluate(policy, {
37
43
  input: args.input,
38
44
  actor: entry.actor,
45
+ ...(args.row === undefined ? {} : { row: args.row }),
39
46
  ...(args.ctx === undefined ? {} : { ctx: args.ctx }),
40
47
  });
41
48
  return {