@feasibleone/blong-gogo 1.35.0 → 1.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.36.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.35.0...blong-gogo-v1.36.0) (2026-09-24)
4
+
5
+
6
+ ### Features
7
+
8
+ * add from property to leg ([4925e76](https://github.com/feasibleone/blong/commit/4925e76a7d69000e83804cd9a5fa1ea58ab26d81))
9
+ * docs screenshots and diagrams ([65e37d9](https://github.com/feasibleone/blong/commit/65e37d93ef3e71d65b340541e14b50a25f14e3c8))
10
+ * sequence diagram progress points ([dc5b153](https://github.com/feasibleone/blong/commit/dc5b15327f31698a26a75ec73bdf9a0956a43dd9))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * build errors ([145a424](https://github.com/feasibleone/blong/commit/145a424ffc2dc4dfc14801f1002c7fa1e7a543ce))
16
+
3
17
  ## [1.35.0](https://github.com/feasibleone/blong/compare/blong-gogo-v1.34.0...blong-gogo-v1.35.0) (2026-09-21)
4
18
 
5
19
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@feasibleone/blong-gogo",
3
- "version": "1.35.0",
3
+ "version": "1.36.0",
4
4
  "repository": {
5
5
  "url": "git+https://github.com/feasibleone/blong.git"
6
6
  },
@@ -91,7 +91,7 @@
91
91
  "tap": "^21.6.3",
92
92
  "typescript": "^6.0.3",
93
93
  "vite": "^8.2.1",
94
- "@feasibleone/blong-dev": "1.5.0"
94
+ "@feasibleone/blong-dev": "1.6.0"
95
95
  },
96
96
  "scripts": {
97
97
  "browser-check": "node scripts/browser-compat-check.mjs",
@@ -15,7 +15,14 @@ import PQueue from 'p-queue';
15
15
  import ConfigRuntime from './ConfigRuntime.ts';
16
16
  import {isExpectedError, portLogCalls} from './lib.ts';
17
17
  import loop from './loop.ts';
18
- import {adoptInbound, inboundIdentities, isFlowId} from './semanticContext.ts';
18
+ import {
19
+ adoptInbound,
20
+ attachProgress,
21
+ beginProgress,
22
+ inboundIdentities,
23
+ isFlowId,
24
+ takeProgress,
25
+ } from './semanticContext.ts';
19
26
 
20
27
  const errorMap: IErrorMap = {
21
28
  'adapter.configValidation': 'Adapter config validation:\r\n{message}',
@@ -376,15 +383,36 @@ export class AdapterBase<T, C extends IContext> implements AdapterHandlerContext
376
383
  // leg that arrived: that is what turns the caller's declaration into a receipt
377
384
  // the ledger can match, and it happens only when a flow was traced at all, so an
378
385
  // untraced request still pays nothing.
379
- const answered = (): unknown => {
386
+ const answered = async (): Promise<unknown> => {
380
387
  const inbound = inboundIdentities($meta as IMeta);
381
- if (isFlowId(inbound.flow) && inbound.leg !== undefined) {
388
+ const traced = isFlowId(inbound.flow) && inbound.leg !== undefined;
389
+ if (traced) {
382
390
  // The receipt is the receiver's record for the leg it was handed. It carries
383
391
  // no declared target - the caller named the ends - so the ledger can tell a
384
392
  // receipt from a declaration by the leg alone, in one process or many.
385
- portLogCalls(this)?.received(inbound.leg);
393
+ portLogCalls(this)?.received(inbound.leg as string);
394
+ // And the collection starts *before* the handler runs, because the boxes it will
395
+ // read are installed with `enterWith`: one created after the handler's first wait
396
+ // never reaches the frame that answers for the call (T-136).
397
+ beginProgress();
386
398
  }
387
- return run();
399
+ const result = await run();
400
+ // What the handler announced while it ran (`$meta.checkpoint`, `$meta.decide`) is
401
+ // staged in the scope around it and written on no record of its own: the records
402
+ // of a call are the framework's, written before the handler starts and after it
403
+ // returns. Reading the progress here — inside that scope, once the handler is
404
+ // done — is what lets the call answer with its own work: a point becomes a note
405
+ // beside this hop, and a branch becomes the alternative that was taken
406
+ // (PRD R26/R27). Nothing is written when a handler announced nothing, so an
407
+ // uninstrumented call still costs one scope read.
408
+ if (traced) {
409
+ const progress = takeProgress();
410
+ if (progress !== undefined) {
411
+ attachProgress(progress);
412
+ portLogCalls(this)?.answered(inbound.leg as string);
413
+ }
414
+ }
415
+ return result;
388
416
  };
389
417
  // The callee adopts the identity its caller declared, so the records its
390
418
  // handler writes belong to the caller's execution and its own calls are
package/src/Gateway.ts CHANGED
@@ -592,17 +592,17 @@ export default class Gateway extends Internal implements IGateway {
592
592
  // Declare the hop this route is making. The caller is
593
593
  // **`public`** - the surface that answered the request,
594
594
  // named apart from the namespace it calls. The gateway realm
595
- // is a namespace called `gateway`, so a leg whose caller was
596
- // also called `gateway` drew the public surface and that
597
- // namespace as one participant, and a realm answering a call
598
- // to its own namespace read as a self-call. A leg id names a
599
- // unit, and the API surface is a unit of its own - not the
600
- // process either: one process hosts a whole suite, so naming
601
- // the process here would draw every flow of a monolith as the
602
- // same participant.
603
- // `methodName` is already the dotted wire name the leg
604
- // grammar wants, so the leg names the method the request
605
- // asked for. The callee's receipt then carries the same leg,
595
+ // is a namespace called `gateway`, so a caller that was also
596
+ // called `gateway` drew the public surface and that namespace
597
+ // as one participant, and a realm answering a call to its own
598
+ // namespace read as a self-call. The API surface is a unit of
599
+ // its own - not the process either: one process hosts a whole
600
+ // suite, so naming the process here would draw every flow of a
601
+ // monolith as the same participant.
602
+ // `methodName` is the wire name the request asked for, and it
603
+ // is the leg: the caller is declared beside it, so a diagram
604
+ // labels the arrow with the method and draws its source from
605
+ // the unit. The callee's receipt then carries the same leg,
606
606
  // which is what makes the drawing a call rather than a
607
607
  // participation.
608
608
  declareCall(
package/src/Registry.ts CHANGED
@@ -25,6 +25,7 @@ import merge from 'ut-function.merge';
25
25
 
26
26
  import * as blongLib from '@feasibleone/blong-lib';
27
27
  import {renderAll} from '@feasibleone/blong-template';
28
+ import {vocabulary} from '@feasibleone/semantic-log/attachable';
28
29
  import {createAttachCheckpoint} from './checkpoint.ts';
29
30
  import {methodId, methodParts} from './lib.ts';
30
31
  import type {IResolution} from './Resolution.ts';
@@ -369,13 +370,25 @@ export default class Registry extends Internal implements IRegistry {
369
370
  port: object | undefined,
370
371
  ): Promise<{local: object; literals: object[]}> {
371
372
  const attachCheckpoint = this.#attachCheckpoint;
373
+ const recording = (this.#config.checkpointMode ?? 'production') !== 'production';
372
374
  const lib = {
375
+ // The library's own surface first, so an injected handle can never be silently
376
+ // overwritten by a name it happens to share: `blongLib` exports `checkpoint`
377
+ // for the snapshot marker, and the spread used to sit *after* these members.
378
+ ...blongLib,
373
379
  error: this.#error.register.bind(this.#error),
374
- assert: attachCheckpoint ? nodeAssert : undefined,
380
+ assert: recording ? nodeAssert : undefined,
381
+ // The progress-point pair (PRD R26). `checkpoint` reports a milestone from code that
382
+ // has no `$meta` — it is the emitter's own `point` reached through the attachable
383
+ // facade, and it is `undefined` in production for the same reason `$meta.checkpoint`
384
+ // is: a note nobody records must cost nothing. `decide` selects, so it is always
385
+ // there, and it is the same function in every mode — what the mode decides is whether
386
+ // a record carries the rationale, not whether the branch is taken.
387
+ checkpoint: recording ? vocabulary.point : undefined,
388
+ decide: vocabulary.decide,
375
389
  render: (what: object[] | object) =>
376
390
  renderAll(Array.isArray(what) ? merge(...what) : what, this.#platform.context),
377
391
  timing: this.#platform.timing,
378
- ...blongLib,
379
392
  // The runtime services a `library()` function needs, mirroring the
380
393
  // port API (`registry: this`, `platform: …`).
381
394
  //
@@ -52,6 +52,54 @@ t.test('a cache configured without a bound takes the default', async t => {
52
52
  t.equal(log.store.stats().size, 1, 'the default bound retains a record comfortably');
53
53
  });
54
54
 
55
+ t.test('a record carries the branch and the points of the scope that wrote it', async t => {
56
+ // PRD R26/R27: the framework's logger must not have to know about progress points for them to
57
+ // reach a record — they ride the emitter's ambient scope, and what this pins is that the
58
+ // framework's own path (`semanticRecord.ts`, `LogBase`, the call channel that goes through the
59
+ // same logger) reads that scope rather than writing a record of its own.
60
+ const dir = await mkdtemp(join(tmpdir(), 'blong-semantic-log-'));
61
+ t.teardown(() => rm(dir, {recursive: true, force: true}));
62
+ const log = new SemanticLog({level: 'info', cache: {dir}});
63
+ await log.init();
64
+ const at = log.logger('info', {name: 'progress'});
65
+
66
+ const chosen = vocabulary.decide('route-selection', {corridor: 'EUR-USD'}, [
67
+ {name: 'hold', when: () => false, run: () => 'hold'},
68
+ {
69
+ name: 'hubB',
70
+ when: () => true,
71
+ run: () => {
72
+ vocabulary.point('corridor-resolved', {hub: 'B'});
73
+ at.info?.('routing to the far ecosystem');
74
+ return 'hubB';
75
+ },
76
+ },
77
+ ]);
78
+ t.equal(chosen, 'hubB', 'the branch ran');
79
+ await log.stop();
80
+
81
+ let record: {msg?: string; progress?: {regions?: unknown[]; points?: unknown[]}} | undefined;
82
+ for (const id of await cacheRecordIds(dir)) {
83
+ const candidate = await log.store.get(id);
84
+ if (candidate?.msg === 'routing to the far ecosystem') {
85
+ record = candidate;
86
+ }
87
+ }
88
+ t.ok(record, 'the record the branch wrote was retained');
89
+ t.same(
90
+ (record?.progress?.regions as Array<{discriminator: string; chosen: string}>).map(
91
+ region => [region.discriminator, region.chosen],
92
+ ),
93
+ [['route-selection', 'hubB']],
94
+ 'it was emitted inside the branch, which is what a diagram draws as an alt block',
95
+ );
96
+ t.same(
97
+ (record?.progress?.points as Array<{name: string}>).map(point => point.name),
98
+ ['corridor-resolved'],
99
+ 'and the point announced in that scope rides the same record',
100
+ );
101
+ });
102
+
55
103
  t.test('the home-relative cache path the framework uses is expanded', t => {
56
104
  // The framework's own configuration names the shared log cache as
57
105
  // `~/.blong/log-cache`, and the pino transport expands that itself — so a
@@ -329,13 +377,14 @@ t.test('a record made inside a flow names the flow, the call and the position',
329
377
  );
330
378
  const [id] = await cacheRecordIds(dir);
331
379
  const record = (await log.store.get(id ?? '')) as unknown as {
332
- flow?: {id?: string; leg?: string; legTo?: string; legSeq?: string};
380
+ flow?: {id?: string; leg?: string; legFrom?: string; legTo?: string; legSeq?: string};
333
381
  };
334
382
  t.equal(
335
383
  record.flow?.leg,
336
- 'access.db.party.subject.find',
337
- 'the record names the call it was made in',
384
+ 'party.subject.find',
385
+ 'the record names the call it was made in, by the method it was made by',
338
386
  );
387
+ t.equal(record.flow?.legFrom, 'access.db', 'and the unit that made it, beside the method');
339
388
  t.equal(record.flow?.legTo, 'party', 'and the participant the caller aimed at');
340
389
  t.match(String(record.flow?.legSeq), /^[0-9]+$/, 'and its position in the execution');
341
390
  t.equal(isFlowId(record.flow?.id), true, 'and the execution it is part of');
@@ -382,8 +431,8 @@ t.test('the records of a flow reach the cluster service and come back as a diagr
382
431
  t.equal(diagram?.statusCode, 200, 'the execution can be drawn from the records alone');
383
432
  t.match(
384
433
  diagram?.body ?? '',
385
- /access\.db\.party\.subject\.find/,
386
- 'and the drawing names the call the caller declared',
434
+ /party\.subject\.find/,
435
+ 'and the drawing names the call the caller declared, by its method',
387
436
  );
388
437
  });
389
438
 
@@ -0,0 +1,190 @@
1
+ /**
2
+ * What a handler announced, on the answer of the call it handled (PRD R26/R27).
3
+ *
4
+ * The gap this closes, measured before it was closed: a realm handler announced five points and
5
+ * one branch through `$meta.checkpoint` / `$meta.decide`, and **no record carried any of them** —
6
+ * 0 of 5056 retained records. The handles work; the records do not. A call's records are written
7
+ * by the framework *around* the handler (the receipt before it runs, the answer from the caller's
8
+ * end), so the ambient scope the handler staged into never emits one, and everything it announced
9
+ * is dropped with that scope.
10
+ *
11
+ * What is pinned here is the receiver's half: the adapter reads what its handler announced once
12
+ * the handler returns and reports it on the leg it answered, which is a record the ledger already
13
+ * matches. Two properties matter and both are asserted below — the progress is *re-announced* (so
14
+ * the logger's own facet machinery puts it on the record, rather than a second way of writing
15
+ * records being invented), and nothing is written when the handler announced nothing.
16
+ */
17
+ import type {IMeta} from '@feasibleone/blong/types';
18
+ import {attachSemanticVocabulary} from '@feasibleone/semantic-log/attachable';
19
+ import * as vocabulary from '@feasibleone/semantic-log/emitter';
20
+ import t from 'tap';
21
+ import {AdapterBase} from './AdapterBase.ts';
22
+ import {createAttachCheckpoint} from './checkpoint.ts';
23
+
24
+ // The framework reaches the vocabulary by attachment (the emitter's scope is Node-only, and the
25
+ // browser bootstrap loads this module too), so a tap process attaches it exactly as `loadServer.ts`
26
+ // does. Without it the handles degrade and there is nothing to report.
27
+ attachSemanticVocabulary(vocabulary);
28
+
29
+ /** A canonical flow ULID. */
30
+ const FLOW = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
31
+
32
+ /** The leg the caller declared for the call this adapter answers. */
33
+ const LEG = 'gateway.bundle.merge';
34
+
35
+ /**
36
+ * The `$meta` a receiver is handed, built the way the wire builds it: the identities travel in
37
+ * the header bag, and the leg is in it because the caller declared one.
38
+ */
39
+ function inboundMeta(): IMeta {
40
+ const forward = vocabulary.withFlow({id: FLOW, kind: 'transfer.single'}, () =>
41
+ vocabulary.bindLeg({id: LEG, from: 'public', to: 'gateway'}, () =>
42
+ vocabulary.identityHeaders(),
43
+ ),
44
+ );
45
+ return {mtid: 'event', method: 'exec', forward};
46
+ }
47
+
48
+ /** What the fake call channel saw, with the progress staged at the moment it was asked. */
49
+ interface ISeen {
50
+ phase: string;
51
+ leg: string;
52
+ points?: unknown;
53
+ regions?: unknown;
54
+ }
55
+
56
+ /**
57
+ * The adapter under test: the real `handle`, on an instance built without a constructor.
58
+ *
59
+ * `handle` reads exactly three things — the imported handler table, the checkpoint attach hook and
60
+ * the call channel — so those are the three that are set. The alternative, going through
61
+ * `adapterFactory`, would drag in the registry, the schema and the config runtime to test a
62
+ * twenty-line method.
63
+ */
64
+ function receiver(handler: (params: unknown[]) => unknown, seen: ISeen[]) {
65
+ const adapter = Object.create(AdapterBase.prototype) as AdapterBase<unknown, never>;
66
+ adapter.imported = {exec: handler as never};
67
+ adapter._attachCheckpoint = createAttachCheckpoint('debug');
68
+ // The method-id hook is the constructor's, and it is asked before the handler is found. Identity
69
+ // is the honest stub for a test: the real one names a method the way its realm does.
70
+ adapter._methodId = (name: string) => name;
71
+ (adapter as unknown as {log: unknown}).log = {
72
+ calls: {
73
+ enabled: () => true,
74
+ start: () => undefined,
75
+ end: () => undefined,
76
+ error: () => undefined,
77
+ received: (leg: string) => void seen.push({phase: 'received', leg}),
78
+ // Read at the moment the answer is written, which is the whole point: what the
79
+ // framework staged onto the scope is what the record will carry.
80
+ answered: (leg: string) => {
81
+ const points = vocabulary.takePoints();
82
+ const regions = vocabulary.currentRegions();
83
+ seen.push({
84
+ phase: 'answered',
85
+ leg,
86
+ ...(points ? {points} : {}),
87
+ ...(regions ? {regions} : {}),
88
+ });
89
+ },
90
+ },
91
+ };
92
+ return adapter;
93
+ }
94
+
95
+ t.test('the answer of a call carries what its handler announced', async t => {
96
+ const seen: ISeen[] = [];
97
+ const meta = inboundMeta();
98
+ const adapter = receiver(() => {
99
+ meta.checkpoint?.('merge-started', {bundles: 1});
100
+ return meta.decide?.('role-bit', {declared: null}, [
101
+ {name: 'allocated', when: () => true, run: () => 'allocated'},
102
+ {name: 'declared', when: () => false, run: () => 'declared'},
103
+ ]);
104
+ }, seen);
105
+
106
+ const result = await adapter.handle({}, meta);
107
+ t.equal(result, 'allocated', 'the handler answered');
108
+
109
+ t.same(
110
+ seen.map(event => event.phase),
111
+ ['received', 'answered'],
112
+ 'the receiver writes a receipt when the call arrives and an answer when its work is done',
113
+ );
114
+ t.equal(seen[1]?.leg, LEG, 'and the answer names the leg it was handed');
115
+ t.same(
116
+ seen[1]?.points,
117
+ [{name: 'merge-started', data: {bundles: 1}}],
118
+ 'the point the handler announced is staged for the answer',
119
+ );
120
+ t.same(
121
+ (
122
+ seen[1]?.regions as Array<{
123
+ discriminator: string;
124
+ chosen: string;
125
+ candidates: string[];
126
+ }>
127
+ ).map(region => [region.discriminator, region.chosen, region.candidates]),
128
+ [['role-bit', 'allocated', ['allocated', 'declared']]],
129
+ 'and so is the branch it took, so the block can be drawn around this hop',
130
+ );
131
+ });
132
+
133
+ t.test('a handler that waits before it announces is still answered for', async t => {
134
+ // The regression this pins: the collection's boxes are installed with `enterWith`, which
135
+ // reaches the current context and what follows it — never a frame that is already suspended.
136
+ // A handler that awaits before it announces anything resumes in a context whose boxes were
137
+ // created after the receiver's continuation was bound, so without installing them first the
138
+ // branch mark (and every point staged after the wait) reached no record: measured live, the
139
+ // points survived and the branch did not.
140
+ const seen: ISeen[] = [];
141
+ const meta = inboundMeta();
142
+ const adapter = receiver(async () => {
143
+ await new Promise(resolve => setTimeout(resolve, 1));
144
+ meta.checkpoint?.('after-the-wait');
145
+ return meta.decide?.('role-bit', {declared: null}, [
146
+ {name: 'allocated', when: () => true, run: () => 'allocated'},
147
+ {name: 'declared', when: () => false, run: () => 'declared'},
148
+ ]);
149
+ }, seen);
150
+
151
+ await adapter.handle({}, meta);
152
+ t.same(
153
+ seen.map(event => event.phase),
154
+ ['received', 'answered'],
155
+ 'the answer is still written after a wait',
156
+ );
157
+ t.same(seen[1]?.points, [{name: 'after-the-wait'}], 'with the point announced after it');
158
+ t.same(
159
+ (
160
+ seen[1]?.regions as Array<{
161
+ discriminator: string;
162
+ chosen: string;
163
+ candidates: string[];
164
+ }>
165
+ ).map(region => [region.discriminator, region.chosen, region.candidates]),
166
+ [['role-bit', 'allocated', ['allocated', 'declared']]],
167
+ 'and the branch it took, which is what the alt block is drawn from',
168
+ );
169
+ });
170
+
171
+ t.test('a handler that announced nothing writes no answer', async t => {
172
+ const seen: ISeen[] = [];
173
+ const meta = inboundMeta();
174
+ const adapter = receiver(() => ({success: true}), seen);
175
+ await adapter.handle({}, meta);
176
+ t.same(
177
+ seen.map(event => event.phase),
178
+ ['received'],
179
+ 'an uninstrumented call still pays nothing beyond its receipt',
180
+ );
181
+ });
182
+
183
+ t.test('an untraced call records nothing at all', async t => {
184
+ const seen: ISeen[] = [];
185
+ const adapter = receiver(() => {
186
+ return {success: true};
187
+ }, seen);
188
+ await adapter.handle({}, {mtid: 'event', method: 'exec'});
189
+ t.same(seen, [], 'no flow on the call, so no records — the gate is asked first');
190
+ });
@@ -0,0 +1,176 @@
1
+ /**
2
+ * The framework's two progress-point handles (PRD R11/R26).
3
+ *
4
+ * A checkpoint reports a moment; a decision selects a branch. That difference is the whole
5
+ * reason one is optional and the other is not, so it is pinned here: a checkpoint exists
6
+ * only where points are recorded, a decision exists in every mode, and both reach the log
7
+ * through the attachable vocabulary — degrading to nothing and to a plain selection where
8
+ * no emitter is attached, because a dispatch path must never reach the emitter's ambient
9
+ * scope (F-197).
10
+ */
11
+ import type {IMeta} from '@feasibleone/blong/types';
12
+ import {
13
+ attachSemanticVocabulary,
14
+ detachSemanticVocabulary,
15
+ } from '@feasibleone/semantic-log/attachable';
16
+ import * as semantic from '@feasibleone/semantic-log/emitter';
17
+ import t from 'tap';
18
+ import {createAttachCheckpoint} from './checkpoint.ts';
19
+
20
+ const MODES = ['production', 'debug', 'test'] as const;
21
+
22
+ t.test('a branch is taken and its rationale kept, in every mode', t => {
23
+ for (const mode of MODES) {
24
+ const meta: IMeta = {};
25
+ createAttachCheckpoint(mode)(meta);
26
+ const result = meta.decide?.('rate-within-limit', {rate: 2}, [
27
+ {name: 'decline', when: values => (values.rate as number) > 1, run: () => 'declined'},
28
+ {name: 'accept', when: () => true, run: () => 'accepted'},
29
+ ]);
30
+ t.equal(result, 'declined', `${mode}: the branch is taken`);
31
+ t.same(
32
+ meta.decisions,
33
+ [
34
+ {
35
+ discriminator: 'rate-within-limit',
36
+ candidates: ['decline', 'accept'],
37
+ chosen: 'decline',
38
+ values: {rate: 2},
39
+ },
40
+ ],
41
+ `${mode}: and the invocation keeps which one it was`,
42
+ );
43
+ }
44
+ t.end();
45
+ });
46
+
47
+ t.test('a checkpoint exists only where points are recorded', t => {
48
+ const production: IMeta = {};
49
+ createAttachCheckpoint('production')(production);
50
+ t.notOk(
51
+ production.checkpoint,
52
+ 'absent in production, which is what makes the `?.` call cost nothing',
53
+ );
54
+ t.ok(production.decide, 'while the branch helper is there, because a branch cannot be skipped');
55
+
56
+ const debug: IMeta = {};
57
+ createAttachCheckpoint('debug')(debug);
58
+ debug.checkpoint?.('total-calculated', {total: 200});
59
+ t.equal(debug.checkpoints?.length, 1, 'recorded where points are');
60
+ t.equal(debug.checkpoints?.[0]?.name, 'total-calculated');
61
+ t.same(debug.checkpoints?.[0]?.data, {total: 200});
62
+ t.type(debug.checkpoints?.[0]?.timestamp, 'number', 'and stamped when it happened');
63
+ t.end();
64
+ });
65
+
66
+ t.test('a checkpoint is announced to the log, and announces nothing without one', t => {
67
+ detachSemanticVocabulary();
68
+ const detached: IMeta = {};
69
+ createAttachCheckpoint('test')(detached);
70
+ detached.checkpoint?.('no-log');
71
+ t.equal(
72
+ semantic.takePoints(),
73
+ undefined,
74
+ 'nothing is announced where no emitter is attached: the degradation is the whole point',
75
+ );
76
+
77
+ attachSemanticVocabulary(semantic);
78
+ t.teardown(() => detachSemanticVocabulary());
79
+ const attached: IMeta = {};
80
+ createAttachCheckpoint('test')(attached);
81
+ attached.checkpoint?.('total-calculated', {total: 200});
82
+ t.same(
83
+ semantic.takePoints(),
84
+ [{name: 'total-calculated', data: {total: 200}}],
85
+ 'PRD R26: the milestone reaches the log, so a diagram can draw it as a note',
86
+ );
87
+ t.equal(semantic.takePoints(), undefined, 'and is taken once, by the record that carries it');
88
+ t.end();
89
+ });
90
+
91
+ t.test('a decision is announced to the log too, and still selects without one', t => {
92
+ detachSemanticVocabulary();
93
+ const detached: IMeta = {};
94
+ createAttachCheckpoint('test')(detached);
95
+ t.equal(
96
+ detached.decide?.('d', {}, [{name: 'only', when: () => true, run: () => 7}]),
97
+ 7,
98
+ 'the branch is taken with nothing attached',
99
+ );
100
+ t.equal(semantic.takeDecision(), undefined, 'and no rationale is staged to record');
101
+
102
+ attachSemanticVocabulary(semantic);
103
+ t.teardown(() => detachSemanticVocabulary());
104
+ const attached: IMeta = {};
105
+ createAttachCheckpoint('test')(attached);
106
+ attached.decide?.('route-selection', {}, [
107
+ {name: 'hubB', when: () => true, run: () => 'hubB'},
108
+ {name: 'hold', when: () => true, run: () => undefined},
109
+ ]);
110
+ t.same(
111
+ semantic.takeDecision()?.candidates,
112
+ ['hubB', 'hold'],
113
+ 'the rationale is staged for the next record, in evaluation order (PRD R11)',
114
+ );
115
+ t.end();
116
+ });
117
+
118
+ t.test('no branch matching is a recorded non-choice', t => {
119
+ const meta: IMeta = {};
120
+ createAttachCheckpoint('test')(meta);
121
+ t.equal(meta.decide?.('never', {}, [{name: 'a', when: () => false, run: () => 1}]), undefined);
122
+ t.equal(meta.decisions?.[0]?.chosen, 'none', 'and it is recorded as `none` rather than lost');
123
+ t.end();
124
+ });
125
+
126
+ t.test('each predicate is evaluated once', t => {
127
+ const meta: IMeta = {};
128
+ createAttachCheckpoint('test')(meta);
129
+ let evaluations = 0;
130
+ meta.decide?.('counting', {}, [
131
+ {
132
+ name: 'only',
133
+ when: () => {
134
+ evaluations++;
135
+ return true;
136
+ },
137
+ run: () => 'taken',
138
+ },
139
+ ]);
140
+ t.equal(evaluations, 1, 'the `run` is wrapped, not the predicate re-evaluated');
141
+ t.end();
142
+ });
143
+
144
+ t.test('a branch that throws records nothing', t => {
145
+ const meta: IMeta = {};
146
+ createAttachCheckpoint('test')(meta);
147
+ t.throws(
148
+ () =>
149
+ meta.decide?.('boom', {}, [
150
+ {
151
+ name: 'a',
152
+ when: () => true,
153
+ run: () => {
154
+ throw new Error('handler failed');
155
+ },
156
+ },
157
+ ]),
158
+ /handler failed/,
159
+ 'the failure is not swallowed',
160
+ );
161
+ t.notOk(
162
+ meta.decisions,
163
+ 'a decision that never completed is not a decision, so nothing is recorded',
164
+ );
165
+ t.end();
166
+ });
167
+
168
+ t.test('attaching twice does not replace a handle already there', t => {
169
+ const meta: IMeta = {};
170
+ const attach = createAttachCheckpoint('test');
171
+ attach(meta);
172
+ const first = meta.decide;
173
+ attach(meta);
174
+ t.equal(meta.decide, first, 'a dispatch that was already prepared is left alone');
175
+ t.end();
176
+ });
package/src/checkpoint.ts CHANGED
@@ -1,7 +1,10 @@
1
- import type {CheckpointFn, IMeta} from '@feasibleone/blong/types';
1
+ import type {CheckpointFn, DecideFn, IMeta} from '@feasibleone/blong/types';
2
+ import {vocabulary} from '@feasibleone/semantic-log/attachable';
2
3
 
3
4
  /**
4
- * Records a checkpoint in the $meta.checkpoints array.
5
+ * Records a checkpoint in the $meta.checkpoints array, and announces it as a progress
6
+ * point (PRD R26).
7
+ *
5
8
  * Uses `this` binding — works correctly when called as
6
9
  * $meta.checkpoint?.('name', data) with optional chaining.
7
10
  */
@@ -11,22 +14,65 @@ const checkpoint: CheckpointFn = function (this: IMeta, name: string, data?: unk
11
14
  data,
12
15
  timestamp: Date.now(),
13
16
  });
17
+ // The same moment is announced to the log, so a checkpoint reaches the record the
18
+ // sequence diagram is drawn from rather than only the list a test asserts on. The
19
+ // vocabulary degrades to nothing where no emitter is attached, so this is one call
20
+ // and never a way for a dispatch path to import the emitter's ambient scope (F-197).
21
+ vocabulary.point(name, data);
14
22
  };
15
23
 
16
24
  /**
17
- * Attaches the checkpoint function to $meta if not already present.
18
- * The function uses `this` to record into the $meta it's called on.
25
+ * Takes a branch, and keeps the rationale on the invocation that took it (PRD R11/R26).
26
+ *
27
+ * `this`-bound like {@link checkpoint}. The branch's `run` is *wrapped* rather than the
28
+ * predicates re-evaluated: a predicate with a side effect must run exactly once, and the
29
+ * decision recorded has to be the one the code actually took. A branch whose `run` throws
30
+ * records nothing — the throw propagates before the push, because a decision that never
31
+ * completed is not a decision.
19
32
  */
20
- export function attachCheckpoint($meta: IMeta): void {
21
- $meta.checkpoint ??= checkpoint;
22
- }
33
+ const decide: DecideFn = function <T>(
34
+ this: IMeta,
35
+ discriminator: string,
36
+ values: Record<string, unknown>,
37
+ branches: ReadonlyArray<{
38
+ name: string;
39
+ when: (values: Record<string, unknown>) => boolean;
40
+ run: () => T;
41
+ }>,
42
+ ): T | undefined {
43
+ let chosen: string | undefined;
44
+ const wrapped = branches.map(branch => ({
45
+ ...branch,
46
+ run: () => {
47
+ chosen = branch.name;
48
+ return branch.run();
49
+ },
50
+ }));
51
+ const result = vocabulary.decide<T>(discriminator, values, wrapped);
52
+ (this.decisions ??= []).push({
53
+ discriminator,
54
+ candidates: branches.map(branch => branch.name),
55
+ chosen: chosen ?? 'none',
56
+ values,
57
+ });
58
+ return result;
59
+ };
23
60
 
24
61
  /**
25
- * Returns an attachCheckpoint function when checkpoint mode is enabled,
26
- * or undefined for production (zero overhead via optional chaining).
62
+ * Attach the framework's progress-point handles to a dispatch's `$meta`.
63
+ *
64
+ * `decide` is attached in **every** mode: it selects a branch, so a missing one would skip
65
+ * the work rather than cost a note. `checkpoint` is attached only where points are
66
+ * recorded, which is what keeps its `?.` call free in production.
27
67
  */
28
68
  export function createAttachCheckpoint(
29
69
  mode: 'test' | 'debug' | 'production',
30
- ): ((meta: IMeta) => void) | undefined {
31
- return mode === 'production' ? undefined : attachCheckpoint;
70
+ ): (meta: IMeta) => void {
71
+ const recording = mode !== 'production';
72
+ return (meta: IMeta): void => {
73
+ meta.decide ??= decide;
74
+ if (recording) {
75
+ meta.checkpoint ??= checkpoint;
76
+ }
77
+ };
32
78
  }
@@ -1,5 +1,5 @@
1
1
  import {orchestrator, type IMeta} from '@feasibleone/blong/types';
2
- import {legName, recordedCall} from '../../lib.ts';
2
+ import {recordedCall} from '../../lib.ts';
3
3
  import {declareCall, namespaceOf} from '../../semanticContext.ts';
4
4
 
5
5
  export default orchestrator<{destination?: string; appendNamespace?: string}>(({remote}) => ({
@@ -35,10 +35,11 @@ export default orchestrator<{destination?: string; appendNamespace?: string}>(({
35
35
  // name would credit the hop to whichever namespace happened to be served
36
36
  // alongside it.
37
37
  //
38
- // The leg is spelled as every other leg is, `<caller>.<target>.<method>`:
39
- // slash routing is the wire form only, and a `/` in an id is flattened to `-`
40
- // by the leg grammar, which would hide the target the arrow names.
41
- const declared = legName(forwarded);
38
+ // The leg is the method the forwarded call reaches its destination with — the
39
+ // wire name, slash and all (`db/gateway.bundle.find`): that is the method the
40
+ // callee strips back to, and a label that dotted it would no longer be the name
41
+ // the call was made by. Who made it is stated beside the method, not inside it.
42
+ const declared = forwarded;
42
43
  const forward = async (): Promise<unknown[] | undefined> =>
43
44
  (await recordedCall(this, declared, () =>
44
45
  remote.dispatch(...params, {...$meta, method: forwarded}),
@@ -0,0 +1,97 @@
1
+ /**
2
+ * The `lib` a handler is built with (PRD R26).
3
+ *
4
+ * `blongLib` is spread into `lib` first, so a name the library exports cannot silently
5
+ * replace a handle the framework injects: `blongLib` exports `snapshot` (a chain step) and
6
+ * used to export `checkpoint` for it, an unrelated thing that shares a word with the
7
+ * runtime's milestone handle, so the spread used to sit after the injections. The order
8
+ * is the invariant, so it is pinned here rather than left to a comment — together with
9
+ * the mode-gated `assert`, the other member whose presence the mode decides.
10
+ */
11
+ import * as blongLib from '@feasibleone/blong-lib';
12
+ import type {ILib} from '@feasibleone/blong/types';
13
+ import {vocabulary} from '@feasibleone/semantic-log/attachable';
14
+ import t from 'tap';
15
+ import Registry from './Registry.ts';
16
+
17
+ // Minimal stand-ins: `_createHandlers` only reads the error factory, the platform's
18
+ // timing and the api schema, and closes over the rest.
19
+ const components = {
20
+ log: {logger: () => ({info() {}, error() {}, warn() {}, debug() {}})},
21
+ error: {register: () => () => undefined},
22
+ rpcServer: {},
23
+ remote: {remote: () => ({})},
24
+ gateway: {},
25
+ local: {},
26
+ resolution: {},
27
+ watch: {},
28
+ apiSchema: {},
29
+ platform: {context: {}, timing: () => undefined},
30
+ };
31
+
32
+ /** Run one handler through the factory and hand back the `lib` it was given. */
33
+ async function captureLib(mode: 'test' | 'debug' | 'production'): Promise<ILib> {
34
+ const registry = new Registry({checkpointMode: mode}, components as never);
35
+ let lib: ILib | undefined;
36
+ await (
37
+ registry as unknown as {
38
+ _createHandlers(handlers: unknown, port: object | undefined): Promise<unknown>;
39
+ }
40
+ )._createHandlers(
41
+ [
42
+ async (params: {lib: ILib}) => {
43
+ lib = params.lib;
44
+ },
45
+ ],
46
+ undefined,
47
+ );
48
+ if (lib === undefined) {
49
+ throw new Error('the handler factory was not called');
50
+ }
51
+ return lib;
52
+ }
53
+
54
+ t.test('a handler is handed the framework branch helper, not a name the library owns', async t => {
55
+ const lib = await captureLib('test');
56
+ t.type(lib.decide, 'function', 'the branch helper is always injected');
57
+ t.equal(
58
+ lib.decide,
59
+ vocabulary.decide,
60
+ 'and it is the attachable one, so a realm needs no dependency on the log library',
61
+ );
62
+ t.equal(lib.checkpoint, vocabulary.point, 'the milestone handle is the ambient one');
63
+ t.notOk(
64
+ 'checkpoint' in blongLib,
65
+ 'the library does not define that name, so the spread cannot replace it',
66
+ );
67
+ t.notOk('decide' in blongLib, 'nor the branch helper');
68
+ t.notOk('assert' in blongLib, 'nor the assertion handle');
69
+ t.end();
70
+ });
71
+
72
+ t.test('checkpoint follows the mode, while decide is always there', async t => {
73
+ t.type((await captureLib('test')).checkpoint, 'function', 'test mode reports points');
74
+ t.type((await captureLib('debug')).checkpoint, 'function', 'debug mode reports them too');
75
+ t.equal(
76
+ (await captureLib('production')).checkpoint,
77
+ undefined,
78
+ 'and production leaves the slot empty, so `lib.checkpoint?.()` is one call',
79
+ );
80
+ t.type(
81
+ (await captureLib('production')).decide,
82
+ 'function',
83
+ 'a branch is selected in production as anywhere else',
84
+ );
85
+ t.end();
86
+ });
87
+
88
+ t.test('assert is there only where a failure may be expected', async t => {
89
+ t.type((await captureLib('test')).assert, 'function', 'test mode asserts');
90
+ t.type((await captureLib('debug')).assert, 'function', 'debug mode asserts too');
91
+ t.equal(
92
+ (await captureLib('production')).assert,
93
+ undefined,
94
+ 'and production leaves the slot empty, so `lib.assert?.(…)` is one call',
95
+ );
96
+ t.end();
97
+ });
@@ -8,13 +8,14 @@ import {
8
8
  callsFor,
9
9
  capabilityOf,
10
10
  currentIdentity,
11
- enterCapability,
11
+ decide,
12
12
  declareCall,
13
+ enterCapability,
13
14
  enterRequestFlow,
14
15
  inboundIdentities,
15
16
  isFlowId,
16
- legIdFor,
17
17
  namespaceOf,
18
+ point,
18
19
  runInFlow,
19
20
  } from './semanticContext.ts';
20
21
 
@@ -32,6 +33,30 @@ function inbound(fields: string): IMeta {
32
33
  return {mtid: 'request', method: 'm', forward: {'x-semantic-trace': fields}};
33
34
  }
34
35
 
36
+ t.test('the progress-point handles report through whichever vocabulary is attached', t => {
37
+ // PRD R26: a realm or a bootstrap reaches these through this module rather than through the
38
+ // emitter, so what they must be is the *façade's* functions — the ones that degrade when
39
+ // nothing is attached (this module is on the browser's path too). With the vocabulary attached
40
+ // above, the same calls reach the emitter itself.
41
+ point('handler-created', {by: 'semanticContext.test'});
42
+ t.same(
43
+ vocabulary.takePoints(),
44
+ [{name: 'handler-created', data: {by: 'semanticContext.test'}}],
45
+ 'a point reported here is taken by the attached vocabulary',
46
+ );
47
+ const chosen = decide('route', {}, [
48
+ {name: 'a', when: () => false, run: () => 'a'},
49
+ {name: 'b', when: () => true, run: () => 'b'},
50
+ ]);
51
+ t.equal(chosen, 'b', 'and a branch taken here still selects');
52
+ t.equal(
53
+ vocabulary.takeDecision()?.chosen,
54
+ 'b',
55
+ 'while its rationale reaches the vocabulary too',
56
+ );
57
+ t.end();
58
+ });
59
+
35
60
  t.test('a request enters its flow before the hooks that may reject it', async t => {
36
61
  // The enter-style entry. A gateway request is decided in two phases with a
37
62
  // framework boundary between them: the auth and metering hooks run before the
@@ -40,7 +65,11 @@ t.test('a request enters its flow before the hooks that may reject it', async t
40
65
  await (async () => {
41
66
  const published = enterRequestFlow({}, 'access.role.find');
42
67
  t.equal(isFlowId(currentContext().flow?.id), true, 'a flow is minted and entered');
43
- t.equal(currentContext().flow?.kind, 'access.role.find', 'named for the path it arrived on');
68
+ t.equal(
69
+ currentContext().flow?.kind,
70
+ 'access.role.find',
71
+ 'named for the path it arrived on',
72
+ );
44
73
  t.equal(
45
74
  published['x-semantic-trace']?.includes(`flow=${currentContext().flow?.id ?? ''}`),
46
75
  true,
@@ -84,31 +113,61 @@ t.test('a flow id is a ULID and nothing else', t => {
84
113
 
85
114
  t.test('identity is read off the forwarded header bag', t => {
86
115
  t.same(inboundIdentities(undefined), {}, 'a call with no meta carries nothing');
87
- t.same(inboundIdentities({mtid: 'request', method: 'm'}), {}, 'and so does one with no forward block');
88
116
  t.same(
89
- inboundIdentities(inbound(`trace=tr-1,flow=${FLOW},leg=access.db.party.subject.find,to=party,seq=1.2`)),
90
- {trace: 'tr-1', flow: FLOW, leg: 'access.db.party.subject.find', to: 'party', seq: '1.2'},
117
+ inboundIdentities({mtid: 'request', method: 'm'}),
118
+ {},
119
+ 'and so does one with no forward block',
120
+ );
121
+ t.same(
122
+ inboundIdentities(
123
+ inbound(
124
+ `trace=tr-1,flow=${FLOW},leg=party.subject.find,from=access.db,to=party,seq=1.2`,
125
+ ),
126
+ ),
127
+ {
128
+ trace: 'tr-1',
129
+ flow: FLOW,
130
+ leg: 'party.subject.find',
131
+ from: 'access.db',
132
+ to: 'party',
133
+ seq: '1.2',
134
+ },
91
135
  'a full set is read back out',
92
136
  );
93
137
  t.end();
94
138
  });
95
139
 
96
- t.test('a leg is named by dotting the caller and the method', t => {
140
+ t.test('a leg is the method, and the caller travels beside it', t => {
141
+ // The method is what a diagram labels the arrow with, so it is the leg id verbatim -
142
+ // including a forwarded hop's slash (`db/party.subject.find`), which is the name the
143
+ // callee strips back to. The caller is its own identity: repeating it in the label said
144
+ // what the arrow already said, and the two are read for different things (D-249).
145
+ t.equal(namespaceOf('party.subject.find'), 'party', 'the method names the unit it reaches');
97
146
  t.equal(
98
- legIdFor('access.db', 'party.subject.find'),
99
- 'access.db.party.subject.find',
100
- 'a port id and a method join with a dot',
147
+ namespaceOf('db/party.subject.find'),
148
+ 'db',
149
+ 'and a forwarded hop names the one it forwards to',
101
150
  );
102
151
  t.equal(
103
- legIdFor('srv/subject', 'party:subject'),
104
- 'srv-subject.party-subject',
105
- 'characters the grammar forbids are flattened rather than rejected',
152
+ vocabulary.isLegId('db/party.subject.find'),
153
+ true,
154
+ 'a forwarded hop is a lawful leg id',
106
155
  );
156
+ t.equal(
157
+ vocabulary.isServiceName('access.db'),
158
+ true,
159
+ 'and a caller is named by the same grammar',
160
+ );
161
+ t.equal(vocabulary.isServiceName('access/db'), false, 'without the slash a method may carry');
107
162
  t.end();
108
163
  });
109
164
 
110
165
  t.test('a participant is the first segment of the method', t => {
111
- t.equal(namespaceOf('party.subject.find'), 'party', 'a dotted method names its namespace first');
166
+ t.equal(
167
+ namespaceOf('party.subject.find'),
168
+ 'party',
169
+ 'a dotted method names its namespace first',
170
+ );
112
171
  t.equal(namespaceOf('db/party.subject.find'), 'db', 'and so does a rewritten destination');
113
172
  t.equal(namespaceOf('party'), 'party', 'a method in the root namespace is its own');
114
173
  t.end();
@@ -121,13 +180,13 @@ t.test('an outermost entry mints a flow and publishes it for the calls inside it
121
180
  identity: currentIdentity(),
122
181
  }));
123
182
  t.equal(isFlowId(seen.identity.flow), true, 'the entry has an execution id');
124
- t.equal(seen.context.flow?.kind, 'access.user.find', 'whose kind is the method it was addressed to');
125
- t.equal(typeof seen.context.trace, 'string', 'and it is correlated by a trace');
126
183
  t.equal(
127
- seen.identity.leg,
128
- undefined,
129
- 'the entry itself is not a call, so it declares no leg',
184
+ seen.context.flow?.kind,
185
+ 'access.user.find',
186
+ 'whose kind is the method it was addressed to',
130
187
  );
188
+ t.equal(typeof seen.context.trace, 'string', 'and it is correlated by a trace');
189
+ t.equal(seen.identity.leg, undefined, 'the entry itself is not a call, so it declares no leg');
131
190
  t.match(
132
191
  meta.forward?.['x-semantic-trace'] ?? '',
133
192
  new RegExp(`flow=${seen.identity.flow}`),
@@ -155,8 +214,16 @@ t.test('a B3 trace that cannot be used is minted instead', t => {
155
214
  method: 'm',
156
215
  forward: {'x-b3-traceid': 42},
157
216
  } as unknown as IMeta;
158
- t.not(runInFlow(empty, 'm', () => currentContext().trace), '', 'an empty trace is not a trace');
159
- t.not(runInFlow(wrongType, 'm', () => currentContext().trace), 42, 'nor is one that is not a string');
217
+ t.not(
218
+ runInFlow(empty, 'm', () => currentContext().trace),
219
+ '',
220
+ 'an empty trace is not a trace',
221
+ );
222
+ t.not(
223
+ runInFlow(wrongType, 'm', () => currentContext().trace),
224
+ 42,
225
+ 'nor is one that is not a string',
226
+ );
160
227
  t.end();
161
228
  });
162
229
 
@@ -199,13 +266,15 @@ t.test('an inbound call carrying no identity is an outermost entry', t => {
199
266
  });
200
267
 
201
268
  t.test('an inbound identity the grammar refuses is dropped, never repaired', t => {
202
- const badSeq = adoptInbound(
203
- inbound(`flow=${FLOW},leg=payer.quote.rates,seq=1.`),
204
- 'm',
205
- () => currentIdentity(),
269
+ const badSeq = adoptInbound(inbound(`flow=${FLOW},leg=payer.quote.rates,seq=1.`), 'm', () =>
270
+ currentIdentity(),
206
271
  );
207
272
  t.equal(badSeq.leg, 'payer.quote.rates', 'a lawful leg is adopted');
208
- t.equal(badSeq.seq, undefined, 'but a position that is not one is left out rather than guessed');
273
+ t.equal(
274
+ badSeq.seq,
275
+ undefined,
276
+ 'but a position that is not one is left out rather than guessed',
277
+ );
209
278
 
210
279
  const badLeg = adoptInbound(inbound(`flow=${FLOW},leg=!`), 'm', () => currentIdentity());
211
280
  t.equal(badLeg.flow, FLOW, 'the execution is still adopted');
@@ -218,12 +287,16 @@ t.test('an inbound identity the grammar refuses is dropped, never repaired', t =
218
287
 
219
288
  t.test('a call made outside any flow is not declared', t => {
220
289
  const meta: IMeta = {mtid: 'request', method: 'm'};
221
- t.equal(declareCall('access.db', 'party.subject.find', () => 'ran', meta), 'ran', 'the call still runs');
290
+ t.equal(
291
+ declareCall('access.db', 'party.subject.find', () => 'ran', meta),
292
+ 'ran',
293
+ 'the call still runs',
294
+ );
222
295
  t.equal(meta.forward, undefined, 'and nothing is published onto a meta no flow holds');
223
296
  t.end();
224
297
  });
225
298
 
226
- t.test('a call inside a flow is named for the caller that makes it', t => {
299
+ t.test('a call inside a flow is named by its method, with the caller beside it', t => {
227
300
  const meta: IMeta = {mtid: 'request', method: 'access.user.find'};
228
301
  // Read the scope from inside the declaration: the leg only exists while the
229
302
  // call it names is being made.
@@ -237,8 +310,13 @@ t.test('a call inside a flow is named for the caller that makes it', t => {
237
310
  );
238
311
  t.equal(
239
312
  seen.identity.leg,
240
- 'access.db.party.subject.find',
241
- 'the leg names the caller and the method it called',
313
+ 'party.subject.find',
314
+ 'the leg is the method the call is made by — what a diagram labels the arrow with',
315
+ );
316
+ t.equal(
317
+ seen.context.legFrom,
318
+ 'access.db',
319
+ 'and the unit that made it is its own identity, not part of the label',
242
320
  );
243
321
  t.match(String(seen.identity.seq), /^[0-9]+$/, 'and takes the first position in the caller');
244
322
  t.equal(
@@ -248,8 +326,8 @@ t.test('a call inside a flow is named for the caller that makes it', t => {
248
326
  );
249
327
  t.match(
250
328
  meta.forward?.['x-semantic-trace'] ?? '',
251
- /leg=access\.db\.party\.subject\.find/,
252
- 'and the callee is handed the call it is part of',
329
+ /leg=party\.subject\.find,from=access\.db,to=party/,
330
+ 'and the callee is handed the call it is part of, both ends stated',
253
331
  );
254
332
  t.end();
255
333
  });
@@ -259,7 +337,11 @@ t.test('a call declared without a meta still binds the caller scope', t => {
259
337
  const leg = runInFlow(meta, 'm', () =>
260
338
  declareCall('access.db', 'party.subject.find', () => currentIdentity().leg),
261
339
  );
262
- t.equal(leg, 'access.db.party.subject.find', 'the leg is bound whether or not there is a meta to publish on');
340
+ t.equal(
341
+ leg,
342
+ 'party.subject.find',
343
+ 'the leg is bound whether or not there is a meta to publish on',
344
+ );
263
345
  t.end();
264
346
  });
265
347
 
@@ -281,9 +363,17 @@ t.test('the scope a caller is in can be asked about', t => {
281
363
 
282
364
  t.test('a flow records calls unless something decided otherwise', t => {
283
365
  configureCallTrace(undefined);
284
- t.equal(callsFor(undefined, 'access.access.find'), true, 'nothing decided and nothing configured');
366
+ t.equal(
367
+ callsFor(undefined, 'access.access.find'),
368
+ true,
369
+ 'nothing decided and nothing configured',
370
+ );
285
371
  configureCallTrace({enabled: false});
286
- t.equal(callsFor(undefined, 'access.access.find'), false, 'the configuration can opt everything out');
372
+ t.equal(
373
+ callsFor(undefined, 'access.access.find'),
374
+ false,
375
+ 'the configuration can opt everything out',
376
+ );
287
377
  configureCallTrace({off: ['payment']});
288
378
  t.equal(callsFor(undefined, 'payment.transfer.prepare'), false, 'or one entry');
289
379
  configureCallTrace(undefined);
@@ -18,15 +18,15 @@
18
18
  * - **A participant is an RPC namespace** — `party`, `db`, `subject`. It is what
19
19
  * a call targets, what becomes a Kubernetes service, and what the emitter
20
20
  * derives for a record from the method name, so the two agree by construction.
21
- * - **A leg is one call site**, named `<caller>.<called method>`, where the caller
22
- * is the logical unit that made the call — the namespace it answers for, not the
23
- * process it runs in. The emitter's own grammar allows letters, digits and `.`,
24
- * `-`, `_` only, so the plan's `a -> b` arrow is written as a dot path and anything
25
- * else in a caller or method name is flattened to `-`. The id is declared by the
26
- * caller, against the namespace it **aimed at** — which is the namespace the callee
27
- * will derive for itself, because blong's destination rewrite (`party.subject.find`
28
- * becomes `db/party.subject.find`) happens *inside* the receiving port, as a call of
29
- * its own.
21
+ * - **A leg is one call site**, named by the method the call reaches its callee with —
22
+ * `party.subject.find`, or `db/party.subject.find` once blong's destination rewrite has
23
+ * prefixed the namespace it forwards to. The unit that made the call travels beside it as
24
+ * its own identity (`legFrom`, with `legTo` naming the receiver it aimed at), because a
25
+ * diagram draws the ends from the identity and the label from the method: repeating the
26
+ * caller in the label said what the arrow already said, and made every label longer than
27
+ * the arrow it sat on (D-249). The caller is the logical unit the port answers for — never
28
+ * the process it runs in — and the grammar's charset is why a `/` is lawful in a method
29
+ * and not in a participant name.
30
30
  *
31
31
  * ## Propagation, and where it rides
32
32
  *
@@ -118,6 +118,29 @@ export const enterCapability = vocabulary.enterCapability;
118
118
  */
119
119
  export const withStep = step;
120
120
 
121
+ /**
122
+ * Report a progress point, or take a branch, from code that holds no `$meta`.
123
+ *
124
+ * Re-exported beside `withStep` for the same reason it is: a realm or a bootstrap that already
125
+ * reaches this module for its scope machinery should not have to import the emitter to say what
126
+ * the logic was doing (PRD R26). Both degrade when nothing is attached — `point` to nothing,
127
+ * `decide` to the selection alone, because a branch has to be taken either way.
128
+ */
129
+ export const point = vocabulary.point;
130
+ export const decide = vocabulary.decide;
131
+
132
+ /**
133
+ * Take the progress announced in the scope a handler ran in, and announce it again.
134
+ *
135
+ * Both halves of one thing, and both used by the same caller: the adapter reads what its
136
+ * handler announced when the handler returns (`takeProgress`) and stages it so the record the
137
+ * call answers with carries it (`attachProgress`) — the only route a realm handler's points
138
+ * and branches have to a record, since the records of a call are the framework's (PRD R26/R27).
139
+ */
140
+ export const takeProgress = vocabulary.takeProgress;
141
+ export const attachProgress = vocabulary.attachProgress;
142
+ export const beginProgress = vocabulary.beginProgress;
143
+
121
144
  /** The capability decided for this scope, or `undefined` when nothing decided it. */
122
145
  export const capabilityOf = vocabulary.capabilityState;
123
146
 
@@ -157,12 +180,6 @@ export function inboundIdentities(meta: IMeta | undefined): Identities {
157
180
  return readIdentities((meta?.forward ?? {}) as Record<string, unknown>);
158
181
  }
159
182
 
160
- /** A leg id as the emitter's grammar allows it: `<caller>.<method>`, flattened. */
161
- export function legIdFor(caller: string, method: string): string {
162
- const flatten = (text: string): string => text.replace(/[^A-Za-z0-9._-]/g, '-');
163
- return `${flatten(caller)}.${flatten(method)}`;
164
- }
165
-
166
183
  /** The participant a call targets: the first segment of its method. */
167
184
  export function namespaceOf(method: string): string {
168
185
  const at = method.search(/[./]/);
@@ -317,22 +334,25 @@ export function adoptInbound<T>(meta: IMeta, kind: string, fn: () => T): T {
317
334
  * emitter refuses to invent an execution to hold a leg (D3) and inventing one here
318
335
  * would put a lie in the data.
319
336
  *
320
- * `caller` is the port making the call and `method` the wire name it is calling —
321
- * including any destination the caller has already applied, so the declaration
322
- * matches the receiver the call will actually reach.
337
+ * `caller` is the **logical unit** that makes the call — the namespace a port serves, not its
338
+ * port id and not the process it runs in — and `method` is the wire name the call is being made
339
+ * by, including any destination the caller has already applied, so the declaration matches the
340
+ * receiver the call will actually reach. The two travel as separate identities: the method is
341
+ * the label a diagram draws on the arrow, the caller is the arrow's source, and repeating the
342
+ * caller in the label said what the arrow already said (D-249).
323
343
  */
324
344
  export function declareCall<T>(caller: string, method: string, fn: () => T, meta?: IMeta): T {
325
345
  if (currentContext().flow === undefined) {
326
346
  return fn();
327
347
  }
328
- const id = legIdFor(caller, method);
348
+ const id = method;
329
349
  const to = namespaceOf(method);
330
- if (!isLegId(id) || !isServiceName(to)) {
331
- // A port id or method the grammar cannot hold: the call still runs, it is
350
+ if (!isLegId(id) || !isServiceName(caller) || !isServiceName(to)) {
351
+ // A caller or a method the grammar cannot hold: the call still runs, it is
332
352
  // simply not named. Throwing here would fail a request over its telemetry.
333
353
  return fn();
334
354
  }
335
- return bindLeg({id, to}, () => {
355
+ return bindLeg({id, from: caller, to}, () => {
336
356
  if (meta !== undefined) {
337
357
  meta.forward = {...meta.forward, ...identityHeaders()};
338
358
  }