@astram/engine 0.3.0 → 0.5.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 (62) hide show
  1. package/dist/decorators/access-bundle.decorator.d.ts.map +1 -1
  2. package/dist/decorators/object-type.decorator.d.ts +2 -0
  3. package/dist/decorators/object-type.decorator.d.ts.map +1 -1
  4. package/dist/decorators/object-type.decorator.js +3 -0
  5. package/dist/index.d.ts +8 -6
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +6 -6
  8. package/dist/internal.d.ts +10 -0
  9. package/dist/internal.d.ts.map +1 -0
  10. package/dist/internal.js +13 -0
  11. package/dist/pipe/context.d.ts +17 -6
  12. package/dist/pipe/context.d.ts.map +1 -1
  13. package/dist/pipe/record-engine.d.ts +38 -5
  14. package/dist/pipe/record-engine.d.ts.map +1 -1
  15. package/dist/pipe/record-engine.js +210 -75
  16. package/dist/pipe/records-service.d.ts +18 -1
  17. package/dist/pipe/records-service.d.ts.map +1 -1
  18. package/dist/pipe/records-service.js +42 -6
  19. package/dist/pipe/stages/audit.d.ts.map +1 -1
  20. package/dist/pipe/stages/derive-write-context.d.ts.map +1 -1
  21. package/dist/pipe/stages/derive-write-context.js +6 -0
  22. package/dist/pipe/stages/fetch-record.d.ts +56 -0
  23. package/dist/pipe/stages/fetch-record.d.ts.map +1 -0
  24. package/dist/pipe/stages/fetch-record.js +103 -0
  25. package/dist/pipe/stages/permission-gate.d.ts +29 -0
  26. package/dist/pipe/stages/permission-gate.d.ts.map +1 -1
  27. package/dist/pipe/stages/permission-gate.js +80 -22
  28. package/dist/pipe/stages/write.d.ts.map +1 -1
  29. package/dist/pipe/stages/write.js +75 -52
  30. package/dist/pipe/where-by-id.d.ts +33 -0
  31. package/dist/pipe/where-by-id.d.ts.map +1 -0
  32. package/dist/pipe/where-by-id.js +69 -0
  33. package/dist/registry.d.ts +8 -1
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +25 -1
  36. package/dist/types.d.ts +59 -3
  37. package/dist/types.d.ts.map +1 -1
  38. package/package.json +13 -2
  39. package/dist/adapters/mariadb-audit-sink.js.map +0 -1
  40. package/dist/adapters/mariadb-unit-of-work.js.map +0 -1
  41. package/dist/decorators/access-bundle.decorator.js.map +0 -1
  42. package/dist/decorators/object-type.decorator.js.map +0 -1
  43. package/dist/errors.js.map +0 -1
  44. package/dist/index.js.map +0 -1
  45. package/dist/module/engine.module.js.map +0 -1
  46. package/dist/module/tokens.js.map +0 -1
  47. package/dist/pipe/context.js.map +0 -1
  48. package/dist/pipe/record-engine.js.map +0 -1
  49. package/dist/pipe/records-service.js.map +0 -1
  50. package/dist/pipe/stages/audit.js.map +0 -1
  51. package/dist/pipe/stages/derive-write-context.js.map +0 -1
  52. package/dist/pipe/stages/fetch-owner.d.ts +0 -28
  53. package/dist/pipe/stages/fetch-owner.d.ts.map +0 -1
  54. package/dist/pipe/stages/fetch-owner.js +0 -99
  55. package/dist/pipe/stages/fetch-owner.js.map +0 -1
  56. package/dist/pipe/stages/junction-sync.js.map +0 -1
  57. package/dist/pipe/stages/permission-gate.js.map +0 -1
  58. package/dist/pipe/stages/validate.js.map +0 -1
  59. package/dist/pipe/stages/write.js.map +0 -1
  60. package/dist/ports.js.map +0 -1
  61. package/dist/registry.js.map +0 -1
  62. package/dist/types.js.map +0 -1
@@ -4,23 +4,27 @@
4
4
  *
5
5
  * Every write (create/update/delete) runs the FIXED, ordered stage sequence inside
6
6
  * ONE transaction:
7
- * permission-gate (MANDATORY) → derive-write-context → validate → compute (no-op
8
- * Phase 1) → write → junction-sync → audit (MANDATORY) → emit-event (no-op).
7
+ * type-pre-gate (update/delete) → fetch-record (update/delete) → permission-gate (MANDATORY) →
8
+ * derive-write-context →
9
+ * validate → compute (no-op Phase 1) → write → junction-sync → audit (MANDATORY) →
10
+ * emit-event (no-op).
9
11
  * A caller cannot reorder, add, or drop stages — the pipe shape is fixed here, so
10
12
  * the security gate + audit ALWAYS run (fixes ORS's optional gate + off-road writes).
11
13
  * Any stage throwing aborts the transaction (rollback) — fail-closed.
12
14
  *
13
- * Reads (get/list) run a lean read path: permission-gate (read) → query → project.
14
- * (Row/column access-filtering + ref/label projection are Phase-2 enrichments; the
15
- * Phase-1 read still gates on decide() and never surfaces raw internal ids as labels.)
15
+ * Reads: get() is fetch → gate (on the row's fields + tenant) → project the same row;
16
+ * list() is gate (object type) → SQL-scoped query (list scope fragment, no fetch-then-
17
+ * discard) → project, ordered with a PK tiebreaker. (Column projection is still pending, G2.)
16
18
  */
17
19
  Object.defineProperty(exports, "__esModule", { value: true });
18
20
  exports.RecordEngineImpl = void 0;
21
+ const node_async_hooks_1 = require("node:async_hooks");
19
22
  const errors_js_1 = require("../errors.js");
20
23
  const registry_js_1 = require("../registry.js");
21
24
  const types_js_1 = require("../types.js");
22
25
  const context_js_1 = require("./context.js");
23
- const fetch_owner_js_1 = require("./stages/fetch-owner.js");
26
+ const fetch_record_js_1 = require("./stages/fetch-record.js");
27
+ const where_by_id_js_1 = require("./where-by-id.js");
24
28
  const permission_gate_js_1 = require("./stages/permission-gate.js");
25
29
  const derive_write_context_js_1 = require("./stages/derive-write-context.js");
26
30
  const validate_js_1 = require("./stages/validate.js");
@@ -28,6 +32,16 @@ const write_js_1 = require("./stages/write.js");
28
32
  const junction_sync_js_1 = require("./stages/junction-sync.js");
29
33
  const audit_js_1 = require("./stages/audit.js");
30
34
  const errors_js_2 = require("../errors.js");
35
+ /** Fail closed when a transaction handle outlives its transaction or is already doomed. */
36
+ function assertUsable(tx) {
37
+ if (!tx.open) {
38
+ throw new errors_js_2.EngineUnavailableError('record transaction has already ended; a transaction handle cannot be used after its ' +
39
+ 'transaction(fn) settled.');
40
+ }
41
+ if (tx.failure !== undefined) {
42
+ throw new errors_js_2.EngineUnavailableError('record transaction is rollback-only: an earlier write inside it failed.');
43
+ }
44
+ }
31
45
  class RecordEngineImpl {
32
46
  registry;
33
47
  uow;
@@ -38,6 +52,8 @@ class RecordEngineImpl {
38
52
  listScope;
39
53
  /** The FIXED write-stage order. Built once; identical for every write. */
40
54
  writeStages;
55
+ /** The ambient `transaction(fn)` (ADR-0034) so plain calls made inside `fn` join it. */
56
+ activeTx = new node_async_hooks_1.AsyncLocalStorage();
41
57
  constructor(deps) {
42
58
  this.registry = deps.registry;
43
59
  this.uow = deps.uow;
@@ -47,11 +63,14 @@ class RecordEngineImpl {
47
63
  this.onRecordDeleted = deps.onRecordDeleted;
48
64
  this.listScope = deps.listScope;
49
65
  // permission-gate + audit are ALWAYS present, in this order — no skip flag.
50
- // fetch-owner runs FIRST (before the gate) so an OWNED update/delete's existing-row
51
- // owner is available to decide() as context.attributes.ownerId (core owner baseline).
52
- // It no-ops for create + non-owned types (no unnecessary query).
66
+ // fetch-record runs FIRST (before the gate): for update/delete on EVERY type it row-locks
67
+ // and reads the existing row through the shared whereById, so decide() sees the record's
68
+ // fields, owner and tenant (block D, #10). A missing row throws there. No-op for create.
69
+ // type-pre-gate runs before it (Phase 8 #7): a coarse object-type decide() so an actor with
70
+ // no permission on the type is denied before it can take any row lock. No-op for create.
53
71
  this.writeStages = [
54
- (0, fetch_owner_js_1.fetchOwnerStage)(),
72
+ (0, permission_gate_js_1.typePreGateStage)(this.decision),
73
+ (0, fetch_record_js_1.fetchRecordStage)(),
55
74
  (0, permission_gate_js_1.permissionGateStage)(this.decision),
56
75
  (0, derive_write_context_js_1.deriveWriteContextStage)(),
57
76
  (0, validate_js_1.validateStage)(),
@@ -62,10 +81,100 @@ class RecordEngineImpl {
62
81
  // emit-event stage: Phase-1 no-op (business rules land later)
63
82
  ];
64
83
  }
65
- async runWrite(op, typeKey, actor, opts) {
84
+ // --- transactions (ADR-0034) ---------------------------------------------------
85
+ /**
86
+ * Run `fn` in ONE unit of work: every op on the handle, and every plain call on this engine
87
+ * made from inside `fn` (found via AsyncLocalStorage), shares the transaction. Each op still
88
+ * runs its full pipe. Commit on resolve, rollback on throw. ROLLBACK-ONLY once any write op
89
+ * (or a nested `fn`) inside it has failed, even if the caller swallowed the error — so the
90
+ * INSERT of a create the wall denied can never be committed by a careless `catch`. Nested calls
91
+ * reuse the outer transaction; a handle used after its transaction ended fails closed.
92
+ */
93
+ async transaction(fn) {
94
+ return this.transactionIn(undefined, fn);
95
+ }
96
+ async transactionIn(bound, fn) {
97
+ const outer = bound ?? this.activeTx.getStore();
98
+ if (outer !== undefined) {
99
+ // NESTED: reuse the outer unit of work. A throw out of the nested fn marks the outer
100
+ // transaction rollback-only (its writes must not commit just because an outer catch ran).
101
+ assertUsable(outer);
102
+ try {
103
+ return await fn(this.handleFor(outer));
104
+ }
105
+ catch (err) {
106
+ outer.failure ??= err;
107
+ throw err;
108
+ }
109
+ }
110
+ return this.uow.run(async (query) => {
111
+ const state = { query, open: true, failure: undefined };
112
+ try {
113
+ const out = await this.activeTx.run(state, () => fn(this.handleFor(state)));
114
+ // Rollback-only: a write failed inside fn and the caller swallowed it ⇒ re-raise it so
115
+ // the unit of work rolls back instead of committing a partial write.
116
+ if (state.failure !== undefined)
117
+ throw state.failure;
118
+ return out;
119
+ }
120
+ finally {
121
+ state.open = false;
122
+ }
123
+ });
124
+ }
125
+ /** The transaction-bound engine handle passed to `transaction(fn)`. */
126
+ handleFor(state) {
127
+ return {
128
+ create: (typeKey, input, actor) => this.createIn(state, typeKey, input, actor),
129
+ update: (typeKey, id, input, actor) => this.updateIn(state, typeKey, id, input, actor),
130
+ delete: (typeKey, id, actor) => this.deleteIn(state, typeKey, id, actor),
131
+ get: (typeKey, id, actor) => this.getIn(state, typeKey, id, actor),
132
+ list: (typeKey, query, actor) => this.listIn(state, typeKey, query, actor),
133
+ transaction: (fn) => this.transactionIn(state, fn),
134
+ lockForUpdate: (typeKey, id, actor) => this.lockIn(state, typeKey, id, actor),
135
+ };
136
+ }
137
+ /**
138
+ * Row lock on one record inside the open transaction (see `RecordEngine.lockForUpdate`). Keyed
139
+ * by the primary key only and returning nothing, so it reveals no data and needs no read gate.
140
+ */
141
+ async lockForUpdate(typeKey, id, actor) {
142
+ return this.lockIn(undefined, typeKey, id, actor);
143
+ }
144
+ async lockIn(bound, typeKey, id, _actor) {
145
+ const tx = bound ?? this.activeTx.getStore();
146
+ if (tx === undefined) {
147
+ throw new errors_js_2.EngineUnavailableError('lockForUpdate needs an open transaction(fn): a lock taken outside one is released at ' +
148
+ 'once and serialises nothing.');
149
+ }
150
+ assertUsable(tx);
151
+ const reg = this.registry.require(typeKey);
152
+ const where = (0, where_by_id_js_1.pkClause)(reg, id);
153
+ await tx.query(`SELECT 1 AS locked FROM ${this.qualified(reg)} WHERE ${where.sql} FOR UPDATE`, where.params);
154
+ }
155
+ /**
156
+ * Run one op's body in the right unit of work: the bound/ambient transaction when there is
157
+ * one, else a fresh `uow.run` (the original per-op transaction). A WRITE failure inside a
158
+ * shared transaction marks it rollback-only.
159
+ */
160
+ async inUnit(bound, write, body) {
161
+ const tx = bound ?? this.activeTx.getStore();
162
+ if (tx === undefined)
163
+ return this.uow.run(body);
164
+ assertUsable(tx);
165
+ try {
166
+ return await body(tx.query);
167
+ }
168
+ catch (err) {
169
+ if (write)
170
+ tx.failure ??= err;
171
+ throw err;
172
+ }
173
+ }
174
+ async runWrite(tx, op, typeKey, actor, opts) {
66
175
  const registration = this.registry.require(typeKey); // resolve-schema
67
176
  const nowMs = this.now();
68
- return this.uow.run(async (query) => {
177
+ return this.inUnit(tx, true, async (query) => {
69
178
  const ctx = (0, context_js_1.newWriteContext)({ op, actor, registration, query, nowMs }, {
70
179
  ...(opts.id !== undefined ? { id: opts.id } : {}),
71
180
  ...(opts.input !== undefined ? { input: opts.input } : {}),
@@ -90,32 +199,42 @@ class RecordEngineImpl {
90
199
  });
91
200
  }
92
201
  async create(typeKey, input, actor) {
93
- const id = await this.runWrite('create', typeKey, actor, { input });
202
+ return this.createIn(undefined, typeKey, input, actor);
203
+ }
204
+ async update(typeKey, id, input, actor) {
205
+ return this.updateIn(undefined, typeKey, id, input, actor);
206
+ }
207
+ async delete(typeKey, id, actor) {
208
+ return this.deleteIn(undefined, typeKey, id, actor);
209
+ }
210
+ async createIn(tx, typeKey, input, actor) {
211
+ const id = await this.runWrite(tx, 'create', typeKey, actor, { input });
94
212
  if (id === undefined)
95
213
  throw new errors_js_1.RegistrationError(`create '${typeKey}' produced no id`);
96
214
  return id;
97
215
  }
98
- async update(typeKey, id, input, actor) {
99
- await this.runWrite('update', typeKey, actor, { id, input });
216
+ async updateIn(tx, typeKey, id, input, actor) {
217
+ await this.runWrite(tx, 'update', typeKey, actor, { id, input });
100
218
  }
101
- async delete(typeKey, id, actor) {
102
- await this.runWrite('delete', typeKey, actor, { id });
219
+ async deleteIn(tx, typeKey, id, actor) {
220
+ await this.runWrite(tx, 'delete', typeKey, actor, { id });
103
221
  }
104
222
  // --- reads -----------------------------------------------------------------
105
- async gateRead(registration, actor, id, ownerId) {
223
+ async gateRead(registration, actor, id, record) {
106
224
  const action = { key: 'read' };
107
225
  const resource = {
108
226
  level: id !== undefined ? 'record' : 'object_type',
109
227
  typeKey: registration.typeKey,
110
- tenantId: actor.tenantId,
228
+ // Block D (#10): a fetched record is judged at ITS tenant; otherwise the actor's.
229
+ tenantId: record?.tenantId ?? actor.tenantId,
111
230
  ...(typeof id === 'string' ? { recordId: id } : {}),
112
231
  };
113
- // For an OWNED record read, pass the pre-fetched row owner so core's owner baseline
114
- // can compare it to the actor. Absent ⇒ no owner match ⇒ deny (missing row / other
115
- // tenant / non-owned type reads without it, as before).
232
+ // For a fetched record, every registered field + `ownerId` reach decide() (block D), so a
233
+ // field-conditioned rule fires on get() as it does in list(). No record (a missing row) ⇒
234
+ // no attributes ⇒ an owned type's owner baseline cannot match ⇒ deny, as before.
116
235
  const context = {
117
236
  isAuthenticated: true,
118
- ...(ownerId !== undefined ? { attributes: { ownerId } } : {}),
237
+ ...(record !== undefined ? { attributes: record.attributes } : {}),
119
238
  };
120
239
  let outcome;
121
240
  try {
@@ -129,7 +248,7 @@ class RecordEngineImpl {
129
248
  }
130
249
  }
131
250
  qualified(reg) {
132
- return reg.schema ? `\`${reg.schema}\`.\`${reg.table}\`` : `\`${reg.table}\``;
251
+ return (0, where_by_id_js_1.qualifiedTable)(reg);
133
252
  }
134
253
  /** Project a raw DB row → RecordView (field keys, not columns). */
135
254
  project(reg, row) {
@@ -152,29 +271,30 @@ class RecordEngineImpl {
152
271
  return { id, fields };
153
272
  }
154
273
  async get(typeKey, id, actor) {
274
+ return this.getIn(undefined, typeKey, id, actor);
275
+ }
276
+ async list(typeKey, query, actor) {
277
+ return this.listIn(undefined, typeKey, query, actor);
278
+ }
279
+ async getIn(tx, typeKey, id, actor) {
155
280
  const reg = this.registry.require(typeKey);
156
- return this.uow.run(async (query) => {
157
- const pk = reg.primaryKey;
158
- let where;
159
- let params;
160
- if (pk.kind === 'uuid') {
161
- if (typeof id !== 'string')
162
- throw new errors_js_1.RegistrationError(`'${typeKey}' needs a string id`);
163
- where = `\`${(0, registry_js_1.uuidPkColumn)(pk)}\` = ?`;
164
- params = [id];
165
- }
166
- else {
167
- if (typeof id === 'string')
168
- throw new errors_js_1.RegistrationError(`'${typeKey}' needs a key map`);
169
- where = pk.columns.map((c) => `\`${c}\` = ?`).join(' AND ');
170
- params = pk.columns.map((c) => id[c]);
171
- }
281
+ return this.inUnit(tx, false, async (query) => {
282
+ const pk = (0, where_by_id_js_1.pkClause)(reg, id);
283
+ let where = pk.sql;
284
+ let params = pk.params;
172
285
  // Tenant scoping: if the table has a tenant column, constrain to the actor's reachable
173
286
  // subtree (ADR-0027 — active node + descendants), NOT just the self-node. ROOT spans all
174
287
  // tenants — only a STRICT `=== true` isRoot counts (fail-closed). Fail-safe to the
175
288
  // self-node if the subtree is malformed/absent (non-widening).
289
+ // Review M6 (get ≡ list): exactly as in list(), when the record-level list scope is wired
290
+ // its fragment is the ONE tenant authority (it carries the own-subtree bound AND the
291
+ // cross-tenant grant reach), so the built-in clause is deferred to it.
292
+ // Resolve the list-scope fragment FIRST (a throw propagates before any SELECT). An EMPTY
293
+ // fragment never drops the tenant bound (review L1): the built-in clause then applies.
294
+ const fragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
295
+ const fragmentSql = fragment?.sql ? fragment : null;
176
296
  const tenantCol = this.tenantColumn(reg);
177
- if (tenantCol && actor.isRoot !== true) {
297
+ if (tenantCol && actor.isRoot !== true && !fragmentSql) {
178
298
  const nodes = this.tenantScopeNodes(actor);
179
299
  where += ` AND \`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`;
180
300
  params = [...params, ...nodes];
@@ -188,37 +308,39 @@ class RecordEngineImpl {
188
308
  where += ` AND ${scope.sql}`;
189
309
  params = [...params, ...scope.params];
190
310
  }
191
- if ((0, registry_js_1.isOwned)(reg)) {
192
- // OWNED read — FETCH-THEN-GATE. Core's owner baseline needs the row's owner,
193
- // which core (zero-dep) can't fetch. So we SELECT the row FIRST (tenant-scoped),
194
- // extract its owner, and gate on it. ⛔ THE FETCHED ROW IS NEVER SURFACED UNLESS
195
- // THE GATE ALLOWS: gateRead throws on deny BEFORE we project()/return, so a denied
196
- // read leaks nothing. A missing row ⇒ no owner ⇒ deny (never "null" that reveals
197
- // presence/absence differently from a real deny — both throw/deny before any row
198
- // data is returned).
199
- const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
200
- const first = rows[0];
201
- const ownerCol = (0, registry_js_1.ownerColumn)(reg);
202
- const owner = first && ownerCol ? first[ownerCol] : undefined;
203
- // gateRead THROWS AuthorizationDeniedError on deny → the row below is never reached.
204
- await this.gateRead(reg, actor, id, typeof owner === 'string' ? owner : undefined);
205
- // Allowed. Project the ALREADY-fetched row (no second query). A genuinely absent
206
- // row (owner undefined) would have been denied above by the missing owner match,
207
- // so reaching here with `!first` is not expected for owned types; guard anyway.
208
- return first ? this.project(reg, first) : null;
311
+ // Review M6: AND the SAME list-scope fragment list() uses, so get() reaches exactly the
312
+ // rows list() shows (incl. cross-tenant-granted ones) and hides what list() hides. A throw
313
+ // propagates (fail closed) before any SELECT runs.
314
+ if (fragmentSql) {
315
+ where += ` AND ${fragmentSql.sql}`;
316
+ params = [...params, ...fragmentSql.params];
209
317
  }
210
- // NON-owned / system type — gate BEFORE fetch (no owner to check; avoid an extra
211
- // query on the deny path). This is the original lean read path.
212
- await this.gateRead(reg, actor, id);
318
+ // FETCH-THEN-GATE for EVERY type (block D, #10). Core's decide() is zero-dep and cannot
319
+ // read the row, so the row is SELECTed first and its fields, owner and tenant are handed
320
+ // to the gate. ⛔ THE FETCHED ROW IS NEVER SURFACED UNLESS THE GATE ALLOWS: gateRead
321
+ // throws on deny before project(). The SAME row is projected (no second query).
213
322
  const rows = (await query(`SELECT * FROM ${this.qualified(reg)} WHERE ${where} LIMIT 1`, params));
214
323
  const first = rows[0];
215
- return first ? this.project(reg, first) : null;
324
+ if (first === undefined) {
325
+ // Missing — or outside the walled WHERE (foreign tenant/subtree, hidden by the list
326
+ // scope): indistinguishable by construction. Gate as before this change: record level,
327
+ // no attributes, actor tenant ⇒ an owned type denies (no owner match), any other type
328
+ // answers null. The caller learns nothing it did not learn before.
329
+ await this.gateRead(reg, actor, id);
330
+ return null;
331
+ }
332
+ const facts = (0, fetch_record_js_1.recordFacts)(reg, first);
333
+ await this.gateRead(reg, actor, id, {
334
+ attributes: (0, fetch_record_js_1.decisionAttributes)(reg, facts),
335
+ ...(facts.tenantId !== undefined ? { tenantId: facts.tenantId } : {}),
336
+ });
337
+ return this.project(reg, first);
216
338
  });
217
339
  }
218
- async list(typeKey, query, actor) {
340
+ async listIn(tx, typeKey, query, actor) {
219
341
  const reg = this.registry.require(typeKey);
220
342
  await this.gateRead(reg, actor);
221
- return this.uow.run(async (q) => {
343
+ return this.inUnit(tx, false, async (q) => {
222
344
  const clauses = [];
223
345
  const params = [];
224
346
  // Mandatory tenant scoping when the table is tenant-owned (cross-tenant safety).
@@ -239,8 +361,12 @@ class RecordEngineImpl {
239
361
  // So when `listScope` is present we DEFER the tenant bound entirely to the fragment — one
240
362
  // authority, no double-filter. (readScope system-types are unaffected: they have no
241
363
  // tenant column, so this clause never fired for them anyway; their wall is `readScope`.)
364
+ // The list-scope fragment is resolved up-front (a throw propagates — fail closed). An EMPTY
365
+ // fragment never drops the tenant bound (review L1): the built-in clause then applies.
366
+ const listFragment = this.listScope ? await this.listScope(reg, actor, 'read') : null;
367
+ const listFragmentSql = listFragment?.sql ? listFragment : null;
242
368
  const tenantCol = this.tenantColumn(reg);
243
- if (tenantCol && actor.isRoot !== true && !this.listScope) {
369
+ if (tenantCol && actor.isRoot !== true && !listFragmentSql) {
244
370
  const nodes = this.tenantScopeNodes(actor);
245
371
  clauses.push(`\`${tenantCol}\` IN (${nodes.map(() => '?').join(', ')})`);
246
372
  params.push(...nodes);
@@ -289,28 +415,37 @@ class RecordEngineImpl {
289
415
  // (gateRead, at the TOP of list()) has allowed the read — the scope narrows rows WITHIN
290
416
  // an allowed object-type list; it NEVER substitutes for the gate (the root/data-plane
291
417
  // leak-guard). A scope-fn throw PROPAGATES (fail-closed — the read fails; we never fall
292
- // back to unscoped rows). An empty `sql` is simply not ANDed (no fabricated widening).
293
- if (this.listScope) {
294
- const scope = await this.listScope(reg, actor, 'read');
295
- if (scope.sql) {
296
- clauses.push(scope.sql);
297
- params.push(...scope.params);
298
- }
418
+ // back to unscoped rows). An empty `sql` is not ANDed; the built-in tenant clause above applies instead.
419
+ if (listFragmentSql) {
420
+ clauses.push(listFragmentSql.sql);
421
+ params.push(...listFragmentSql.params);
299
422
  }
300
423
  const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : '';
301
424
  // Sort: resolve `sortBy` to a column the SAME whitelisted way as where-keys
302
425
  // (unknown key → throw). The direction is an enum-checked literal (only 'desc'
303
426
  // flips to DESC; anything else is ascending) — neither is ever raw-interpolated
304
427
  // user input.
305
- let orderBy = '';
428
+ //
429
+ // Review M1: the PK column(s) are ALWAYS appended as the final tiebreaker (`ORDER BY <pk>`
430
+ // alone when there is no sortBy), so the order is total and OFFSET paging never repeats or
431
+ // skips rows that tie on the sort column. PK columns come from `primaryKey` (often not a
432
+ // registered field); a PK column already used as the sort key is not repeated.
433
+ const orderTerms = [];
434
+ let sortColumn;
306
435
  if (query.sortBy !== undefined) {
307
436
  const field = reg.fields.find((f) => f.key === query.sortBy);
308
437
  if (!field) {
309
438
  throw new errors_js_1.RegistrationError(`list '${typeKey}': unknown sort field '${query.sortBy}'`);
310
439
  }
311
440
  const dir = query.sortOrder === 'desc' ? 'DESC' : 'ASC';
312
- orderBy = `ORDER BY \`${(0, registry_js_1.columnOf)(field)}\` ${dir}`;
441
+ sortColumn = (0, registry_js_1.columnOf)(field);
442
+ orderTerms.push(`\`${sortColumn}\` ${dir}`);
443
+ }
444
+ for (const col of (0, where_by_id_js_1.pkColumns)(reg)) {
445
+ if (col !== sortColumn)
446
+ orderTerms.push(`\`${col}\` ASC`);
313
447
  }
448
+ const orderBy = `ORDER BY ${orderTerms.join(', ')}`;
314
449
  const limit = Math.min(Math.max(query.limit ?? 100, 1), 1000);
315
450
  const offset = Math.max(query.offset ?? 0, 0);
316
451
  const rows = (await q(`SELECT * FROM ${this.qualified(reg)} ${where} ${orderBy} LIMIT ? OFFSET ?`, [...params, limit, offset]));
@@ -24,6 +24,16 @@ export declare class RecordsService {
24
24
  list(typeKey: string, query: ListQuery): Promise<RecordPage>;
25
25
  update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
26
26
  delete(typeKey: string, id: RecordId): Promise<void>;
27
+ /** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
28
+ lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
29
+ /**
30
+ * Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
31
+ * principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
32
+ * transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
33
+ * resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
34
+ * Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
35
+ */
36
+ transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
27
37
  /**
28
38
  * Run subsequent ops as a declared identity (an in-request act-as, or — via a
29
39
  * registered resolver — a named service account). Explicit + auditable; never a
@@ -33,15 +43,22 @@ export declare class RecordsService {
33
43
  private actor;
34
44
  private resolveServiceAccount;
35
45
  }
36
- /** The `.as(...)` bound view — same 5 ops, fixed actor. */
46
+ /**
47
+ * The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
48
+ * (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
49
+ */
37
50
  export declare class BoundRecords {
38
51
  private readonly engine;
39
52
  private readonly actor;
40
53
  constructor(engine: RecordEngine, actor: Principal);
54
+ /** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
55
+ transaction<T>(fn: (tx: BoundRecords) => Promise<T>): Promise<T>;
41
56
  create(typeKey: string, input: RecordInput): Promise<RecordId>;
42
57
  get(typeKey: string, id: RecordId): Promise<RecordView | null>;
43
58
  list(typeKey: string, query: ListQuery): Promise<RecordPage>;
44
59
  update(typeKey: string, id: RecordId, input: RecordInput): Promise<void>;
45
60
  delete(typeKey: string, id: RecordId): Promise<void>;
61
+ /** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
62
+ lockForUpdate(typeKey: string, id: RecordId): Promise<void>;
46
63
  }
47
64
  //# sourceMappingURL=records-service.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EAAE,YAAY,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,SAAS,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAG1G,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AACD,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,qBACa,cAAc;IAEA,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG;IACS,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAFvC,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EACD,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWnG,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAI1D;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED,2DAA2D;AAC3D,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAC9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAC9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAC5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IACxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CACrD"}
1
+ {"version":3,"file":"records-service.d.ts","sourceRoot":"","sources":["../../src/pipe/records-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,OAAO,KAAK,EACV,YAAY,EACZ,WAAW,EACX,QAAQ,EACR,UAAU,EACV,SAAS,EACT,UAAU,EACX,MAAM,aAAa,CAAC;AAGrB,0EAA0E;AAC1E,MAAM,WAAW,sBAAsB;IACrC,OAAO,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,CAAC;CACjC;AACD,eAAO,MAAM,wBAAwB,eAAqC,CAAC;AAE3E;;;;;;GAMG;AACH,qBACa,cAAc;IAEA,OAAO,CAAC,QAAQ,CAAC,MAAM;IAC7B,OAAO,CAAC,QAAQ,CAAC,GAAG;IAGrC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAC;gBAJO,MAAM,EAAE,YAAY,EAC1B,GAAG,EAAE;QAAE,SAAS,CAAC,EAAE,SAAS,CAAA;KAAE,EAG/C,eAAe,CAAC,EAAE,sBAAsB,YAAA;IAWrD,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAG1D,qFAAqF;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAIjE;;;;;;OAMG;IACG,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAKtE;;;;OAIG;IACH,EAAE,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,YAAY;IAKzC,OAAO,CAAC,KAAK;IAWb,OAAO,CAAC,qBAAqB;CAS9B;AAED;;;GAGG;AACH,qBAAa,YAAY;IAErB,OAAO,CAAC,QAAQ,CAAC,MAAM;IACvB,OAAO,CAAC,QAAQ,CAAC,KAAK;gBADL,MAAM,EAAE,YAAY,EACpB,KAAK,EAAE,SAAS;IAEnC,4FAA4F;IAC5F,WAAW,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,EAAE,EAAE,YAAY,KAAK,OAAO,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC;IAGhE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,QAAQ,CAAC;IAG9D,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC;IAG9D,IAAI,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,SAAS,GAAG,OAAO,CAAC,UAAU,CAAC;IAG5D,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAAC,IAAI,CAAC;IAGxE,MAAM,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;IAGpD,+EAA+E;IAC/E,aAAa,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,EAAE,QAAQ,GAAG,OAAO,CAAC,IAAI,CAAC;CAG5D"}
@@ -57,6 +57,21 @@ let RecordsService = class RecordsService {
57
57
  async delete(typeKey, id) {
58
58
  return this.engine.delete(typeKey, id, this.actor());
59
59
  }
60
+ /** Row lock inside the open `transaction(fn)` (see `RecordEngine.lockForUpdate`). */
61
+ async lockForUpdate(typeKey, id) {
62
+ return this.engine.lockForUpdate(typeKey, id, this.actor());
63
+ }
64
+ /**
65
+ * Run `fn` in ONE unit of work (ADR-0034): `tx` carries the same five ops bound to the request
66
+ * principal, and every op on it — plus any `records.*` call made from inside `fn` — shares one
67
+ * transaction, each still through the full gate/validate/write/audit pipe. Commits when `fn`
68
+ * resolves, rolls back when it throws (and when a write inside it failed, even if swallowed).
69
+ * Nested calls reuse the outer transaction. Fail-closed: no principal ⇒ throw before any work.
70
+ */
71
+ async transaction(fn) {
72
+ const actor = this.actor();
73
+ return this.engine.transaction((tx) => fn(new BoundRecords(tx, actor)));
74
+ }
60
75
  /**
61
76
  * Run subsequent ops as a declared identity (an in-request act-as, or — via a
62
77
  * registered resolver — a named service account). Explicit + auditable; never a
@@ -91,7 +106,10 @@ exports.RecordsService = RecordsService = __decorate([
91
106
  __param(2, (0, common_1.Inject)(exports.SERVICE_ACCOUNT_RESOLVER)),
92
107
  __metadata("design:paramtypes", [Object, Object, Object])
93
108
  ], RecordsService);
94
- /** The `.as(...)` bound view — same 5 ops, fixed actor. */
109
+ /**
110
+ * The `.as(...)` bound view — same 5 ops, fixed actor. Also the handle `transaction(fn)` passes
111
+ * (ADR-0034): there `engine` is the transaction-bound engine, so its ops share one unit of work.
112
+ */
95
113
  class BoundRecords {
96
114
  engine;
97
115
  actor;
@@ -99,10 +117,28 @@ class BoundRecords {
99
117
  this.engine = engine;
100
118
  this.actor = actor;
101
119
  }
102
- create(typeKey, input) { return this.engine.create(typeKey, input, this.actor); }
103
- get(typeKey, id) { return this.engine.get(typeKey, id, this.actor); }
104
- list(typeKey, query) { return this.engine.list(typeKey, query, this.actor); }
105
- update(typeKey, id, input) { return this.engine.update(typeKey, id, input, this.actor); }
106
- delete(typeKey, id) { return this.engine.delete(typeKey, id, this.actor); }
120
+ /** Run `fn` in one unit of work as this actor; nested inside a transaction it is reused. */
121
+ transaction(fn) {
122
+ return this.engine.transaction((tx) => fn(new BoundRecords(tx, this.actor)));
123
+ }
124
+ create(typeKey, input) {
125
+ return this.engine.create(typeKey, input, this.actor);
126
+ }
127
+ get(typeKey, id) {
128
+ return this.engine.get(typeKey, id, this.actor);
129
+ }
130
+ list(typeKey, query) {
131
+ return this.engine.list(typeKey, query, this.actor);
132
+ }
133
+ update(typeKey, id, input) {
134
+ return this.engine.update(typeKey, id, input, this.actor);
135
+ }
136
+ delete(typeKey, id) {
137
+ return this.engine.delete(typeKey, id, this.actor);
138
+ }
139
+ /** Row lock inside the open transaction (see `RecordEngine.lockForUpdate`). */
140
+ lockForUpdate(typeKey, id) {
141
+ return this.engine.lockForUpdate(typeKey, id, this.actor);
142
+ }
107
143
  }
108
144
  exports.BoundRecords = BoundRecords;
@@ -1 +1 @@
1
- {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CA0BlD"}
1
+ {"version":3,"file":"audit.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/audit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAgC,MAAM,gBAAgB,CAAC;AAC9E,OAAO,KAAK,EAAE,KAAK,EAAyB,MAAM,eAAe,CAAC;AAMlE,wBAAgB,UAAU,CAAC,KAAK,EAAE,SAAS,GAAG,KAAK,CA2BlD"}
@@ -1 +1 @@
1
- {"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,CA4B/C"}
1
+ {"version":3,"file":"derive-write-context.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/derive-write-context.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAGH,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAYzD,wBAAgB,uBAAuB,IAAI,KAAK,CAiC/C"}
@@ -52,6 +52,12 @@ function deriveWriteContextStage() {
52
52
  ctx.columns[col] = trustedValue(field.trusted, ctx);
53
53
  }
54
54
  }
55
+ else if (field.immutable === true && ctx.op !== 'create') {
56
+ // ADR-0034: a CREATE-ONLY field. Like a trusted field on update, any client value is
57
+ // IGNORED — the column is not written, so the stored value stands. Moving such a link
58
+ // (a company's parent, a membership's user/company) is a dedicated, checked operation,
59
+ // never a side effect of a generic patch.
60
+ }
55
61
  else if (Object.prototype.hasOwnProperty.call(input, field.key)) {
56
62
  ctx.columns[col] = input[field.key];
57
63
  }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Stage: fetch-record (update/delete on EVERY type — runs BEFORE the permission gate).
3
+ * Security hardening block D (#10); replaces the owner-only `fetch-owner` stage. Since Phase 8
4
+ * #7 it runs AFTER `type-pre-gate` (permission-gate.ts), so an actor with no permission on the
5
+ * type is denied before this stage can take a row lock.
6
+ *
7
+ * Core's decision authority is zero-dep (no DB), so it cannot read the record it judges. This
8
+ * stage reads the EXISTING row, inside the pipe's unit of work, with
9
+ *
10
+ * SELECT * FROM <table> WHERE <whereById> LIMIT 1 FOR UPDATE
11
+ *
12
+ * through the SAME `whereById` the write stage uses (pk + actor-tenant clause + ADR-0030 write
13
+ * scope), and stashes:
14
+ * - `ctx.existing` — the row projected to registered field keys (the PRE-image, D9),
15
+ * - `ctx.ownerId` — the `trusted:'actorId'` column, when it is a string,
16
+ * - `ctx.recordTenantId` — the `trusted:'tenantId'` column, when it is a string.
17
+ * The gate passes `attributes = { ...existing, ownerId }` and `resource.tenantId =
18
+ * recordTenantId ?? actor.tenantId` to `decide()`, so field-conditioned rules (e.g. deny
19
+ * `status eq locked`) fire on update/delete exactly as they do in `list()`.
20
+ *
21
+ * ── SECURITY (fail-closed, no existence oracle):
22
+ * - No row ⇒ `AuthorizationDeniedError('no_record_permit')`, thrown inside the transaction
23
+ * (rollback: no write, no junction sync, no audit). A missing id and a foreign-tenant or
24
+ * foreign-subtree id both match zero rows here, so they are the same error. It is also the
25
+ * reason core returns for an owned record the actor does not own, which is what the
26
+ * previous owner pre-fetch produced for a missing owned row.
27
+ * - `FOR UPDATE` row-locks the pre-image until commit, so the row the gate judged is the row
28
+ * the write changes (no TOCTOU between gate and write).
29
+ * - The row is never returned to the caller; only the gate sees it.
30
+ * - `create` no-ops: there is no existing row (the create wall is ADR-0034, in `write.ts`).
31
+ */
32
+ import { AuthorizationDeniedError } from '../../errors.js';
33
+ import type { ObjectType } from '../../types.js';
34
+ import type { Stage } from '../context.js';
35
+ /** The decision-relevant facts of one fetched row. */
36
+ export interface RecordFacts {
37
+ /** Registered fields present in the row, keyed by FIELD KEY (never the column). */
38
+ readonly fields: Record<string, unknown>;
39
+ /** The `trusted:'actorId'` column value, when the type has one and it is a string. */
40
+ readonly ownerId?: string;
41
+ /** The `trusted:'tenantId'` column value, when the type has one and it is a string. */
42
+ readonly tenantId?: string;
43
+ }
44
+ /** Project a raw row to its decision facts (shared by the write pre-fetch and `get()`). */
45
+ export declare function recordFacts(reg: ObjectType, row: Record<string, unknown>): RecordFacts;
46
+ /**
47
+ * The `context.attributes` bag for `decide()`: every registered field by key, plus `ownerId`
48
+ * (the owner baseline's input). When the type has an owner column, `ownerId` is that column's
49
+ * string value or absent: a null/non-string owner is never passed, so core's strict `===` owner
50
+ * match can never spuriously succeed.
51
+ */
52
+ export declare function decisionAttributes(reg: ObjectType, facts: Pick<RecordFacts, 'fields' | 'ownerId'>): Record<string, unknown>;
53
+ /** The ONE refusal for a record the actor cannot reach (missing ≡ foreign; no ids). */
54
+ export declare function recordNotReachable(reg: ObjectType): AuthorizationDeniedError;
55
+ export declare function fetchRecordStage(): Stage;
56
+ //# sourceMappingURL=fetch-record.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"fetch-record.d.ts","sourceRoot":"","sources":["../../../src/pipe/stages/fetch-record.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAE,wBAAwB,EAAqB,MAAM,iBAAiB,CAAC;AAE9E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,gBAAgB,CAAC;AACjD,OAAO,KAAK,EAAE,KAAK,EAAgB,MAAM,eAAe,CAAC;AAGzD,sDAAsD;AACtD,MAAM,WAAW,WAAW;IAC1B,mFAAmF;IACnF,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACzC,sFAAsF;IACtF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,uFAAuF;IACvF,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,2FAA2F;AAC3F,wBAAgB,WAAW,CAAC,GAAG,EAAE,UAAU,EAAE,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,WAAW,CAetF;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAChC,GAAG,EAAE,UAAU,EACf,KAAK,EAAE,IAAI,CAAC,WAAW,EAAE,QAAQ,GAAG,SAAS,CAAC,GAC7C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAOzB;AAED,uFAAuF;AACvF,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,UAAU,GAAG,wBAAwB,CAK5E;AAED,wBAAgB,gBAAgB,IAAI,KAAK,CAwBxC"}