@feasibleone/blong-gogo 1.37.0 → 1.38.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.38.1](https://github.com/feasibleone/blong/compare/blong-gogo-v1.38.0...blong-gogo-v1.38.1) (2026-10-01)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * error handling ([fc1af53](https://github.com/feasibleone/blong/commit/fc1af536ae814a88a661341936bd9f9c519b38d1))
9
+
10
+ ## [1.38.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.37.0...blong-gogo-v1.38.0) (2026-09-29)
11
+
12
+
13
+ ### Features
14
+
15
+ * improve storybook scaffold ([cc66ab4](https://github.com/feasibleone/blong/commit/cc66ab480eb9af2def562873cac12d06591c0de4))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * failed tests ([108caf5](https://github.com/feasibleone/blong/commit/108caf5f3e92151f8b929115e0a8f6b2acdd3d23))
21
+ * gaps ([19beb7d](https://github.com/feasibleone/blong/commit/19beb7d191e853c8182409256c97af7a1e413d57))
22
+
3
23
  ## [1.37.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.36.0...blong-gogo-v1.37.0) (2026-09-26)
4
24
 
5
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.37.0",
3
+ "version": "1.38.1",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -88,11 +88,11 @@
88
88
  "@rushstack/heft-typescript-plugin": "^1.3.1",
89
89
  "@types/cacache": "^20.0.1",
90
90
  "@types/node": "^24",
91
- "playwright": "^1.60.0",
91
+ "playwright": "^1.63.0",
92
92
  "tap": "^21.6.3",
93
93
  "typescript": "^6.0.3",
94
94
  "vite": "^8.2.1",
95
- "@feasibleone/blong-dev": "1.7.0"
95
+ "@feasibleone/blong-dev": "1.8.0"
96
96
  },
97
97
  "scripts": {
98
98
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -17,7 +17,7 @@
17
17
 
18
18
  import {test} from 'tap';
19
19
 
20
- import {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
20
+ import ConfigRuntime, {createConfigProxy, deepDiff} from './ConfigRuntime.ts';
21
21
 
22
22
  // ---------------------------------------------------------------------------
23
23
  // deepDiff
@@ -325,3 +325,81 @@ test('factory phase guard — exitConfigFactoryPhase returns empty array in thro
325
325
  const errors = exitConfig();
326
326
  t.equal(errors.length, 0, 'no errors collected in throw mode (they were thrown)');
327
327
  });
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // liveLayerConfig — the config a handler closure receives
331
+ // ---------------------------------------------------------------------------
332
+
333
+ /**
334
+ * The merge input is a getter over a `let`, so a "reload" in these tests replaces
335
+ * the object the next merge sees — the way `ConfigRuntime.#rawSnapshot` is
336
+ * replaced while the version moves with it.
337
+ */
338
+
339
+ test('liveLayerConfig — a handler sees a reloaded value', async t => {
340
+ let source: Record<string, unknown> = {timeout: 5, db: {host: 'a', port: 1}};
341
+ let version = 0;
342
+ const config = ConfigRuntime.liveLayerConfig(
343
+ () => source,
344
+ () => version,
345
+ ) as {
346
+ timeout: number;
347
+ db: {host: string};
348
+ };
349
+
350
+ t.equal(config.timeout, 5, 'the value at assembly time');
351
+
352
+ source = {timeout: 9, db: {host: 'b', port: 2}};
353
+ version += 1;
354
+
355
+ t.equal(config.timeout, 9, 'a leaf read after the reload is fresh');
356
+ t.end();
357
+ });
358
+
359
+ test('liveLayerConfig — an intermediate node destructured at startup stays live', async t => {
360
+ let source: Record<string, unknown> = {db: {host: 'a'}};
361
+ let version = 0;
362
+ const config = ConfigRuntime.liveLayerConfig(
363
+ () => source,
364
+ () => version,
365
+ ) as {
366
+ db: {host: string};
367
+ };
368
+
369
+ // The pattern the config rationale calls safe: the node is taken once, its
370
+ // leaves are read at call time.
371
+ const {db} = config;
372
+ t.equal(db.host, 'a', 'the leaf before the reload');
373
+ t.equal(config.db, db, 'and the node reference is stable');
374
+
375
+ source = {db: {host: 'b'}};
376
+ version += 1;
377
+
378
+ t.equal(db.host, 'b', 'the same node reads the reloaded leaf');
379
+ t.end();
380
+ });
381
+
382
+ test('liveLayerConfig — an unchanged version does not re-merge', async t => {
383
+ let merges = 0;
384
+ let version = 3;
385
+ const config = ConfigRuntime.liveLayerConfig(
386
+ () => {
387
+ merges += 1;
388
+ return {timeout: 5};
389
+ },
390
+ () => version,
391
+ ) as {timeout: number};
392
+
393
+ t.equal(merges, 1, 'assembled once');
394
+ void config.timeout;
395
+ void config.timeout;
396
+ t.equal(merges, 1, 'reads at the same version do not merge again');
397
+
398
+ version += 1;
399
+ void config.timeout;
400
+ t.equal(merges, 2, 'a new version merges once');
401
+
402
+ void config.timeout;
403
+ t.equal(merges, 2, 'and then caches again');
404
+ t.end();
405
+ });
@@ -43,6 +43,17 @@ import merge from 'ut-function.merge';
43
43
  */
44
44
  export function createConfigProxy<T extends object>(
45
45
  store: T,
46
+ options: {
47
+ /**
48
+ * Called before every read, at every level of the proxy.
49
+ *
50
+ * A caller whose backing store has to be re-read from somewhere charges
51
+ * the check here, so a node destructured once at startup (a handler's
52
+ * `config.db`, say) refreshes on its own reads rather than only when the
53
+ * root is touched.
54
+ */
55
+ beforeRead?: () => void;
56
+ } = {},
46
57
  ): {
47
58
  proxy: T;
48
59
  update: (next: T) => void;
@@ -131,6 +142,7 @@ export function createConfigProxy<T extends object>(
131
142
 
132
143
  const pathProxy = new Proxy({} as T, {
133
144
  get(_target, prop) {
145
+ options.beforeRead?.();
134
146
  const container = getNode(path);
135
147
  const val = container == null ? undefined : Reflect.get(container as object, prop);
136
148
  if (
@@ -166,16 +178,19 @@ export function createConfigProxy<T extends object>(
166
178
  return Reflect.set(container as object, prop, value);
167
179
  },
168
180
  has(_target, prop) {
181
+ options.beforeRead?.();
169
182
  const container = getNode(path);
170
183
  if (container == null) return false;
171
184
  return Reflect.has(container as object, prop);
172
185
  },
173
186
  ownKeys() {
187
+ options.beforeRead?.();
174
188
  const container = getNode(path);
175
189
  if (container == null) return [];
176
190
  return Reflect.ownKeys(container as object);
177
191
  },
178
192
  getOwnPropertyDescriptor(_target, prop) {
193
+ options.beforeRead?.();
179
194
  const container = getNode(path);
180
195
  if (container == null) return undefined;
181
196
  return Object.getOwnPropertyDescriptor(container as object, prop);
@@ -265,6 +280,7 @@ export default class ConfigRuntime implements IConfigRuntime {
265
280
  #rawSnapshot: object = {};
266
281
  #proxy: object;
267
282
  #updateProxy: (next: object) => void;
283
+ #version = 0;
268
284
 
269
285
  readonly enterConfig: (mode?: FactoryPhaseMode) => void;
270
286
  readonly exitConfig: () => Error[];
@@ -292,6 +308,11 @@ export default class ConfigRuntime implements IConfigRuntime {
292
308
  return this.#rawSnapshot;
293
309
  }
294
310
 
311
+ /** Bumped whenever the effective config is replaced; see `IConfigRuntime`. */
312
+ public get version(): number {
313
+ return this.#version;
314
+ }
315
+
295
316
  private mergeConfigs(blongConfig: object): object {
296
317
  const loaded = loadBlong(blongConfig);
297
318
  return merge({}, this.#baseConfig, ...this.#configs, loaded);
@@ -305,6 +326,7 @@ export default class ConfigRuntime implements IConfigRuntime {
305
326
  public async load(params: object = {}): Promise<object> {
306
327
  const loaded = this.mergeConfigs({config: {suite: this.#suite}, ...params});
307
328
  this.#rawSnapshot = loaded;
329
+ this.#version += 1;
308
330
  this.#updateProxy(loaded);
309
331
  return this.#proxy;
310
332
  }
@@ -324,6 +346,7 @@ export default class ConfigRuntime implements IConfigRuntime {
324
346
  // Only do work when something actually changed
325
347
  if (diff.size > 0) {
326
348
  this.#rawSnapshot = next;
349
+ this.#version += 1;
327
350
  this.#updateProxy(next);
328
351
  for (const subscriber of this.#subscribers) {
329
352
  try {
@@ -374,6 +397,44 @@ export default class ConfigRuntime implements IConfigRuntime {
374
397
  return merge({}, moduleConfigSlice, portNamespaceConfig) as Record<string, unknown>;
375
398
  }
376
399
 
400
+ /**
401
+ * A live view of one component's config slice, for a handler closure.
402
+ *
403
+ * `mergeLayerConfig` copies, so a handler that reads `config.timeout` inside
404
+ * its function keeps whatever the value was when the layer was assembled,
405
+ * however often the config is reloaded (T-159). This hands the closure a
406
+ * proxy over the merged slice instead: `mergeOnce` is called again when
407
+ * `version()` reports a new runtime version, and the readers see the new
408
+ * values — the handler's own `config` argument, and any intermediate node it
409
+ * destructured at startup, because the nodes are the path proxies of a
410
+ * `createConfigProxy` instance whose backing is replaced rather than a copy
411
+ * of it.
412
+ *
413
+ * The merge runs at most once per runtime version, so a hot path that reads a
414
+ * config leaf pays a property lookup and an integer compare, not a merge.
415
+ */
416
+ static liveLayerConfig(
417
+ mergeOnce: () => Record<string, unknown>,
418
+ version: () => number,
419
+ ): Record<string, unknown> {
420
+ let seen = version();
421
+ const refresh = (): void => {
422
+ const current = version();
423
+ if (current === seen) return;
424
+ seen = current;
425
+ live.update(mergeOnce());
426
+ };
427
+ // The hook is passed to the proxy instance itself, so a node the closure
428
+ // destructured at startup refreshes on its own reads — reading through
429
+ // the root would leave it stale.
430
+ //
431
+ // This instance's factory-phase guard is never armed: layer assembly arms
432
+ // the *runtime's* proxy, and a handler has always been free to destructure
433
+ // a leaf from its `config` argument at factory time.
434
+ const live = createConfigProxy(mergeOnce(), {beforeRead: refresh});
435
+ return live.proxy as Record<string, unknown>;
436
+ }
437
+
377
438
  /**
378
439
  * Merge activation-scoped config for an adapter.
379
440
  *
package/src/Gateway.ts CHANGED
@@ -14,7 +14,12 @@ import type {
14
14
  IPlatformApi,
15
15
  } from '@feasibleone/blong/types';
16
16
  import {Internal} from '@feasibleone/blong/types';
17
- import fastify, {type FastifyReply, type FastifyRequest, type RouteOptions} from 'fastify';
17
+ import fastify, {
18
+ LogController,
19
+ type FastifyReply,
20
+ type FastifyRequest,
21
+ type RouteOptions,
22
+ } from 'fastify';
18
23
  import os from 'os';
19
24
  import type {LevelWithSilent} from 'pino';
20
25
  import {Type, type TSchema} from 'typebox';
@@ -706,7 +711,12 @@ export default class Gateway extends Internal implements IGateway {
706
711
  // both more than a reader needs and less than the flow records say.
707
712
  // Nothing else depends on those two lines: the request has a flow, the
708
713
  // flow has its calls, and both are recorded where they belong.
709
- disableRequestLogging: true,
714
+ //
715
+ // Passed as a `LogController` instance rather than as fastify's own
716
+ // top-level `disableRequestLogging`, which is deprecated and warned
717
+ // about a line per process — one line per package in a CI run — for a
718
+ // flag whose replacement does exactly the same thing.
719
+ logController: new LogController({disableRequestLogging: true}),
710
720
  ajv: {
711
721
  customOptions: {
712
722
  allErrors: true,
package/src/Registry.ts CHANGED
@@ -396,8 +396,8 @@ export default class Registry extends Internal implements IRegistry {
396
396
  // carries only config/handler/errors/schema/gateway/apiSchema. And
397
397
  // the lib proxy returns an attached function **raw**, so `this`
398
398
  // inside it is this object — not the port, which is only bound in the
399
- // proxy's not-yet-attached fallback path. Without these keys a
400
- // library function therefore cannot reach the platform or registry at
399
+ // proxy's not-yet-attached fallback path. Without these keys an
400
+ // injected library function therefore cannot reach the platform or registry at
401
401
  // all, which is what a library needs to be reusable outside a
402
402
  // request.
403
403
  platform: this.#platform,
package/src/Watch.test.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import {test} from 'tap';
2
2
 
3
- import {affectedNamespaces} from './Watch.ts';
3
+ import type {ConfigDiff} from '@feasibleone/blong/types';
4
+
5
+ import {
6
+ affectedNamespaces,
7
+ applyConfigReload,
8
+ type IReloadablePort,
9
+ type IReloadRegistry,
10
+ } from './Watch.ts';
4
11
 
5
12
  // ---------------------------------------------------------------------------
6
13
  // affectedNamespaces
@@ -49,3 +56,145 @@ test('affectedNamespaces — partial name prefix does not match', async t => {
49
56
  const affected = affectedNamespaces(diff, ports);
50
57
  t.equal(affected.size, 0, 'partial-name prefix should not match');
51
58
  });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // applyConfigReload — what a reload does to the ports it affects
62
+ // ---------------------------------------------------------------------------
63
+
64
+ interface FakePort extends IReloadablePort {
65
+ /** Every lifecycle call, in order — the assertion surface. */
66
+ calls: string[];
67
+ /** The arguments `configChanged` was handed, once per call. */
68
+ changed: Array<{diff: ConfigDiff; next: unknown; prev: unknown}>;
69
+ }
70
+
71
+ function fakePort(id: string, options: {configChanged?: boolean; throws?: boolean} = {}): FakePort {
72
+ const calls: string[] = [];
73
+ const changed: FakePort['changed'] = [];
74
+ const port: FakePort = {
75
+ calls,
76
+ changed,
77
+ async stop() {
78
+ calls.push('stop');
79
+ },
80
+ async start() {
81
+ calls.push('start');
82
+ },
83
+ async ready() {
84
+ calls.push('ready');
85
+ },
86
+ };
87
+ if (options.configChanged !== false) {
88
+ port.configChanged = async (diff, next, prev) => {
89
+ calls.push('configChanged');
90
+ changed.push({diff, next, prev});
91
+ if (options.throws) throw new Error(`${id}: reload failed`);
92
+ };
93
+ }
94
+ return port;
95
+ }
96
+
97
+ function fakeRegistry(ports: Record<string, FakePort>): IReloadRegistry {
98
+ return {
99
+ ports: {keys: () => Object.keys(ports)},
100
+ async getPort(id) {
101
+ return ports[id];
102
+ },
103
+ async createPort(id) {
104
+ return ports[id];
105
+ },
106
+ };
107
+ }
108
+
109
+ const reloadDiff: ConfigDiff = new Map([['payment.adapter.db.host', {prev: 'a', next: 'b'}]]);
110
+
111
+ test('applyConfigReload — the affected port is asked to reconfigure itself', async t => {
112
+ const port = fakePort('payment.adapter.db');
113
+ const registry = fakeRegistry({'payment.adapter.db': port});
114
+ const next = {payment: {adapter: {db: {host: 'b'}}}};
115
+
116
+ const reloaded = await applyConfigReload(
117
+ registry,
118
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
119
+ reloadDiff,
120
+ next,
121
+ {payment: {adapter: {db: {host: 'a'}}}},
122
+ {},
123
+ );
124
+
125
+ t.equal(reloaded, 1, 'one port reached');
126
+ t.equal(port.calls.join(','), 'configChanged', 'and reconfigured, not restarted');
127
+ t.equal(port.changed.length, 1, 'configChanged called once');
128
+ t.equal(port.changed[0]?.diff, reloadDiff, 'handed the diff');
129
+ t.equal(port.changed[0]?.next, next, 'and the new effective config');
130
+ t.end();
131
+ });
132
+
133
+ test('applyConfigReload — an unrelated key leaves a port running', async t => {
134
+ const affectedPort = fakePort('payment.adapter.db');
135
+ const untouched = fakePort('audit.adapter.kafka');
136
+ const registry = fakeRegistry({
137
+ 'payment.adapter.db': affectedPort,
138
+ 'audit.adapter.kafka': untouched,
139
+ });
140
+
141
+ const reloaded = await applyConfigReload(
142
+ registry,
143
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
144
+ reloadDiff,
145
+ {payment: {adapter: {db: {host: 'b'}}}},
146
+ {},
147
+ {},
148
+ );
149
+
150
+ t.equal(reloaded, 1, 'only the affected port was reached');
151
+ t.equal(untouched.calls.length, 0, 'the unrelated port was not stopped or started');
152
+ t.end();
153
+ });
154
+
155
+ test('applyConfigReload — a port without configChanged is stopped and started again', async t => {
156
+ const port = fakePort('payment.adapter.db', {configChanged: false});
157
+ const registry = fakeRegistry({'payment.adapter.db': port});
158
+ const override = {marker: true};
159
+
160
+ await applyConfigReload(
161
+ registry,
162
+ affectedNamespaces(reloadDiff, registry.ports.keys()),
163
+ reloadDiff,
164
+ {},
165
+ {},
166
+ override,
167
+ );
168
+
169
+ t.equal(port.calls.join(','), 'stop,start,ready', 'the fallback restarts the port in order');
170
+ t.end();
171
+ });
172
+
173
+ test('applyConfigReload — a failing port does not stop the ones behind it', async t => {
174
+ const failing = fakePort('payment.adapter.db', {throws: true});
175
+ const healthy = fakePort('user.adapter.db');
176
+ const registry = fakeRegistry({
177
+ 'payment.adapter.db': failing,
178
+ 'user.adapter.db': healthy,
179
+ });
180
+ const diff: ConfigDiff = new Map([
181
+ ['payment.adapter.db.host', {prev: 'a', next: 'b'}],
182
+ ['user.adapter.db.host', {prev: 'a', next: 'b'}],
183
+ ]);
184
+ const errors: unknown[] = [];
185
+
186
+ const reloaded = await applyConfigReload(
187
+ registry,
188
+ affectedNamespaces(diff, registry.ports.keys()),
189
+ diff,
190
+ {},
191
+ {},
192
+ {},
193
+ error => errors.push(error),
194
+ );
195
+
196
+ t.equal(reloaded, 2, 'both ports were reached');
197
+ t.equal(healthy.calls.join(','), 'configChanged', 'the second port still reloaded');
198
+ t.equal(errors.length, 1, 'the failure was reported');
199
+ t.end();
200
+ });
package/src/Watch.ts CHANGED
@@ -116,6 +116,73 @@ export function affectedNamespaces(diff: ConfigDiff, portNames: Iterable<string>
116
116
  return affected;
117
117
  }
118
118
 
119
+ /**
120
+ * The port surface a config reload needs — `IRegistry`, narrowed so the reload
121
+ * loop can be driven with fakes (see `Watch.test.ts`).
122
+ */
123
+ export interface IReloadablePort {
124
+ /** Zero-downtime path: the port reconfigures itself. */
125
+ configChanged?(diff: ConfigDiff, next: unknown, prev: unknown): Promise<void> | void;
126
+ stop(): Promise<void> | void;
127
+ start(configOverride: object): Promise<void> | void;
128
+ ready(): Promise<void> | void;
129
+ }
130
+
131
+ export interface IReloadRegistry {
132
+ ports: {keys: () => Iterable<string>};
133
+ getPort(id: string): Promise<IReloadablePort | undefined>;
134
+ createPort(id: string): Promise<IReloadablePort | undefined>;
135
+ }
136
+
137
+ /**
138
+ * Apply a config reload to the ports the diff affects.
139
+ *
140
+ * A port that implements `configChanged` is asked to reconfigure itself — the
141
+ * zero-downtime path, which is what keeps an in-flight request or an expensive
142
+ * connection alive. A port that does not is stopped and started again with the
143
+ * current override. A port the diff does not name is not touched at all, which is
144
+ * the property that makes a log-level change safe: it must not cost a database
145
+ * pool.
146
+ *
147
+ * Returns the number of ports the loop reached, and reports each one through
148
+ * `onReloaded` so the caller's progress view can follow it.
149
+ */
150
+ export async function applyConfigReload(
151
+ registry: IReloadRegistry,
152
+ affected: Iterable<string>,
153
+ diff: ConfigDiff,
154
+ next: object,
155
+ prev: object,
156
+ configOverride: object,
157
+ onPortError: (error: unknown) => void = () => undefined,
158
+ onReloaded?: (done: number, total: number) => void,
159
+ ): Promise<number> {
160
+ const total = [...affected].length;
161
+ let reloaded = 0;
162
+ for (const portId of affected) {
163
+ const portInstance = await registry.getPort(portId);
164
+ if (portInstance) {
165
+ if (typeof portInstance.configChanged === 'function') {
166
+ try {
167
+ await portInstance.configChanged(diff, next, prev);
168
+ } catch (error) {
169
+ onPortError(error);
170
+ }
171
+ } else {
172
+ await portInstance.stop();
173
+ const fresh = await registry.createPort(portId);
174
+ if (fresh) {
175
+ await fresh.start(configOverride);
176
+ await fresh.ready();
177
+ }
178
+ }
179
+ }
180
+ reloaded += 1;
181
+ onReloaded?.(reloaded, total);
182
+ }
183
+ return reloaded;
184
+ }
185
+
119
186
  export default class Watch extends Internal implements IWatch {
120
187
  #config: IConfig = {
121
188
  enabled: false,
@@ -566,28 +633,18 @@ export default class Watch extends Internal implements IWatch {
566
633
  this.log,
567
634
  'reload config',
568
635
  (async () => {
569
- for (const portId of affected) {
570
- const portInstance = await registry.getPort(portId);
571
- if (portInstance) {
572
- if (typeof portInstance['configChanged'] === 'function') {
573
- // Adapter supports the configChanged hook — zero-downtime update
574
- try {
575
- await portInstance['configChanged'](diff, next, prev);
576
- } catch (error) {
577
- this.log?.error?.(error);
578
- }
579
- } else {
580
- // Fallback: stop and restart the port with the current configOverride
581
- await portInstance.stop();
582
- const fresh = await registry.createPort(portId);
583
- if (fresh) {
584
- await fresh.start(configOverride);
585
- await fresh.ready();
586
- }
587
- }
588
- }
589
- reloaded += 1;
590
- }
636
+ await applyConfigReload(
637
+ registry as unknown as IReloadRegistry,
638
+ affected,
639
+ diff,
640
+ next,
641
+ prev,
642
+ configOverride,
643
+ error => this.log?.error?.(error),
644
+ done => {
645
+ reloaded = done;
646
+ },
647
+ );
591
648
  })(),
592
649
  {getProgress: () => ({done: reloaded, total: affected.size})},
593
650
  );
@@ -96,9 +96,10 @@ export interface IResourceTableSpec {
96
96
  * Record-level (ACL) guard for a table.
97
97
  *
98
98
  * The ACL lives in `access_acl` rows — `(principalId, actionId, targetId,
99
- * targetKind, effect)` — where `principalId` is a user, role, unit or capability,
100
- * `targetId` is the guarded record (or a scope node) and `effect` is `allow` /
101
- * `deny` (deny always wins).
99
+ * targetKind, effect)` — where `principalId` is a user, role or unit (never a
100
+ * capability — the filter resolves the caller, their effective roles and their
101
+ * units), `targetId` is the guarded record (or a scope node) and `effect` is
102
+ * `allow` / `deny` (a deny beats a grant for a record inside a scope).
102
103
  */
103
104
  export interface IAclTableSpec {
104
105
  /**
@@ -13,10 +13,12 @@ import type {IAclAdapterConfig} from '../schema/knex/types.ts';
13
13
  * access_acl(aclId, principalId, actionId, targetId, targetKind, effect, isActive)
14
14
  * ```
15
15
  *
16
- * - `principalId` — a user, role, unit or capability resource id;
16
+ * - `principalId` — a user, role or unit resource id (never a capability: the
17
+ * filter resolves the caller, their effective roles and their units);
17
18
  * - `actionId` — the `access_action` resource of the guarded method;
18
19
  * - `targetId` — the guarded record, or a **scope** node (`targetKind`);
19
- * - `effect` — `allow` / `deny`, where **deny always wins**.
20
+ * - `effect` — `allow` / `deny`; a deny beats a grant for a record inside
21
+ * a scope, while a `record`-targeted deny on a record in no scope is ignored.
20
22
  *
21
23
  * Two things grant access, and both narrow RBAC (a method the caller's action
22
24
  * list does not contain is refused by the gateway before this ever runs):