@weave-kit/engine 1.4.0 → 1.6.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.
Files changed (182) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +15 -8
  3. package/dist/adapters/graphql/resolvers.d.ts +3 -2
  4. package/dist/adapters/graphql/resolvers.d.ts.map +1 -1
  5. package/dist/adapters/graphql/resolvers.js +1 -0
  6. package/dist/adapters/graphql/resolvers.js.map +1 -1
  7. package/dist/adapters/graphql/schema.d.ts.map +1 -1
  8. package/dist/adapters/graphql/schema.js +11 -1
  9. package/dist/adapters/graphql/schema.js.map +1 -1
  10. package/dist/adapters/mcp/generate.d.ts +0 -1
  11. package/dist/adapters/mcp/generate.d.ts.map +1 -1
  12. package/dist/adapters/mcp/generate.js +2 -12
  13. package/dist/adapters/mcp/generate.js.map +1 -1
  14. package/dist/adapters/mcp/tools.d.ts.map +1 -1
  15. package/dist/adapters/mcp/tools.js +2 -0
  16. package/dist/adapters/mcp/tools.js.map +1 -1
  17. package/dist/adapters/openapi/document.d.ts.map +1 -1
  18. package/dist/adapters/openapi/document.js +4 -3
  19. package/dist/adapters/openapi/document.js.map +1 -1
  20. package/dist/adapters/rest/plugin.d.ts.map +1 -1
  21. package/dist/adapters/rest/plugin.js +8 -1
  22. package/dist/adapters/rest/plugin.js.map +1 -1
  23. package/dist/cli/commands/deploy.d.ts.map +1 -1
  24. package/dist/cli/commands/deploy.js +8 -4
  25. package/dist/cli/commands/deploy.js.map +1 -1
  26. package/dist/cli/commands/dev.js +4 -4
  27. package/dist/cli/commands/dev.js.map +1 -1
  28. package/dist/cli/commands/doctor.d.ts.map +1 -1
  29. package/dist/cli/commands/doctor.js +69 -2
  30. package/dist/cli/commands/doctor.js.map +1 -1
  31. package/dist/cli/commands/migrate.d.ts.map +1 -1
  32. package/dist/cli/commands/migrate.js +6 -2
  33. package/dist/cli/commands/migrate.js.map +1 -1
  34. package/dist/cli/commands/workflow-switch.js +2 -2
  35. package/dist/cli/commands/workflow-switch.js.map +1 -1
  36. package/dist/cli/project-types/manifest.d.ts +7 -0
  37. package/dist/cli/project-types/manifest.d.ts.map +1 -1
  38. package/dist/cli/project-types/manifest.js +4 -0
  39. package/dist/cli/project-types/manifest.js.map +1 -1
  40. package/dist/cli/scaffold.d.ts.map +1 -1
  41. package/dist/cli/scaffold.js +33 -7
  42. package/dist/cli/scaffold.js.map +1 -1
  43. package/dist/core/api/params.d.ts.map +1 -1
  44. package/dist/core/api/params.js +3 -0
  45. package/dist/core/api/params.js.map +1 -1
  46. package/dist/core/api/types.d.ts +4 -1
  47. package/dist/core/api/types.d.ts.map +1 -1
  48. package/dist/core/i18n/ar.d.ts.map +1 -1
  49. package/dist/core/i18n/ar.js +1 -0
  50. package/dist/core/i18n/ar.js.map +1 -1
  51. package/dist/core/i18n/de.d.ts.map +1 -1
  52. package/dist/core/i18n/de.js +1 -0
  53. package/dist/core/i18n/de.js.map +1 -1
  54. package/dist/core/i18n/en.d.ts +1 -0
  55. package/dist/core/i18n/en.d.ts.map +1 -1
  56. package/dist/core/i18n/en.js +1 -0
  57. package/dist/core/i18n/en.js.map +1 -1
  58. package/dist/core/i18n/es.d.ts.map +1 -1
  59. package/dist/core/i18n/es.js +1 -0
  60. package/dist/core/i18n/es.js.map +1 -1
  61. package/dist/core/i18n/fr.d.ts.map +1 -1
  62. package/dist/core/i18n/fr.js +1 -0
  63. package/dist/core/i18n/fr.js.map +1 -1
  64. package/dist/core/i18n/hi.d.ts.map +1 -1
  65. package/dist/core/i18n/hi.js +1 -0
  66. package/dist/core/i18n/hi.js.map +1 -1
  67. package/dist/core/i18n/id.d.ts.map +1 -1
  68. package/dist/core/i18n/id.js +1 -0
  69. package/dist/core/i18n/id.js.map +1 -1
  70. package/dist/core/i18n/ja.d.ts.map +1 -1
  71. package/dist/core/i18n/ja.js +1 -0
  72. package/dist/core/i18n/ja.js.map +1 -1
  73. package/dist/core/i18n/ko.d.ts.map +1 -1
  74. package/dist/core/i18n/ko.js +1 -0
  75. package/dist/core/i18n/ko.js.map +1 -1
  76. package/dist/core/i18n/pt.d.ts.map +1 -1
  77. package/dist/core/i18n/pt.js +1 -0
  78. package/dist/core/i18n/pt.js.map +1 -1
  79. package/dist/core/i18n/ru.d.ts.map +1 -1
  80. package/dist/core/i18n/ru.js +1 -0
  81. package/dist/core/i18n/ru.js.map +1 -1
  82. package/dist/core/i18n/vi.d.ts.map +1 -1
  83. package/dist/core/i18n/vi.js +1 -0
  84. package/dist/core/i18n/vi.js.map +1 -1
  85. package/dist/core/i18n/zh-hant.d.ts.map +1 -1
  86. package/dist/core/i18n/zh-hant.js +1 -0
  87. package/dist/core/i18n/zh-hant.js.map +1 -1
  88. package/dist/core/i18n/zh.d.ts.map +1 -1
  89. package/dist/core/i18n/zh.js +1 -0
  90. package/dist/core/i18n/zh.js.map +1 -1
  91. package/dist/core/rbac/index.d.ts +1 -1
  92. package/dist/core/rbac/index.d.ts.map +1 -1
  93. package/dist/core/rbac/index.js +1 -1
  94. package/dist/core/rbac/index.js.map +1 -1
  95. package/dist/core/rbac/rls.d.ts +34 -0
  96. package/dist/core/rbac/rls.d.ts.map +1 -1
  97. package/dist/core/rbac/rls.js +110 -0
  98. package/dist/core/rbac/rls.js.map +1 -1
  99. package/dist/core/storage/diff.d.ts +11 -0
  100. package/dist/core/storage/diff.d.ts.map +1 -1
  101. package/dist/core/storage/diff.js +56 -12
  102. package/dist/core/storage/diff.js.map +1 -1
  103. package/dist/core/storage/index.d.ts +5 -2
  104. package/dist/core/storage/index.d.ts.map +1 -1
  105. package/dist/core/storage/index.js +4 -2
  106. package/dist/core/storage/index.js.map +1 -1
  107. package/dist/core/storage/inspect.d.ts +10 -0
  108. package/dist/core/storage/inspect.d.ts.map +1 -1
  109. package/dist/core/storage/inspect.js +33 -24
  110. package/dist/core/storage/inspect.js.map +1 -1
  111. package/dist/core/storage/link-table.d.ts +3 -2
  112. package/dist/core/storage/link-table.d.ts.map +1 -1
  113. package/dist/core/storage/link-table.js +5 -4
  114. package/dist/core/storage/link-table.js.map +1 -1
  115. package/dist/core/storage/meta.d.ts +3 -2
  116. package/dist/core/storage/meta.d.ts.map +1 -1
  117. package/dist/core/storage/meta.js +5 -4
  118. package/dist/core/storage/meta.js.map +1 -1
  119. package/dist/core/storage/migrate.d.ts +16 -0
  120. package/dist/core/storage/migrate.d.ts.map +1 -1
  121. package/dist/core/storage/migrate.js +54 -23
  122. package/dist/core/storage/migrate.js.map +1 -1
  123. package/dist/core/storage/object-rls.d.ts +25 -0
  124. package/dist/core/storage/object-rls.d.ts.map +1 -0
  125. package/dist/core/storage/object-rls.js +45 -0
  126. package/dist/core/storage/object-rls.js.map +1 -0
  127. package/dist/core/storage/pool.d.ts +7 -0
  128. package/dist/core/storage/pool.d.ts.map +1 -1
  129. package/dist/core/storage/pool.js +9 -0
  130. package/dist/core/storage/pool.js.map +1 -1
  131. package/dist/core/storage/record-meta.d.ts +2 -1
  132. package/dist/core/storage/record-meta.d.ts.map +1 -1
  133. package/dist/core/storage/record-meta.js +3 -1
  134. package/dist/core/storage/record-meta.js.map +1 -1
  135. package/dist/core/storage/schema-scope.d.ts +33 -0
  136. package/dist/core/storage/schema-scope.d.ts.map +1 -0
  137. package/dist/core/storage/schema-scope.js +45 -0
  138. package/dist/core/storage/schema-scope.js.map +1 -0
  139. package/dist/core/storage/system-tables.d.ts +3 -2
  140. package/dist/core/storage/system-tables.d.ts.map +1 -1
  141. package/dist/core/storage/system-tables.js +7 -4
  142. package/dist/core/storage/system-tables.js.map +1 -1
  143. package/dist/index.d.ts.map +1 -1
  144. package/dist/index.js +6 -3
  145. package/dist/index.js.map +1 -1
  146. package/dist/runtime/data-access/builder.d.ts +7 -0
  147. package/dist/runtime/data-access/builder.d.ts.map +1 -1
  148. package/dist/runtime/data-access/builder.js +12 -0
  149. package/dist/runtime/data-access/builder.js.map +1 -1
  150. package/dist/runtime/data-access/index.d.ts +3 -3
  151. package/dist/runtime/data-access/index.d.ts.map +1 -1
  152. package/dist/runtime/data-access/index.js +2 -2
  153. package/dist/runtime/data-access/index.js.map +1 -1
  154. package/dist/runtime/data-access/query.d.ts +49 -7
  155. package/dist/runtime/data-access/query.d.ts.map +1 -1
  156. package/dist/runtime/data-access/query.js +235 -113
  157. package/dist/runtime/data-access/query.js.map +1 -1
  158. package/dist/runtime/data-access/rls-session.d.ts +24 -0
  159. package/dist/runtime/data-access/rls-session.d.ts.map +1 -0
  160. package/dist/runtime/data-access/rls-session.js +30 -0
  161. package/dist/runtime/data-access/rls-session.js.map +1 -0
  162. package/dist/runtime/data-access/sql-executor.d.ts.map +1 -1
  163. package/dist/runtime/data-access/sql-executor.js +2 -16
  164. package/dist/runtime/data-access/sql-executor.js.map +1 -1
  165. package/dist/runtime/data-access/types.d.ts +10 -2
  166. package/dist/runtime/data-access/types.d.ts.map +1 -1
  167. package/dist/runtime/data-access/types.js.map +1 -1
  168. package/dist/runtime/data-access/values.d.ts +10 -0
  169. package/dist/runtime/data-access/values.d.ts.map +1 -1
  170. package/dist/runtime/data-access/values.js +9 -0
  171. package/dist/runtime/data-access/values.js.map +1 -1
  172. package/dist/runtime/engine.d.ts +44 -4
  173. package/dist/runtime/engine.d.ts.map +1 -1
  174. package/dist/runtime/engine.js +38 -4
  175. package/dist/runtime/engine.js.map +1 -1
  176. package/dist/runtime/git/sync.d.ts +6 -0
  177. package/dist/runtime/git/sync.d.ts.map +1 -1
  178. package/dist/runtime/git/sync.js +2 -0
  179. package/dist/runtime/git/sync.js.map +1 -1
  180. package/dist/version.d.ts +1 -1
  181. package/dist/version.js +1 -1
  182. package/package.json +1 -1
@@ -1,21 +1,24 @@
1
- import { SchemaError, METRIC_NAMES, primaryFieldsOf, encodeRecordKey, decodeRecordKey, canonicalizePrimaryValue, principalActorId } from '../../core/index.js';
1
+ import { SchemaError, METRIC_NAMES, isSafeRlsRole, primaryFieldsOf, encodeRecordKey, decodeRecordKey, canonicalizePrimaryValue, principalActorId } from '../../core/index.js';
2
2
  import { DETAILS_COLUMNS, FIELD_TYPES, isSideTableVirtualField, RECORD_META_ID_FIELD, recordKeySql, recordMetaTableName, scopeFieldName } from '../../core/index.js';
3
3
  import { AUDIT_ACTOR_TYPES, DATA_ACTIONS } from '../../core/audit/index.js';
4
4
  import { NOOP_SCRIPT_DISPATCHER, SCRIPT_HOOKS } from '../../core/index.js';
5
5
  import { evaluateTransition } from '../tools/policies.js';
6
- import { buildCountSql, buildFindSql, resolvePagination, scopeSuffix } from './builder.js';
6
+ import { buildCountSql, buildEstimateSql, buildFindSql, resolvePagination, scopeSuffix } from './builder.js';
7
7
  import { deleteDetailsChildren, insertDetails } from './details.js';
8
8
  import { insertLinks, replaceLinks } from './link.js';
9
- import { runWorkflowTransition, getWorkflowStatus, acquireWorkflowLock, releaseWorkflowLock, getWorkflowHistory, getWorkflowTodos, overrideWorkflow, } from './workflow.js';
9
+ import { WORKFLOW_ACTIONS, runWorkflowTransition, getWorkflowStatus, acquireWorkflowLock, releaseWorkflowLock, getWorkflowHistory, getWorkflowTodos, overrideWorkflow, } from './workflow.js';
10
10
  import { computeFormulas } from './formula.js';
11
11
  import { generateSeqNo } from './seqno.js';
12
12
  import { subjectOf, scopeOf } from './types.js';
13
13
  import { validateRecord } from './validate.js';
14
- import { FILTER_OPS, SORT_DIRS, WRITE_MODES } from './values.js';
14
+ import { FILTER_OPS, SORT_DIRS, TOTAL_MODES, WRITE_MODES } from './values.js';
15
15
  import { decodeCursor, encodeCursor } from './cursor.js';
16
16
  import { QUERY_BUDGET_DEFAULTS, assertQueryBudget } from './query-budget.js';
17
- import { EXECUTION_OUTCOMES, EXECUTION_STAGES } from '../../core/index.js';
17
+ import { EXECUTION_OUTCOMES } from '../../core/index.js';
18
+ import { runPipeline } from '../execution/index.js';
18
19
  import { upsertRecordMeta, deleteRecordMeta } from '../record-meta/index.js';
20
+ import { SYSTEM_TABLES } from '../../core/storage/system-tables.js';
21
+ import { asRlsRole } from './rls-session.js';
19
22
  const q = (id) => `"${id}"`;
20
23
  /** cross-object formula authorization derived from the request context */
21
24
  function formulaAuth(ctx) {
@@ -168,6 +171,11 @@ function pkValuesOf(def, id, ctx) {
168
171
  }
169
172
  return values;
170
173
  }
174
+ /** reject a malformed external id up front (keyed objects only; key-less objects 404 later) */
175
+ function validateId(def, id, ctx) {
176
+ if (primaryNames(def).length > 0)
177
+ pkValuesOf(def, id, ctx);
178
+ }
171
179
  /** primary-key filter object for `find` (declared names zipped with values) */
172
180
  function pkFilter(def, values) {
173
181
  const filter = {};
@@ -264,6 +272,10 @@ export class DefaultObjectDataAccess {
264
272
  schemaRevision;
265
273
  /** optional metrics sink (query-budget rejections); absent = zero overhead */
266
274
  metrics;
275
+ /** object-path RLS role (`rls.enforce`); user-principal customer-table reads/updates/deletes run under it */
276
+ rlsRole;
277
+ /** default `total` computation for `find` (per-call `opts.total` overrides) */
278
+ totalMode;
267
279
  constructor(options = {}) {
268
280
  this.audit = options.audit;
269
281
  this.script = options.script ?? NOOP_SCRIPT_DISPATCHER;
@@ -276,6 +288,43 @@ export class DefaultObjectDataAccess {
276
288
  this.evidence = options.evidence;
277
289
  this.schemaRevision = options.schemaRevision;
278
290
  this.metrics = options.metrics;
291
+ if (options.rls !== undefined && !isSafeRlsRole(options.rls.role)) {
292
+ throw new Error(`invalid RLS role name "${options.rls.role}"`);
293
+ }
294
+ this.rlsRole = options.rls?.role;
295
+ this.totalMode = options.total ?? TOTAL_MODES.EXACT;
296
+ }
297
+ /**
298
+ * Run customer-table statements under the object RLS role for a user
299
+ * principal (a pool request gets its own short transaction); system
300
+ * principals and enforcement-off run as the owner.
301
+ */
302
+ async readScoped(ctx, fn) {
303
+ const subject = this.rlsRole === undefined ? undefined : subjectOf(ctx);
304
+ if (subject === undefined)
305
+ return fn(ctx.client ?? ctx.pool);
306
+ const role = this.rlsRole;
307
+ if (ctx.client !== undefined)
308
+ return asRlsRole(ctx.client, role, subject, () => fn(ctx.client));
309
+ const client = await ctx.pool.connect();
310
+ try {
311
+ await client.query('BEGIN');
312
+ const result = await asRlsRole(client, role, subject, () => fn(client));
313
+ await client.query('COMMIT');
314
+ return result;
315
+ }
316
+ catch (error) {
317
+ await client.query('ROLLBACK').catch(() => { });
318
+ throw error;
319
+ }
320
+ finally {
321
+ client.release();
322
+ }
323
+ }
324
+ /** {@link readScoped} for a client already inside the write transaction */
325
+ underRls(client, ctx, fn) {
326
+ const subject = this.rlsRole === undefined ? undefined : subjectOf(ctx);
327
+ return subject === undefined ? fn() : asRlsRole(client, this.rlsRole, subject, fn);
279
328
  }
280
329
  /**
281
330
  * Run the read hook (`onLoad`) over a fetched batch. The hook receives the
@@ -353,9 +402,9 @@ export class DefaultObjectDataAccess {
353
402
  }
354
403
  async find(objectName, opts, ctx) {
355
404
  const def = requireDef(ctx, objectName);
356
- const q = ctx.client ?? ctx.pool;
357
405
  const pks = primaryFieldsOf(def);
358
406
  const budget = ctx.budget ?? this.budget;
407
+ const totalMode = opts.total ?? this.totalMode;
359
408
  try {
360
409
  assertQueryBudget(opts, budget, ctx.locale);
361
410
  }
@@ -369,6 +418,21 @@ export class DefaultObjectDataAccess {
369
418
  throw error;
370
419
  }
371
420
  const { limit } = resolvePagination(opts, budget.maxRows);
421
+ const noneTotal = totalMode === TOTAL_MODES.NONE;
422
+ // `total` per the effective mode: exact COUNT(*), a reltuples estimate, or
423
+ // nothing (none). Counts run on the same (RLS-scoped) connection as the page.
424
+ const countTotal = async (db, buildCtx) => {
425
+ if (noneTotal)
426
+ return undefined;
427
+ if (totalMode === TOTAL_MODES.ESTIMATE) {
428
+ const { sql, params } = buildEstimateSql(def);
429
+ const { rows } = await runTableQuery(db, objectName, sql, params, ctx.locale);
430
+ return rows[0]?.total;
431
+ }
432
+ const { sql, params } = buildCountSql(def, opts, buildCtx, ctx.rowScope);
433
+ const { rows } = await runTableQuery(db, objectName, sql, params, ctx.locale);
434
+ return rows[0].total;
435
+ };
372
436
  // keyset cursor (single primary key): `pk > cursor` ordered by pk. A
373
437
  // caller-provided sort is ignored in cursor mode (documented) so pages
374
438
  // traverse one deterministic sequence.
@@ -385,17 +449,18 @@ export class DefaultObjectDataAccess {
385
449
  const meta = needsSideTable(pageOpts) ? { table: recordMetaTableName(def.name) } : undefined;
386
450
  const buildCtx = { ...bctx(ctx, objectName), ...(meta === undefined ? {} : { meta }) };
387
451
  const { sql, params } = buildFindSql(def, pageOpts, buildCtx, ctx.rowScope, pageOpts.exclude, undefined, budget.maxRows + 1);
388
- const { rows } = await runTableQuery(q, objectName, sql, params, ctx.locale);
452
+ const [{ rows }, total] = await this.readScoped(ctx, async (db) => [
453
+ await runTableQuery(db, objectName, sql, params, ctx.locale),
454
+ await countTotal(db, buildCtx),
455
+ ]);
389
456
  const hasMore = rows.length > limit;
390
457
  const pageRows = hasMore ? rows.slice(0, limit) : rows;
391
458
  const last = pageRows[pageRows.length - 1];
392
459
  const nextCursor = hasMore && last !== undefined ? encodeCursor([last[pkName]]) : undefined;
393
- const { sql: countSql, params: countParams } = buildCountSql(def, opts, buildCtx, ctx.rowScope);
394
- const { rows: countRows } = await runTableQuery(q, objectName, countSql, countParams, ctx.locale);
395
460
  const loaded = await this.runOnLoad(objectName, pageRows, ctx);
396
461
  return {
397
462
  rows: loaded,
398
- total: countRows[0].total,
463
+ ...(total === undefined ? {} : { total }),
399
464
  hasMore,
400
465
  ...(nextCursor === undefined ? {} : { nextCursor }),
401
466
  };
@@ -404,13 +469,19 @@ export class DefaultObjectDataAccess {
404
469
  // LEFT JOIN it and select/filter/sort them in SQL (never customer columns)
405
470
  const meta = needsSideTable(opts) ? { table: recordMetaTableName(def.name) } : undefined;
406
471
  const buildCtx = { ...bctx(ctx, objectName), ...(meta === undefined ? {} : { meta }) };
407
- const { sql, params } = buildFindSql(def, opts, buildCtx, ctx.rowScope, opts.exclude, undefined, budget.maxRows);
408
- const { rows } = await runTableQuery(q, objectName, sql, params, ctx.locale);
409
- const { sql: countSql, params: countParams } = buildCountSql(def, opts, buildCtx, ctx.rowScope);
410
- const { rows: countRows } = await runTableQuery(q, objectName, countSql, countParams, ctx.locale);
411
- const total = countRows[0].total;
412
- const loaded = await this.runOnLoad(objectName, rows, ctx);
413
- return { rows: loaded, total, hasMore: (opts.offset ?? 0) + rows.length < total };
472
+ // with `total: 'none'` there is no count query to derive `hasMore`, so fetch
473
+ // one extra row (like cursor mode) and slice it off
474
+ const findOpts = noneTotal ? { ...opts, limit: limit + 1 } : opts;
475
+ const { sql, params } = buildFindSql(def, findOpts, buildCtx, ctx.rowScope, findOpts.exclude, undefined, noneTotal ? budget.maxRows + 1 : budget.maxRows);
476
+ const [{ rows }, total] = await this.readScoped(ctx, async (db) => [
477
+ await runTableQuery(db, objectName, sql, params, ctx.locale),
478
+ await countTotal(db, buildCtx),
479
+ ]);
480
+ const offset = opts.offset ?? 0;
481
+ const hasMore = noneTotal ? rows.length > limit : offset + rows.length < (total ?? 0);
482
+ const pageRows = noneTotal && hasMore ? rows.slice(0, limit) : rows;
483
+ const loaded = await this.runOnLoad(objectName, pageRows, ctx);
484
+ return { rows: loaded, ...(total === undefined ? {} : { total }), hasMore };
414
485
  }
415
486
  async findOne(objectName, id, ctx) {
416
487
  const def = requireDef(ctx, objectName);
@@ -425,22 +496,25 @@ export class DefaultObjectDataAccess {
425
496
  const result = await this.find(objectName, { filter: pkFilter(def, pkValuesOf(def, id, ctx)), limit: 1, fields: [...cols, RECORD_META_ID_FIELD] }, ctx);
426
497
  return result.rows[0] ?? null;
427
498
  }
428
- /**
429
- * Guardrail/approval gate for a data-access write (create/update/delete).
430
- * Runs only when policies are configured (`policies=[]` → zero overhead) and
431
- * is protocol-agnostic (action `object.<object>.<op>`), so REST / MCP /
432
- * GraphQL / script writes are all covered from this single choke point.
433
- * Deny throws; a `requiresApproval` policy throws with the approval key.
434
- */
435
- async guardWrite(op, objectName, ctx, args) {
436
- if (this.policies.length === 0)
437
- return;
438
- const action = `object.${objectName}.${op}`;
499
+ /** pipeline context (actor/subject/correlation) for a governed write */
500
+ pipelineCtx(ctx) {
439
501
  const subject = subjectOf(ctx);
440
502
  const actorId = principalActorId(ctx.principal);
441
- const guardrailCtx = {
503
+ const schemaRevision = ctx.schemaRevision ?? this.schemaRevision;
504
+ return {
442
505
  actor: { key: actorId, label: subject?.id ?? 'system', onBehalfOf: subject?.id ?? actorId },
443
506
  subject: subject ?? { id: 'system', roles: [] },
507
+ ...(ctx.requestId === undefined ? {} : { requestId: ctx.requestId }),
508
+ ...(ctx.traceId === undefined ? {} : { traceId: ctx.traceId }),
509
+ ...(schemaRevision === undefined ? {} : { schemaRevision }),
510
+ ...(ctx.locale === undefined ? {} : { locale: ctx.locale }),
511
+ };
512
+ }
513
+ /** guardrail + approval decision for `action` (shared by writes and transitions) */
514
+ async decide(action, args, ctx, pctx, requireApproval) {
515
+ const guardrailCtx = {
516
+ actor: pctx.actor,
517
+ subject: pctx.subject,
444
518
  action,
445
519
  args,
446
520
  dataAccess: toolDataAccessOver(this, {
@@ -451,55 +525,67 @@ export class DefaultObjectDataAccess {
451
525
  ...(ctx.client === undefined ? {} : { client: ctx.client }),
452
526
  }),
453
527
  };
454
- const gate = await evaluateTransition(this.policies, this.approvals, guardrailCtx, false);
455
- if (this.evidence !== undefined) {
456
- const now = new Date();
457
- const schemaRevision = ctx.schemaRevision ?? this.schemaRevision;
458
- const outcome = gate.kind === 'deny'
459
- ? EXECUTION_OUTCOMES.DENY
460
- : gate.kind === 'pending'
461
- ? EXECUTION_OUTCOMES.PENDING
462
- : EXECUTION_OUTCOMES.OK;
463
- const stages = [
464
- { stage: EXECUTION_STAGES.PLAN, outcome: EXECUTION_OUTCOMES.OK, at: now },
465
- { stage: EXECUTION_STAGES.GUARDRAIL, outcome, at: now },
466
- ...(gate.kind === 'pending'
467
- ? [{ stage: EXECUTION_STAGES.APPROVAL, outcome: EXECUTION_OUTCOMES.PENDING, at: now }]
468
- : []),
469
- ];
470
- void this.evidence
471
- .record({
472
- ...(ctx.requestId === undefined ? {} : { requestId: ctx.requestId }),
473
- ...(ctx.traceId === undefined ? {} : { traceId: ctx.traceId }),
474
- ...(schemaRevision === undefined ? {} : { schemaRevision }),
475
- actor: guardrailCtx.actor,
476
- ...(subject === undefined ? {} : { subjectId: subject.id }),
477
- plan: { action, objectName, args },
478
- stages,
479
- ...(gate.kind === 'pending' ? { approvalKey: gate.approvalKey } : {}),
480
- isError: gate.kind !== 'allow',
481
- ...(gate.kind === 'deny'
482
- ? { errorCode: gate.code }
483
- : gate.kind === 'pending'
484
- ? { errorCode: 'mcp.approval.pending' }
485
- : {}),
486
- timestamp: now,
487
- })
488
- .catch(() => { });
489
- }
490
- if (gate.kind === 'deny') {
491
- throw new SchemaError(gate.code, { object: objectName, action, reason: gate.reason ?? '' }, ctx.locale);
492
- }
493
- if (gate.kind === 'pending') {
494
- throw new SchemaError('object.action.pending', { object: objectName, action, approvalKey: gate.approvalKey }, ctx.locale);
528
+ const gate = await evaluateTransition(this.policies, this.approvals, guardrailCtx, requireApproval);
529
+ if (gate.kind === 'deny')
530
+ return { kind: 'deny', reason: gate.reason ?? '', errorCode: gate.code };
531
+ return gate;
532
+ }
533
+ /**
534
+ * Run a write (create/update/delete/transition) through the execution
535
+ * pipeline: guardrail/approval gate (when policies or `requireApproval` apply)
536
+ * → execute → one evidence row (when evidence is enabled). Protocol-agnostic
537
+ * (action `object.<object>.<op>` / `workflow.transition.<object>.<action>`),
538
+ * so REST / MCP / GraphQL / script writes share this single choke point.
539
+ * Without policies, approval or evidence the write runs directly.
540
+ */
541
+ async governed(plan, ctx, opts, execute) {
542
+ const gated = this.policies.length > 0 || opts.requireApproval !== undefined;
543
+ if (!gated && this.evidence === undefined)
544
+ return execute();
545
+ const reject = async (error) => {
546
+ await opts.onReject?.(error);
547
+ throw error;
548
+ };
549
+ if (opts.preflight !== undefined)
550
+ await opts.preflight().catch(reject);
551
+ const pctx = this.pipelineCtx(ctx);
552
+ const result = await runPipeline(plan, pctx, {
553
+ ...(gated
554
+ ? {
555
+ gate: async () => this.decide(plan.action, plan.args, ctx, pctx, (await opts.requireApproval?.()) ?? false),
556
+ }
557
+ : {}),
558
+ execute,
559
+ ...(this.evidence === undefined ? {} : { evidence: this.evidence }),
560
+ });
561
+ const params = { object: plan.objectName ?? '', action: opts.actionParam ?? plan.action };
562
+ if (result.outcome === EXECUTION_OUTCOMES.OK)
563
+ return result.result;
564
+ if (result.outcome === EXECUTION_OUTCOMES.PENDING) {
565
+ return reject(new SchemaError(opts.pendingCode, { ...params, approvalKey: result.approvalKey ?? '' }, ctx.locale));
566
+ }
567
+ if (result.outcome === EXECUTION_OUTCOMES.DENY) {
568
+ return reject(new SchemaError((result.errorCode ?? 'mcp.policy.denied'), { ...params, reason: result.reason ?? '' }, ctx.locale));
495
569
  }
570
+ throw result.error;
496
571
  }
497
572
  async create(objectName, data, ctx) {
573
+ requireDef(ctx, objectName);
574
+ return this.governed({ action: `object.${objectName}.create`, objectName, args: data }, ctx, { pendingCode: 'object.action.pending' }, () => this.doCreate(objectName, data, ctx));
575
+ }
576
+ async update(objectName, id, changes, ctx) {
577
+ validateId(requireDef(ctx, objectName), id, ctx);
578
+ return this.governed({ action: `object.${objectName}.update`, objectName, objectId: id, args: changes }, ctx, { pendingCode: 'object.action.pending' }, () => this.doUpdate(objectName, id, changes, ctx));
579
+ }
580
+ async delete(objectName, id, ctx) {
581
+ validateId(requireDef(ctx, objectName), id, ctx);
582
+ return this.governed({ action: `object.${objectName}.delete`, objectName, objectId: id, args: {} }, ctx, { pendingCode: 'object.action.pending' }, () => this.doDelete(objectName, id, ctx));
583
+ }
584
+ async doCreate(objectName, data, ctx) {
498
585
  const def = requireDef(ctx, objectName);
499
586
  if (primaryNames(def).length === 0) {
500
587
  throw new SchemaError('data.recordNotFound', { object: objectName }, ctx.locale);
501
588
  }
502
- await this.guardWrite('create', objectName, ctx, data);
503
589
  let client;
504
590
  let owned = false;
505
591
  if (ctx.client !== undefined) {
@@ -614,13 +700,12 @@ export class DefaultObjectDataAccess {
614
700
  client.release();
615
701
  }
616
702
  }
617
- async update(objectName, id, changes, ctx) {
703
+ async doUpdate(objectName, id, changes, ctx) {
618
704
  const def = requireDef(ctx, objectName);
619
705
  if (primaryNames(def).length === 0) {
620
706
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
621
707
  }
622
708
  const pkValues = pkValuesOf(def, id, ctx);
623
- await this.guardWrite('update', objectName, ctx, changes);
624
709
  let client;
625
710
  let owned = false;
626
711
  if (ctx.client !== undefined) {
@@ -636,7 +721,7 @@ export class DefaultObjectDataAccess {
636
721
  await client.query('BEGIN');
637
722
  await validateRecord(def, changes, WRITE_MODES.UPDATE, { pool: client, registry: ctx.registry, locale: ctx.locale });
638
723
  const { sql, params } = buildFindSql(def, { filter: pkFilter(def, pkValues), limit: 1 }, bctx(ctx, objectName), ctx.rowScope, undefined, [recordKeySelect(def)]);
639
- const { rows } = await runTableQuery(client, objectName, sql, params, ctx.locale);
724
+ const { rows } = await this.underRls(client, ctx, () => runTableQuery(client, objectName, sql, params, ctx.locale));
640
725
  const raw = rows[0];
641
726
  if (raw === undefined) {
642
727
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
@@ -692,7 +777,9 @@ export class DefaultObjectDataAccess {
692
777
  const setSql = cols.map((c, i) => `${q(c)} = $${i + 1}`).join(', ');
693
778
  const scope = ctx.rowScope !== undefined ? scopeSuffix(ctx.rowScope, cols.length + pkValues.length) : undefined;
694
779
  const values = cols.map((c) => dbJsonValue(def.fields.find((f) => f.name === c), record[c] ?? null));
695
- await runTableQuery(client, objectName, `UPDATE ${q(def.name)} SET ${setSql} WHERE ${pkWhereSql(def, cols.length + 1)}${scope !== undefined ? ` AND (${scope.sql})` : ''}`, [...values, ...pkValues, ...(scope?.params ?? [])], ctx.locale);
780
+ const res = await this.underRls(client, ctx, () => runTableQuery(client, objectName, `UPDATE ${q(def.name)} SET ${setSql} WHERE ${pkWhereSql(def, cols.length + 1)}${scope !== undefined ? ` AND (${scope.sql})` : ''}`, [...values, ...pkValues, ...(scope?.params ?? [])], ctx.locale));
781
+ if (res.rowCount === 0)
782
+ throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
696
783
  }
697
784
  await this.writeRecordMeta(client, def, metaKey, ctx, now, false);
698
785
  await replaceLinks(client, def, pkValues, payload, ctx.registry, ctx.locale);
@@ -731,13 +818,51 @@ export class DefaultObjectDataAccess {
731
818
  */
732
819
  async transition(objectName, id, action, ctx, payload) {
733
820
  const def = requireDef(ctx, objectName);
734
- if (def.workflow === undefined) {
821
+ const workflow = def.workflow;
822
+ if (workflow === undefined) {
735
823
  throw new SchemaError('workflow.transition.unknown', { object: objectName, action }, ctx.locale);
736
824
  }
737
825
  if (primaryNames(def).length === 0) {
738
826
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
739
827
  }
740
828
  const pkValues = pkValuesOf(def, id, ctx);
829
+ // a policy self-filters on `workflow.transition.<object>.<action>`; a node may
830
+ // also declare `requiresApproval` (deny → error; requireApproval → pending)
831
+ const approvalNodes = workflow.nodes.some((n) => n.requiresApproval === true);
832
+ return this.governed({ action: `workflow.transition.${def.name}.${action}`, objectName, objectId: id, args: { object: def.name, id, action } }, ctx, {
833
+ pendingCode: 'workflow.transition.pending',
834
+ actionParam: action,
835
+ preflight: async () => {
836
+ const [sql, params] = this.transitionTargetSql(def, pkValues, action, ctx);
837
+ const { rows } = action === WORKFLOW_ACTIONS.SUBMIT
838
+ ? await this.readScoped(ctx, (db) => runTableQuery(db, objectName, sql, params, ctx.locale))
839
+ : await runTableQuery(ctx.client ?? ctx.pool, objectName, sql, params, ctx.locale);
840
+ if (rows[0] === undefined)
841
+ throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
842
+ },
843
+ onReject: (error) => auditWrite(this.audit, ctx, DATA_ACTIONS.TRANSITION, objectName, id, { action }, error),
844
+ ...(approvalNodes
845
+ ? {
846
+ requireApproval: async () => {
847
+ const status = await getWorkflowStatus(ctx.client ?? ctx.pool, def, id, subjectOf(ctx));
848
+ return workflow.nodes.find((n) => n.id === status.node)?.requiresApproval === true;
849
+ },
850
+ }
851
+ : {}),
852
+ }, () => this.doTransition(def, id, pkValues, action, ctx, payload));
853
+ }
854
+ /**
855
+ * Existence check for a transition target. Submit is an owner action → the
856
+ * caller's manage scope; the other actions are authorized by workitem
857
+ * assignment / originator, not by row scope.
858
+ */
859
+ transitionTargetSql(def, pkValues, action, ctx) {
860
+ const { sql, params } = buildFindSql(def, { filter: pkFilter(def, pkValues), limit: 1 }, bctx(ctx, def.name), action === WORKFLOW_ACTIONS.SUBMIT ? ctx.rowScope : undefined, undefined, [recordKeySelect(def)]);
861
+ return [sql, params];
862
+ }
863
+ async doTransition(def, id, pkValues, action, ctx, payload) {
864
+ const objectName = def.name;
865
+ const workflow = def.workflow;
741
866
  let client;
742
867
  let owned = false;
743
868
  if (ctx.client !== undefined) {
@@ -751,41 +876,12 @@ export class DefaultObjectDataAccess {
751
876
  if (owned)
752
877
  await client.query('BEGIN');
753
878
  // the customer record must exist (transitions never create records)
754
- const { sql, params } = buildFindSql(def, { filter: pkFilter(def, pkValues), limit: 1 }, bctx(ctx, objectName), undefined, undefined, [recordKeySelect(def)]);
755
- const found = await runTableQuery(client, objectName, sql, params, ctx.locale);
879
+ const [sql, params] = this.transitionTargetSql(def, pkValues, action, ctx);
880
+ const lookup = () => runTableQuery(client, objectName, sql, params, ctx.locale);
881
+ const found = action === WORKFLOW_ACTIONS.SUBMIT ? await this.underRls(client, ctx, lookup) : await lookup();
756
882
  if (found.rows[0] === undefined) {
757
883
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
758
884
  }
759
- // guardrail policy + approval gate (shared policy set with the open contract):
760
- // a policy self-filters on `workflow.transition.<object>.<action>`; a node may
761
- // also declare `requiresApproval`. deny → error; requireApproval → pending.
762
- const currentNode = await getWorkflowStatus(client, def, id, subjectOf(ctx));
763
- const nodeDef = currentNode.node === undefined ? undefined : def.workflow.nodes.find((n) => n.id === currentNode.node);
764
- const requireApproval = nodeDef?.requiresApproval === true;
765
- if (this.policies.length > 0 || requireApproval) {
766
- const subject = subjectOf(ctx);
767
- const actorId = subject?.id ?? principalActorId(ctx.principal);
768
- const guardrailCtx = {
769
- actor: { key: actorId, label: actorId, onBehalfOf: actorId },
770
- subject: subject ?? { id: 'system', roles: [] },
771
- action: `workflow.transition.${def.name}.${action}`,
772
- args: { object: def.name, id, action },
773
- dataAccess: toolDataAccessOver(this, {
774
- pool: ctx.pool,
775
- registry: ctx.registry,
776
- principal: ctx.principal,
777
- ...(ctx.locale === undefined ? {} : { locale: ctx.locale }),
778
- ...(ctx.client === undefined ? {} : { client: ctx.client }),
779
- }),
780
- };
781
- const gate = await evaluateTransition(this.policies, this.approvals, guardrailCtx, requireApproval);
782
- if (gate.kind === 'deny') {
783
- throw new SchemaError(gate.code, { object: objectName, action, reason: gate.reason ?? '' }, ctx.locale);
784
- }
785
- if (gate.kind === 'pending') {
786
- throw new SchemaError('workflow.transition.pending', { object: objectName, action, approvalKey: gate.approvalKey }, ctx.locale);
787
- }
788
- }
789
885
  const outcome = await runWorkflowTransition(client, def, id, action, subjectOf(ctx), payload, ctx.locale);
790
886
  const transitionChanges = {
791
887
  action,
@@ -803,7 +899,7 @@ export class DefaultObjectDataAccess {
803
899
  await auditWrite(this.audit, ctx, DATA_ACTIONS.TRANSITION, objectName, id, transitionChanges);
804
900
  }
805
901
  this.events?.publishRecordChange('updated', objectName, id);
806
- this.events?.publishRecordTransitioned(objectName, id, outcome.fromNodeId ?? '', outcome.toNodeId ?? '', action, def.workflow.version, def.workflowHash);
902
+ this.events?.publishRecordTransitioned(objectName, id, outcome.fromNodeId ?? '', outcome.toNodeId ?? '', action, workflow.version, def.workflowHash);
807
903
  if (outcome.nodeId !== undefined) {
808
904
  await this.workflowTimers?.sync(objectName, id, outcome.nodeId).catch(() => { });
809
905
  }
@@ -841,8 +937,34 @@ export class DefaultObjectDataAccess {
841
937
  if (def.workflow === undefined) {
842
938
  throw new SchemaError('workflow.transition.unknown', { object: objectName, action: 'status' }, ctx.locale);
843
939
  }
940
+ await this.assertWorkflowVisible(def, id, ctx);
844
941
  return getWorkflowStatus(ctx.client ?? ctx.pool, def, id, subjectOf(ctx));
845
942
  }
943
+ /**
944
+ * A record's workflow is visible when the record is inside the caller's read
945
+ * scope, or the caller participates (originator / any workitem) — an assigned
946
+ * approver may sit outside the record's row scope.
947
+ */
948
+ async assertWorkflowVisible(def, id, ctx) {
949
+ if (ctx.rowScope === undefined)
950
+ return;
951
+ const db = ctx.client ?? ctx.pool;
952
+ const pkValues = pkValuesOf(def, id, ctx);
953
+ const { sql, params } = buildFindSql(def, { filter: pkFilter(def, pkValues), limit: 1 }, bctx(ctx, def.name), ctx.rowScope);
954
+ const { rows } = await this.readScoped(ctx, (scoped) => runTableQuery(scoped, def.name, sql, params, ctx.locale));
955
+ if (rows.length > 0)
956
+ return;
957
+ const subject = subjectOf(ctx);
958
+ if (subject !== undefined) {
959
+ const res = await db.query(`SELECT 1 FROM ${q(SYSTEM_TABLES.WORKFLOW_INSTANCES)} WHERE object = $1 AND record_key = $2 AND originator = $3
960
+ UNION ALL
961
+ SELECT 1 FROM ${q(SYSTEM_TABLES.WORKFLOW_WORKITEMS)} WHERE object = $1 AND record_key = $2 AND participant = $3
962
+ LIMIT 1`, [def.name, id, subject.id]);
963
+ if (res.rows.length > 0)
964
+ return;
965
+ }
966
+ throw new SchemaError('data.recordNotFound', { object: def.name, id }, ctx.locale);
967
+ }
846
968
  /** Acquire/renew the caller's presence lock on the current step (TTL lease). */
847
969
  async acquireWorkflowLock(objectName, id, ctx) {
848
970
  const def = requireDef(ctx, objectName);
@@ -859,6 +981,7 @@ export class DefaultObjectDataAccess {
859
981
  if (def.workflow === undefined) {
860
982
  throw new SchemaError('workflow.transition.unknown', { object: objectName, action: 'history' }, ctx.locale);
861
983
  }
984
+ await this.assertWorkflowVisible(def, id, ctx);
862
985
  return getWorkflowHistory(ctx.client ?? ctx.pool, def, id);
863
986
  }
864
987
  /** The subject's pending workitems across all objects. */
@@ -914,13 +1037,12 @@ export class DefaultObjectDataAccess {
914
1037
  ? { createdBy: actor, modifiedBy: actor, createdTime: now, modifiedTime: now }
915
1038
  : { modifiedBy: actor, modifiedTime: now });
916
1039
  }
917
- async delete(objectName, id, ctx) {
1040
+ async doDelete(objectName, id, ctx) {
918
1041
  const def = requireDef(ctx, objectName);
919
1042
  if (primaryNames(def).length === 0) {
920
1043
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
921
1044
  }
922
1045
  const pkValues = pkValuesOf(def, id, ctx);
923
- await this.guardWrite('delete', objectName, ctx, {});
924
1046
  let client;
925
1047
  let owned = false;
926
1048
  if (ctx.client !== undefined) {
@@ -935,7 +1057,7 @@ export class DefaultObjectDataAccess {
935
1057
  if (owned)
936
1058
  await client.query('BEGIN');
937
1059
  const { sql, params } = buildFindSql(def, { filter: pkFilter(def, pkValues), limit: 1 }, bctx(ctx, objectName), ctx.rowScope, undefined, [recordKeySelect(def)]);
938
- const { rows } = await runTableQuery(client, objectName, sql, params, ctx.locale);
1060
+ const { rows } = await this.underRls(client, ctx, () => runTableQuery(client, objectName, sql, params, ctx.locale));
939
1061
  const raw = rows[0];
940
1062
  if (raw === undefined) {
941
1063
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
@@ -946,7 +1068,7 @@ export class DefaultObjectDataAccess {
946
1068
  await this.runBeforeHook(SCRIPT_HOOKS.BEFORE_DELETE, objectName, existing, {}, ctx);
947
1069
  await deleteDetailsChildren(client, def, id, ctx.registry);
948
1070
  const scope = ctx.rowScope !== undefined ? scopeSuffix(ctx.rowScope, pkValues.length) : undefined;
949
- const res = await runTableQuery(client, objectName, `DELETE FROM ${q(def.name)} WHERE ${pkWhereSql(def, 1)}${scope !== undefined ? ` AND (${scope.sql})` : ''}`, [...pkValues, ...(scope?.params ?? [])], ctx.locale);
1071
+ const res = await this.underRls(client, ctx, () => runTableQuery(client, objectName, `DELETE FROM ${q(def.name)} WHERE ${pkWhereSql(def, 1)}${scope !== undefined ? ` AND (${scope.sql})` : ''}`, [...pkValues, ...(scope?.params ?? [])], ctx.locale));
950
1072
  if (res.rowCount === 0)
951
1073
  throw new SchemaError('data.recordNotFound', { object: objectName, id }, ctx.locale);
952
1074
  await deleteRecordMeta(client, def.name, metaKey);