clearotron 0.4.0-beta.2 → 0.4.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/THIRD-PARTY-NOTICES.md +5 -5
- package/build-info.json +2 -2
- package/driver/CHANGELOG.md +90 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/coverage-form.mjs +6 -0
- package/driver/coverage-ledger.mjs +14 -2
- package/driver/driver.config.mjs +24 -7
- package/driver/engine/mcp/band-server.mjs +3 -3
- package/driver/engine/mcp/stdio-server.mjs +12 -1
- package/driver/gateway.mjs +27 -3
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +41 -10
- package/driver/pipeline.mjs +104 -12
- package/driver/progress.mjs +6 -1
- package/driver/provider-usage.mjs +16 -0
- package/driver/publish/index.mjs +11 -5
- package/driver/publish/knockout.mjs +16 -3
- package/driver/publish/render-knockout.mjs +38 -11
- package/driver/record-carry.mjs +17 -1
- package/driver/reference-strip-signatures.mjs +11 -1
- package/driver/register-plan.mjs +7 -3
- package/driver/register-served.mjs +91 -0
- package/driver/remedy-accounting.mjs +38 -9
- package/driver/reviewer-open-points.mjs +20 -23
- package/driver/score-redaction.mjs +416 -20
- package/driver/screen-gate.mjs +3 -3
- package/driver/skills/clearance-register/SKILL.md +0 -8
- package/driver/skills/clearance-register/digest.md +1 -1
- package/driver/skills/clearance-register/providers/corsearch.md +1 -1
- package/driver/skills/clearance-register/register-recipes.md +3 -9
- package/driver/skills/clearance-search/SKILL.md +2 -2
- package/driver/skills/clearance-search/phase2-execution.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +2 -0
- package/driver/stages-knockout.mjs +22 -0
- package/driver/suite-census.json +152 -26
- package/driver/unit-inventory.mjs +38 -43
- package/mcp-server/CHANGELOG.md +8 -0
- package/mcp-server/package.json +1 -1
- package/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/brace-expansion/package.json +1 -1
- package/node_modules/readdir-glob/node_modules/brace-expansion/index.js +78 -22
- package/node_modules/readdir-glob/node_modules/brace-expansion/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +2 -2
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/jx/src/core.js +0 -1
- package/providers/jx/src/judge.js +0 -1
- package/providers/jx/src/nativeread.js +0 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/signa/src/core.js +125 -17
- package/scripts/e2e-first-time.mjs +12 -4
- package/scripts/e2e-scenario-ops.mjs +25 -2
- package/scripts/e2e.mjs +246 -24
- package/scripts/mint-reference-strip-backlog.mjs +36 -2
- package/scripts/score.mjs +102 -29
- package/shared/identifier-scan.mjs +31 -1
package/driver/pipeline.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, renameSync, copyFil
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import { join, dirname, basename, resolve } from "node:path"; // resolve: the resume line must work from any cwd
|
|
12
12
|
import { driverDir, driverRel, ensureDriverDir } from "../shared/driver-dir.mjs";
|
|
13
|
-
import { REVIEWER_OPEN_QUESTIONS_FILE
|
|
13
|
+
import { REVIEWER_OPEN_QUESTIONS_FILE } from "./reviewer-open-points.mjs"; // the run-record file the reviewer's open points go to, and the only thing that reads them now
|
|
14
14
|
import { goodsOf } from "./queue-markers.mjs"; // — one reading of "does this job name goods", shared with the intake gate
|
|
15
15
|
import { terminalClampDecision, orderClausesForLede, clientConditions, clauseForDefect } from "./terminal-clamp.mjs"; // — deliver and clamp, never withhold
|
|
16
16
|
import { recordSpan } from "./attributed-span.mjs"; // — driver work the decomposition can attribute
|
|
@@ -47,6 +47,7 @@ import { readRegisterTaint, readActiveTaintAxes } from "./register-taint.mjs";
|
|
|
47
47
|
import { parseNamedBand, mergeNamedBands, findCollapsedBands, quarantineUnknownStates, taintQuarantineCleanBlocks, bandRecords } from "./named-band.mjs";
|
|
48
48
|
import { recordOriginsFor } from "./record-origins.mjs";
|
|
49
49
|
import { REGISTER_PROVIDER } from "./driver.config.mjs";
|
|
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
|
|
50
51
|
import { FACTS_FILE as DIGEST_FACTS_FILE, ACCOUNTING_STAMP as DIGEST_ACCOUNTING_STAMP, recordedFindingUris,
|
|
51
52
|
digestAccountingGap, digestBatchBrief, batchesOf } from "./register-digest-record.mjs"; // conversion 11 — the render's facts sidecar and the accounting era stamp
|
|
52
53
|
import { buildBandShape, dominantElementComposites, deriveRegisterPositions, floorTierByMark, floorMarkKey } from "./band-shape.mjs"; // PR-8 — the deterministic reading layer; P2-A — candidates + positions
|
|
@@ -94,7 +95,7 @@ import { deriveScopeFacts } from "./scope-facts.mjs";
|
|
|
94
95
|
import { documentGrowth } from "./gate-metrics.mjs";
|
|
95
96
|
import { editRepairTail, abbrev } from "./repair-contract.mjs";
|
|
96
97
|
import { repairFollowup } from "./repair-composers.mjs";
|
|
97
|
-
import { seedRunStatus, recordTransition, writeRunStatus, rollupStatus, atomicWrite, finalStepFields, terminalRunState, signoffPatch, readSignoff } from "./progress.mjs";
|
|
98
|
+
import { seedRunStatus, recordTransition, writeRunStatus, readRunStatus, rollupStatus, atomicWrite, finalStepFields, terminalRunState, signoffPatch, readSignoff } from "./progress.mjs";
|
|
98
99
|
import { batchMarkName } from "./mark-name.mjs";
|
|
99
100
|
import { writeOutboxPacket } from "./outbox.mjs";
|
|
100
101
|
import { publishReport, composeEmailHtml, deliverySubject } from "./publish/index.mjs";
|
|
@@ -208,8 +209,21 @@ const CL_SUPP_TAGS = ["closure"];
|
|
|
208
209
|
// body) under the un-namespaced sessionKey we pass, so the row prefix-matches this run and the assembled
|
|
209
210
|
// record set picks it up on re-assembly. Credential = the provider's own env var (already in the driver's
|
|
210
211
|
// systemd EnvironmentFile); absence is a named mechanical cause. One provider is active per run.
|
|
212
|
+
/**
|
|
213
|
+
* Note the adapter's own id against this run, and hand the adapter straight back.
|
|
214
|
+
*
|
|
215
|
+
* RECORDED WHERE IT IS RESOLVED, so the run's record says what SERVED it rather than what was configured
|
|
216
|
+
* at launch. Noting is idempotent per run and writes only when the set changes, so a run fetching a
|
|
217
|
+
* thousand records pays one write. A null run directory notes nothing: a call outside a run has no record
|
|
218
|
+
* to carry the field, and inventing one is the guess this field exists to replace.
|
|
219
|
+
*/
|
|
220
|
+
function noteAdapter(ctx, adapter) {
|
|
221
|
+
noteRegisterServed(ctx?.run?.runDir ?? ctx?.paths?.runDir ?? null, adapter?.id);
|
|
222
|
+
return adapter;
|
|
223
|
+
}
|
|
224
|
+
|
|
211
225
|
async function defaultRecordFetcher(uri, ctx) {
|
|
212
|
-
return activeProvider().recordFetch(uri, ctx);
|
|
226
|
+
return noteAdapter(ctx, activeProvider()).recordFetch(uri, ctx);
|
|
213
227
|
}
|
|
214
228
|
|
|
215
229
|
/**
|
|
@@ -2399,7 +2413,7 @@ async function verifyAndRecordHouseElement(ctx, opts = {}) {
|
|
|
2399
2413
|
const caps = registerCapabilities();
|
|
2400
2414
|
const { regions } = resolvePlanRegions(registerJurisdictions(ctx.job, ctx.profile), caps);
|
|
2401
2415
|
const rec = resolveRecordExecutor({
|
|
2402
|
-
lister: opts?.recordLister ?? null, adapter: activeProvider(),
|
|
2416
|
+
lister: opts?.recordLister ?? null, adapter: noteAdapter(ctx, activeProvider()),
|
|
2403
2417
|
agentId: ctx.agentId ?? null, sessionKey: `clearance-${ctx.run.slug}-${ctx.run.codename}`,
|
|
2404
2418
|
recordLog: runRecordLogPath(P.runDir),
|
|
2405
2419
|
fixtureDir: ctx.job?.registerFixtures?.records ?? null,
|
|
@@ -7671,9 +7685,27 @@ export function buildOnlyYouSection(actions, findings, { nowMs = Date.now(), wit
|
|
|
7671
7685
|
// addressed TO the reader; monitoring and filing-routine are standing items. Same tags as before, so
|
|
7672
7686
|
// a reader who knows the old list reads the new one unchanged, one level down.
|
|
7673
7687
|
const ASK_KINDS = new Set(["client-fact", "commercial-decision"]);
|
|
7688
|
+
// ── THE KINDS THAT DO NOT REACH A CLIENT SURFACE ────────────────────────────────────────────────
|
|
7689
|
+
//
|
|
7690
|
+
// Owner ruling, 2026-10-01, on the clearance emails production sent in the preceding day: the
|
|
7691
|
+
// "[Open question]" rows are non-critical and confuse, and they come OUT of the client's report and
|
|
7692
|
+
// email. They are not reworded and nothing replaces them.
|
|
7693
|
+
//
|
|
7694
|
+
// THE FILTER IS HERE AND NOT ON `advisories` ABOVE, which is the whole care in this change. That list
|
|
7695
|
+
// also feeds `actionDates`, so dropping a kind from it would silently take a declared deadline out of
|
|
7696
|
+
// the date set three predelivery checks key on — a client-facing removal quietly changing a date
|
|
7697
|
+
// check. Filtering at the point of RENDERING removes the rows from the document and touches nothing
|
|
7698
|
+
// else: the register in findings.json is unchanged, the kind stays valid, an advisory still never
|
|
7699
|
+
// conditions reliance, and the verdict and its bound read from the register rather than from these
|
|
7700
|
+
// lines.
|
|
7701
|
+
//
|
|
7702
|
+
// So the row is gone from what the client reads and the fact is still on the run, which is what the
|
|
7703
|
+
// ruling asks for.
|
|
7704
|
+
const NOT_FOR_CLIENT = new Set(["client-fact"]);
|
|
7674
7705
|
const advisoryLine = (a) => `- **${ADVISORY_TAG[a.kind]}** ${askLine(a)}${a.deadline?.date ? ` (by ${a.deadline.date})` : ""}`;
|
|
7675
|
-
const
|
|
7676
|
-
const
|
|
7706
|
+
const forClient = (a) => !NOT_FOR_CLIENT.has(a.kind);
|
|
7707
|
+
const askLines = advisories.filter((a) => ASK_KINDS.has(a.kind)).filter(forClient).map(advisoryLine);
|
|
7708
|
+
const watchLines = advisories.filter((a) => !ASK_KINDS.has(a.kind)).filter(forClient).map(advisoryLine);
|
|
7677
7709
|
const groups = [
|
|
7678
7710
|
["Before you can rely on this result", conditionLines],
|
|
7679
7711
|
["We need an answer from you", askLines],
|
|
@@ -9173,7 +9205,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
9173
9205
|
// which is the same bug in the other direction.
|
|
9174
9206
|
const planExec = opts.planExecutor
|
|
9175
9207
|
?? (envGateOn("CLEAROTRON_PLAN_DISPATCH") && activeProvider().executePlan
|
|
9176
|
-
? (args, c) => activeProvider().executePlan(args, c) : null);
|
|
9208
|
+
? (args, c) => noteAdapter(c ?? ctx, activeProvider()).executePlan(args, c) : null);
|
|
9177
9209
|
// Kill switch CLEAROTRON_SATPROBE_CODESIDE=0 (the inline `!== "0"` pattern): legacy mock harnesses
|
|
9178
9210
|
// opt out — their scenarios pre-date the code-side member and script the AGENT path (and an armed
|
|
9179
9211
|
// real executor lane would dial the provider from a test). Production default is ON.
|
|
@@ -10841,6 +10873,13 @@ async function pipelineInner(job, opts = {}) {
|
|
|
10841
10873
|
let regArm = null;
|
|
10842
10874
|
const regSwept = new Set();
|
|
10843
10875
|
const regDeferReason = new Map();
|
|
10876
|
+
// — WHAT THE FOLD REFUSED, AND WHICH KIND OF REFUSAL IT WAS. Read by the remedy accounting
|
|
10877
|
+
// below, which otherwise requires every minted qid to land and so records a term as
|
|
10878
|
+
// dispatch-failed when the fold refused its slice as a duplicate of a question the plan had
|
|
10879
|
+
// already asked and answered. A fold-refused qid can never land, by construction — the
|
|
10880
|
+
// re-attempt above deliberately filters it out — so only the accounting was still waiting
|
|
10881
|
+
// for it. qid -> { kind, twin }.
|
|
10882
|
+
const regFoldRefusals = new Map();
|
|
10844
10883
|
// Partial mints must not read as full sweeps: when SOME of a directive's remedy proposals mint
|
|
10845
10884
|
// and a sibling is refused by the shape lint, the directive can still verify-closed on the
|
|
10846
10885
|
// minted subset — the refusal is disclosed per directive (receipt `partials` row on a close,
|
|
@@ -10974,9 +11013,25 @@ async function pipelineInner(job, opts = {}) {
|
|
|
10974
11013
|
// belief about two screens agreeing, and the day they stop agreeing this is the only place
|
|
10975
11014
|
// that would say so.
|
|
10976
11015
|
if (refused.length) {
|
|
11016
|
+
for (const r of refused) if (r?.qid) regFoldRefusals.set(r.qid, { kind: r.kind ?? null, twin: r.twin ?? null });
|
|
11017
|
+
// THE REASON THE FOLD GAVE IS LOGGED, not just the qid it refused. The fold is the only
|
|
11018
|
+
// place that knows WHY, and for a duplicate it knows WHICH plan row already holds the
|
|
11019
|
+
// answer. Logging the qid alone threw that away: recovering it on one run meant
|
|
11020
|
+
// re-deriving the twin from a shared identity hash by hand, and a receipt that cannot
|
|
11021
|
+
// say why a slice is missing reads as an engine fault whatever the cause was.
|
|
10977
11022
|
runLog(run.runDir, { event: "frame-reopen-fold-refused", refused: refused.length,
|
|
10978
|
-
qids: refused.map((r) => r.qid).slice(0, 6)
|
|
10979
|
-
|
|
11023
|
+
qids: refused.map((r) => r.qid).slice(0, 6),
|
|
11024
|
+
kinds: refused.reduce((a, r) => { const k = r.kind ?? "unstated"; a[k] = (a[k] ?? 0) + 1; return a; }, {}),
|
|
11025
|
+
rows: refused.slice(0, 6).map((r) => ({ qid: r.qid, kind: r.kind ?? null, twin: r.twin ?? null, issue: r.issue ?? null })) });
|
|
11026
|
+
// ONE NOTE PER KIND, because they are three different findings and only one of them is a
|
|
11027
|
+
// wiring fault. A duplicate-question refusal is the fold working: the question is already
|
|
11028
|
+
// in the plan and its existing row carries the coverage. An identity collision or a
|
|
11029
|
+
// malformed term IS the mint screen and the fold screen disagreeing, because the mint
|
|
11030
|
+
// runs entryTermIssues and should have caught it.
|
|
11031
|
+
const dupes = refused.filter((r) => r.kind === "duplicate-question");
|
|
11032
|
+
const faults = refused.filter((r) => r.kind !== "duplicate-question");
|
|
11033
|
+
if (dupes.length) note(`frame-reopen: ${dupes.length} minted entr${dupes.length === 1 ? "y" : "ies"} refused at the fold as already-asked — the plan row${dupes.length === 1 ? "" : "s"} ${dupes.map((r) => `"${r.twin ?? "(twin unstated)"}"`).join(", ")} carr${dupes.length === 1 ? "ies" : "y"} that coverage; the term is answered, not unsearched`);
|
|
11034
|
+
if (faults.length) note(`frame-reopen: ${faults.length} minted entr${faults.length === 1 ? "y" : "ies"} REFUSED at the fold (${[...new Set(faults.map((r) => r.kind ?? "unstated"))].join(", ")}) — the mint screen and the fold screen disagree, which is a wiring finding`);
|
|
10980
11035
|
}
|
|
10981
11036
|
// Dispatch → derive band → VERIFY per directive (qid-landed + non-collapse + class-scope).
|
|
10982
11037
|
// A byte-changed band with only a wrong-scope/empty/error block closes NOTHING.
|
|
@@ -11306,6 +11361,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
11306
11361
|
terms: termRows,
|
|
11307
11362
|
blocksByQid: regLastJoin?.blocksByQid ?? new Map(),
|
|
11308
11363
|
executedQids: regLastJoin?.executedQids ?? new Set(),
|
|
11364
|
+
foldRefusals: regFoldRefusals,
|
|
11309
11365
|
outOfScope,
|
|
11310
11366
|
});
|
|
11311
11367
|
} catch (e) {
|
|
@@ -15136,8 +15192,37 @@ async function pipelineInner(job, opts = {}) {
|
|
|
15136
15192
|
try {
|
|
15137
15193
|
const provider = activeProvider().id;
|
|
15138
15194
|
const usage = tallyRegisterCalls(DEFAULT_LEDGER_PATH, `clearance-${run.slug}-${run.codename}-`);
|
|
15139
|
-
|
|
15140
|
-
|
|
15195
|
+
// ── A WHOLE-RUN TALLY FILED UNDER ONE REGISTER SAYS SO WHEN THE RUN SERVED MORE THAN ONE ──────
|
|
15196
|
+
//
|
|
15197
|
+
// This label is resolved HERE, at publish, while the tally it labels spans the whole run. On a run
|
|
15198
|
+
// whose register changed part-way that key is whichever register happened to be active at publish,
|
|
15199
|
+
// and every call is filed under it — the confident-and-wrong shape the served field exists to end.
|
|
15200
|
+
//
|
|
15201
|
+
// The key's SHAPE is untouched because a ledger reads this map. What is added is the caveat, in the
|
|
15202
|
+
// same record, so the two register fields in one status cannot silently disagree: the served list
|
|
15203
|
+
// says two, and this says the tally covers both and could only be filed under one. Splitting the
|
|
15204
|
+
// tally by register is a second question and is not answered here.
|
|
15205
|
+
// ── THE LEDGER IS WHAT KNOWS, SO ASK IT BEFORE READING THE RECORD ───────────────────────────
|
|
15206
|
+
//
|
|
15207
|
+
// `registersServed` was written from ONE place: the adapter wrapper the record fetcher, the record
|
|
15208
|
+
// lister and a gated plan executor pass through. A clearance that takes counts and never fetches a
|
|
15209
|
+
// record went through none of them and recorded no register at all — the run could not say which
|
|
15210
|
+
// vendor answered it, and `registersServedFrom` returned [] for a run that had called a register
|
|
15211
|
+
// hundreds of times.
|
|
15212
|
+
//
|
|
15213
|
+
// Every one of those calls wrote a ledger row, and every row carries the vendor that produced it, so
|
|
15214
|
+
// the tally just computed already holds the answer per call. Noting them here is not a second source
|
|
15215
|
+
// of truth competing with the first: it is the same memoised setter, which unions and writes only on
|
|
15216
|
+
// change, so the eager note during the run and this reconciliation at publish cannot disagree.
|
|
15217
|
+
//
|
|
15218
|
+
// WHY NOT `activeProvider()` HERE. That resolves the register active at publish, which is the
|
|
15219
|
+
// confident-and-wrong answer this whole field exists to end — and on a run that changed register it
|
|
15220
|
+
// would name one vendor while the rows name two. The rows are per call; the resolver is per moment.
|
|
15221
|
+
for (const id of usage.providers) noteRegisterServed(run.runDir, id);
|
|
15222
|
+
const served = registersServedFrom(readRunStatus(run.runDir));
|
|
15223
|
+
const spans = providerUsageCaveat(served);
|
|
15224
|
+
runLog(run.runDir, { event: "provider-usage", provider, ...(served.length > 1 ? { spans: served } : {}), ...usage });
|
|
15225
|
+
writeRunStatus(ctx, { providerUsage: { [provider]: usage }, ...spans });
|
|
15141
15226
|
providerTally = { [provider]: usage };
|
|
15142
15227
|
// AD-4: the line prints the complete by_tool census, not a hand-picked subset — the R2 run printed
|
|
15143
15228
|
// "search=0" while 286 execute_plan calls carried the whole register workload, and the subset read
|
|
@@ -15201,7 +15286,14 @@ async function pipelineInner(job, opts = {}) {
|
|
|
15201
15286
|
// rule still forbids is the ENGINE'S OWN WORDS getting there: composeEmailHtml enumerates
|
|
15202
15287
|
// predelivery-lint's code-owned projection (deliveryFlagLines), never the checks' raw `detail` —
|
|
15203
15288
|
// this mail is addressed to job.forwarderEmail, which on a client-principal run is the client.
|
|
15204
|
-
|
|
15289
|
+
// THE REVIEWER'S OPEN POINTS NO LONGER RIDE THE EMAIL (owner ruling, 2026-10-01). This line read them
|
|
15290
|
+
// out of the run record and handed them to the cover, which is how the sentence naming the independent
|
|
15291
|
+
// reviewer reached a client after the 2026-09-24 ruling had already taken it off the report page: the
|
|
15292
|
+
// section stopped being spliced into the body and started being posted to the email instead. Measured
|
|
15293
|
+
// on the last thirty days of production, the report carries neither the heading nor that sentence since
|
|
15294
|
+
// that ruling, and the email carries both. The record is still written beside the report for the
|
|
15295
|
+
// reviewing lawyer; nothing reads it onto a client surface.
|
|
15296
|
+
writeFileSync(P.emailBody, composeEmailHtml(P.report, published.url, published.auditFile, emailNames, deliveryForRun(ctx), emailVerdictOpts));
|
|
15205
15297
|
// ctx.verdict is set BEFORE the packet is composed, because the packet's copy reads it.
|
|
15206
15298
|
ctx.verdict = verdict;
|
|
15207
15299
|
// DELIVERY (Phase 2). The driver writes a self-contained delivery packet and leaves
|
package/driver/progress.mjs
CHANGED
|
@@ -153,7 +153,12 @@ export function atomicWrite(file, text) {
|
|
|
153
153
|
|
|
154
154
|
function statusPath(runDir) { return join(runDir, "status.json"); }
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
/**
|
|
157
|
+
* The run's own status as last written. `{}` when it cannot be read, which callers must treat as an
|
|
158
|
+
* absence rather than as an empty run — exported so a lane can read the run's own `startedAt` without
|
|
159
|
+
* a second copy of where status.json lives.
|
|
160
|
+
*/
|
|
161
|
+
export function readRunStatus(runDir) {
|
|
157
162
|
try { return JSON.parse(readFileSync(statusPath(runDir), "utf8")); }
|
|
158
163
|
catch { return {}; }
|
|
159
164
|
}
|
|
@@ -115,6 +115,14 @@ function emptyTally() {
|
|
|
115
115
|
// confused with "the ledger used a name this module doesn't know" again.
|
|
116
116
|
by_tool: {},
|
|
117
117
|
unclassified: 0,
|
|
118
|
+
// WHICH VENDORS ACTUALLY ANSWERED THIS RUN, read off the rows rather than resolved from the
|
|
119
|
+
// environment. Every ledger row carries a `provider` discriminator (providers/_shared/ledger.mjs), so
|
|
120
|
+
// the set of vendors a run used is a fact the ledger already holds — and it is the ONLY place that
|
|
121
|
+
// holds it per call. A label resolved at publish instead names whichever register happened to be
|
|
122
|
+
// active then, which is wrong for a run whose register changed part-way; this cannot be, because each
|
|
123
|
+
// row says who produced it. Empty is a real answer and means the ledger carried no row for this run,
|
|
124
|
+
// which the four `ledger.*` provenance facts below then explain.
|
|
125
|
+
providers: [],
|
|
118
126
|
// …and the PROVENANCE of the zeros above. Same house rule, the last place in this module still broken
|
|
119
127
|
// by it: a ledger that is missing, mis-pointed or unreadable returned a clean all-zero tally that was
|
|
120
128
|
// indistinguishable from "the run made no provider calls", and the note line printed `total=0 ((none))`
|
|
@@ -163,6 +171,7 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
|
|
|
163
171
|
out.ledger.readable = true;
|
|
164
172
|
|
|
165
173
|
const firstFetchSession = new Map(); // record_fetch target → the session that first (network-)fetched it
|
|
174
|
+
const providersSeen = new Set(); // the vendors this run's rows name, in the order the ledger names them
|
|
166
175
|
|
|
167
176
|
for (const line of text.split("\n")) {
|
|
168
177
|
if (!line.trim()) continue;
|
|
@@ -179,6 +188,11 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
|
|
|
179
188
|
const isCacheHit = row.cache_hit === true;
|
|
180
189
|
if (isCacheHit) out.cache_hits++;
|
|
181
190
|
|
|
191
|
+
// Collected for EVERY matching row, whatever tool it rode — a count-only run rides `count`-shaped
|
|
192
|
+
// rows and no record fetch, and that is exactly the run whose register went unrecorded before.
|
|
193
|
+
const rowProvider = typeof row.provider === "string" ? row.provider.trim().toLowerCase() : "";
|
|
194
|
+
if (rowProvider) providersSeen.add(rowProvider);
|
|
195
|
+
|
|
182
196
|
const toolName = typeof row.tool === "string" && row.tool ? row.tool : "(none)";
|
|
183
197
|
out.by_tool[toolName] = (out.by_tool[toolName] ?? 0) + 1;
|
|
184
198
|
if (KINDS.includes(row.tool)) out[row.tool]++;
|
|
@@ -221,6 +235,8 @@ export function tallyRegisterCalls(ledgerPath = DEFAULT_LEDGER_PATH, runPrefix)
|
|
|
221
235
|
}
|
|
222
236
|
}
|
|
223
237
|
}
|
|
238
|
+
// Sorted so two runs with the same vendors compare equal whatever order the ledger happened to append in.
|
|
239
|
+
out.providers = [...providersSeen].sort();
|
|
224
240
|
return out;
|
|
225
241
|
}
|
|
226
242
|
|
package/driver/publish/index.mjs
CHANGED
|
@@ -1712,9 +1712,13 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1712
1712
|
const { fm, secs } = parseReport(reportMdPath);
|
|
1713
1713
|
const auditUrl = auditUrlFor(url, auditFile);
|
|
1714
1714
|
|
|
1715
|
-
// 1) internal review headline — short: the bottom line
|
|
1716
|
-
//
|
|
1717
|
-
|
|
1715
|
+
// 1) internal review headline — short: the bottom line, and nothing of the reviewer's own notes.
|
|
1716
|
+
//
|
|
1717
|
+
// THE SCRAPE THAT STOOD HERE IS GONE (owner ruling, 2026-10-01). It lifted a "Reviewer's open questions"
|
|
1718
|
+
// block out of the report's own # Summary and reprinted it on the cover. Two ways that reached a client:
|
|
1719
|
+
// a report that still carried an authored section of that name, and any archived report re-rendered
|
|
1720
|
+
// later. Removing the producer is not enough while something downstream goes looking for the text, so
|
|
1721
|
+
// this goes with it.
|
|
1718
1722
|
// The report link rides HIGH — right under the bottom line in the headline, not buried below the table.
|
|
1719
1723
|
const reportLink = hrefAttr(url)
|
|
1720
1724
|
? `<p style="margin:0 0 10px"><a href="${hrefAttr(url)}" style="color:#1a4fd6;font-weight:bold;font-size:12pt;text-decoration:none">▶ Open the full report</a>`
|
|
@@ -1826,8 +1830,10 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1826
1830
|
// second rung on any shipped build. Nothing renders a failover note into a report.
|
|
1827
1831
|
// B5b checkpoint 4 — a customer named after the analysis was written ships as a delivery note, never silently.
|
|
1828
1832
|
+ (fm.late_bind_note ? `<p style="margin:0 0 8px;color:#7a2b12"><b>Applicant named mid-run:</b> ${cell(fm.late_bind_note)}</p>` : '')
|
|
1829
|
-
|
|
1830
|
-
|
|
1833
|
+
// The reviewer's open points stood here, from the run record. They do not ride the cover any more
|
|
1834
|
+
// (owner ruling, 2026-10-01): the sentence naming the independent reviewer reads as a human declining
|
|
1835
|
+
// to sign, and the points themselves are the engine's own vocabulary. Both stay on the run, beside the
|
|
1836
|
+
// report, for the reviewing lawyer. Nothing is reworded and nothing replaces them.
|
|
1831
1837
|
// wp50: the two-bucket # Actions list no longer rides the email — it renders on the report itself
|
|
1832
1838
|
// (the single master document); the cover keeps only the headline, link, and surviving flags.
|
|
1833
1839
|
+ `</div>`;
|
|
@@ -475,16 +475,26 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
475
475
|
// Depth 2's sidecar, read from the RUN DIR rather than taken on trust from the caller: the
|
|
476
476
|
// publisher renders what the driver measured and wrote, and a republish of an archived run picks up
|
|
477
477
|
// the same file. An absent sidecar publishes exactly today's knockout.
|
|
478
|
+
// AN UNREADABLE SIDECAR IS NOT AN ABSENT ONE, and until now both arrived here as null. The
|
|
479
|
+
// instructed-scope load eight lines down has told the two apart since it was written; these two had
|
|
480
|
+
// the same catch and neither flag. A run whose counts file is present but corrupt therefore published
|
|
481
|
+
// the sentence for a run that never took any counts.
|
|
482
|
+
//
|
|
483
|
+
// `registerCountsUnreadable` HAS NO CONSUMER YET, deliberately. The page's line for an absent counts
|
|
484
|
+
// file says none could be taken, and there is no shipped sentence for a file that could not be read —
|
|
485
|
+
// writing one is a decision about what a client is told, not a defect fix. The distinction is recorded
|
|
486
|
+
// here so the decision has something to key on when it is made.
|
|
487
|
+
let registerCountsUnreadable = false;
|
|
478
488
|
if (!registerCounts) {
|
|
479
489
|
try { registerCounts = JSON.parse(readFileSync(driverDir(runDir, 'register-counts.json'), 'utf8')); }
|
|
480
|
-
catch { registerCounts = null; }
|
|
490
|
+
catch { registerCounts = null; registerCountsUnreadable = existsSync(driverDir(runDir, 'register-counts.json')); }
|
|
481
491
|
}
|
|
482
492
|
// part 5 — the filings sidecar, read the same way and for the same reason: the publisher renders
|
|
483
493
|
// what the driver measured and wrote, and a republish of an archived run picks up the same file. An
|
|
484
494
|
// absent sidecar publishes exactly the counts-only knockout, with no filings section anywhere.
|
|
485
|
-
let registerRecords = null;
|
|
495
|
+
let registerRecords = null, registerRecordsUnreadable = false;
|
|
486
496
|
try { registerRecords = JSON.parse(readFileSync(driverDir(runDir, 'register-records.json'), 'utf8')); }
|
|
487
|
-
catch { registerRecords = null; }
|
|
497
|
+
catch { registerRecords = null; registerRecordsUnreadable = existsSync(driverDir(runDir, 'register-records.json')); }
|
|
488
498
|
// The owner lookups this run made, read the same tolerant way as the records above:
|
|
489
499
|
// an archived run that predates the lane has no file, and its cards then render exactly as they were
|
|
490
500
|
// delivered. The source line the report prints comes from HERE, not from anything the seat typed.
|
|
@@ -699,6 +709,9 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
699
709
|
const markBand = single ? overall : worstBand(framework, [m]);
|
|
700
710
|
writeRO(file, renderKnockoutHtml(one, framework, {
|
|
701
711
|
runId, overall: markBand, issued, auditFile, probeRan, registerCounts, registerRecords, ownerChecks, instructedScope, identity, matter: runId,
|
|
712
|
+
// Carried as its own value rather than folded into `registerRecords`, because three sites PRINT
|
|
713
|
+
// `registerRecords.unavailable` and a marker invented here would become client-facing wording.
|
|
714
|
+
registerRecordsUnreadable,
|
|
702
715
|
// — an invented mark says so on its own report. Resolved ONCE above the loop:
|
|
703
716
|
// the answer is a property of the run, and asking per mark would let a multi-mark demo mark some
|
|
704
717
|
// documents and not others if the roster moved mid-publish.
|
|
@@ -1377,16 +1377,16 @@ function firstRef(marks, framework) {
|
|
|
1377
1377
|
return '';
|
|
1378
1378
|
}
|
|
1379
1379
|
|
|
1380
|
-
// — THE REGISTER SENTENCE IS THE RENDERER'S, and it is the whole reason
|
|
1380
|
+
// — THE REGISTER SENTENCE IS THE RENDERER'S, and it is the whole reason the two clauses below exist.
|
|
1381
1381
|
//
|
|
1382
1382
|
// A live run printed "the register overlay has not been run" in model prose directly under a table of
|
|
1383
1383
|
// register counts the same run had taken. The model was not lying: it cannot see the count lane, it is
|
|
1384
1384
|
// deliberately never shown the figures (register-count.mjs rule 1), and it filled the gap with the only
|
|
1385
1385
|
// thing it had. The fix is not a better prompt — it is that nobody who cannot see the machinery gets to
|
|
1386
|
-
// describe it. The validator forbids the model from saying anything about register coverage;
|
|
1387
|
-
//
|
|
1386
|
+
// describe it. The validator forbids the model from saying anything about register coverage; these
|
|
1387
|
+
// clauses say it, from the artifacts.
|
|
1388
1388
|
//
|
|
1389
|
-
//
|
|
1389
|
+
// They read the SIDECARS, never the prose: the counts entry for this mark, and whether the product bought
|
|
1390
1390
|
// the probe at all. Four states, four sentences, and none of them is a judgment.
|
|
1391
1391
|
//
|
|
1392
1392
|
// — AND IT NOW STATES THE POSITION, NOT ONLY THE COVERAGE. Coverage alone ("hit-counts were taken
|
|
@@ -1394,10 +1394,12 @@ function firstRef(marks, framework) {
|
|
|
1394
1394
|
// STANDS on the register for this name. That sentence is appended by registerPositionClause below and
|
|
1395
1395
|
// it is present in every state, including the state where the answer is "nothing does" — an absence is
|
|
1396
1396
|
// a finding and must be said out loud, never left as a silence over a table of numbers.
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1397
|
+
//
|
|
1398
|
+
// THE FUNCTION THAT COMPOSED THE PAIR IS GONE, and removing it was the point rather than tidiness. It
|
|
1399
|
+
// was called from nowhere in the tree, and its call to `registerPositionClause` was the one that did NOT
|
|
1400
|
+
// pass the unreadable-sidecar flag — so anybody reviving it would have revived exactly the defect that
|
|
1401
|
+
// flag closes, silently, with nothing at the call site to say so. The live composition is inline in
|
|
1402
|
+
// `renderKnockoutHtml`, which passes it. Found independently twice before being removed once.
|
|
1401
1403
|
|
|
1402
1404
|
/**
|
|
1403
1405
|
* Where the name STANDS on the register that was searched. Six states, and they are six because
|
|
@@ -1421,12 +1423,22 @@ function registerLine(mark, registerCounts, probeRan, registerRecords = null, ca
|
|
|
1421
1423
|
// unconditionally, which was true while the clause was drawn under the cards. It is drawn under the
|
|
1422
1424
|
// COUNTS now — the numbers it qualifies — and the cards are below it, so a fixed word would send a
|
|
1423
1425
|
// reader the wrong way up the page. The caller knows the order; this function does not guess it.
|
|
1424
|
-
|
|
1426
|
+
// The one sentence for a listing whose answer this run cannot state, whether the lister refused or the
|
|
1427
|
+
// sidecar itself would not parse. Held once so the two callers below cannot drift: they are the same fact
|
|
1428
|
+
// to a reader, and two copies of a sentence are two sentences waiting to differ.
|
|
1429
|
+
const FILINGS_NOT_LISTED = 'The filings behind the counts could not be listed on this run, so nothing here says whether one stands.';
|
|
1430
|
+
|
|
1431
|
+
function registerPositionClause(mark, registerCounts, registerRecords, cards = [], where = 'above', recordsUnreadable = false) {
|
|
1425
1432
|
if (!registerRecords) {
|
|
1433
|
+
// AN UNREADABLE SIDECAR IS NOT AN ABSENT ONE. Absent means the listing was never taken, and the page
|
|
1434
|
+
// says so. Unreadable means it WAS taken and this run cannot say what it found. The second sentence
|
|
1435
|
+
// was already written, for the lister's own refusal, and was simply unreachable from a republish:
|
|
1436
|
+
// a file that would not parse arrived as null, and null reads here as absent.
|
|
1437
|
+
if (recordsUnreadable) return FILINGS_NOT_LISTED;
|
|
1426
1438
|
return registerCounts ? 'The filings behind those counts were not listed on this run.' : '';
|
|
1427
1439
|
}
|
|
1428
1440
|
if (registerRecords.unavailable) {
|
|
1429
|
-
return
|
|
1441
|
+
return FILINGS_NOT_LISTED;
|
|
1430
1442
|
}
|
|
1431
1443
|
const entry = recordsForMark(registerRecords, mark?.name);
|
|
1432
1444
|
const provider = registerRecords.providerLabel ?? registerRecords.provider ?? 'the register';
|
|
@@ -1775,6 +1787,11 @@ const CAVEAT_LEAD = 'This screen also carries the following limits:';
|
|
|
1775
1787
|
export function renderKnockoutHtml(findings, framework, {
|
|
1776
1788
|
runId, overall, issued = null, auditFile = null, probeRan = false, registerCounts = null,
|
|
1777
1789
|
registerRecords = null,
|
|
1790
|
+
// — was the filings sidecar PRESENT but unreadable? Defaults to false, so every existing caller — the
|
|
1791
|
+
// unit fixtures and both render-check scripts, none of which pass one — renders exactly as it did. It
|
|
1792
|
+
// is its own value rather than a marker on `registerRecords`, because three sites PRINT
|
|
1793
|
+
// `registerRecords.unavailable` and anything invented for it would become client-facing wording.
|
|
1794
|
+
registerRecordsUnreadable = false,
|
|
1778
1795
|
// The driver's own record of the owner lookups it ran. Defaults to [] so an
|
|
1779
1796
|
// archived run that predates the lane renders exactly as it was delivered.
|
|
1780
1797
|
ownerChecks = [],
|
|
@@ -1853,7 +1870,7 @@ export function renderKnockoutHtml(findings, framework, {
|
|
|
1853
1870
|
// promoted, and the clause would name refs no card carries.
|
|
1854
1871
|
const cardsByMark = new Map(marks.map((m) => [m?.name, registerCardViews(m, framework, registerRecords).cards]));
|
|
1855
1872
|
const positions = marks.map((m) => {
|
|
1856
|
-
const clause = registerPositionClause(m, registerCounts, registerRecords, cardsByMark.get(m?.name) ?? [], 'below');
|
|
1873
|
+
const clause = registerPositionClause(m, registerCounts, registerRecords, cardsByMark.get(m?.name) ?? [], 'below', registerRecordsUnreadable);
|
|
1857
1874
|
if (!clause) return '';
|
|
1858
1875
|
return marks.length > 1 && m?.name ? `${m.name}: ${clause}` : clause;
|
|
1859
1876
|
}).filter(Boolean).join(' ');
|
|
@@ -2220,6 +2237,16 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
|
|
|
2220
2237
|
line: recordsLine(listed),
|
|
2221
2238
|
fetched: listed.fetched ?? (listed.records ?? []).length,
|
|
2222
2239
|
capped: Boolean(listed.capped),
|
|
2240
|
+
// THE CAP THE RUN RECORDED, beside the flag that says it bit. `capped: true` on its own cannot
|
|
2241
|
+
// tell a listing truncated at fifty from one a cap of zero stopped before it began: the first
|
|
2242
|
+
// is a register outcome and the second is a configuration, and a consumer reading the flag
|
|
2243
|
+
// alone reports them the same way. The run has always written `cap` per mark; nothing read it.
|
|
2244
|
+
//
|
|
2245
|
+
// NULL IS NOT ZERO and the two are kept apart all the way out, the way the portal's allowance
|
|
2246
|
+
// contract keeps them. Null is a sidecar that recorded no cap at all, which is every run
|
|
2247
|
+
// archived before the field existed; zero is a cap that was set to zero. Folding them together
|
|
2248
|
+
// would put an archived run and a misconfigured one on the same line.
|
|
2249
|
+
cap: Number.isFinite(listed.cap) ? listed.cap : null,
|
|
2223
2250
|
records: (listed.records ?? []).map((r) => ({
|
|
2224
2251
|
mark: r.mark, owner: r.owner, status: r.status, classes: r.classes ?? [],
|
|
2225
2252
|
territory: r.territory, matchedForm: r.matchedForm, matchedBasis: r.matchedBasis,
|
package/driver/record-carry.mjs
CHANGED
|
@@ -567,6 +567,21 @@ export function traceRecordCarry({ bandRecords = [], placements = [], registerFi
|
|
|
567
567
|
// and the one that run shipped without stating, so it is counted separately and never folded
|
|
568
568
|
// into an ordinary drop total.
|
|
569
569
|
const upstreamAbsent = rows.filter((r) => /:stage-incomplete$/.test(String(r.reason ?? "")));
|
|
570
|
+
// Records the step PASSED OVER: it ran, it reached them, and it recorded no ground of its own, so the
|
|
571
|
+
// driver's own literal stands as the reason. A reason, so not `unreasoned` — the same distinction
|
|
572
|
+
// `upstreamAbsent` above is drawn on, and counted separately for the same reason.
|
|
573
|
+
//
|
|
574
|
+
// WHY IT HAS TO BE IN `totals` AND NOT ONLY IN `by_reason_source`. This module's own header says a
|
|
575
|
+
// reader who opens the file and sees `unreasoned: 0` must be able to tell that from "nothing was
|
|
576
|
+
// dropped anywhere in this run". On R18 of 2026-09-27 the totals block read `unreasoned: 0` beside
|
|
577
|
+
// 5,760 records the step passed over — the count was in `by_reason_source` and not in the block a
|
|
578
|
+
// reader opens first, so the loudest fact about that run was one level down from the answer.
|
|
579
|
+
//
|
|
580
|
+
// `unreasoned` IS NOT REDEFINED, deliberately. A shipped Machine QC check passes only on
|
|
581
|
+
// `unreasoned === 0` and that row reaches a client's workbook; folding these in would turn a green
|
|
582
|
+
// into a red on every run with a silent exit, which is a decision about what a client is told rather
|
|
583
|
+
// than a correction to a record.
|
|
584
|
+
const stepSilent = rows.filter((r) => r.reason_source === "step-silent");
|
|
570
585
|
const incompleteStages = Object.values(outcomes ?? {}).filter((o) => o && o.completed !== true).map((o) => o.stage);
|
|
571
586
|
const slices = untraceableSlices({ crowds, planExecution });
|
|
572
587
|
const deliveredFindings = Array.isArray(findings) ? findings.length : 0;
|
|
@@ -601,6 +616,7 @@ export function traceRecordCarry({ bandRecords = [], placements = [], registerFi
|
|
|
601
616
|
finding: byReach.finding ?? 0,
|
|
602
617
|
dropped: rows.length - (byReach.finding ?? 0),
|
|
603
618
|
unreasoned: unreasoned.length,
|
|
619
|
+
step_silent: stepSilent.length,
|
|
604
620
|
upstream_absent: upstreamAbsent.length,
|
|
605
621
|
untraceable_slices: slices.length,
|
|
606
622
|
// — THE SUM STATES ITS OWN INCOMPLETENESS. `n + s.untraced` coerced a null to 0, so a
|
|
@@ -886,7 +902,7 @@ export function statedDivergenceFindings({ reconciliation = null, carryRows = nu
|
|
|
886
902
|
// statedDivergenceFindings checked=5 matched=5 diverged=0 ← should have named two marks
|
|
887
903
|
//
|
|
888
904
|
// The reconciliation names five finding-ended positions and they are five OTHER marks — VELTRIN
|
|
889
|
-
// bioenergetische Kosmetik, KORPHIC HSE, KORPHI,
|
|
905
|
+
// bioenergetische Kosmetik, KORPHIC HSE, KORPHI, KORPHIN & ACME, KORPHI DIAGNOSTICS. The two that
|
|
890
906
|
// were lost sit in the CARRY rows and the reconciliation never mentions them:
|
|
891
907
|
//
|
|
892
908
|
// HALVER KORPHI reach=placed stopped_at=digest reason_source=step-stated reason=digest:reasoned-negative
|
|
@@ -66,8 +66,18 @@ export const RULE_DEFINITIONS = [
|
|
|
66
66
|
];
|
|
67
67
|
|
|
68
68
|
/** Files worth scanning: prose-bearing, tracked, not generated, and not this rule's own definition. */
|
|
69
|
+
// `.js` IS IN THE LIST BECAUSE IT WAS THE HOLE. The extensions here were the ones the driver is written
|
|
70
|
+
// in, and the register adapters are not: 26 tracked `.js` files, every one carrying the same kind of
|
|
71
|
+
// header prose as the rest of the tree, scanned by nothing. The strip ran over them like everywhere
|
|
72
|
+
// else, and one of its casualties sat in an adapter header for as long as this check has existed,
|
|
73
|
+
// under a table reading zero. A floor that cannot see a whole file extension reports a repaired tree.
|
|
74
|
+
//
|
|
75
|
+
// It costs one row, and the row is NOT residue: an adapter header names a list separator as
|
|
76
|
+
// `(, ; / etc)`, which is a parenthesis opening on a comma and is also correct English about
|
|
77
|
+
// punctuation. `notes` in the backlog table records why that row is there, so the next reader meets an
|
|
78
|
+
// explanation rather than a number that will not fall.
|
|
69
79
|
export const isScannable = (f) =>
|
|
70
|
-
/\.(mjs|md|yml|ts)$/.test(f) && !f.startsWith("portal-ui/dist/") && !RULE_DEFINITIONS.includes(f);
|
|
80
|
+
/\.(mjs|js|md|yml|ts)$/.test(f) && !f.startsWith("portal-ui/dist/") && !RULE_DEFINITIONS.includes(f);
|
|
71
81
|
|
|
72
82
|
/**
|
|
73
83
|
* Count both signatures per file across `files`.
|
package/driver/register-plan.mjs
CHANGED
|
@@ -2079,7 +2079,7 @@ export function foldSupplementalEntries(plan, entries) {
|
|
|
2079
2079
|
const eTerm = String(e.term ?? e.terms?.[0] ?? "");
|
|
2080
2080
|
if (inBatch.has(e.qid) && inBatch.get(e.qid) === eTerm) continue; // the same row twice — one refusal
|
|
2081
2081
|
if (inBatch.has(e.qid)) {
|
|
2082
|
-
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""),
|
|
2082
|
+
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "identity-collision",
|
|
2083
2083
|
issue: planRowRefusal({ qid: e.qid, issue:
|
|
2084
2084
|
`a different term ("${inBatch.get(e.qid)}") already minted this identity in the same batch, so `
|
|
2085
2085
|
+ `one of the two would be dropped with nothing recorded. Two DIFFERENT terms sharing one qid is `
|
|
@@ -2098,7 +2098,7 @@ export function foldSupplementalEntries(plan, entries) {
|
|
|
2098
2098
|
e = kept;
|
|
2099
2099
|
const issues = entryTermIssues(e);
|
|
2100
2100
|
if (issues.length) {
|
|
2101
|
-
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""),
|
|
2101
|
+
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "malformed-term",
|
|
2102
2102
|
issue: planRowRefusal({ qid: e.qid, issue: issues[0].issue }) });
|
|
2103
2103
|
continue;
|
|
2104
2104
|
}
|
|
@@ -2107,7 +2107,11 @@ export function foldSupplementalEntries(plan, entries) {
|
|
|
2107
2107
|
const key = entryQuestionKey(e, plan);
|
|
2108
2108
|
const twin = key ? asked.get(key) : undefined;
|
|
2109
2109
|
if (twin) {
|
|
2110
|
-
|
|
2110
|
+
// THE TWIN IS A FIELD, NOT ONLY A PHRASE IN THE PROSE. This row is the only place that knows the
|
|
2111
|
+
// question is already asked, and by which plan row. A reader could parse it back out of the
|
|
2112
|
+
// sentence; a caller cannot be asked to. The remedy accounting reads it to tell a duplicate
|
|
2113
|
+
// refusal (answered elsewhere) from the other two kinds (genuinely unasked).
|
|
2114
|
+
refused.push({ qid: e.qid, term: String(e.term ?? e.terms?.[0] ?? ""), kind: "duplicate-question", twin,
|
|
2111
2115
|
issue: planRowRefusal({ qid: e.qid, issue:
|
|
2112
2116
|
`it asks the same question as plan row "${twin}" once regions are resolved (an entry with no `
|
|
2113
2117
|
+ `regions of its own inherits the plan's, so an empty list is the WIDEST scope, not a narrower `
|