clearotron 0.4.1-beta.1 → 0.4.1-beta.2

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/build-info.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "commit": "0ac7d328864dbb4446ea2dd472f64acff14b7a4f",
3
- "version": "0.4.1-beta.1"
2
+ "commit": "08583753fbde693fdecdc8e16f11781dae789f55",
3
+ "version": "0.4.1-beta.2"
4
4
  }
@@ -1,5 +1,11 @@
1
1
  # clearotron-driver
2
2
 
3
+ ## 0.4.1-beta.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Fixed: A clearance no longer stops before its report when findings link to an office's own page instead of the register record.
8
+
3
9
  ## 0.4.1-beta.1
4
10
 
5
11
  ### Patch Changes
@@ -849,6 +849,10 @@ function serverEnv({ sessionKey, agent, runDir, recordAxis }) {
849
849
  // the server per turn so the acceptance boundary can refuse a payload naming any other index, which
850
850
  // turns O3c's measured 224/0 habit into a structure. Absent for every non-fan-out stage.
851
851
  if (recordAxis) e.CLEAROTRON_RECORD_AXIS = String(recordAxis);
852
+ // — THE RUN'S REGISTER, so a recording tool gates a record link at the call as verify.mjs gates the
853
+ // saved file (record-origins.mjs). Handed over explicitly: Codex gives a server only its own `env` and
854
+ // the names it forwards, so a value left to inheritance reaches a Claude server and never a Codex one.
855
+ if (process.env.CLEAROTRON_DATABASE) e.CLEAROTRON_DATABASE = process.env.CLEAROTRON_DATABASE;
852
856
  // — the ledger paths are RESOLVED HERE and handed to the child explicitly, not forwarded as raw
853
857
  // env. Two reasons, and the second is the one that kept this rename parked for months:
854
858
  // · the old line was `if (process.env[v]) e[v] = …`, and NO box sets those vars — prod, test and dev
@@ -858,7 +858,7 @@ export function parseFindingsJson(raw, opts = {}) {
858
858
  method: "method" in opts ? (opts.method ?? null) : undefined,
859
859
  // — null means the caller named no provider, and the record-URL gate stays inactive. An empty
860
860
  // ARRAY is a real answer ("this provider publishes no per-record page") and is not the same thing.
861
- recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null };
861
+ recordOrigins: Array.isArray(opts.recordOrigins) ? opts.recordOrigins : null, foreignLinks: Array.isArray(opts.recordOrigins) ? [] : null };
862
862
  onlyKeys(doc, topKeysFor(mode), (k) => `findings_key_unknown:${short(k)}`);
863
863
  if (mode.v4) {
864
864
  if (typeof doc.rated_under_framework !== "string" || !doc.rated_under_framework.trim())
@@ -879,7 +879,7 @@ export function parseFindingsJson(raw, opts = {}) {
879
879
  throw new Error("findings_empty: the record carries no findings AND no coverage areas — at least one is required");
880
880
 
881
881
  const seenOrdinals = new Set();
882
- const outFindings = findings.map((f, idx) => validateFinding(f, idx, seenOrdinals, mode));
882
+ const outFindings = findings.map((f, idx) => validateFinding(f, idx, seenOrdinals, mode)); throwForeignLinks(mode);
883
883
  const outCoverage = coverage.map((c) => validateCoverage(c));
884
884
  const outContextNotes = contextNotes.map((n) => validateContextNote(n));
885
885
  const outActions = validateActionsList(doc.actions, outFindings);
@@ -1381,10 +1381,10 @@ function validateFinding(f, idx, seenOrdinals, mode = { v4: false, manifest: nul
1381
1381
  if (f.withdrawn_reason != null && f.disposition !== "withdrawn")
1382
1382
  throw new Error(`finding_withdrawn_reason_orphan:${ord} (withdrawn_reason is only valid when disposition is "withdrawn")`);
1383
1383
 
1384
- validateOwner(f.owner, ord, mode?.recordOrigins ?? null);
1384
+ validateOwner(f.owner, ord, mode?.recordOrigins ?? null, mode?.foreignLinks ?? null);
1385
1385
  validateMeters(f.meters);
1386
1386
  validateQuadrant(f.quadrant);
1387
- validateSource(f.source, ord, mode?.recordOrigins ?? null);
1387
+ validateSource(f.source, ord, mode?.recordOrigins ?? null, mode?.foreignLinks ?? null);
1388
1388
 
1389
1389
  // Instance #5 — per-finding use-check / own-rights cite. SHAPE-ONLY here (present ⇒ a { source } object
1390
1390
  // whose source is a string). The composite>=3 NON-EMPTY-source enforcement lives in verify.mjs
@@ -1969,13 +1969,13 @@ export function isDeadRecordLink(value) {
1969
1969
  return t === "" || t.startsWith("#") || t.startsWith("/");
1970
1970
  }
1971
1971
 
1972
- function checkRecordUrlHost(value, origins, ord, what) {
1972
+ function checkRecordUrlHost(value, origins, ord, what, collect = null) {
1973
1973
  if (origins == null) return; // gate inactive — see the header
1974
1974
  const origin = recordUrlOrigin(value);
1975
- if (origin == null) return;
1976
- if (origins.includes(origin)) return;
1975
+ if (origin == null || origins.includes(origin)) return;
1976
+ if (collect) { collect.push({ ord, what, value }); return; } // the strict parse names EVERY offender in one refusal
1977
1977
  throw new Error(
1978
- `finding_record_url_foreign_host:${ord} (${what} points at ${origin}, which is not a register this run searched — `
1978
+ `finding_record_url_foreign_host:${Array.isArray(ord) ? ord.join(",") : ord} (${what} points at ${origin}, which is not a register this run searched — `
1979
1979
  + (origins.length
1980
1980
  ? `this provider's record host is ${origins.join(" or ")}. Compose the URL from the record's own uri and THIS provider's base host, per providers/<name>.md.`
1981
1981
  : "this provider publishes no per-record page at all — cite the office register in the text and leave the URL as the record's own uri path.")
@@ -1983,7 +1983,7 @@ function checkRecordUrlHost(value, origins, ord, what) {
1983
1983
  );
1984
1984
  }
1985
1985
 
1986
- function validateOwner(owner, ord, origins = null) {
1986
+ function validateOwner(owner, ord, origins = null, collect = null) {
1987
1987
  if (!isPlainObject(owner)) throw new Error(`finding_owner_invalid:${ord} (owner must be an object { name, country, registrations })`);
1988
1988
  onlyKeys(owner, OWNER_KEYS, (k) => `finding_owner_key_unknown:${short(k)}`);
1989
1989
  if (typeof owner.name !== "string" || !owner.name.trim()) throw new Error(`finding_owner_invalid:${ord} (owner.name must be a non-empty string)`);
@@ -1999,7 +1999,7 @@ function validateOwner(owner, ord, origins = null) {
1999
1999
  if (!isPlainObject(r)) throw new Error(`finding_registration_invalid:${ord} (each registration must be a plain object)`);
2000
2000
  onlyKeys(r, REGISTRATION_KEYS, (k) => `finding_registration_key_unknown:${short(k)}`);
2001
2001
  if (typeof r.uri !== "string" || !r.uri.trim()) throw new Error(`finding_registration_invalid:${short(r.uri)} (registration.uri must be a non-empty string)`);
2002
- checkRecordUrlHost(r.uri, origins, ord, `registration.uri "${short(r.uri)}"`);
2002
+ checkRecordUrlHost(r.uri, origins, ord, `registration.uri "${short(r.uri)}"`, collect);
2003
2003
  if (r.classes != null && !(Array.isArray(r.classes) && r.classes.every((c) => typeof c === "string")))
2004
2004
  throw new Error(`finding_registration_invalid:${short(r.uri)} (registration.classes must be an array of strings)`);
2005
2005
  }
@@ -2042,7 +2042,7 @@ function validateQuadrant(q) {
2042
2042
  }
2043
2043
  }
2044
2044
 
2045
- function validateSource(source, ord, origins = null) {
2045
+ function validateSource(source, ord, origins = null, collect = null) {
2046
2046
  if (!isPlainObject(source)) throw new Error(`finding_source_invalid:${ord} (source must be { source_type, resolved_link })`);
2047
2047
  onlyKeys(source, SOURCE_KEYS, (k) => `finding_source_key_unknown:${short(k)}`);
2048
2048
  if (!SOURCE_TYPES.includes(source.source_type))
@@ -2061,7 +2061,7 @@ function validateSource(source, ord, origins = null) {
2061
2061
  + "dead anchor in the report. Give the record's real URL, or — if this provider publishes no "
2062
2062
  + 'per-record page — leave it "" and cite the office register in the text.)',
2063
2063
  );
2064
- checkRecordUrlHost(source.resolved_link, origins, ord, "source.resolved_link");
2064
+ checkRecordUrlHost(source.resolved_link, origins, ord, "source.resolved_link", collect);
2065
2065
  }
2066
2066
  }
2067
2067
 
@@ -2571,3 +2571,70 @@ export function knockoutFindingRange(mark) {
2571
2571
  if (!ords.length || !markName) return "";
2572
2572
  return ords.length === 1 ? `${markName} #${ords[0]}` : `${markName} #${ords[0]}–#${ords[ords.length - 1]}`;
2573
2573
  }
2574
+
2575
+ /**
2576
+ * ONE REFUSAL FOR EVERY FOREIGN RECORD LINK the strict parse collected.
2577
+ *
2578
+ * The gate used to throw at the first offending link, so each refusal named one finding and a corrective
2579
+ * attempt fixed one. A seat that had copied an office's page into eleven findings ran out of attempts at
2580
+ * the third. The token now carries every offending ordinal; the sentence after it is the gate's own,
2581
+ * unchanged, about the first offender.
2582
+ */
2583
+ function throwForeignLinks(mode) {
2584
+ const found = mode?.foreignLinks ?? [];
2585
+ if (!found.length) return;
2586
+ const ords = [...new Set(found.map((f) => f.ord))];
2587
+ checkRecordUrlHost(found[0].value, mode.recordOrigins, ords.length === 1 ? ords[0] : ords, found[0].what);
2588
+ }
2589
+
2590
+ /** The canonical record path inside an absolute URL (`/mark/<cc>/<rest>`), or null. PURE. */
2591
+ function canonicalRecordPath(value) {
2592
+ try {
2593
+ const path = new URL(String(value)).pathname;
2594
+ return /^\/mark\/[a-z]{2}\/[^/]+/i.test(path) ? path : null;
2595
+ } catch { return null; }
2596
+ }
2597
+
2598
+ /**
2599
+ * THE SALVAGE FOR A FOREIGN RECORD LINK: blank it, and deliver. PURE.
2600
+ *
2601
+ * A record link on a host the run's register does not publish is refused by the gate above. When a seat
2602
+ * keeps writing one, the report still goes out: a register-sourced `resolved_link` on a foreign host
2603
+ * becomes `""`, the value the findings model already reads as "this register publishes no page for the
2604
+ * record", and publish still links a register that publishes no pages to the office's own page from the
2605
+ * record itself. A `registration.uri` written as a foreign URL keeps the record's canonical path when
2606
+ * the URL carries one, since the path is the record's identity. One that does not is left as written and
2607
+ * returned in `kept`, because no identity can be read from it.
2608
+ *
2609
+ * `origins` is the gate's own list (recordOriginsFor); null turns this off, as it turns the gate off.
2610
+ * Returns the repaired document, every value it changed (`blanked`, with what it was), and `kept`.
2611
+ */
2612
+ export function blankForeignRecordLinks(doc, origins) {
2613
+ const blanked = [], kept = [];
2614
+ if (origins == null || !isPlainObject(doc) || !Array.isArray(doc.findings)) return { doc, blanked, kept };
2615
+ const foreign = (value) => { const o = recordUrlOrigin(value); return o != null && !origins.includes(o); };
2616
+ const findings = doc.findings.map((f) => {
2617
+ if (!isPlainObject(f)) return f;
2618
+ let out = f;
2619
+ const s = f.source;
2620
+ if (isPlainObject(s) && String(s.source_type ?? "").startsWith("register") && typeof s.resolved_link === "string" && foreign(s.resolved_link)) {
2621
+ out = { ...out, source: { ...s, resolved_link: "" } };
2622
+ blanked.push({ ordinal: f.ordinal ?? null, field: "source.resolved_link", was: s.resolved_link, now: "" });
2623
+ }
2624
+ const regs = isPlainObject(f.owner) && Array.isArray(f.owner.registrations) ? f.owner.registrations : null;
2625
+ if (regs) {
2626
+ let changed = false;
2627
+ const next = regs.map((r, index) => {
2628
+ if (!isPlainObject(r) || typeof r.uri !== "string" || !foreign(r.uri)) return r;
2629
+ const path = canonicalRecordPath(r.uri);
2630
+ if (!path) { kept.push({ ordinal: f.ordinal ?? null, field: "registration.uri", index, was: r.uri }); return r; }
2631
+ changed = true;
2632
+ blanked.push({ ordinal: f.ordinal ?? null, field: "registration.uri", index, was: r.uri, now: path });
2633
+ return { ...r, uri: path };
2634
+ });
2635
+ if (changed) out = { ...out, owner: { ...out.owner, registrations: next } };
2636
+ }
2637
+ return out;
2638
+ });
2639
+ return { doc: { ...doc, findings }, blanked, kept };
2640
+ }
@@ -0,0 +1,50 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // foreign-record-links.mjs — a record link on a host the run's register does not publish: refused once, then
5
+ // blanked, and the run delivers.
6
+ //
7
+ // The findings gate refuses such a link (`finding_record_url_foreign_host`, findings-model.mjs). R2 on
8
+ // 0.4.1-beta.1 delivered no report over eleven of them: the seat copied each office's own page into a
9
+ // record link, every attempt was refused, and nothing could repair it. Two layers now act on it, and both
10
+ // record what they did under one event name and in one file, so a reader meets one fact however it arose:
11
+ // · the synthesis transport, at the call (synthesis-record.mjs): the first offending call in a run is
12
+ // refused, naming every finding, so the seat can fix them in the same turn; a repeat is accepted with
13
+ // the foreign links blanked;
14
+ // · the synthesis salvage lane (pipeline.mjs), for a findings file the transport did not write.
15
+ // Blanking is blankForeignRecordLinks (findings-model.mjs): a register link becomes "", the value that
16
+ // already means "this register publishes no page for the record".
17
+ import { writeFileSync } from "node:fs";
18
+ import { driverDir } from "../shared/driver-dir.mjs";
19
+ import { blankForeignRecordLinks } from "./findings-model.mjs";
20
+ import { activeRecordOrigins } from "./record-origins.mjs";
21
+ import { runLog } from "./log.mjs";
22
+
23
+ export const FOREIGN_HOST_TOKEN = "finding_record_url_foreign_host";
24
+ export const BLANKED_FILE = "foreign-record-links.json";
25
+
26
+ /** The gate's list of record hosts: the caller's when it names one, else the run's register. */
27
+ export const recordOriginsOr = (given) => (given === undefined ? activeRecordOrigins() : given);
28
+
29
+ /**
30
+ * The synthesis call, refused once and then blanked. PURE. `refusedBefore` is whether this run's transport
31
+ * already refused a call for a foreign record link; until it has, the call is returned unchanged and the
32
+ * gate refuses it.
33
+ */
34
+ export function blankOnRepeat(call, origins, refusedBefore) {
35
+ if (!refusedBefore || !Array.isArray(origins) || !call?.findings) return { call, blanked: [], kept: [] };
36
+ const { doc, blanked, kept } = blankForeignRecordLinks(call.findings, origins);
37
+ return { call: blanked.length ? { ...call, findings: doc } : call, blanked, kept };
38
+ }
39
+
40
+ /** What a blanking changed: the originals kept beside the run, and one run-log row. Never throws. */
41
+ export function recordBlanking(runDir, { stage, layer, blanked, kept, fail = null }) {
42
+ try {
43
+ writeFileSync(driverDir(runDir, BLANKED_FILE),
44
+ JSON.stringify({ stage, layer, ...(fail ? { fail: String(fail).slice(0, 300) } : {}), blanked, kept }, null, 2) + "\n");
45
+ } catch { /* the file is forensics; the run-log row below is the record */ }
46
+ try {
47
+ runLog(runDir, { event: "foreign-record-links-blanked", stage, layer, blanked: blanked.length, kept: kept.length,
48
+ ordinals: [...new Set(blanked.map((b) => b.ordinal))] });
49
+ } catch { /* a log that cannot be written must not stop the record it describes */ }
50
+ }
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron-driver",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.1",
5
+ "version": "0.4.1-beta.2",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
8
8
  "engines": {
@@ -45,7 +45,7 @@ import { parseCoverageLedgerJson, parseCoverageLedgerFull, deriveCoverageStatus,
45
45
  import { receiptSettled, readEnvelopeDecision, settleReceipt, settledDeferralsSection, readStickyGaps } from "./envelope-settle.mjs";
46
46
  import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
47
47
  import { parseNamedBand, mergeNamedBands, findCollapsedBands, quarantineUnknownStates, taintQuarantineCleanBlocks, bandRecords } from "./named-band.mjs";
48
- import { recordOriginsFor } from "./record-origins.mjs";
48
+ import { recordOriginsFor, activeRecordOrigins } from "./record-origins.mjs"; import { recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs";
49
49
  import { REGISTER_PROVIDER } from "./driver.config.mjs";
50
50
  import { noteRegisterServed, registersServedFrom, providerUsageCaveat } from "./register-served.mjs"; // which registers actually served this run — noted eagerly from the dispatch's own resolver, and reconciled at publish against the ledger rows, which carry the vendor per call
51
51
  import { FACTS_FILE as DIGEST_FACTS_FILE, ACCOUNTING_STAMP as DIGEST_ACCOUNTING_STAMP, recordedFindingUris,
@@ -163,7 +163,7 @@ import { parsePlacementsJson } from "./placement-model.mjs"; // B2 — the str
163
163
  import { readAnchors } from "./anchor-reader.mjs";
164
164
  import { findEngagementReceipts } from "./engagement-receipt.mjs";
165
165
  import { plainRegisterFlags } from "./plain-register.mjs";
166
- import { parseFindingsJson, parseFindingsJsonLenient, consolidateFindings, deriveDisplayVerdict, bindRecommendation, compareBlockingPower, inDispositionMode, DISPOSITION_GROUP, deriveActionConditions, cardedParties, actionPartyReferences, quarantinedConditionRows, salvageRepairTargets, riskStatement, verdictStance, remapActionOrdinals, joinAskToAnswer, stripAskLabel, bandBorderlineDeclarations, reasonedNegativeGroups } from "./findings-model.mjs";
166
+ import { parseFindingsJson, parseFindingsJsonLenient, consolidateFindings, deriveDisplayVerdict, bindRecommendation, compareBlockingPower, inDispositionMode, DISPOSITION_GROUP, deriveActionConditions, cardedParties, actionPartyReferences, quarantinedConditionRows, salvageRepairTargets, riskStatement, verdictStance, remapActionOrdinals, joinAskToAnswer, stripAskLabel, bandBorderlineDeclarations, reasonedNegativeGroups, blankForeignRecordLinks } from "./findings-model.mjs";
167
167
  import { foldCaption, foldCardRead } from "./card-budget.mjs";
168
168
  // S2 — the report card's mechanical frame, composed from the record instead of dictated (see below).
169
169
  import { carriesOwnFrame, composeCard } from "./card-frame.mjs";
@@ -5035,7 +5035,7 @@ export function buildFailurePacket({ runId, agent, job = {}, failedStage, shortR
5035
5035
  // a top-level defect, or nothing salvageable — fails the run exactly as before. One bad object never silently
5036
5036
  // suppresses a real conflict, and never silently kills a ~$40 run.
5037
5037
  async function quarantineSynth(r, ctx, name) {
5038
- if (r.ok) return r;
5038
+ if (r.ok) return r; if (foreignRecordLinksBlanked(r, ctx, name)) return { ...r, ok: true, fail: undefined, repaired: "foreign-record-links-blanked" };
5039
5039
  // A6: a max_tokens-named content fault wraps the underlying validator token — strip the wrapper so a
5040
5040
  // per-finding shape defect on a ceiling-cut turn still reaches the lenient-salvage lane.
5041
5041
  const fail = String(r.fail || "").replace(/^max_tokens_no_output:/, "");
@@ -16993,3 +16993,30 @@ function orphanAreaLabel(qid, axis, plan) {
16993
16993
  export function instructedScopeToWrite(job, { jobRebuiltFromStatus = false, scopeOnDisk = false } = {}) { // @internal
16994
16994
  return jobRebuiltFromStatus && scopeOnDisk ? null : instructedScopeOf(job);
16995
16995
  }
16996
+
16997
+ // ── THE SALVAGE FOR A FOREIGN RECORD LINK: BLANK IT, RE-CHECK, DELIVER ──────────────────────────────────
16998
+ //
16999
+ // A seat that writes an office's page, or another register's, into a record link is refused by the
17000
+ // findings gate (`finding_record_url_foreign_host`). The salvage lane below takes that token, because it is
17001
+ // a `finding_…` token, but its lenient re-parse does not judge hosts, so it found nothing to repair and
17002
+ // failed the run: R2 on 0.4.1-beta.1 delivered no report over eleven copied office links. Here the driver
17003
+ // blanks every foreign link itself (blankForeignRecordLinks says how), keeps what it changed beside the run,
17004
+ // and re-checks the file with the stage's own validator. A pass delivers; anything still refused falls
17005
+ // through to the lanes below exactly as before, with fewer defects in the file.
17006
+ export function foreignRecordLinksBlanked(r, ctx, name) { // exported for its test
17007
+ const fail = String(r?.fail || "").replace(/^max_tokens_no_output:/, "");
17008
+ if (!fail.includes(FOREIGN_HOST_TOKEN) || !existsSync(ctx.paths.findings)) return false;
17009
+ let doc;
17010
+ try { doc = JSON.parse(readFileSync(ctx.paths.findings, "utf8")); } catch { return false; }
17011
+ const { doc: repaired, blanked, kept } = blankForeignRecordLinks(doc, activeRecordOrigins());
17012
+ if (!blanked.length) return false;
17013
+ atomicWrite(ctx.paths.findings, JSON.stringify(repaired, null, 2) + "\n");
17014
+ recordBlanking(ctx.paths.runDir, { stage: name, layer: "salvage", blanked, kept, fail });
17015
+ note(`[${name}] ${blanked.length} record link(s) on a host this register does not publish were blanked by the driver; the originals are in _driver/foreign-record-links.json`);
17016
+ const def = STAGES[name];
17017
+ const out = def?.out ? def.out(ctx.paths, ctx.axis) : null;
17018
+ const v = out && existsSync(out) && def.validate ? def.validate(out, readFileSync(out, "utf8")) : null;
17019
+ if (v?.ok) return true;
17020
+ runLog(ctx.paths.runDir, { event: "foreign-record-links-recheck", stage: name, ok: false, fail: String(v?.reason ?? "no validator").slice(0, 200) });
17021
+ return false;
17022
+ }
@@ -585,6 +585,12 @@
585
585
  "skips": 0,
586
586
  "todos": 0
587
587
  },
588
+ "a-foreign-record-link-is-named-once-and-blanked-at-salvage.test.mjs": {
589
+ "tests": 8,
590
+ "asserts": 31,
591
+ "skips": 0,
592
+ "todos": 0
593
+ },
588
594
  "a-frame-call-with-places-per-name-passes-the-gate.test.mjs": {
589
595
  "tests": 2,
590
596
  "asserts": 7,
@@ -53,7 +53,7 @@ import { declinationCallPaths, readDeclinations } from "./declination-tool.mjs";
53
53
  import { reconcileDeclinationDuty, declinationDutyRefusal } from "./declination-duty.mjs";
54
54
  import { findingUris } from "./record-carry.mjs"; // one derivation of "which records did the findings name", called not copied
55
55
  import { foldAfterSave } from "./record-fold.mjs"; // once the delivery seam is passed, the saved record is folded again
56
-
56
+ import { recordOriginsOr, blankOnRepeat, recordBlanking, FOREIGN_HOST_TOKEN } from "./foreign-record-links.mjs"; // a foreign record link: refused once, then blanked
57
57
  export const NARRATIVE_FILE = "narrative.md";
58
58
  export const FINDINGS_FILE = "findings.json";
59
59
 
@@ -269,7 +269,7 @@ export function withoutWithheldRows(rows, ledger) {
269
269
  return (Array.isArray(rows) ? rows : []).filter((r) => !namesIn(r?.area).some((n) => withheld.has(n)));
270
270
  }
271
271
 
272
- export function acceptSynthesis(params, { asks = [], ledger = null, manifest = null, method = undefined, methodInvalid = null, owed = null, declined = null } = {}) {
272
+ export function acceptSynthesis(params, { asks = [], ledger = null, manifest = null, method = undefined, methodInvalid = null, owed = null, declined = null, recordOrigins = null } = {}) {
273
273
  const doc = params?.findings;
274
274
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) {
275
275
  return { ok: false, reason: "synthesis_findings_missing: `findings` must be the findings document object { schema_version, rated_under_framework, findings, coverage, … } — this is the record the report and the workbook are built from, and there is no path that renders without it" };
@@ -383,7 +383,7 @@ export function acceptSynthesis(params, { asks = [], ledger = null, manifest = n
383
383
  // own file gone wrong, and the seat cannot fix it — so it is refused by name, never read as "no method".
384
384
  if (methodInvalid) return { ok: false, reason: `framework_method_unreadable: _driver/${FROZEN_METHOD_FILE} does not read back (${String(methodInvalid).slice(0, 160)}) — a driver fault, not the writer's` };
385
385
  let parsedDoc;
386
- try { parsedDoc = parseFindingsJson(findings, manifest ? { manifest, ...(method !== undefined ? { method } : {}) } : {}); }
386
+ try { parsedDoc = parseFindingsJson(findings, { ...(manifest ? { manifest, ...(method !== undefined ? { method } : {}) } : {}), ...(Array.isArray(recordOrigins) ? { recordOrigins } : {}) }); }
387
387
  catch (e) {
388
388
  return { ok: false, reason: `synthesis_findings_invalid: ${String(e?.message ?? e).slice(0, 300)}` };
389
389
  }
@@ -608,7 +608,7 @@ export function recordSynthesis(runDir, received, opts = {}) {
608
608
  const auto = driverReadsFor(dir0);
609
609
  const { asks = auto.asks, ledger = auto.ledger, manifest = auto.manifest,
610
610
  method = auto.method, methodInvalid = auto.methodInvalid ?? null,
611
- owed = auto.owed, declined = auto.declined,
611
+ owed = auto.owed, declined = auto.declined, recordOrigins = undefined,
612
612
  now = () => new Date().toISOString() } = opts;
613
613
  const { dir, payload, accepted, refusals } = synthesisCallPaths(dir0);
614
614
  // Its sibling rule — ONE FILE PER CALL, refusals included. This used to
@@ -644,7 +644,16 @@ export function recordSynthesis(runDir, received, opts = {}) {
644
644
  call = merged.merged;
645
645
  }
646
646
 
647
- const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined });
647
+ // A RECORD LINK ON A HOST THIS REGISTER DOES NOT PUBLISH is gated here as verify.mjs gates the saved
648
+ // file. The run's first such call is refused, naming every finding, so the seat can fix them in this
649
+ // turn; a repeat is accepted with those links blanked and the originals kept (foreign-record-links.mjs).
650
+ const origins = recordOriginsOr(recordOrigins);
651
+ const repeat = blankOnRepeat(call, origins, refusalsFor(dir0).some((r) => String(r?.reason ?? "").includes(FOREIGN_HOST_TOKEN)));
652
+ if (repeat.blanked.length) {
653
+ call = repeat.call;
654
+ recordBlanking(dir0, { stage: "synthesis", layer: "call", blanked: repeat.blanked, kept: repeat.kept });
655
+ }
656
+ const v = acceptSynthesis(call, { asks, ledger, manifest, method, methodInvalid, owed, declined, recordOrigins: origins });
648
657
  if (!v.ok) {
649
658
  noteRefusal(v.reason);
650
659
  return { written: null, refused: v.reason,
@@ -1,5 +1,9 @@
1
1
  # trademark-artifacts-mcp
2
2
 
3
+ ## 0.4.1-beta.2
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.1
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-artifacts-mcp",
3
- "version": "0.4.1-beta.1",
3
+ "version": "0.4.1-beta.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "clearotron",
3
3
  "mcpName": "io.github.CordilleraSarl/clearotron",
4
4
  "type": "module",
5
- "version": "0.4.1-beta.1",
5
+ "version": "0.4.1-beta.2",
6
6
  "license": "AGPL-3.0-only",
7
7
  "repository": {
8
8
  "type": "git",
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.4.1-beta.1",
5
+ "version": "0.4.1-beta.2",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,9 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.4.1-beta.2
4
+
5
+ No changes in this release.
6
+
3
7
  ## 0.4.1-beta.1
4
8
 
5
9
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.4.1-beta.1",
3
+ "version": "0.4.1-beta.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -86,7 +86,7 @@ export const COUNTED_EVENTS = {
86
86
  "record-carry-failed": FAILED, "record-discard-failed": FAILED, "register-digest-facts-failed": FAILED,
87
87
  "register-plan-infeasible": FAILED, "register-positions-failed": FAILED, "register-presence-failed": FAILED,
88
88
  "remedy-accounting-failed": FAILED, "report-cards-rate-limited": FAILED, "restamp-miss": FAILED,
89
- "reviewer-degenerate": FAILED, "salvage-lane-no-target": FAILED, "searched-jurisdictions-unresolved": FAILED,
89
+ "reviewer-degenerate": FAILED, "salvage-lane-no-target": FAILED, "searched-jurisdictions-unresolved": FAILED, "foreign-record-links-recheck": FAILED,
90
90
  "skeptic-skipped": FAILED, "stage-input-over-ceiling": FAILED, "status-write-failed": FAILED,
91
91
  "stale-repair-entry-done": { kind: FAILED, when: (e) => (Array.isArray(e.failed) ? e.failed.length : Number(e.failed)) > 0 },
92
92
  // written only where the step threw
@@ -138,7 +138,7 @@ export const COUNTED_EVENTS = {
138
138
  "commonlaw-channels-unstated": REPAIR, "commonlaw-channels-unusable": REPAIR,
139
139
  "corrective-unnamed-removal-repaired": REPAIR, "coverage-floor-clamp": REPAIR, "jx-receipt-repaired": REPAIR,
140
140
  "knockout-next-step-removed": REPAIR, "recall-reconciliation-positions-rederived": REPAIR, "registry-auto-correct": REPAIR,
141
- "repair-attempted": REPAIR, "terminal-guard-clamp": REPAIR, "verdict-conditions-dropped": REPAIR,
141
+ "repair-attempted": REPAIR, "terminal-guard-clamp": REPAIR, "verdict-conditions-dropped": REPAIR, "foreign-record-links-blanked": REPAIR,
142
142
  "verdict-hardened-by-repair": REPAIR, "verdict-rederive-repair": REPAIR,
143
143
  "common-law-half-quarantined": QUARANTINE, "coverage-ledger-quarantined": QUARANTINE,
144
144
  "findings-actions-quarantined": QUARANTINE, "named-band-state-quarantined": QUARANTINE, "quarantine": QUARANTINE,