@intentius/behold 0.14.0 → 0.16.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/web/app.js CHANGED
@@ -52,6 +52,10 @@ import { hasPlayhead, renderPlayhead, waitingFor } from "./run-playhead.js";
52
52
  // records a fact for the next tick rather than releasing anything. Same split as
53
53
  // the playhead — the decisions are pure in there, the fetches stay here.
54
54
  import { hasOperator, renderOperator, APPROVED_SEMANTICS } from "./operator.js";
55
+ // #165: the recorded release on a component — which run put it here, from
56
+ // which commit, approved by whom — and, when the ledger's run id cannot be
57
+ // followed, the reason in words rather than a guessed link.
58
+ import { releaseRows } from "./release.js";
55
59
  initTheme();
56
60
  initPanel();
57
61
  mountThemePicker(document.getElementById("panel-theme"));
@@ -314,10 +318,36 @@ function shortDigest(d) {
314
318
  * declared → live form the field-drift section uses for a k8s resource.
315
319
  * Provenance comes last: which bytes were compared, and where they came from.
316
320
  */
321
+ /** The identity axis (chant#2031) in one sentence — what the observed release
322
+ * was deployed from, compared to what this project renders. */
323
+ function renderIdentityText(i) {
324
+ const o = i.observed || {};
325
+ const d = i.declared || {};
326
+ switch (i.match) {
327
+ case "content":
328
+ return `this render, byte for byte — content ${shortDigest(o.contentDigest)} matches`;
329
+ case "input":
330
+ return `this render's inputs — input digest ${shortDigest(o.inputDigest)} matches (bytes may differ per cluster)`;
331
+ case "mismatch":
332
+ return o.contentDigest && d.contentDigest
333
+ ? `a different render — the release records content ${shortDigest(o.contentDigest)}, this project renders ${shortDigest(d.contentDigest)}`
334
+ : `different inputs — the release records input ${shortDigest(o.inputDigest)}, this project renders ${shortDigest(d.inputDigest)}`;
335
+ case "unrecorded":
336
+ return "unknown — no release record names this deploy's render (deployed outside the recorded path, or the ledger could not be read)";
337
+ case "unpinned":
338
+ return "not comparable — this render is unpinned, so it has no identity to compare";
339
+ default:
340
+ return i.match;
341
+ }
342
+ }
343
+
317
344
  function renderDriftSection(section, r) {
318
345
  const rd = section("render diff");
319
346
  rd("verdict", RENDER_VERDICT_LABEL[r.verdict] || r.verdict);
320
347
  if (r.reason) rd("reason", RENDER_UNOBSERVED_LABEL[r.reason] || r.reason);
348
+ // chant#2031: whether what runs was deployed from this render at all — the
349
+ // question the live diff cannot ask, answered from two recorded digests.
350
+ if (r.identity) rd("deployed from", renderIdentityText(r.identity));
321
351
  if (r.counts) {
322
352
  const c = r.counts;
323
353
  rd(
@@ -410,6 +440,10 @@ function inspect(node) {
410
440
  if (a.status) id("release status", a.status);
411
441
  if (a.revision) id("revision", a.revision);
412
442
  if (a.chart) id("chart", a.chart);
443
+ // The deploy's recorded render identity, read back off the ledger
444
+ // (chant#2031, chant ≥ 0.54.0). Absent for a release no record names.
445
+ if (a.inputDigest) id("input digest", shortDigest(a.inputDigest));
446
+ if (a.contentDigest) id("content digest", shortDigest(a.contentDigest));
413
447
  }
414
448
  // chant#1168 (#1089): `_unobserved` carries WHY chant couldn't read this
415
449
  // entity's live state — only ever set alongside the entity overlay's
@@ -542,8 +576,66 @@ function inspect(node) {
542
576
  }
543
577
  }
544
578
 
579
+ // chant#2040: the Terraform address this chant entity was carved out of —
580
+ // the manifest names the file, `chant graph` names the file, and the join is
581
+ // a byte comparison (src/carve-manifest.ts `joinCarvedSources`).
582
+ const carve = node.attrs && node.attrs._carve;
583
+ if (carve) {
584
+ const cf = section("carved from");
585
+ cf("terraform", carve.tfType ? `${carve.target} (${carve.tfType})` : carve.target);
586
+ cf("stage", carve.graduated ? "graduated — chant owns it" : `${carve.stage} — Terraform still owns it`);
587
+ if (carve.at) cf("at", carve.at);
588
+ if (carve.from) cf("estate", carve.from);
589
+ cf("emitted as", carve.file);
590
+ }
591
+
592
+ // chant#2022: what an Op step touches in the estate, as the op.json states it
593
+ // and as the lens resolved it — a resolved value names the card it joined
594
+ // to; an unresolved one is stated as such, never guessed at (src/ops-lens.ts
595
+ // `joinStepEntities`).
596
+ const entityLinks = node.attrs && node.attrs._entityLinks;
597
+ if (entityLinks) {
598
+ const touches = section("touches");
599
+ for (const r of entityLinks.resolved || []) touches(r.value, `${r.node} (${r.via === "id" ? "by id" : "by attribute value"})`);
600
+ for (const v of entityLinks.unresolved || []) touches(v, "no estate node carries this value — not linked");
601
+ }
602
+ // The other end of the same edge: an estate card copied into the ops lens
603
+ // because a step names it.
604
+ const touchedBy = node.attrs && node.attrs._touchedBy;
605
+ if (Array.isArray(touchedBy) && touchedBy.length) {
606
+ const by = section("touched by");
607
+ for (const step of touchedBy) by("step", step);
608
+ }
609
+
610
+ // The recorded release (#165, #61's second clause): the ledger row behold
611
+ // reads on `chant components status` and, until now, read past. Its own
612
+ // section beside "live status", because the two answer different questions
613
+ // — what is running versus what was recorded putting it there — and the
614
+ // reconciliation verdict above is exactly their comparison. The run is a link
615
+ // only when the record itself carries an address (chant#2045); a bare id
616
+ // gets its reason, never a guessed forge (web/release.js).
617
+ const release = node.attrs && node.attrs._release;
618
+ if (release) {
619
+ const rel = section("release");
620
+ for (const [k, v] of releaseRows(release)) {
621
+ if (v && typeof v === "object" && v.href) {
622
+ const a = document.createElement("a");
623
+ a.href = v.href;
624
+ a.textContent = v.text;
625
+ a.target = "_blank";
626
+ a.rel = "noreferrer noopener";
627
+ a.title = `${v.href} — the address the release record itself carries (chant#2045). behold never invents this link.`;
628
+ rel(k, a);
629
+ } else {
630
+ rel(k, v);
631
+ }
632
+ }
633
+ }
634
+
545
635
  // Declared attributes — the source-of-truth values / cross-resource refs.
546
- const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_"));
636
+ // The `release` chip is the card's abbreviation of the section above, not a
637
+ // declared value, so it stays out of this list when the section rendered.
638
+ const attrKeys = Object.keys(node.attrs || {}).filter((k) => !k.startsWith("_") && !(k === "release" && release));
547
639
  if (attrKeys.length) {
548
640
  const decl = section("declared");
549
641
  for (const k of attrKeys) decl(k, fmtValue(node.attrs[k]));
@@ -1626,10 +1718,16 @@ function carvePick(node) {
1626
1718
  * the follow-up; this is the honest still frame of it. */
1627
1719
  function markCarvedCards() {
1628
1720
  const svg = document.querySelector("#graph svg");
1629
- if (!svg || !carvedIds.size) return;
1721
+ if (!svg) return;
1722
+ // chant#2040: a chant node whose declaring file a carve manifest names is
1723
+ // carved out of Terraform — the server joins it as `_carve` on the entity
1724
+ // graph and the overlay, so the same still frame the walkthrough draws also
1725
+ // appears on an ordinary estate that holds manifests.
1726
+ const joined = new Set((lastGraphIr && lastGraphIr.nodes ? lastGraphIr.nodes : []).filter((n) => n.attrs && n.attrs._carve).map((n) => n.id));
1727
+ if (!carvedIds.size && !joined.size) return;
1630
1728
  for (const g of svg.querySelectorAll("[data-node-id]")) {
1631
1729
  const id = g.getAttribute("data-node-id");
1632
- if (!carvedIds.has(id) || g.querySelector('[data-carved="1"]')) continue;
1730
+ if ((!carvedIds.has(id) && !joined.has(id)) || g.querySelector('[data-carved="1"]')) continue;
1633
1731
  g.classList.add("carved");
1634
1732
  // Measured, not read off attributes: pinhole sizes the card from its
1635
1733
  // content and doesn't always stamp width/height, and a missing attribute
@@ -2263,6 +2361,19 @@ function renderApplyProgress(state) {
2263
2361
  // executor — and says so instead of claiming to be an apply.
2264
2362
  summary.textContent = `${state.kind || "apply"}: ${state.status}`;
2265
2363
  wrap.appendChild(summary);
2364
+ // #165: a dispatched run's own page — the one place an environment
2365
+ // protection rule's approval can be granted. A link, never a button: behold
2366
+ // holds no identity that could clear a forge gate, and must not acquire one.
2367
+ if (state.kind === "pipeline" && state.url) {
2368
+ const a = document.createElement("a");
2369
+ a.href = state.url;
2370
+ a.target = "_blank";
2371
+ a.rel = "noreferrer noopener";
2372
+ a.textContent = `run on the forge — approval, if required, is granted there`;
2373
+ a.style.cssText = "font-size:var(--t-caption)";
2374
+ a.title = state.url;
2375
+ wrap.appendChild(a);
2376
+ }
2266
2377
  for (const w of state.waves) {
2267
2378
  const row = document.createElement("div");
2268
2379
  row.style.cssText = "display:flex;align-items:center;gap:8px;flex-wrap:wrap";
@@ -3372,7 +3483,11 @@ async function load(opts = {}) {
3372
3483
  // and no tier (a tier re-evaluates tier-conditioned source, and an Op's
3373
3484
  // IR was already resolved at build). Sending them anyway would also give
3374
3485
  // a static export a different key per tier for one identical snapshot.
3375
- q = new URLSearchParams({ ops: "1" });
3486
+ // `entities=1` (chant#2022): link each step to the estate cards its
3487
+ // contract-declared entity args name. It is the one thing on the ops
3488
+ // branch that may read a member IR (through the cache the graph above
3489
+ // already warmed), so it is asked for explicitly rather than implied.
3490
+ q = new URLSearchParams({ ops: "1", entities: "1" });
3376
3491
  // #284 item 2: this is the view run state paints over, so this is when
3377
3492
  // it's worth shelling chant for the one thing the record stream can't
3378
3493
  // carry — whether a gate is pending. Asked once per lens load; the
@@ -3648,9 +3763,10 @@ function bringUpSubstrate(s) {
3648
3763
  // login. The server refuses honestly (no gh, unauthenticated, no matching
3649
3764
  // workflow_dispatch workflow) and the reason lands as a toast. Shared by the
3650
3765
  // Substrates tab's button and the ⌘K entry.
3651
- function dispatchPipeline() {
3652
- if (!window.confirm("Dispatch the GitHub Actions pipeline?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.")) return;
3653
- fetch("/api/ci/dispatch", { method: "POST" })
3766
+ function dispatchPipeline(env) {
3767
+ const target = env || view.env || opsInitialEnv;
3768
+ if (!window.confirm(`Dispatch the GitHub Actions pipeline${target ? ` for ${target}` : ""}?\nRuns via YOUR gh login (gh workflow run); behold follows the run on the dial.`)) return;
3769
+ fetch(`/api/ci/dispatch${target ? `?env=${encodeURIComponent(target)}` : ""}`, { method: "POST" })
3654
3770
  .then((r) => r.json())
3655
3771
  .then((j) => {
3656
3772
  if (j.error) {
@@ -4020,6 +4136,14 @@ function paletteRollback() {
4020
4136
  openRollback(rb);
4021
4137
  }
4022
4138
 
4139
+ // #165: per-env executor designations, off /api/project. A designated env's
4140
+ // Deploy dispatches the forge workflow instead of running chant here; a
4141
+ // designation behold cannot honour disables Deploy with its reason.
4142
+ let executorInfo = {};
4143
+ function designatedFor(env) {
4144
+ return env && executorInfo && executorInfo[env] ? executorInfo[env] : null;
4145
+ }
4146
+
4023
4147
  // Deploy/ops state for the ⌘K palette (#73) — populated once in initActions(),
4024
4148
  // which used to turn each of these straight into a toolbar button. previewMode
4025
4149
  // (declared above, near view) is what the palette gates on: it hides exactly
@@ -4047,6 +4171,9 @@ async function initActions() {
4047
4171
  // lock that hides the git/PR ops.
4048
4172
  const project = await apiFetch("/api/project").then((r) => r.json()).catch(() => ({}));
4049
4173
  opsInitialEnv = project.currentEnv || null;
4174
+ // #165: which envs deploy through a forge, and whether Deploy can dispatch
4175
+ // them right now (`/api/project`'s executor block).
4176
+ executorInfo = project.executor || {};
4050
4177
  previewMode = staticMode || !!project.previewMode; // static ⇒ read-only, no writes at all
4051
4178
  const { ops, adoptLexicons, autoSync, local, applyProgress: apInit, runState: runInit, operatorState: opInit } = await apiFetch("/api/ops")
4052
4179
  .then((r) => r.json())
@@ -4108,7 +4235,27 @@ async function initActions() {
4108
4235
  // the button says Deploy whether the project routes it through a committed
4109
4236
  // ApplyOp or a raw `chant run --components` — the tooltip carries the
4110
4237
  // mechanism.
4111
- if (opsApply && !previewMode) {
4238
+ const deployEnv = view.env || opsInitialEnv;
4239
+ const designated = designatedFor(deployEnv);
4240
+ if (designated && !previewMode) {
4241
+ // #165: the env deploys through a forge. One button, the forge's name on
4242
+ // it, dispatching the DESIGNATED workflow through the operator's own gh —
4243
+ // and disabled, with the reason, when the designation cannot be honoured.
4244
+ // Never the local button: falling back is what the contract forbids.
4245
+ const forge = { github: "GitHub Actions", gitlab: "GitLab CI", forgejo: "Forgejo" }[designated.forge] || designated.forge || "the forge";
4246
+ const deploy = button(`▶ Deploy via ${forge}`, "", () => dispatchPipeline(deployEnv));
4247
+ if (!designated.ok) {
4248
+ deploy.disabled = true;
4249
+ deploy.title = `Deploy is disabled for ${deployEnv}: ${designated.reason}`;
4250
+ } else {
4251
+ deploy.title = `gh workflow run ${designated.workflow} — ${deployEnv} deploys through ${forge} per .behold.json. behold dispatches through YOUR gh and follows the run on the dial; any approval the workflow's environment requires is granted on the forge, not here.`;
4252
+ }
4253
+ bar.appendChild(deploy);
4254
+ const note = document.createElement("span");
4255
+ note.style.cssText = "color:var(--muted);font-size:var(--t-caption);align-self:center";
4256
+ note.textContent = designated.ok ? `${deployEnv}: approval, if any, lives on ${forge}` : `${deployEnv}: ${designated.reason}`;
4257
+ bar.appendChild(note);
4258
+ } else if (opsApply && !previewMode) {
4112
4259
  const deploy = button(`▶ Deploy (${opsApply.name})`, "", () => {
4113
4260
  if (window.confirm(`Run the committed ApplyOp "${opsApply.name}"?\nDelegated write: behold triggers, chant's executor applies.`)) runOp(opsApply.name);
4114
4261
  });
@@ -4327,17 +4474,25 @@ function paletteCommands() {
4327
4474
  // renderSubstrates()'s previous button-gating precisely.
4328
4475
  if (!previewMode) {
4329
4476
  if (opsApply) {
4330
- c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4477
+ // #165: not for a designated env — its ApplyOp is refused server-side too.
4478
+ if (!designatedFor(view.env || opsInitialEnv)) c.push([`Deploy: Sync (${opsApply.name})`, () => runOp(opsApply.name)]);
4331
4479
  if (opsApply.gate) c.push([`Approve ${opsApply.gate}`, () => signal(opsApply.name, opsApply.gate)]);
4332
4480
  }
4333
- c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4481
+ // #165: a designated env's applies happen on the forge, so a rollback PR
4482
+ // opened from here would have nothing local to land — hidden, as designed.
4483
+ if (!designatedFor(view.env || opsInitialEnv)) c.push(["Rollback to a prior revision…", () => paletteRollback()]);
4334
4484
  for (const op of opsRunnable) {
4335
4485
  c.push([`Run: ${op.name}`, () => { if (window.confirm(`Run Op "${op.name}"?`)) runOp(op.name); }]);
4336
4486
  }
4337
4487
  }
4338
4488
  // No committed ApplyOp, but there's an env → "Apply all" is the equivalent
4339
4489
  // deploy action (M3) — stays available in preview (a local write, not git/PR).
4340
- if (!opsApply && opsInitialEnv) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4490
+ if (!opsApply && opsInitialEnv && !designatedFor(opsInitialEnv)) c.push([`Deploy: Apply all → ${opsInitialEnv}`, () => confirmApplyAll()]);
4491
+ // #165: a designated env deploys through its forge, from the palette too.
4492
+ if (designatedFor(view.env || opsInitialEnv) && !previewMode) {
4493
+ const d = designatedFor(view.env || opsInitialEnv);
4494
+ if (d.ok) c.push([`Deploy via ${d.forge} → ${view.env || opsInitialEnv}`, () => dispatchPipeline(view.env || opsInitialEnv)]);
4495
+ }
4341
4496
 
4342
4497
  for (const s of lastSubstrates) {
4343
4498
  if (s.bringUp) c.push([`Bring up ${s.label}`, () => bringUpSubstrate(s)]);
package/web/release.js ADDED
@@ -0,0 +1,66 @@
1
+ // #165 (#61's second clause): the recorded release, read off a component
2
+ // node's `_release` attr (src/component-status.ts's `releaseStatus`). The pure
3
+ // half, tested the way web/operator.js and web/run-playhead.js are — no DOM.
4
+ // The card chip (`release`) already answers "which commit, through what"; this
5
+ // is the pane's fuller answer — which run, from which commit, approved by whom
6
+ // — and, when the record cannot be followed, the reason in words.
7
+ import { approvalLink } from "./operator.js";
8
+ import { waitingFor } from "./run-playhead.js";
9
+
10
+ /** How the pane names each id space. `laptop` for a local mint: "local" would
11
+ * read as a place, and the point is that there is nowhere to go. */
12
+ export const FORGE_LABEL = { github: "GitHub Actions", gitlab: "GitLab CI", op: "an Op run", local: "a laptop run", unknown: "an unlinked run" };
13
+
14
+ /** The one line that says why there is no link, or null when there is one.
15
+ * Never a placeholder link, never a guessed forge: the three id spaces a
16
+ * `runId` can live in are mutually incompatible, so a link behold built from
17
+ * a bare id would as often be wrong as right (chant#2045). */
18
+ export function unlinkedReason(release) {
19
+ if (!release) return null;
20
+ if (runLink(release)) return null;
21
+ switch (release.forge) {
22
+ case "local":
23
+ return release.originSource === "record"
24
+ ? "minted on the machine that deployed — the record says so; there is no run to open"
25
+ : "minted on the machine that deployed (a local- id) — there is no run to open";
26
+ case "unknown":
27
+ return "the record names a run id and nothing else — no forge, no repo, no URL. A chant older than chant#2045 wrote it; behold will not guess which forge the id belongs to";
28
+ case "op":
29
+ return "an orchestrator run id — the run lives in Temporal, not on a forge";
30
+ default:
31
+ return `${FORGE_LABEL[release.forge] || release.forge} named no server URL when this was recorded, so the id cannot be resolved to a link`;
32
+ }
33
+ }
34
+
35
+ /** The run's absolute http(s) URL, or null. Same validator as the gate card's
36
+ * `approve at:` — an address the ledger itself carries, or nothing. */
37
+ export function runLink(release) {
38
+ return release && release.originSource === "record" ? approvalLink(release.url) : null;
39
+ }
40
+
41
+ /** `<runId> (<forge>)` — the run row's text, link or no link. */
42
+ export function runText(release) {
43
+ const forge = FORGE_LABEL[release.forge] || release.forge;
44
+ return release.repo ? `${release.runId} · ${forge} · ${release.repo}` : `${release.runId} · ${forge}`;
45
+ }
46
+
47
+ /** The pane's rows, in order: `[key, value]` pairs plus an optional caveat
48
+ * row keyed `""`. The link is returned as `{href, text}` so the DOM half makes
49
+ * the anchor and this half stays testable. */
50
+ export function releaseRows(release, now = Date.now()) {
51
+ if (!release) return [];
52
+ const rows = [];
53
+ const href = runLink(release);
54
+ rows.push(["run", href ? { href, text: runText(release) } : runText(release)]);
55
+ const why = unlinkedReason(release);
56
+ if (why) rows.push(["", why]);
57
+ rows.push(["commit", release.gitSha]);
58
+ rows.push(["digest", release.digest]);
59
+ const ago = release.timestamp ? waitingFor(release.timestamp, now) : null;
60
+ rows.push(["recorded", ago ? `${release.timestamp} (${ago} ago)` : release.timestamp]);
61
+ rows.push(["actor", release.actor]);
62
+ // Absent means ungated — chant omits `approver` exactly then — so the row
63
+ // says that rather than leaving a gap the reader could take for a redaction.
64
+ rows.push(["approver", release.approver || "none — an ungated change"]);
65
+ return rows;
66
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { releaseRows, runLink, unlinkedReason, runText } from "../web/release.js";
3
+
4
+ const NOW = Date.parse("2026-09-01T12:00:00Z");
5
+ const base = { runId: "18234771902", gitSha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678", digest: "sha256:86bf5e3c", timestamp: "2026-09-01T11:30:00Z", actor: "ci-bot" };
6
+
7
+ describe("release rows (#165)", () => {
8
+ it("a record that carries its own address links the run, and names forge + repo", () => {
9
+ const r = { ...base, forge: "github", originSource: "record", repo: "INTENTIUS/loomster", url: "https://github.com/INTENTIUS/loomster/actions/runs/18234771902", approver: "alex" };
10
+ const rows = releaseRows(r, NOW);
11
+ expect(rows[0]).toEqual(["run", { href: r.url, text: "18234771902 · GitHub Actions · INTENTIUS/loomster" }]);
12
+ expect(rows.find(([k]) => k === "")).toBeUndefined();
13
+ expect(rows.find(([k]) => k === "approver")[1]).toBe("alex");
14
+ expect(rows.find(([k]) => k === "recorded")[1]).toBe("2026-09-01T11:30:00Z (30m ago)");
15
+ });
16
+
17
+ it("a bare id from a pre-chant#2045 record is never linked, and the caveat says why", () => {
18
+ const r = { ...base, forge: "unknown", originSource: "inferred" };
19
+ expect(runLink(r)).toBeNull();
20
+ expect(unlinkedReason(r)).toMatch(/chant#2045/);
21
+ expect(unlinkedReason(r)).toMatch(/will not guess/);
22
+ expect(releaseRows(r, NOW)[0]).toEqual(["run", "18234771902 · an unlinked run"]);
23
+ });
24
+
25
+ it("a laptop run says there is nowhere to go — whether the record said local or the id's spelling did", () => {
26
+ const said = { ...base, runId: "local-1756726200000", forge: "local", originSource: "record" };
27
+ const spelled = { ...base, runId: "local-1756726200000", forge: "local", originSource: "inferred" };
28
+ expect(unlinkedReason(said)).toMatch(/the record says so/);
29
+ expect(unlinkedReason(spelled)).toMatch(/a local- id/);
30
+ expect(runText(said)).toBe("local-1756726200000 · a laptop run");
31
+ });
32
+
33
+ it("an inferred origin never yields a link even if a url is somehow present", () => {
34
+ expect(runLink({ ...base, forge: "github", originSource: "inferred", url: "https://example.com/run" })).toBeNull();
35
+ });
36
+
37
+ it("a recorded origin with a non-http url is not a link — the gate card's rule", () => {
38
+ const r = { ...base, forge: "github", originSource: "record", url: "javascript:alert(1)" };
39
+ expect(runLink(r)).toBeNull();
40
+ expect(unlinkedReason(r)).toMatch(/named no server URL/);
41
+ });
42
+
43
+ it("an ungated change says so in the approver row rather than leaving a gap", () => {
44
+ const rows = releaseRows({ ...base, forge: "op", originSource: "record" }, NOW);
45
+ expect(rows.find(([k]) => k === "approver")[1]).toBe("none — an ungated change");
46
+ expect(unlinkedReason({ ...base, forge: "op", originSource: "record" })).toMatch(/Temporal/);
47
+ });
48
+
49
+ it("nothing recorded → no rows", () => {
50
+ expect(releaseRows(undefined)).toEqual([]);
51
+ });
52
+ });