@catheadowl/ukp 0.1.0 → 0.2.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.
@@ -2,7 +2,7 @@ import { ENDPOINT_NAME, loadManifest } from "../config/manifest.ts";
2
2
  import { FILE_NATIVE_CAPABILITIES, isFileNativeCapability } from "../config/file-native.ts";
3
3
  import { diagnoseService, type ProviderResolver } from "./diagnose.ts";
4
4
  import {
5
- assertRemoteUrlAllowed,
5
+ assertRegistrableRemoteUrl,
6
6
  isRemoteBinding,
7
7
  parseRemoteUrl,
8
8
  readRegistry,
@@ -56,7 +56,9 @@ export const REGISTER_SPEC: UkpCommandSpec = {
56
56
  usage: "[options]",
57
57
  strictArguments: true,
58
58
  options: [
59
- { flags: "--url <url>", help: "register a remote ukp-serve endpoint (https, ssh://host[:port][/endpoint], or loopback http); a host door url (its document declares scope:\"host\") imports every endpoint behind the door — binding urls gain the endpoint-name path (ssh://ali/notes)" },
59
+ { flags: "--url <url>", help: "register a remote ukp-serve endpoint (https://host[:port], ssh://host[/endpoint] — ssh takes no port, the woken door picks its own — or loopback http); a host door url (its document declares scope:\"host\") imports every endpoint behind the door — binding urls gain the endpoint-name path (ssh://ali/notes)" },
60
+ { flags: "--endpoint <name>", help: "assert the remote-declared name of the endpoint being registered (expected-name assertion; with a host door this imports only that endpoint)" },
61
+ { flags: "--name <handle>", help: "remote only: register under this local handle — the registry key and ukp:// authority — instead of the declared name; use it when the declared name is already taken" },
60
62
  { flags: "--select <names>", help: "with a host door url: import only the named endpoints (comma-separated); a name not on the door is a usage error" },
61
63
  { flags: "--token <token>", help: "store the bearer token in the binding (plaintext, 0600 registry file); with a host door it is copied into every imported binding; UKP_ENDPOINT_<NAME>_TOKEN overrides it at call time" },
62
64
  ],
@@ -70,17 +72,23 @@ export const REGISTER_SPEC: UkpCommandSpec = {
70
72
  "Binding:",
71
73
  " The Host Registry binds the Service Manifest's effective name to this",
72
74
  " folder's location; 'ukp list' shows the resulting bindings.",
73
- " With --url, the name and instance identity come from the remote",
74
- " discovery document (/.well-known/ukp.json) and are pinned TOFU-style;",
75
- " bearer-token services read UKP_ENDPOINT_<NAME>_TOKEN at call time.",
75
+ " With --url, the declared name and instance identity come from the",
76
+ " remote discovery document (/.well-known/ukp.json) and are pinned",
77
+ " TOFU-style as provenance; your registry binds them under a local",
78
+ " handle — the declared name by default, or --name <handle> when that",
79
+ " name is already taken. Use --endpoint <name> as an expected-name",
80
+ " assertion on the declared name; bearer-token services read",
81
+ " UKP_ENDPOINT_<NAME>_TOKEN (keyed by the handle) at call time.",
76
82
  "",
77
83
  "Host doors (ADR-REM-004):",
78
84
  " 'ukp register --url ssh://ali' fetches the door's discovery document",
79
85
  " and imports every endpoint behind it (a door url may also name one",
80
86
  " endpoint: ssh://ali/notes). Import is explicit and idempotent:",
81
87
  " re-running refreshes TOFU pins and tokens. Name conflicts are skipped",
82
- " with a visible reason (never silently renamed); --select narrows the",
83
- " import; door growth shows in 'ukp list' as drift notes until imported.",
88
+ " with a visible reason (never silently renamed) — re-register that one",
89
+ " endpoint with --name to land it under another handle; --select narrows",
90
+ " the import; door growth shows in 'ukp list' as drift notes until",
91
+ " imported.",
84
92
  "",
85
93
  ].join("\n"),
86
94
  };
@@ -146,19 +154,39 @@ export function executeRegisterCommand(
146
154
  }
147
155
 
148
156
  try {
149
- const parsed = parseKitArgs<{ url?: string; token?: string; select?: string }>(REGISTER_SPEC, args);
157
+ const parsed = parseKitArgs<{ url?: string; token?: string; select?: string; endpoint?: string; name?: string }>(REGISTER_SPEC, args);
158
+ const expectedName = parsed.options.endpoint;
159
+ if (expectedName !== undefined && !ENDPOINT_NAME.test(expectedName)) {
160
+ throw new KitUsageError("--endpoint requires a valid endpoint name");
161
+ }
162
+ const handleName = parsed.options.name;
163
+ if (handleName !== undefined && !ENDPOINT_NAME.test(handleName)) {
164
+ throw new KitUsageError("--name requires a valid endpoint name");
165
+ }
166
+ if (handleName !== undefined && parsed.options.url === undefined) {
167
+ // N-1 (ADR-REM-007): a local name lives in the Service Manifest the
168
+ // folder owner controls; the registry naming slot is remote-only.
169
+ throw new KitUsageError("--name chooses a remote registration handle and requires --url");
170
+ }
150
171
  if (parsed.options.url !== undefined) {
151
- return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, context);
172
+ return executeRegisterRemote(parsed.options.url, parsed.options.token, parsed.options.select, expectedName, handleName, context);
152
173
  }
153
174
  if (parsed.options.select !== undefined) {
154
175
  throw new KitUsageError("--select requires --url <door url>");
155
176
  }
156
177
  const report = diagnoseService(context.currentDirectory, context.resolveProvider);
178
+ if (expectedName !== undefined && expectedName !== report.service.effectiveName) {
179
+ throw new Error(`expected endpoint '${expectedName}', but this Service effective name is '${report.service.effectiveName}'`);
180
+ }
157
181
  registerAt(context.registryPath, report.service.effectiveName, report.service.folder);
158
182
  const lines = [
159
183
  `registered: ${report.service.effectiveName}`,
160
184
  ...(report.service.manifest.description ? [`description: ${report.service.manifest.description}`] : []),
161
185
  `location: ${report.service.folder}`,
186
+ // Register never edits Client Config (guide client owns that); this is
187
+ // a pointer for the cross-workspace consumption case, which is the
188
+ // standard follow-up after registering a Service others will reference.
189
+ "next: to use this endpoint by default from another workspace, see 'ukp guide client' (workspace .ukp/client.toml)",
162
190
  ];
163
191
  return {
164
192
  exitCode: 0,
@@ -193,37 +221,71 @@ function declaredCapabilitySummary(capabilities: Record<string, unknown>): strin
193
221
  return extras.length > 0 ? extras.join(",") : "-";
194
222
  }
195
223
 
196
- /** Import the door roster into the registry (ADR-REM-004 / O-4): default is
197
- * every endpoint, `--select` narrows, name/url conflicts are skipped with a
198
- * visible reason (RQ-14: never silently renamed), and same name+url is the
199
- * idempotent refresh (TOFU pin + token, the W4 semantics in batch). ≥1
200
- * import/refresh exits 0; all-skipped exits 1. */
224
+ /** Import the door roster into the registry (ADR-REM-004 / O-4; naming
225
+ * residence ADR-REM-007 / D-086): default is every endpoint, `--select`
226
+ * narrows, conflicts are skipped with a visible reason plus the
227
+ * single-endpoint `--name` remedy (never silently renamed), and same
228
+ * url+declared name is the idempotent refresh under the existing handle
229
+ * (TOFU pin + token, the W4 semantics in batch). ≥1 import/refresh exits 0;
230
+ * all-skipped exits 1. */
201
231
  function importDoorEndpoints(
202
232
  origin: string,
203
233
  door: { endpoints: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }> },
204
234
  targets: ReadonlyArray<{ name: string; instance_uid: string; capabilities: Record<string, unknown> }>,
205
235
  credentials: { token?: string; tls_cert?: string; tls_pin?: string },
206
236
  context: InventoryCommandContext,
237
+ handleOverride?: string,
207
238
  ): InventoryCommandResult {
208
239
  const existing = readRegistry(context.registryPath);
209
240
  const lines = [`door ${origin}: ${door.endpoints.length} endpoint(s)`];
210
241
  let landed = 0;
211
242
  for (const target of targets) {
243
+ // N-5 (ADR-REM-007): the url path segment stays the door-declared name —
244
+ // the door routes by it; only the local handle may differ.
212
245
  const url = doorEndpointUrl(origin, target.name);
213
- const sameName = existing.find((endpoint) => endpoint.name === target.name);
214
- if (sameName !== undefined && !(sameName.kind === "remote" && sameName.url === url)) {
215
- lines.push(`skipped: ${target.name} (already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path})`);
246
+ const handle = handleOverride ?? target.name;
247
+ // Mirror of registerRemoteBinding's same-url branch (registry.ts):
248
+ // skip-line wording lives here, refusal semantics there — keep in step.
249
+ const sameUrl = existing.find((endpoint) => endpoint.kind === "remote" && endpoint.url === url);
250
+ if (sameUrl !== undefined) {
251
+ // Legacy bindings carry no declared_name; their handle stood in for it.
252
+ const existingDeclared = sameUrl.declared_name ?? sameUrl.name;
253
+ if (existingDeclared !== target.name) {
254
+ lines.push(`skipped: ${target.name} (url ${url} is bound to '${sameUrl.name}' declaring '${existingDeclared}')`);
255
+ continue;
256
+ }
257
+ // A default gesture (no --name) refreshes the instance under its
258
+ // EXISTING handle (N-2); an explicit --name must match it — explicit
259
+ // means explicit, renaming goes through unregister.
260
+ if (sameUrl.name !== handle && (handleOverride !== undefined || handle !== target.name)) {
261
+ lines.push(`skipped: ${target.name} (url ${url} already bound to '${sameUrl.name}'; unregister it to change the handle)`);
262
+ continue;
263
+ }
264
+ registerRemoteAt(context.registryPath, {
265
+ name: sameUrl.name,
266
+ declared_name: target.name,
267
+ url,
268
+ instance_uid: target.instance_uid,
269
+ ...(credentials.token !== undefined ? { token: credentials.token } : {}),
270
+ ...(credentials.tls_cert !== undefined && credentials.tls_pin !== undefined
271
+ ? { tls_cert: credentials.tls_cert, tls_pin: credentials.tls_pin }
272
+ : {}),
273
+ });
274
+ landed += 1;
275
+ lines.push(`refreshed: ${sameUrl.name} (TOFU pin/credentials refreshed)`);
216
276
  continue;
217
277
  }
218
- const sameUrl = existing.find(
219
- (endpoint) => endpoint.kind === "remote" && endpoint.url === url && endpoint.name !== target.name,
220
- );
221
- if (sameUrl !== undefined) {
222
- lines.push(`skipped: ${target.name} (url ${url} already bound to '${sameUrl.name}')`);
278
+ const sameName = existing.find((endpoint) => endpoint.name === handle);
279
+ if (sameName !== undefined) {
280
+ lines.push(
281
+ `skipped: ${target.name} (name '${handle}' already bound to ${sameName.kind === "remote" ? sameName.url : sameName.path}; `
282
+ + `re-register this endpoint with --name <handle> to land it under another)`,
283
+ );
223
284
  continue;
224
285
  }
225
286
  registerRemoteAt(context.registryPath, {
226
- name: target.name,
287
+ name: handle,
288
+ declared_name: target.name,
227
289
  url,
228
290
  instance_uid: target.instance_uid,
229
291
  ...(credentials.token !== undefined ? { token: credentials.token } : {}),
@@ -233,9 +295,9 @@ function importDoorEndpoints(
233
295
  });
234
296
  landed += 1;
235
297
  lines.push(
236
- sameName !== undefined
237
- ? `refreshed: ${target.name} (TOFU pin/credentials refreshed)`
238
- : `imported: ${target.name} (${declaredCapabilitySummary(target.capabilities)})`,
298
+ handle !== target.name
299
+ ? `imported: ${handle} (declares ${target.name}; ${declaredCapabilitySummary(target.capabilities)})`
300
+ : `imported: ${handle} (${declaredCapabilitySummary(target.capabilities)})`,
239
301
  );
240
302
  }
241
303
  return {
@@ -246,17 +308,21 @@ function importDoorEndpoints(
246
308
  }
247
309
 
248
310
  /** Remote registration (D-077/D-078 / spec endpoint-registration; host doors
249
- * since W7 / ADR-REM-004): the well-known document at the url's origin
250
- * self-describes — an endpoint document (no `scope`) registers one endpoint
251
- * (today's behavior, name from the document per RQ-14, TOFU pin, --token in
252
- * the binding); a door document (`scope:"host"`) runs the import flow above.
253
- * ssh:// urls tunnel transparently; https urls probe TLS first (W5' / D-079):
254
- * a public-CA chain validates normally, a self-signed certificate is
255
- * TOFU-pinned into the binding(s) and verified on every later call. */
311
+ * since W7 / ADR-REM-004; naming residence ADR-REM-007): the well-known
312
+ * document at the url's origin self-describes — an endpoint document (no
313
+ * `scope`) registers one endpoint (declared name from the document as
314
+ * provenance, local handle = declared name or --name, TOFU pin, --token in
315
+ * the binding); a door document (`scope:"host"`) runs the import flow above
316
+ * (--name only when exactly one target results). ssh:// urls tunnel
317
+ * transparently; https urls probe TLS first (W5' / D-079): a public-CA chain
318
+ * validates normally, a self-signed certificate is TOFU-pinned into the
319
+ * binding(s) and verified on every later call. */
256
320
  async function executeRegisterRemote(
257
321
  url: string,
258
322
  token: string | undefined,
259
323
  select: string | undefined,
324
+ expectedName: string | undefined,
325
+ handleName: string | undefined,
260
326
  context: InventoryCommandContext,
261
327
  ): Promise<InventoryCommandResult> {
262
328
  const selected = select === undefined
@@ -265,10 +331,15 @@ async function executeRegisterRemote(
265
331
  if (selected !== undefined && selected.length === 0) {
266
332
  throw new KitUsageError("--select requires at least one endpoint name");
267
333
  }
334
+ if (selected !== undefined && expectedName !== undefined) {
335
+ throw new KitUsageError("--endpoint and --select cannot be used together");
336
+ }
268
337
 
269
338
  let pool: RemoteTransportPool | undefined;
270
339
  try {
271
- assertRemoteUrlAllowed(url);
340
+ // D-089: registration intake rejects an explicit ssh:// port (dead
341
+ // grammar since the W9 wake); parse-time surfaces stay permissive.
342
+ assertRegistrableRemoteUrl(url);
272
343
  const parts = parseRemoteUrl(url);
273
344
  if (parts === undefined) {
274
345
  throw new Error(`remote endpoint url is not admissible: ${url}`);
@@ -292,6 +363,11 @@ async function executeRegisterRemote(
292
363
  const rosterNames = roster.map((endpoint) => endpoint.name).join(", ");
293
364
  let targets;
294
365
  if (parts.endpointName !== undefined) {
366
+ if (expectedName !== undefined && expectedName !== parts.endpointName) {
367
+ throw new KitUsageError(
368
+ `remote url selects endpoint '${parts.endpointName}', but --endpoint asserts '${expectedName}'`,
369
+ );
370
+ }
295
371
  // A path segment addresses ONE endpoint through the door (H-rehearsal):
296
372
  // membership is checked against the live roster, unknown names say so.
297
373
  const found = roster.find((endpoint) => endpoint.name === parts.endpointName);
@@ -299,6 +375,12 @@ async function executeRegisterRemote(
299
375
  throw new Error(`no such endpoint '${parts.endpointName}' on door ${parts.origin} (available: ${rosterNames})`);
300
376
  }
301
377
  targets = [found];
378
+ } else if (expectedName !== undefined) {
379
+ const found = roster.find((endpoint) => endpoint.name === expectedName);
380
+ if (found === undefined) {
381
+ throw new Error(`no such endpoint '${expectedName}' on door ${parts.origin} (available: ${rosterNames})`);
382
+ }
383
+ targets = [found];
302
384
  } else if (selected !== undefined) {
303
385
  const unknown = selected.filter((name) => !roster.some((endpoint) => endpoint.name === name));
304
386
  if (unknown.length > 0) {
@@ -310,6 +392,11 @@ async function executeRegisterRemote(
310
392
  } else {
311
393
  targets = [...roster];
312
394
  }
395
+ if (handleName !== undefined && targets.length !== 1) {
396
+ throw new KitUsageError(
397
+ `--name applies to a single endpoint, but this import targets ${targets.length}; narrow with --select/--endpoint or drop --name`,
398
+ );
399
+ }
313
400
  const result = importDoorEndpoints(
314
401
  parts.origin,
315
402
  { endpoints: roster },
@@ -321,6 +408,7 @@ async function executeRegisterRemote(
321
408
  : {}),
322
409
  },
323
410
  context,
411
+ handleName,
324
412
  );
325
413
  const nameHint = wellKnown.door.bearerRequired && token === undefined
326
414
  ? [`note: this door requires a bearer token; pass --token or set UKP_ENDPOINT_<NAME>_TOKEN per endpoint`]
@@ -340,15 +428,41 @@ async function executeRegisterRemote(
340
428
  if (selected !== undefined) {
341
429
  throw new KitUsageError(`--select requires a host door url: ${parts.origin} serves a single endpoint`);
342
430
  }
343
- const name = wellKnown.discovery.doc.name;
344
- if (!ENDPOINT_NAME.test(name)) {
345
- throw new Error(`discovery document declares an invalid endpoint name '${name}'`);
431
+ const declaredName = wellKnown.discovery.doc.name;
432
+ if (!ENDPOINT_NAME.test(declaredName)) {
433
+ throw new Error(`discovery document declares an invalid endpoint name '${declaredName}'`);
434
+ }
435
+ if (expectedName !== undefined && expectedName !== declaredName) {
436
+ throw new Error(`expected endpoint '${expectedName}', but discovery document declares '${declaredName}'`);
346
437
  }
347
438
  if (wellKnown.discovery.doc.instance_uid === undefined || typeof wellKnown.discovery.doc.instance_uid !== "string") {
348
439
  throw new Error("discovery document carries no instance_uid; the service must run a ukp-remote v1 server");
349
440
  }
441
+ // ADR-REM-007 / D-086: the handle is the consumer-chosen local name
442
+ // (defaults to the declared name); the declared name is stored as
443
+ // provenance and stays the target of the expected-name assertion above.
444
+ // N-2: a default-gesture re-registration of an already-tracked instance
445
+ // (same url, same declared name) refreshes it under its EXISTING handle —
446
+ // an explicitly different --name is refused first (explicit means
447
+ // explicit; renaming goes through unregister).
448
+ const requestedHandle = handleName ?? declaredName;
449
+ const tracked = readRegistry(context.registryPath).find(
450
+ (endpoint) => endpoint.kind === "remote" && endpoint.url === url,
451
+ );
452
+ if (
453
+ tracked !== undefined && handleName !== undefined && handleName !== tracked.name
454
+ && (tracked.declared_name ?? tracked.name) === declaredName
455
+ ) {
456
+ throw new Error(`remote endpoint ${url} is already registered as '${tracked.name}'; unregister it first to change the handle`);
457
+ }
458
+ const handle = tracked !== undefined
459
+ && (tracked.declared_name ?? tracked.name) === declaredName
460
+ && requestedHandle === declaredName
461
+ ? tracked.name
462
+ : requestedHandle;
350
463
  registerRemoteAt(context.registryPath, {
351
- name,
464
+ name: handle,
465
+ declared_name: declaredName,
352
466
  url,
353
467
  instance_uid: wellKnown.discovery.doc.instance_uid,
354
468
  ...(token !== undefined ? { token } : {}),
@@ -356,9 +470,10 @@ async function executeRegisterRemote(
356
470
  ? { tls_cert: tlsProbe.certPem, tls_pin: tlsProbe.spkiPin }
357
471
  : {}),
358
472
  });
359
- const envToken = remoteTokenFor(name);
473
+ const envToken = remoteTokenFor(handle);
360
474
  const lines = [
361
- `registered (remote): ${name}`,
475
+ `registered (remote): ${handle}`,
476
+ ...(handle !== declaredName ? [`declares: ${declaredName} (remote-declared name, stored as provenance)`] : []),
362
477
  `url: ${url}`,
363
478
  `instance_uid: ${wellKnown.discovery.doc.instance_uid}`,
364
479
  ...(pinSelfSigned && tlsProbe !== undefined
@@ -366,9 +481,9 @@ async function executeRegisterRemote(
366
481
  : []),
367
482
  ...(wellKnown.discovery.doc.description !== undefined ? [`description: ${wellKnown.discovery.doc.description}`] : []),
368
483
  ...(token !== undefined
369
- ? [`auth: token stored in registry binding (plaintext; env UKP_ENDPOINT_${name.toUpperCase().replace(/-/g, "_")}_TOKEN overrides)`]
484
+ ? [`auth: token stored in registry binding (plaintext; env UKP_ENDPOINT_${handle.toUpperCase().replace(/-/g, "_")}_TOKEN overrides)`]
370
485
  : wellKnown.discovery.bearerRequired && envToken === undefined
371
- ? [`note: this endpoint requires a bearer token; pass --token or set UKP_ENDPOINT_${name.toUpperCase().replace(/-/g, "_")}_TOKEN`]
486
+ ? [`note: this endpoint requires a bearer token; pass --token or set UKP_ENDPOINT_${handle.toUpperCase().replace(/-/g, "_")}_TOKEN`]
372
487
  : []),
373
488
  ];
374
489
  return { exitCode: 0, stdout: lines.join("\n"), stderr: "" };
@@ -420,7 +535,7 @@ export function executeListCommand(
420
535
  rendered.push(await renderRemoteListRow(endpoint, pool));
421
536
  }
422
537
  const drift = await collectDoorDriftNotes(endpoints, pool);
423
- return renderListOutput(rendered, drift);
538
+ return renderListOutput(rendered, [...collectSameInstanceNotes(endpoints), ...drift]);
424
539
  } finally {
425
540
  pool.close();
426
541
  }
@@ -456,12 +571,16 @@ function renderListOutput(
456
571
  * endpoints degrade to `(unavailable)` + one stderr warning — the same
457
572
  * inventory semantics as an unreadable local manifest. Door-form bindings
458
573
  * (`ssh://ali/notes`) fetch their per-endpoint document through the shared
459
- * pool's tunnel. */
574
+ * pool's tunnel. A handle that differs from the declared name (ADR-REM-007)
575
+ * carries the declaration inline for audit. */
460
576
  async function renderRemoteListRow(
461
577
  endpoint: RegistryBinding,
462
578
  pool: RemoteTransportPool,
463
579
  ): Promise<{ line: string; warning?: string }> {
464
580
  const url = endpoint.url!;
581
+ const declaredNote = endpoint.declared_name !== undefined && endpoint.declared_name !== endpoint.name
582
+ ? ` (declares ${endpoint.declared_name})`
583
+ : "";
465
584
  try {
466
585
  const transport = await pool.acquire(endpoint);
467
586
  const discovery = await fetchDiscoveryDocument(endpoint, transport, resolveRemoteToken(endpoint));
@@ -470,16 +589,40 @@ async function renderRemoteListRow(
470
589
  .map(([name]) => name)
471
590
  .sort();
472
591
  const warnings = discovery.warnings.length > 0 ? { warning: discovery.warnings.join("; ") } : {};
473
- return { line: `${endpoint.name}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
592
+ return { line: `${endpoint.name}${declaredNote}\t${url}\t${extras.length > 0 ? extras.join(",") : "-"}`, ...warnings };
474
593
  } catch (error) {
475
594
  const headline = (error instanceof Error ? error.message : String(error)).split("\n")[0];
476
595
  return {
477
- line: `${endpoint.name}\t${url}\t(unavailable)`,
596
+ line: `${endpoint.name}${declaredNote}\t${url}\t(unavailable)`,
478
597
  warning: `endpoint '${endpoint.name}' capabilities unavailable: ${headline}`,
479
598
  };
480
599
  }
481
600
  }
482
601
 
602
+ /** Same-instance double-handle note (ADR-REM-007 / N-2): two handles may
603
+ * legitimately point at one service through different urls (e.g. an ssh door
604
+ * and an https door of the same host); the shared instance_uid makes it
605
+ * visible. A note, not an error — each handle is a valid addressing surface,
606
+ * but ukp:// references under the two handles are not interchangeable. */
607
+ function collectSameInstanceNotes(endpoints: readonly RegistryBinding[]): string[] {
608
+ const byUid = new Map<string, string[]>();
609
+ for (const endpoint of endpoints) {
610
+ if (!isRemoteBinding(endpoint) || endpoint.instance_uid === undefined) continue;
611
+ const handles = byUid.get(endpoint.instance_uid) ?? [];
612
+ handles.push(endpoint.name);
613
+ byUid.set(endpoint.instance_uid, handles);
614
+ }
615
+ const notes: string[] = [];
616
+ for (const [uid, handles] of byUid) {
617
+ if (handles.length > 1) {
618
+ notes.push(
619
+ `note: ${[...handles].sort().map((handle) => `'${handle}'`).join(" and ")} pin the same instance_uid ${uid} — one service under two handles`,
620
+ );
621
+ }
622
+ }
623
+ return notes;
624
+ }
625
+
483
626
  /** Door drift notes (ADR-REM-004 / O-4): group door-form bindings by their
484
627
  * url origin, fetch each door's document ONCE per invocation (riding the
485
628
  * pool's shared tunnel), and note the endpoints the door serves that the
@@ -501,7 +644,9 @@ async function collectDoorDriftNotes(
501
644
  origins.set(parts.origin, group);
502
645
  }
503
646
  group.members.push(endpoint);
504
- group.imported.add(endpoint.name);
647
+ // The roster speaks declared names; a handle that differs from the
648
+ // declared name (ADR-REM-007) must not read as "not imported".
649
+ group.imported.add(endpoint.declared_name ?? endpoint.name);
505
650
  }
506
651
  const notes: string[] = [];
507
652
  for (const [origin, group] of [...origins.entries()].sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)) {
@@ -125,7 +125,7 @@ export function executeNavCommand(
125
125
  if (error instanceof HelpRequestError) {
126
126
  return { exitCode: 0, stdout: renderNavHelp(), stderr: "" };
127
127
  }
128
- if (error instanceof KitUsageError) {
128
+ if (error instanceof KitUsageError || error instanceof NavUsageError) {
129
129
  return { exitCode: 2, stdout: "", stderr: renderNavUsageError(error.message) };
130
130
  }
131
131
  throw error;
@@ -11,7 +11,8 @@ import {
11
11
  type ReadRequest,
12
12
  type LineRange,
13
13
  } from "../capabilities/read.ts";
14
- import { isValidPin } from "../capabilities/rename-recovery.ts";
14
+ import { isValidPin, pinHashOf } from "../capabilities/rename-recovery.ts";
15
+ import { isBareDocidReference, stripDocidHash } from "../capabilities/qmd.ts";
15
16
  import {
16
17
  fetchDiscoveryDocument,
17
18
  openRemoteTransport,
@@ -89,7 +90,14 @@ export const READ_SPEC: UkpCommandSpec = {
89
90
  flags: "--from <route>",
90
91
  help: "resolve a document-relative reference (../x.md, bare filename) against this source document's endpoint-relative route",
91
92
  },
92
- { flags: "--pin <sha256-hex>", help: "verify rename recovery against this ukp-pin content hash (sha256-<64 hex>)" },
93
+ {
94
+ flags: "--pin <sha256-hex>",
95
+ help: "verify rename recovery against this ukp-pin content hash (sha256-<64 hex>; emit the current pin with --show-pin)",
96
+ },
97
+ {
98
+ flags: "--show-pin",
99
+ help: "emit the ukp-pin for the read resource on stderr (<!-- ukp-pin: sha256-… -->, whole-file LF-normalized sha256; embed next to a ukp:// reference to power rename recovery). Local reads allow a line window — the pin still covers the whole file; remote reads need the whole file, so drop the window there",
100
+ },
93
101
  { flags: "--format <mode>", help: "output mode: 'json' emits a structured failure envelope (body still goes to stdout); default is human output" },
94
102
  ],
95
103
  helpSuffix: [
@@ -111,6 +119,7 @@ interface ReadCommandOptions extends Record<string, unknown> {
111
119
  lines?: string;
112
120
  from?: string;
113
121
  pin?: string;
122
+ showPin?: boolean;
114
123
  format?: string;
115
124
  }
116
125
 
@@ -266,11 +275,22 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
266
275
  if (parsed.options.pin !== undefined && !isValidPin(parsed.options.pin)) {
267
276
  // Q7-narrowed SRI form only: no multi-algorithm negotiation, no upper-case
268
277
  // hex tolerance — the pin is machine-written, not hand-typed.
269
- throw new KitUsageError("--pin must use sha256-<64 lowercase hex> (the ukp-pin form emitted next to ukp:// references)");
278
+ throw new KitUsageError("--pin must use sha256-<64 lowercase hex> (the ukp-pin form embedded next to ukp:// references; emit one with --show-pin)");
270
279
  }
271
280
  if (parsed.options.format !== undefined && parsed.options.format !== "json") {
272
281
  throw new KitUsageError("--format only supports 'json'");
273
282
  }
283
+ // The ukp-pin is a file-slot concept (same boundary as pin verification in
284
+ // the recovery descent): provider-tier references never expose whole-file
285
+ // content, so emission is refused by input shape at parse time.
286
+ if (parsed.options.showPin === true && path !== undefined) {
287
+ const barePath = stripDocidHash(path);
288
+ if (path.startsWith("qmd://") || isBareDocidReference(barePath)) {
289
+ throw new KitUsageError(
290
+ "--show-pin is file-slot-only: the ukp-pin covers whole-file content, and provider references (docid, qmd://) do not expose it",
291
+ );
292
+ }
293
+ }
274
294
  const pin = parsed.options.pin;
275
295
  if (parsed.options.g) throw new KitUsageError("read requires --endpoint <name> and does not support -g");
276
296
  // Unexpected positionals are rejected before the missing-flag checks so the
@@ -280,15 +300,19 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
280
300
  `unexpected argument '${unexpected}'; read accepts exactly one reference. Use '--endpoint <name>' to select an endpoint.`,
281
301
  );
282
302
  }
283
- const withPin = <T extends object>(request: T): T & { pin?: string } =>
284
- parsed.options.pin === undefined ? request : { ...request, pin: parsed.options.pin };
303
+ const withPinOptions = <T extends object>(request: T): T & { pin?: string; emitPin?: boolean } => {
304
+ let out: T & { pin?: string; emitPin?: boolean } = request;
305
+ if (parsed.options.pin !== undefined) out = { ...out, pin: parsed.options.pin };
306
+ if (parsed.options.showPin === true) out = { ...out, emitPin: true };
307
+ return out;
308
+ };
285
309
  const format = parsed.options.format === "json" ? ("json" as const) : undefined;
286
310
 
287
311
  if (path !== undefined && UKP_URI_PREFIX_RE.test(path)) {
288
312
  if (parsed.options.from !== undefined) {
289
313
  throw new KitUsageError("a ukp:// URI carries its own endpoint; --from is for document-relative references");
290
314
  }
291
- return { request: withPin(parseUkpUri(path, { endpoint: parsed.options.endpoint, lines: parsed.options.lines })), ...(format ? { format } : {}) };
315
+ return { request: withPinOptions(parseUkpUri(path, { endpoint: parsed.options.endpoint, lines: parsed.options.lines })), ...(format ? { format } : {}) };
292
316
  }
293
317
  // Tolerant tier (ADR-URI-001): an absolute filesystem path carries its own
294
318
  // endpoint (Registry-matched), so --endpoint is neither required nor
@@ -303,7 +327,7 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
303
327
  );
304
328
  }
305
329
  return {
306
- request: withPin({
330
+ request: withPinOptions({
307
331
  path,
308
332
  ...(parsed.options.lines === undefined ? {} : { lines: parseLineRange(parsed.options.lines) }),
309
333
  }),
@@ -321,7 +345,7 @@ function parseReadWithFormat(args: readonly string[]): { request: ReadRequest; f
321
345
  }
322
346
 
323
347
  return {
324
- request: withPin({
348
+ request: withPinOptions({
325
349
  endpoint: parsed.options.endpoint,
326
350
  path,
327
351
  ...(parsed.options.from === undefined ? {} : { fromRef: parsed.options.from }),
@@ -450,6 +474,17 @@ async function executeRemoteRead(
450
474
  binding: RegistryBinding,
451
475
  registryPath?: string,
452
476
  ): Promise<ReadCommandResult> {
477
+ // Remote emission needs whole-file content, and a line window would arrive
478
+ // windowed from the server — refuse before opening any transport.
479
+ if (request.emitPin === true && request.lines !== undefined) {
480
+ return {
481
+ exitCode: 2,
482
+ stdout: "",
483
+ stderr: renderReadUsageError(
484
+ "--show-pin needs whole-file content; drop the line window (--lines / #L) — the pin always covers the whole file",
485
+ ),
486
+ };
487
+ }
453
488
  const token = resolveRemoteToken(binding);
454
489
  const warnings: string[] = [];
455
490
  let transport: RemoteTransportHandle | undefined;
@@ -516,9 +551,13 @@ async function executeRemoteRead(
516
551
  transport.close();
517
552
  }
518
553
  if (result.ok) {
554
+ // No line window can be present here (rejected above), so the server
555
+ // returned the whole file — the client-side hash is the same contract as
556
+ // the local capability's pre-window computation.
557
+ const pin = request.emitPin === true ? `sha256-${pinHashOf(result.content)}` : undefined;
519
558
  return renderReadOutcome(
520
559
  request,
521
- { ok: true, result: { content: result.content, recoveryWarnings: [] } },
560
+ { ok: true, result: { content: result.content, ...(pin !== undefined ? { pin } : {}), recoveryWarnings: [] } },
522
561
  format,
523
562
  warnings,
524
563
  );
@@ -282,7 +282,10 @@ async function executeMixedRg(
282
282
  const remoteOutcomes = new Map<string, RgEndpointOutcome>();
283
283
  // One transport pool for the whole invocation (W7 / O-5): same-origin door
284
284
  // bindings share a single ssh tunnel instead of one per row.
285
- const pool = createTransportPool({ registryPath: context.registryPath });
285
+ const pool = createTransportPool({
286
+ registryPath: context.registryPath,
287
+ ...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
288
+ });
286
289
  try {
287
290
  for (const binding of remotes) {
288
291
  if (interrupted) {
@@ -223,7 +223,10 @@ async function executeMixedSearch(
223
223
  const remoteOutcomes = new Map<string, SearchEndpointOutcome>();
224
224
  // One transport pool for the whole invocation (W7 / O-5): same-origin door
225
225
  // bindings share a single ssh tunnel instead of one per row.
226
- const pool = createTransportPool({ registryPath: context.registryPath });
226
+ const pool = createTransportPool({
227
+ registryPath: context.registryPath,
228
+ ...(context.sshCommand === undefined ? {} : { sshCommand: context.sshCommand }),
229
+ });
227
230
  try {
228
231
  for (const binding of remotes) {
229
232
  const token = resolveRemoteToken(binding);