@suveren/gateway 0.18.2 → 0.19.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.
@@ -1,23 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  CASE_TIME_UNKNOWN_REASON,
4
- METRIC_LABELS,
4
+ REPORT_BRIEF,
5
+ REPORT_ELEMENTS,
6
+ START_TIME_METRICS,
7
+ agentApproval,
8
+ agentMandate,
9
+ agentTicket,
10
+ agentTicketRow,
11
+ approvalFields,
12
+ buildExportBundle,
13
+ buildExportDocument,
14
+ checkedValueLine,
15
+ computeMetric,
5
16
  fingerprintOf,
6
- formatActionLabel,
7
- formatCurrency,
8
17
  formatDateTime,
9
- formatDuration,
10
- formatMetricValue,
18
+ glossaryUsage,
19
+ mandateFields,
11
20
  parseCaseAttrs,
12
21
  parseElements,
13
22
  parseTimestampSeconds,
14
23
  profileShortLabel,
15
24
  readPairing,
25
+ renderReportHtml,
16
26
  resolveApprovalElement,
17
27
  resolveCaseElement,
18
28
  resolveMandateElement,
19
- resolveTicketElement
20
- } from "./chunk-QOKPECVY.mjs";
29
+ resolveTicketElement,
30
+ sanitizeReport,
31
+ suggestedFilename,
32
+ ticketFields
33
+ } from "./chunk-ZRRZFGRL.mjs";
21
34
 
22
35
  // bin/http.ts
23
36
  import { randomUUID as randomUUID2 } from "crypto";
@@ -1678,8 +1691,8 @@ var ReceiptArchive = class {
1678
1691
  */
1679
1692
  record(entry, now = Math.floor(Date.now() / 1e3)) {
1680
1693
  let changed = false;
1681
- const receiptId2 = typeof entry.receipt?.id === "string" ? entry.receipt.id : void 0;
1682
- const alreadyArchived = receiptId2 !== void 0 && this.data.receipts.some((r) => r.receipt.id === receiptId2);
1694
+ const receiptId = typeof entry.receipt?.id === "string" ? entry.receipt.id : void 0;
1695
+ const alreadyArchived = receiptId !== void 0 && this.data.receipts.some((r) => r.receipt.id === receiptId);
1683
1696
  if (!alreadyArchived) {
1684
1697
  this.data.receipts.push({
1685
1698
  archivedAt: now,
@@ -2084,125 +2097,6 @@ import { homedir as homedir7 } from "os";
2084
2097
  import { dirname as dirname8, join as join10 } from "path";
2085
2098
  import { createCipheriv as createCipheriv5, createDecipheriv as createDecipheriv5, randomBytes as randomBytes5 } from "crypto";
2086
2099
 
2087
- // src/lib/report-brief.ts
2088
- var REPORT_ELEMENTS = [
2089
- "sv-ticket",
2090
- "sv-approval",
2091
- "sv-mandate",
2092
- "sv-record",
2093
- "sv-case",
2094
- "sv-metric"
2095
- ];
2096
- var REPORT_BRIEF = `How to write the report
2097
-
2098
- Purpose: show the company's management what the AI achieved in this test \u2014 case by case, from the request that started it to the goal that completed it \u2014 with proof for every fact that matters.
2099
-
2100
- Who reads it: managers who are not technical. Plain words, short sentences, the result first.
2101
-
2102
- What you write: one HTML page (HTML, CSS and inline SVG only \u2014 no JavaScript, no external files or links except the check links the gateway adds). The layout, headings, charts, tables and your assessment are yours.
2103
-
2104
- It must read well everywhere: on a phone, in a narrow side panel (about 360 pixels wide) and on a large screen. Use fluid widths (no fixed page widths), let columns stack on narrow screens (CSS grid/flex with wrapping, or media queries), make wide tables scroll horizontally or turn into stacked rows, give SVG charts a viewBox so they scale, and keep text at a readable size.
2105
-
2106
- What you cannot write yourself: facts that need proof. Place these elements instead; the gateway looks the data up, checks it and draws the element. You give references, never the content:
2107
-
2108
- - <sv-ticket ref="TICKET_ID"></sv-ticket> \u2014 one action you took, with its signed ticket.
2109
- - <sv-approval ticket="TICKET_ID"></sv-approval> \u2014 who approved that action, when, and how long it waited.
2110
- - <sv-mandate ticket="TICKET_ID"></sv-mandate> \u2014 the authority the action ran under: limits, mode, intent.
2111
- - <sv-record system="email|crm|erp" ref="RECORD_ID"></sv-record> \u2014 an email, CRM entry, quote or order.
2112
- - <sv-case start="email:MESSAGE_ID" goal="ticket:TICKET_ID" steps="TICKET_ID TICKET_ID"></sv-case> \u2014 one business case: the incoming email that started it, the ticket that completed it, and the tickets in between.
2113
- - <sv-metric kind="KIND" cases="all"></sv-metric> \u2014 a number the gateway computes over the cases you defined. KIND: completed, median-time, average-time, without-approval, approvals, median-approval-wait, tickets, refusals. cases is "all" or a space-separated list of case ids, e.g. cases="C1 C3".
2114
-
2115
- Rules:
2116
- 1. Define every case you worked on with sv-case \u2014 also the ones that did not reach their goal (omit goal, or say why in your text). The gateway shows management how many of the loaded cases your report covers and names the missing ones \u2014 and lists every ticket from the test period that your report does not reference, so name every step.
2117
- 2. Use sv-metric for every headline number. Everything you write yourself \u2014 text, tables, charts, numbers you compute \u2014 sits under the gateway's label "AI analysis \u2014 not verified"; only the drawn elements carry the gateway's green check. Do not imitate them: class names starting with "sv-" are removed from your HTML.
2118
- 3. A case's goal must be what actually completed it \u2014 for an email reply, the reply to the start email. The gateway checks this link. A case is timed from when its email reached the test (the test data may date its emails earlier \u2014 the gateway uses the later of the two), so steps from before that moment do not belong to it.
2119
- 4. A wrong or unknown reference is shown as "not verifiable". Do not guess IDs \u2014 read them first.
2120
- 5. Never claim more than the elements prove. Say plainly what went wrong or waited for a person.
2121
-
2122
- Suggested structure:
2123
- 1. The result in one sentence, then 3\u20134 sv-metric figures (completed, median time, share without approval, approvals).
2124
- 2. All cases at a glance \u2014 a table with one row per case, linked to its sv-case below.
2125
- 3. Each case: sv-case, then the sv-ticket and sv-approval elements that explain it.
2126
- 4. What was stopped or needed a person, and why (sv-approval, refusals).
2127
- 5. Your assessment: what worked, what slowed things down, what a different mandate would change \u2014 marked as your view.
2128
-
2129
- Update the report whenever cases progress; each write replaces the previous report.`;
2130
-
2131
- // src/lib/report/sanitize.ts
2132
- import sanitizeHtmlLib from "sanitize-html";
2133
- var FORBIDDEN_TAGS = /* @__PURE__ */ new Set(["script", "iframe", "object", "embed", "link"]);
2134
- function isUrlAttribute(name) {
2135
- const n = name.toLowerCase();
2136
- return n === "href" || n === "src" || n === "xlink:href" || n.endsWith(":href");
2137
- }
2138
- function isAllowedUrl(value) {
2139
- const v = value.trim();
2140
- if (v.startsWith("#")) return true;
2141
- if (/^data:image\//i.test(v)) return true;
2142
- return false;
2143
- }
2144
- function stripGatewayClasses(value) {
2145
- return value.split(/\s+/).filter((c) => c && !/^sv-/i.test(c)).join(" ");
2146
- }
2147
- function sanitizeReportHtml(html) {
2148
- return sanitizeHtmlLib(html, {
2149
- // Allow every tag/attribute by default — see module doc comment for why
2150
- // this is deny-by-name rather than allow-by-name. `transformTags['*']`
2151
- // below is then the ONLY attribute filter (allowedAttributes: false
2152
- // skips sanitize-html's own, so there is exactly one place attributes
2153
- // are decided, not two that could disagree). Logs a harmless warning for
2154
- // "script"/"style" on every call (sanitize-html's own vulnerable-tag
2155
- // notice, fired from its internal tag list even though we pass `false`,
2156
- // not an array containing them) — `allowVulnerableTags` is the
2157
- // documented way to accept that, and <script> is still removed, by
2158
- // `exclusiveFilter` below, regardless of this flag.
2159
- allowedTags: false,
2160
- allowedAttributes: false,
2161
- allowVulnerableTags: true,
2162
- // sanitize-html's OWN scheme check (independent of transformTags, and
2163
- // applied to it/src/cite only — NOT xlink:href, which is why the
2164
- // transformTags filter above is the one actually relied on for SVG) uses
2165
- // this list; it has no "data" scheme by default, which would undo our
2166
- // own decision to keep `data:image/*` sources. Nothing wider is opened:
2167
- // transformTags has already removed every href/src that isn't a
2168
- // fragment link or a data:image/* URI by the time this check runs.
2169
- allowedSchemes: [...sanitizeHtmlLib.defaults.allowedSchemes, "data"],
2170
- // Preserve attribute name casing — inline SVG uses camelCase attributes
2171
- // (viewBox, preserveAspectRatio, …) that lowercasing would silently
2172
- // break. Safe for the `on*`/url checks below: both compare case-folded.
2173
- parser: { lowerCaseAttributeNames: false },
2174
- // Removes the named tag AND all of its children/text — the only correct
2175
- // behaviour for <script>: dropping just the tag would leave its JS as
2176
- // visible text, but the content must not survive either.
2177
- exclusiveFilter: (frame) => {
2178
- const tag = frame.tag.toLowerCase();
2179
- if (FORBIDDEN_TAGS.has(tag)) return true;
2180
- if (tag === "meta" && Object.keys(frame.attribs).some((a) => a.toLowerCase() === "http-equiv")) {
2181
- return true;
2182
- }
2183
- return false;
2184
- },
2185
- transformTags: {
2186
- "*": (tagName, attribs) => {
2187
- const kept = {};
2188
- for (const [name, value] of Object.entries(attribs)) {
2189
- const lower = name.toLowerCase();
2190
- if (lower.startsWith("on")) continue;
2191
- if (isUrlAttribute(name) && !isAllowedUrl(value)) continue;
2192
- if (lower.startsWith("data-sv")) continue;
2193
- if (lower === "class") {
2194
- const classes = stripGatewayClasses(value);
2195
- if (classes) kept[name] = classes;
2196
- continue;
2197
- }
2198
- kept[name] = value;
2199
- }
2200
- return { tagName, attribs: kept };
2201
- }
2202
- }
2203
- });
2204
- }
2205
-
2206
2100
  // src/lib/report/record-resolvers.ts
2207
2101
  function findById(rows, id) {
2208
2102
  return rows.find((r) => r.id === id);
@@ -2251,68 +2145,6 @@ function resolveRecord(system, exportData, ref) {
2251
2145
  return resolveCrmRecord(exportData, ref);
2252
2146
  }
2253
2147
 
2254
- // src/lib/report/metric-resolvers.ts
2255
- var METRIC_KINDS = [
2256
- "completed",
2257
- "median-time",
2258
- "average-time",
2259
- "without-approval",
2260
- "approvals",
2261
- "median-approval-wait",
2262
- "tickets",
2263
- "refusals"
2264
- ];
2265
- var UnknownMetricKindError = class extends Error {
2266
- constructor(kind) {
2267
- super(`Unknown sv-metric kind "${kind}" \u2014 expected one of: ${METRIC_KINDS.join(", ")}.`);
2268
- this.name = "UnknownMetricKindError";
2269
- }
2270
- };
2271
- var START_TIME_METRICS = /* @__PURE__ */ new Set(["median-time", "average-time", "refusals"]);
2272
- function median(values) {
2273
- if (values.length === 0) return 0;
2274
- const sorted = [...values].sort((a, b) => a - b);
2275
- const mid = Math.floor(sorted.length / 2);
2276
- return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
2277
- }
2278
- function average(values) {
2279
- if (values.length === 0) return 0;
2280
- return values.reduce((a, b) => a + b, 0) / values.length;
2281
- }
2282
- function computeMetric(kind, cases, refusalTimes = []) {
2283
- if (START_TIME_METRICS.has(kind) && cases.some((c) => c.totalDurationSeconds === null)) {
2284
- throw new Error("Case start times are not verifiable.");
2285
- }
2286
- switch (kind) {
2287
- case "completed":
2288
- return cases.length;
2289
- case "median-time":
2290
- return median(cases.map((c) => c.totalDurationSeconds));
2291
- case "average-time":
2292
- return average(cases.map((c) => c.totalDurationSeconds));
2293
- case "without-approval": {
2294
- if (cases.length === 0) return 0;
2295
- const withoutApproval = cases.filter((c) => c.approvals.length === 0).length;
2296
- return withoutApproval / cases.length;
2297
- }
2298
- case "approvals":
2299
- return cases.reduce((sum, c) => sum + c.approvals.length, 0);
2300
- case "median-approval-wait":
2301
- return median(cases.flatMap((c) => c.approvals.map((a) => a.waitSeconds).filter((w) => typeof w === "number")));
2302
- case "tickets": {
2303
- const ids = new Set(cases.flatMap((c) => c.ticketIds));
2304
- return ids.size;
2305
- }
2306
- case "refusals":
2307
- return cases.reduce(
2308
- (sum, c) => sum + refusalTimes.filter((t) => t >= c.startTime && t <= c.goalTime).length,
2309
- 0
2310
- );
2311
- default:
2312
- throw new UnknownMetricKindError(kind);
2313
- }
2314
- }
2315
-
2316
2148
  // src/lib/report/types.ts
2317
2149
  function isEmailExport(v) {
2318
2150
  const o = v;
@@ -2330,7 +2162,7 @@ function isCrmExport(v) {
2330
2162
  // src/lib/report/verify-report.ts
2331
2163
  var KNOWN_KINDS = new Set(REPORT_ELEMENTS);
2332
2164
  async function verifyReport(html, sources) {
2333
- const sanitized = sanitizeReportHtml(html);
2165
+ const { html: sanitized, notes: sanitizeNotes } = sanitizeReport(html);
2334
2166
  const parsed = parseElements(sanitized);
2335
2167
  const exportCache = /* @__PURE__ */ new Map();
2336
2168
  function getExport(system) {
@@ -2351,7 +2183,16 @@ async function verifyReport(html, sources) {
2351
2183
  try {
2352
2184
  switch (p.kind) {
2353
2185
  case "sv-ticket": {
2354
- const r = await resolveTicketElement(sources.archive, p.attrs.ref ?? "");
2186
+ const ref = p.attrs.ref ?? "";
2187
+ const r = await resolveTicketElement(sources.archive, ref);
2188
+ if (r.status === "verified" && (p.attrs.variant ?? "").trim().toLowerCase() === "full") {
2189
+ const m = await resolveMandateElement(sources.archive, ref);
2190
+ const a = await resolveApprovalElement(sources.archive, ref);
2191
+ const data = { ...r.data };
2192
+ if (m.status === "verified") data.mandate = m.data;
2193
+ if (a.status === "verified") data.approval = a.data;
2194
+ return { id: p.id, kind: p.kind, attrs: p.attrs, status: r.status, data };
2195
+ }
2355
2196
  return { id: p.id, kind: p.kind, attrs: p.attrs, ...r };
2356
2197
  }
2357
2198
  case "sv-approval": {
@@ -2456,7 +2297,7 @@ async function verifyReport(html, sources) {
2456
2297
  }
2457
2298
  const refusalTimes = kind === "refusals" ? await collectRefusalTimes(getExport) : [];
2458
2299
  const value = computeMetric(kind, caseInputs, refusalTimes);
2459
- const data = { kind, cases: casesAttr, value, caseCount: verifiedCount };
2300
+ const data = { kind, cases: casesAttr, value, caseCount: verifiedCount, caseIds: caseInputs.map((c) => c.caseId) };
2460
2301
  if (verifiedCount < requestedCount) {
2461
2302
  elements.push({
2462
2303
  id: p.id,
@@ -2474,8 +2315,8 @@ async function verifyReport(html, sources) {
2474
2315
  }
2475
2316
  }
2476
2317
  const proof = buildProof(elements, caseElements);
2477
- const coverage = await buildCoverage(caseElements, getExport, sources.archive, proof.ticketsReferenced);
2478
- return { html: sanitized, elements, proof, coverage };
2318
+ const coverage = await buildCoverage(caseElements, getExport, sources.archive, proof.ticketsReferenced, sources.window);
2319
+ return { html: sanitized, elements, proof, coverage, sanitizeNotes };
2479
2320
  }
2480
2321
  async function collectRefusalTimes(getExport) {
2481
2322
  const times = [];
@@ -2491,38 +2332,7 @@ async function collectRefusalTimes(getExport) {
2491
2332
  return times;
2492
2333
  }
2493
2334
  function summarize(el) {
2494
- const d = el.data ?? {};
2495
- switch (el.kind) {
2496
- case "sv-ticket": {
2497
- const actionLabel = typeof d.actionLabel === "string" ? d.actionLabel : formatActionLabel(d.action);
2498
- const timeLabel = typeof d.timeLabel === "string" ? d.timeLabel : formatDateTime(d.time);
2499
- return `${actionLabel} \u2014 ${timeLabel}`;
2500
- }
2501
- case "sv-approval": {
2502
- const who = typeof d.whoLabel === "string" ? d.whoLabel : d.who?.join(", ") ?? "unknown";
2503
- const waitLabel = typeof d.waitLabel === "string" ? d.waitLabel : typeof d.waitSeconds === "number" ? formatDuration(d.waitSeconds) : "unknown";
2504
- return `Approval by ${who} \u2014 waited ${waitLabel}`;
2505
- }
2506
- case "sv-mandate": {
2507
- const profileLabel = typeof d.profileLabel === "string" ? d.profileLabel : profileShortLabel(typeof d.profile === "string" ? d.profile : void 0);
2508
- const owners = Array.isArray(d.owners) && d.owners.length > 0 ? d.owners.join(", ") : "unknown owner";
2509
- return `Mandate: ${profileLabel} \xB7 ${owners}`;
2510
- }
2511
- case "sv-record":
2512
- return `${String(d.kind ?? "record")} record checked`;
2513
- case "sv-case": {
2514
- const steps = d.steps?.length ?? 0;
2515
- const time = typeof d.totalDurationSeconds === "number" ? formatDuration(d.totalDurationSeconds) : "time not verifiable";
2516
- return `Case ${String(d.caseId)}: ${steps} ${steps === 1 ? "step" : "steps"}, ${time}`;
2517
- }
2518
- case "sv-metric": {
2519
- const kind = String(d.kind ?? "");
2520
- const label = METRIC_LABELS[kind] ?? kind;
2521
- return `${label} = ${formatMetricValue(kind, d.value)}`;
2522
- }
2523
- default:
2524
- return el.id;
2525
- }
2335
+ return checkedValueLine(el);
2526
2336
  }
2527
2337
  function buildProof(elements, caseElements) {
2528
2338
  const ticketsReferenced = /* @__PURE__ */ new Set();
@@ -2563,7 +2373,7 @@ function buildProof(elements, caseElements) {
2563
2373
  verifiedValues
2564
2374
  };
2565
2375
  }
2566
- async function buildCoverage(caseElements, getExport, archive, referenced) {
2376
+ async function buildCoverage(caseElements, getExport, archive, referenced, window) {
2567
2377
  const exp = await getExport("email");
2568
2378
  const emailExportError = !exp.ok ? exp.reason : !isEmailExport(exp.data) ? "Email simulator export had an unexpected shape." : void 0;
2569
2379
  const loadedCases = exp.ok && isEmailExport(exp.data) ? [...new Set(exp.data.inbox.map((m) => m.case_id).filter((c) => !!c))] : [];
@@ -2573,7 +2383,8 @@ async function buildCoverage(caseElements, getExport, archive, referenced) {
2573
2383
  const coveredCases = loadedCases.filter((c) => covered.has(c));
2574
2384
  const missingCases = loadedCases.filter((c) => !covered.has(c));
2575
2385
  const loadedAt = exp.ok && isEmailExport(exp.data) ? exp.data.simulation_load?.loaded_at : void 0;
2576
- const periodStart = loadedAt !== void 0 ? parseTimestampSeconds(loadedAt) ?? null : null;
2386
+ const loadedAtSeconds = loadedAt !== void 0 ? parseTimestampSeconds(loadedAt) ?? null : null;
2387
+ const periodStart = window ? loadedAtSeconds !== null ? Math.max(window.start, loadedAtSeconds) : window.start : loadedAtSeconds;
2577
2388
  const inPeriod = archive.getReceipts().map((r) => ({ id: String(r.receipt.id ?? ""), time: Number(r.receipt.timestamp ?? r.archivedAt) })).filter((t) => t.id && (periodStart === null || t.time >= periodStart)).sort((a, b) => a.time - b.time).map((t) => t.id);
2578
2389
  const referencedSet = new Set(referenced);
2579
2390
  const ticketsReferenced = inPeriod.filter((id) => referencedSet.has(id));
@@ -2584,6 +2395,7 @@ async function buildCoverage(caseElements, getExport, archive, referenced) {
2584
2395
  coveredCases,
2585
2396
  missingCases,
2586
2397
  periodStart,
2398
+ ...window ? { window: { start: window.start, end: window.end, days: window.days, label: window.label } } : {},
2587
2399
  ticketsInPeriod: inPeriod,
2588
2400
  ticketsReferenced,
2589
2401
  ticketsNotReferenced
@@ -3035,8 +2847,8 @@ var FOOTER_RE = /\n*(?:—|--) (?:Sent|Published) by (?:an AI agent|[^\n]+?'s AI
3035
2847
  function shouldAttachFooter() {
3036
2848
  return true;
3037
2849
  }
3038
- function receiptUrl(receiptId2) {
3039
- return `${AS_BASE}/r/${receiptId2}`;
2850
+ function receiptUrl(receiptId) {
2851
+ return `${AS_BASE}/r/${receiptId}`;
3040
2852
  }
3041
2853
  function verbForProfile(profile) {
3042
2854
  return profile === "publish" ? "Published" : "Sent";
@@ -3044,11 +2856,11 @@ function verbForProfile(profile) {
3044
2856
  function hapLine() {
3045
2857
  return `-- ${OPERATOR_NAME} implements HAP, the open Human Agency Protocol: ${HAP_URL}`;
3046
2858
  }
3047
- function footerText(receiptId2, verb, subject) {
2859
+ function footerText(receiptId, verb, subject) {
3048
2860
  const identity = deriveIdentityLine(subject, { operatorName: OPERATOR_NAME }).replace(/^Sent\b/, verb);
3049
2861
  return `
3050
2862
 
3051
- -- ${identity}. Receipt: ${receiptUrl(receiptId2)}
2863
+ -- ${identity}. Receipt: ${receiptUrl(receiptId)}
3052
2864
 
3053
2865
  ${hapLine()}`;
3054
2866
  }
@@ -3067,13 +2879,13 @@ function detectContentField(tool) {
3067
2879
  function stripFooter(value) {
3068
2880
  return value.replace(FOOTER_RE, "").replace(/\n+$/, "");
3069
2881
  }
3070
- function appendVerificationFooter(tool, args, receiptId2, subject) {
2882
+ function appendVerificationFooter(tool, args, receiptId, subject) {
3071
2883
  const profile = tool.gating?.profile;
3072
2884
  if (!profile || !FOOTER_PROFILES.has(profile)) return args;
3073
2885
  const field = detectContentField(tool);
3074
2886
  if (!field) return args;
3075
2887
  const current = typeof args[field] === "string" ? args[field] : "";
3076
- return { ...args, [field]: stripFooter(current) + footerText(receiptId2, verbForProfile(profile), subject) };
2888
+ return { ...args, [field]: stripFooter(current) + footerText(receiptId, verbForProfile(profile), subject) };
3077
2889
  }
3078
2890
 
3079
2891
  // src/lib/content-binding.ts
@@ -3126,10 +2938,10 @@ function computeContentBinding(profileId, tool, toolArgs, actionType) {
3126
2938
  boundContent
3127
2939
  };
3128
2940
  }
3129
- function attachReceiptId(tool, args, receiptId2) {
2941
+ function attachReceiptId(tool, args, receiptId) {
3130
2942
  const schema = tool.inputSchema;
3131
2943
  if (!schema?.properties || !("receipt_id" in schema.properties)) return args;
3132
- return { ...args, receipt_id: receiptId2 };
2944
+ return { ...args, receipt_id: receiptId };
3133
2945
  }
3134
2946
 
3135
2947
  // src/lib/ticket-verify.ts
@@ -3680,6 +3492,25 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
3680
3492
  };
3681
3493
  }
3682
3494
  const { profile, executionMapping, staticExecution, category } = tool.gating;
3495
+ const eligibleMandates = () => {
3496
+ const refusals = [];
3497
+ const matching = state2.getEnrichedAuthorizations().filter((a) => a.complete && profileMatches(a.profileId, profile)).filter((a) => {
3498
+ const refusal = integrationManager2.mandateRefusal?.(tool, a) ?? null;
3499
+ if (refusal) refusals.push(refusal);
3500
+ return !refusal;
3501
+ });
3502
+ return { matching, refusals };
3503
+ };
3504
+ const noEligibleMandate = (refusals) => {
3505
+ const reasons = [...new Set(refusals)].join(" ");
3506
+ return {
3507
+ content: [{
3508
+ type: "text",
3509
+ text: refusals.length > 0 ? `Refused: ${reasons.charAt(0).toUpperCase() + reasons.slice(1)} No ticket was requested.` : `No active authorization matching profile "${profile}". A decision owner must grant authority via the Authority UI before this tool can be used.`
3510
+ }],
3511
+ isError: true
3512
+ };
3513
+ };
3683
3514
  const lockedGuard = (inner) => async (args) => {
3684
3515
  if (!state2.spClient.isUnlocked()) {
3685
3516
  return {
@@ -3712,19 +3543,8 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
3712
3543
  `${tool.originalName} declares no read governance (no static gate, no read adapter, no explicit exemption), so the gateway cannot bound what it returns. This is a manifest defect \u2014 the tool must declare how its reads are limited before it can be used.`
3713
3544
  );
3714
3545
  }
3715
- const auths = state2.getEnrichedAuthorizations();
3716
- const matchingAuths = auths.filter(
3717
- (a) => a.complete && profileMatches(a.profileId, profile)
3718
- );
3719
- if (matchingAuths.length === 0) {
3720
- return {
3721
- content: [{
3722
- type: "text",
3723
- text: `No active authorization matching profile "${profile}". A decision owner must grant authority via the Authority UI before this tool can be used.`
3724
- }],
3725
- isError: true
3726
- };
3727
- }
3546
+ const { matching: matchingAuths, refusals } = eligibleMandates();
3547
+ if (matchingAuths.length === 0) return noEligibleMandate(refusals);
3728
3548
  if (readGate.boundField) {
3729
3549
  const permitted = matchingAuths.some(
3730
3550
  (a) => boundsSatisfyReadGate(a.bounds ?? a.frame, readGate)
@@ -3906,19 +3726,8 @@ function createGatedToolHandlerInner(tool, integrationManager2, state2) {
3906
3726
  };
3907
3727
  }
3908
3728
  }
3909
- const auths = state2.getEnrichedAuthorizations();
3910
- const matchingAuths = auths.filter(
3911
- (a) => a.complete && profileMatches(a.profileId, profile)
3912
- );
3913
- if (matchingAuths.length === 0) {
3914
- return {
3915
- content: [{
3916
- type: "text",
3917
- text: `No active authorization matching profile "${profile}". A decision owner must grant authority via the Authority UI before this tool can be used.`
3918
- }],
3919
- isError: true
3920
- };
3921
- }
3729
+ const { matching: matchingAuths, refusals } = eligibleMandates();
3730
+ if (matchingAuths.length === 0) return noEligibleMandate(refusals);
3922
3731
  const errors = [];
3923
3732
  const passers = [];
3924
3733
  for (const candidate of matchingAuths) {
@@ -4019,7 +3828,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
4019
3828
  };
4020
3829
  }
4021
3830
  }
4022
- let receiptId2;
3831
+ let receiptId;
4023
3832
  try {
4024
3833
  const actionType = declaredActionType;
4025
3834
  const binding = computeContentBinding(auth.profileId, tool, args, actionType);
@@ -4041,7 +3850,7 @@ Proposal ID: ${proposal.id}. Check status with check-pending-commitments(proposa
4041
3850
  // field by field rather than spread.
4042
3851
  ...binding ? { contentHash: binding.contentHash, contentBinding: binding.contentBinding } : {}
4043
3852
  });
4044
- receiptId2 = typeof receipt?.id === "string" ? receipt.id : void 0;
3853
+ receiptId = typeof receipt?.id === "string" ? receipt.id : void 0;
4045
3854
  await verifyTicket(state2.cache, receipt, {
4046
3855
  action: tool.namespacedName,
4047
3856
  executionContext: { ...execution },
@@ -4195,12 +4004,12 @@ Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.`
4195
4004
  execution: { ...execution },
4196
4005
  timestamp: Math.floor(Date.now() / 1e3)
4197
4006
  });
4198
- let outgoingArgs = shouldAttachFooter() && receiptId2 ? appendVerificationFooter(tool, args, receiptId2, auth.subjects?.[0]) : args;
4199
- if (receiptId2) outgoingArgs = attachReceiptId(tool, outgoingArgs, receiptId2);
4007
+ let outgoingArgs = shouldAttachFooter() && receiptId ? appendVerificationFooter(tool, args, receiptId, auth.subjects?.[0]) : args;
4008
+ if (receiptId) outgoingArgs = attachReceiptId(tool, outgoingArgs, receiptId);
4200
4009
  outgoingArgs = encodeOutgoingArgs(tool, outgoingArgs);
4201
- if (receiptId2) {
4010
+ if (receiptId) {
4202
4011
  const begun = state2.executionJournal.begin({
4203
- ticketId: receiptId2,
4012
+ ticketId: receiptId,
4204
4013
  tool: tool.namespacedName,
4205
4014
  argsHash: hashToolArgs(args)
4206
4015
  });
@@ -4208,17 +4017,17 @@ Proposal ID: ${proposal.id}. Use check-pending-commitments to track status.`
4208
4017
  return {
4209
4018
  content: [{
4210
4019
  type: "text",
4211
- text: `Blocked: ticket ${receiptId2} was already used to execute ${tool.namespacedName} (state: ${begun.existing.state}). Not running it again.`
4020
+ text: `Blocked: ticket ${receiptId} was already used to execute ${tool.namespacedName} (state: ${begun.existing.state}). Not running it again.`
4212
4021
  }],
4213
4022
  isError: true
4214
4023
  };
4215
4024
  }
4216
4025
  try {
4217
4026
  const result = await integrationManager2.callTool(tool.integrationId, tool.originalName, outgoingArgs);
4218
- state2.executionJournal.complete(receiptId2, "done");
4027
+ state2.executionJournal.complete(receiptId, "done");
4219
4028
  return result;
4220
4029
  } catch (err) {
4221
- state2.executionJournal.complete(receiptId2, "failed");
4030
+ state2.executionJournal.complete(receiptId, "failed");
4222
4031
  throw err;
4223
4032
  }
4224
4033
  }
@@ -4699,7 +4508,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
4699
4508
  };
4700
4509
  }
4701
4510
  }
4702
- let receiptId2;
4511
+ let receiptId;
4703
4512
  let replayed = false;
4704
4513
  try {
4705
4514
  const binding = computeContentBinding(
@@ -4732,7 +4541,7 @@ async function executeCommitted(proposal, state2, integrationManager2) {
4732
4541
  // this machine — see the archive call below.
4733
4542
  ...binding ? { contentHash: binding.contentHash, contentBinding: binding.contentBinding } : {}
4734
4543
  });
4735
- receiptId2 = typeof receipt?.id === "string" ? receipt.id : void 0;
4544
+ receiptId = typeof receipt?.id === "string" ? receipt.id : void 0;
4736
4545
  replayed = idempotent;
4737
4546
  await verifyTicket(state2.cache, receipt, {
4738
4547
  action: proposal.tool,
@@ -4787,14 +4596,14 @@ async function executeCommitted(proposal, state2, integrationManager2) {
4787
4596
  isError: true
4788
4597
  };
4789
4598
  }
4790
- if (!receiptId2) {
4599
+ if (!receiptId) {
4791
4600
  return {
4792
4601
  text: `Proposal ${proposal.id}: the ticket carries no id \u2014 refusing to execute an action that could not be recorded.`,
4793
4602
  isError: true
4794
4603
  };
4795
4604
  }
4796
4605
  const begun = state2.executionJournal.begin({
4797
- ticketId: receiptId2,
4606
+ ticketId: receiptId,
4798
4607
  proposalId: proposal.id,
4799
4608
  tool: proposal.tool,
4800
4609
  argsHash: hashToolArgs(proposal.toolArgs)
@@ -4804,36 +4613,36 @@ async function executeCommitted(proposal, state2, integrationManager2) {
4804
4613
  const when = new Date(prior.startedAt * 1e3).toISOString();
4805
4614
  if (prior.state === "done") {
4806
4615
  return {
4807
- text: `Proposal ${proposal.id} was already executed under ticket ${receiptId2} at ${when}. Not running it again.`
4616
+ text: `Proposal ${proposal.id} was already executed under ticket ${receiptId} at ${when}. Not running it again.`
4808
4617
  };
4809
4618
  }
4810
4619
  return {
4811
- text: `Proposal ${proposal.id}: an execution under ticket ${receiptId2} started at ${when} (pid ${prior.pid}) and ${prior.state === "failed" ? "reported failure" : "never reported completion"}. Not re-running: the action may already have taken effect. Check the downstream system before deciding.`,
4620
+ text: `Proposal ${proposal.id}: an execution under ticket ${receiptId} started at ${when} (pid ${prior.pid}) and ${prior.state === "failed" ? "reported failure" : "never reported completion"}. Not re-running: the action may already have taken effect. Check the downstream system before deciding.`,
4812
4621
  isError: true
4813
4622
  };
4814
4623
  }
4815
4624
  if (replayed) {
4816
4625
  console.error(
4817
- `[Suveren MCP] Proposal ${proposal.id}: ticket ${receiptId2} was replayed by the AS with no local execution record \u2014 executing once (lost-response recovery).`
4626
+ `[Suveren MCP] Proposal ${proposal.id}: ticket ${receiptId} was replayed by the AS with no local execution record \u2014 executing once (lost-response recovery).`
4818
4627
  );
4819
4628
  }
4820
4629
  try {
4821
4630
  let outgoingArgs = proposal.toolArgs;
4822
- if (discovered && receiptId2) {
4631
+ if (discovered && receiptId) {
4823
4632
  if (shouldAttachFooter()) {
4824
- outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId2, cachedAuth?.subjects?.[0]);
4633
+ outgoingArgs = appendVerificationFooter(discovered, outgoingArgs, receiptId, cachedAuth?.subjects?.[0]);
4825
4634
  }
4826
- outgoingArgs = attachReceiptId(discovered, outgoingArgs, receiptId2);
4635
+ outgoingArgs = attachReceiptId(discovered, outgoingArgs, receiptId);
4827
4636
  outgoingArgs = encodeOutgoingArgs(discovered, outgoingArgs);
4828
4637
  }
4829
4638
  let result;
4830
4639
  try {
4831
4640
  result = await integrationManager2.callTool(integrationId, toolName, outgoingArgs);
4832
4641
  } catch (err) {
4833
- state2.executionJournal.complete(receiptId2, "failed");
4642
+ state2.executionJournal.complete(receiptId, "failed");
4834
4643
  throw err;
4835
4644
  }
4836
- state2.executionJournal.complete(receiptId2, "done");
4645
+ state2.executionJournal.complete(receiptId, "done");
4837
4646
  state2.executionLog.record({
4838
4647
  profileId: proposal.profileId,
4839
4648
  path: proposal.path,
@@ -5945,6 +5754,15 @@ var IntegrationManager = class {
5945
5754
  const refusal = await def?.validate?.(args);
5946
5755
  return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
5947
5756
  }
5757
+ /**
5758
+ * Why this tool's integration refuses the held mandate `auth`, or null when
5759
+ * it accepts it (always null for connectors and built-ins that declare no
5760
+ * `mandateRefusal`). tool-proxy.ts drops refused mandates before selection.
5761
+ */
5762
+ mandateRefusal(tool, auth) {
5763
+ const builtin = this.builtins.get(tool.integrationId);
5764
+ return builtin?.def.mandateRefusal?.(auth) ?? null;
5765
+ }
5948
5766
  /**
5949
5767
  * Proxy a tool call to a downstream MCP server (or a built-in's handler).
5950
5768
  */
@@ -6278,252 +6096,11 @@ function createConnectorExportRunner(config) {
6278
6096
  };
6279
6097
  }
6280
6098
 
6281
- // src/lib/report/render-report.ts
6282
- import { Parser } from "htmlparser2";
6283
- function findElementSpans(html) {
6284
- const spans = [];
6285
- const seenCount = /* @__PURE__ */ new Map();
6286
- const openStack = [];
6287
- let parserRef;
6288
- const parser = new Parser(
6289
- {
6290
- onparserinit(p) {
6291
- parserRef = p;
6292
- },
6293
- onopentag(name) {
6294
- if (!name.startsWith("sv-") || !parserRef) return;
6295
- const n = seenCount.get(name) ?? 0;
6296
- seenCount.set(name, n + 1);
6297
- openStack.push({ tag: name, id: `${name}-${n}`, start: parserRef.startIndex });
6298
- },
6299
- onclosetag(name) {
6300
- if (!name.startsWith("sv-") || !parserRef) return;
6301
- const idx = [...openStack].reverse().findIndex((o) => o.tag === name);
6302
- if (idx === -1) return;
6303
- const realIdx = openStack.length - 1 - idx;
6304
- const [open] = openStack.splice(realIdx, 1);
6305
- spans.push({ id: open.id, kind: open.tag, start: open.start, end: parserRef.endIndex + 1 });
6306
- }
6307
- },
6308
- { decodeEntities: true, recognizeSelfClosing: true }
6309
- );
6310
- parser.write(html);
6311
- parser.end();
6312
- return spans.sort((a, b) => a.start - b.start);
6313
- }
6314
- function escapeHtml(value) {
6315
- return String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
6316
- }
6317
- function badge(status, okLabel) {
6318
- if (status === "verified") return `<span class="sv-badge sv-badge-ok">\u2713 ${escapeHtml(okLabel)}</span>`;
6319
- if (status === "warning") return `<span class="sv-badge sv-badge-warn">\u26A0 verified with a note</span>`;
6320
- return `<span class="sv-badge sv-badge-bad">\u2717 not verifiable</span>`;
6321
- }
6322
- function unverifiableCard(el) {
6323
- return `<div class="sv-el sv-el-unverifiable" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>Not verifiable</b>${badge("unverifiable", "")}</div><div class="sv-el-reason">${escapeHtml(el.reason ?? "This reference could not be checked.")}</div></div>`;
6324
- }
6325
- function renderTicket(el) {
6326
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6327
- const d = el.data;
6328
- const href = typeof d.checkUrl === "string" ? d.checkUrl : void 0;
6329
- const actionLabel = typeof d.actionLabel === "string" ? d.actionLabel : formatActionLabel(d.action);
6330
- const timeLabel = typeof d.timeLabel === "string" ? d.timeLabel : formatDateTime(d.time);
6331
- const profileLabel = typeof d.profileLabel === "string" ? d.profileLabel : escapeHtml(d.profile ?? "");
6332
- const links = [
6333
- href ? `<a href="${escapeHtml(href)}" target="_blank" rel="noopener noreferrer">Check on suveren.ai \u2197</a>` : ""
6334
- ].filter(Boolean);
6335
- return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>${escapeHtml(actionLabel)}</b>${badge(el.status, "signature valid")}</div><div class="sv-el-row"><span>${escapeHtml(profileLabel)} \xB7 ${escapeHtml(timeLabel)}</span></div>` + (links.length > 0 ? `<div class="sv-el-row"><span>${links.join(" \xB7 ")}</span></div>` : "") + `</div>`;
6336
- }
6337
- function renderApproval(el) {
6338
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6339
- const d = el.data;
6340
- const who = typeof d.whoLabel === "string" ? d.whoLabel : Array.isArray(d.who) ? d.who.join(", ") : "unknown";
6341
- const asked = typeof d.createdAtLabel === "string" ? d.createdAtLabel : "unknown";
6342
- const approved = typeof d.decidedAtLabel === "string" ? d.decidedAtLabel : "unknown";
6343
- const waited = typeof d.waitLabel === "string" ? d.waitLabel : "unknown";
6344
- return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>Approval</b>${badge(el.status, "from ticket archive")}</div><div class="sv-el-row"><span>asked ${escapeHtml(asked)} \xB7 approved ${escapeHtml(approved)} by ${escapeHtml(who)} (${escapeHtml(waited)})</span></div></div>`;
6345
- }
6346
- function renderMandate(el) {
6347
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6348
- const d = el.data;
6349
- const profileLabel = typeof d.profileLabel === "string" ? d.profileLabel : String(d.profile ?? "");
6350
- const owners = Array.isArray(d.owners) && d.owners.length > 0 ? d.owners.join(", ") : "unknown owner";
6351
- const limits = Array.isArray(d.limits) && d.limits.length > 0 ? d.limits.join(" \xB7 ") : "";
6352
- return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>${escapeHtml(profileLabel)} \xB7 ${escapeHtml(owners)}</b>${badge(el.status, "matches ticket")}</div>` + (limits ? `<div class="sv-el-row">${escapeHtml(limits)}${d.mode ? ` \xB7 ${escapeHtml(d.mode)}` : ""}</div>` : "") + (d.intent ? `<div class="sv-el-intent">Intent: \u201C${escapeHtml(d.intent)}\u201D</div>` : "") + `</div>`;
6353
- }
6354
- var RECORD_FIELD_ORDER = [
6355
- "subject",
6356
- "from_email",
6357
- "from_name",
6358
- "to_json",
6359
- "received_at",
6360
- "number",
6361
- "status",
6362
- "net_total",
6363
- "customer_id",
6364
- "causingReceiptId",
6365
- "message"
6366
- ];
6367
- var RECORD_TIME_FIELDS = /* @__PURE__ */ new Set(["received_at", "created_at", "sent_at"]);
6368
- function renderRecordField(key, d) {
6369
- if (key === "net_total") {
6370
- const currency = typeof d.currency === "string" ? d.currency : void 0;
6371
- return `<div class="sv-el-row"><span>Value</span><span>${escapeHtml(formatCurrency(d.net_total, currency))}</span></div>`;
6372
- }
6373
- if (RECORD_TIME_FIELDS.has(key)) {
6374
- const t = parseTimestampSeconds(d[key]);
6375
- const display = t !== void 0 ? formatDateTime(t) : d[key];
6376
- return `<div class="sv-el-row"><span>${escapeHtml(key)}</span><span>${escapeHtml(display)}</span></div>`;
6377
- }
6378
- return `<div class="sv-el-row"><span>${escapeHtml(key)}</span><span>${escapeHtml(d[key])}</span></div>`;
6379
- }
6380
- function renderRecord(el) {
6381
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6382
- const d = el.data;
6383
- const kind = String(d.kind ?? "record");
6384
- const rows = RECORD_FIELD_ORDER.filter((k) => d[k] !== void 0 && d[k] !== null && d[k] !== "").slice(0, 5).map((k) => renderRecordField(k, d)).join("");
6385
- return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>${escapeHtml(kind)} record</b>${badge(el.status, "in simulator database")}</div>` + rows + `</div>`;
6386
- }
6387
- function renderCaseTicketStep(node, isGoal) {
6388
- const cls = isGoal ? "sv-step sv-step-goal" : "sv-step";
6389
- const kind = isGoal ? "goal" : "ticket";
6390
- const inner = `<div class="sv-step-k">${kind}</div><div class="sv-step-t">${escapeHtml(formatActionLabel(node.action))}</div>${formatDateTime(node.time)}`;
6391
- return `<div class="${cls}">${inner}</div>`;
6392
- }
6393
- function renderCaseApprovalStep(approval) {
6394
- const who = approval.whoLabel ?? (Array.isArray(approval.who) ? approval.who.join(", ") : "unknown");
6395
- const asked = approval.createdAtLabel ?? "unknown";
6396
- const decided = approval.decidedAtLabel ?? "unknown";
6397
- const waited = approval.waitLabel ?? "unknown";
6398
- return `<div class="sv-step sv-step-approval"><div class="sv-step-k">approval</div><div class="sv-step-t">Approval</div><div class="sv-step-sub">asked ${escapeHtml(asked)} \xB7 approved ${escapeHtml(decided)} by ${escapeHtml(who)} (${escapeHtml(waited)})</div></div>`;
6399
- }
6400
- function renderCaseStartStep(start) {
6401
- let sub = "";
6402
- if (start.basis === "loaded" && typeof start.emailTime === "number") {
6403
- sub = `<div class="sv-step-sub">Email dated ${escapeHtml(formatDateTime(start.emailTime))} \xB7 timed from when the test data was loaded</div>`;
6404
- } else if (start.basis === "load-unknown") {
6405
- sub = `<div class="sv-step-sub">Email dated ${escapeHtml(formatDateTime(start.emailTime ?? start.time))} \xB7 start time not verifiable</div>`;
6406
- }
6407
- const time = start.basis === "load-unknown" ? "" : escapeHtml(formatDateTime(start.time));
6408
- return `<div class="sv-step sv-step-start"><div class="sv-step-k">start</div><div class="sv-step-t">Email in</div>${time}${sub}</div>`;
6409
- }
6410
- function renderCase(el) {
6411
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6412
- const d = el.data;
6413
- const steps = Array.isArray(d.steps) ? d.steps : [];
6414
- const goal = d.goal;
6415
- const approvalsByTicket = /* @__PURE__ */ new Map();
6416
- if (Array.isArray(d.approvals)) {
6417
- for (const a of d.approvals) {
6418
- approvalsByTicket.set(a.ticketId, a);
6419
- }
6420
- }
6421
- const start = d.start;
6422
- const startStep = start ? { time: start.time, html: renderCaseStartStep(start) } : void 0;
6423
- const ticketNodes = [
6424
- ...steps.map((s) => ({ node: s, isGoal: false })),
6425
- ...goal ? [{ node: goal, isGoal: true }] : []
6426
- ];
6427
- const items = [];
6428
- if (startStep) items.push(startStep);
6429
- for (const { node, isGoal } of ticketNodes) {
6430
- const approval = approvalsByTicket.get(node.ticketId);
6431
- if (approval) {
6432
- const approvalTime = Math.min(approval.decidedAt ?? approval.createdAt ?? node.time, node.time);
6433
- items.push({ time: approvalTime, html: renderCaseApprovalStep(approval) });
6434
- }
6435
- items.push({ time: node.time, html: renderCaseTicketStep(node, isGoal) });
6436
- }
6437
- items.sort((a, b) => a.time - b.time);
6438
- const stepHtml = items.map((i) => i.html).join("");
6439
- return `<div class="sv-el sv-el-${el.status}" data-sv-id="${escapeHtml(el.id)}"><div class="sv-el-row"><b>Case ${escapeHtml(d.caseId)} \xB7 ${typeof d.totalDurationSeconds === "number" ? formatDuration(d.totalDurationSeconds) : "time not verifiable"}</b>${badge(el.status, "assembled by gateway")}</div>` + (el.status === "warning" && el.reason ? `<div class="sv-el-warn">${escapeHtml(el.reason)}</div>` : "") + (typeof d.totalDurationSeconds !== "number" ? `<div class="sv-el-warn">${escapeHtml(d.timeUnverifiableReason ?? "Case time not verifiable.")}</div>` : "") + `<div class="sv-tl">${stepHtml}</div></div>`;
6440
- }
6441
- function renderMetric(el) {
6442
- if (el.status === "unverifiable" || !el.data) return unverifiableCard(el);
6443
- const d = el.data;
6444
- const kind = String(d.kind ?? "");
6445
- const label = METRIC_LABELS[kind] ?? kind;
6446
- const display = formatMetricValue(kind, d.value);
6447
- return `<div class="sv-el sv-el-${el.status} sv-el-metric" data-sv-id="${escapeHtml(el.id)}"><div class="sv-big">${escapeHtml(display)}</div><div class="sv-el-row"><span>${escapeHtml(label)} (${escapeHtml(d.caseCount ?? 0)} case${d.caseCount === 1 ? "" : "s"})</span>${badge(el.status, "computed by gateway")}</div>` + (el.status === "warning" && el.reason ? `<div class="sv-el-warn">${escapeHtml(el.reason)}</div>` : "") + `</div>`;
6448
- }
6449
- function renderElement(el) {
6450
- switch (el.kind) {
6451
- case "sv-ticket":
6452
- return renderTicket(el);
6453
- case "sv-approval":
6454
- return renderApproval(el);
6455
- case "sv-mandate":
6456
- return renderMandate(el);
6457
- case "sv-record":
6458
- return renderRecord(el);
6459
- case "sv-case":
6460
- return renderCase(el);
6461
- case "sv-metric":
6462
- return renderMetric(el);
6463
- default:
6464
- return unverifiableCard(el);
6465
- }
6466
- }
6467
- var DRAWN_ELEMENT_STYLES = `
6468
- .sv-el { border:1px solid #e5e5e5; border-radius:10px; padding:10px 12px; margin:10px 0; font:13px/1.5 system-ui, sans-serif; background:#fff; }
6469
- .sv-el-unverifiable { outline:2px solid #b91c1c; outline-offset:2px; }
6470
- .sv-el-verified, .sv-el-warning { outline:2px solid #15803d; outline-offset:2px; }
6471
- .sv-el-row { display:flex; justify-content:space-between; gap:10px; flex-wrap:wrap; margin:2px 0; }
6472
- .sv-el-reason, .sv-el-warn { color:#b45309; font-size:12px; margin-top:4px; }
6473
- .sv-el-intent { color:#666; font-size:12px; margin-top:4px; }
6474
- .sv-badge { display:inline-flex; gap:4px; font-size:10.5px; font-weight:600; padding:1px 7px; border-radius:999px; white-space:nowrap; }
6475
- .sv-badge-ok { color:#15803d; background:#ecfdf3; }
6476
- .sv-badge-warn { color:#b45309; background:#fff7e6; }
6477
- .sv-badge-bad { color:#b91c1c; background:#fdecea; }
6478
- .sv-big { font-size:24px; font-weight:700; }
6479
- .sv-tl { display:flex; flex-wrap:wrap; gap:10px 16px; padding:6px 0; }
6480
- .sv-step { min-width:100px; border:1px solid #e5e5e5; border-radius:8px; padding:6px 8px; font-size:11.5px; text-decoration:none; color:inherit; flex-shrink:0; }
6481
- .sv-step-k { font-size:9.5px; text-transform:uppercase; color:#666; }
6482
- .sv-step-t { font-weight:600; font-size:12px; }
6483
- .sv-step-sub { color:#666; white-space:normal; }
6484
- /* The approval step's text ("asked ... approved ... by ... (duration)") is
6485
- * longer than a ticket step's \u2014 without a width to wrap against, a
6486
- * flex item with flex-shrink:0 takes its one-line content width instead of
6487
- * wrapping, which reads as the text being cut off at the container's visible
6488
- * edge (polish 2026-10-05, second pass). */
6489
- .sv-step-approval { max-width:240px; white-space:normal; }
6490
- .sv-step-start { max-width:240px; white-space:normal; }
6491
- @media (max-width: 480px) {
6492
- .sv-tl { flex-direction:column; overflow-x:visible; }
6493
- .sv-step { min-width:0; }
6494
- .sv-step-approval { max-width:none; }
6495
- .sv-step-start { max-width:none; }
6496
- .sv-el-row { flex-direction:column; gap:2px; }
6497
- }
6498
- `;
6499
- var AI_ANALYSIS_LABEL = "AI analysis \u2014 not verified";
6500
- var AI_LEGEND_TEXT = "Boxes with a green \u2713 are drawn and checked by the gateway, and each one is listed under \u201CChecked values\u201D. Everything else is the AI's own analysis and is not verified.";
6501
- function aiLegendBanner() {
6502
- const box = "display:block !important;visibility:visible !important;opacity:1 !important;position:relative !important;transform:none !important;clip-path:none !important;filter:none !important;z-index:2147483647 !important;margin:0 0 12px 0 !important;padding:8px 12px !important;border:1px solid #d4d4d8 !important;border-left:4px solid #a1a1aa !important;border-radius:8px !important;background:#f4f4f5 !important;color:#3f3f46 !important;font:12.5px/1.45 system-ui, sans-serif !important;text-align:left !important;max-width:none !important;width:auto !important;height:auto !important;";
6503
- const strong = "display:inline !important;visibility:visible !important;font-weight:700 !important;color:#18181b !important;font-size:inherit !important;";
6504
- return `<div class="sv-ai-legend" role="note" style="${box}"><b style="${strong}">${escapeHtml(AI_ANALYSIS_LABEL)}.</b> ${escapeHtml(AI_LEGEND_TEXT)}</div>`;
6505
- }
6506
- function renderReportHtml(rawHtml, elements) {
6507
- const html = sanitizeReportHtml(rawHtml);
6508
- const byId = new Map(elements.map((e) => [e.id, e]));
6509
- const spans = findElementSpans(html);
6510
- let out = "";
6511
- let cursor = 0;
6512
- for (const span of spans) {
6513
- out += html.slice(cursor, span.start);
6514
- const el = byId.get(span.id) ?? { id: span.id, kind: span.kind, attrs: {}, status: "unverifiable", reason: "No verification result for this element." };
6515
- out += renderElement(el);
6516
- cursor = span.end;
6517
- }
6518
- out += html.slice(cursor);
6519
- const styleTag = `<style data-sv-drawn-styles="1">${DRAWN_ELEMENT_STYLES}</style>${aiLegendBanner()}`;
6520
- if (/<body[^>]*>/i.test(out)) {
6521
- return out.replace(/<body([^>]*)>/i, (m) => `${m}${styleTag}`);
6522
- }
6523
- return styleTag + out;
6524
- }
6525
-
6526
6099
  // src/lib/report/ticket-details.ts
6100
+ function withFields(fact, build) {
6101
+ if (fact.status !== "verified" || !fact.data) return fact;
6102
+ return { ...fact, data: { ...fact.data, fields: build(fact.data) } };
6103
+ }
6527
6104
  async function buildTicketDetails(archive, ticketIds) {
6528
6105
  const out = {};
6529
6106
  for (const id of ticketIds) {
@@ -6532,118 +6109,170 @@ async function buildTicketDetails(archive, ticketIds) {
6532
6109
  resolveApprovalElement(archive, id),
6533
6110
  resolveMandateElement(archive, id)
6534
6111
  ]);
6535
- out[id] = { ticket, approval, mandate };
6112
+ out[id] = {
6113
+ ticket: withFields(ticket, ticketFields),
6114
+ approval: withFields(approval, approvalFields),
6115
+ mandate: withFields(mandate, mandateFields)
6116
+ };
6536
6117
  }
6537
6118
  return out;
6538
6119
  }
6539
6120
 
6540
- // src/lib/report/export-report.ts
6541
- function receiptId(receipt) {
6542
- return typeof receipt.id === "string" ? receipt.id : "";
6543
- }
6544
- function buildExportBundle(params) {
6545
- const { stored, archive, authorityServer, gatewayVersion, now = Math.floor(Date.now() / 1e3) } = params;
6546
- const proof = stored.result.proof;
6547
- const coverage = stored.result.coverage;
6548
- const ticketIdSet = /* @__PURE__ */ new Set([...proof.ticketsReferenced, ...coverage.ticketsInPeriod]);
6549
- const entries = archive.getReceipts().filter((r) => ticketIdSet.has(receiptId(r.receipt)));
6550
- const tickets = entries.map((r) => r.receipt);
6551
- const authorizationIds = new Set(entries.map((r) => r.authorizationId));
6552
- const authorizations = {};
6553
- for (const a of archive.getAuthorizations()) {
6554
- if (authorizationIds.has(a.authorizationId)) authorizations[a.authorizationId] = a;
6121
+ // src/lib/report/window.ts
6122
+ import { getProfile as getProfile6 } from "@hap/core";
6123
+ var REPORT_AGE_FIELD = "read_age_days";
6124
+ var REPORTING_PROFILE = "reporting";
6125
+ var END_SKEW_SECONDS = 120;
6126
+ var OLD_PROFILE_REFUSAL = "this reporting mandate is from an older profile version \u2014 create a new reporting mandate (reporting@0.2).";
6127
+ var DAY = 86400;
6128
+ function boundsOf(a) {
6129
+ return a.bounds ?? a.frame;
6130
+ }
6131
+ function windowLabel(start, days, loadedAt) {
6132
+ const since = `since ${formatDateTime(start)}`;
6133
+ if (loadedAt !== null && loadedAt === start) return `${since} (when the test data was loaded)`;
6134
+ return `${since} (the last ${days} ${days === 1 ? "day" : "days"})`;
6135
+ }
6136
+ var defaultLookup = (id) => getProfile6(id);
6137
+ function reportingMandateRefusal(m, lookup = defaultLookup) {
6138
+ const schema = lookup(m.profileId)?.boundsSchema;
6139
+ const field = resolveAgeBoundField(schema, REPORT_AGE_FIELD);
6140
+ if (!field) return OLD_PROFILE_REFUSAL;
6141
+ if (maxReadAgeDays([boundsOf(m)], field) === null) {
6142
+ return `the reporting mandate does not set ${field} (the reporting window), so nothing can be shown \u2014 create a new reporting mandate.`;
6555
6143
  }
6144
+ return void 0;
6145
+ }
6146
+ function resolveReportWindow(input) {
6147
+ const lookup = input.lookupProfile ?? defaultLookup;
6148
+ const mandates = input.authorizations.filter((a) => a.complete && profileMatches(a.profileId, REPORTING_PROFILE));
6149
+ if (mandates.length === 0) {
6150
+ return { ok: false, reason: "No active reporting mandate \u2014 the reporting window comes from it, so no evidence can be shown." };
6151
+ }
6152
+ const candidates = [];
6153
+ const refusals = [];
6154
+ for (const m of mandates) {
6155
+ const refusal = reportingMandateRefusal(m, lookup);
6156
+ if (refusal) {
6157
+ refusals.push(refusal);
6158
+ continue;
6159
+ }
6160
+ const schema = lookup(m.profileId)?.boundsSchema;
6161
+ const field = resolveAgeBoundField(schema, REPORT_AGE_FIELD);
6162
+ const declared = maxReadAgeDays([boundsOf(m)], field);
6163
+ const def = schema?.fields?.[field] ?? {};
6164
+ const maximum = typeof def.maximum === "number" && Number.isFinite(def.maximum) ? def.maximum : null;
6165
+ const days = Math.max(0, maximum !== null ? Math.min(declared, maximum) : declared);
6166
+ let start = input.now - days * DAY;
6167
+ let clampedBy = null;
6168
+ if (input.simulation && input.loadedAt !== null && input.loadedAt > start) {
6169
+ start = input.loadedAt;
6170
+ clampedBy = input.loadedAt;
6171
+ }
6172
+ candidates.push({ start, days, loadedAt: clampedBy });
6173
+ }
6174
+ if (candidates.length === 0) {
6175
+ const reason = [...new Set(refusals)].join(" ");
6176
+ return { ok: false, reason: reason.charAt(0).toUpperCase() + reason.slice(1) };
6177
+ }
6178
+ const best = candidates.reduce((a, b) => b.start < a.start ? b : a);
6556
6179
  return {
6557
- format: "suveren-report-export",
6558
- version: 1,
6559
- exportedAt: now,
6560
- gatewayVersion,
6561
- report: { html: stored.html, savedAt: stored.savedAt, checkedAt: stored.checkedAt },
6562
- proof,
6563
- coverage,
6564
- authorityServer,
6565
- tickets,
6566
- authorizations
6180
+ ok: true,
6181
+ window: {
6182
+ start: best.start,
6183
+ end: input.now,
6184
+ days: best.days,
6185
+ loadedAt: best.loadedAt,
6186
+ label: windowLabel(best.start, best.days, best.loadedAt)
6187
+ }
6567
6188
  };
6568
6189
  }
6569
- function countLine(count, noneText = "none in this report") {
6570
- return count > 0 ? `${count} \u2713` : noneText;
6571
- }
6572
- function renderProofCoveragePanels(proof, coverage) {
6573
- const casesLine = coverage.emailExportError ? "unknown \u2014 the email simulator could not be read" : `${coverage.coveredCases.length} of ${coverage.loadedCases.length}`;
6574
- const periodNote = coverage.periodStart === null ? `<p class="sv-export-note">${coverage.emailExportError ? "Test period start unknown \u2014 the email simulator could not be read. All saved tickets were counted." : "Test period start unknown \u2014 all saved tickets were counted."}</p>` : "";
6575
- const missingParts = [];
6576
- if (coverage.missingCases.length > 0) missingParts.push(`cases ${coverage.missingCases.map(escapeHtml).join(", ")}`);
6577
- if (coverage.ticketsNotReferenced.length > 0) missingParts.push(`${coverage.ticketsNotReferenced.length} ticket${coverage.ticketsNotReferenced.length === 1 ? "" : "s"}`);
6578
- const missingLine = missingParts.length > 0 ? `<p class="sv-export-missing">Not in the report: ${missingParts.join(" \xB7 ")}</p>` : "";
6579
- const checkedValues = proof.verifiedValues.length > 0 ? `<div class="sv-export-card"><div class="sv-export-card-title">Checked values</div>${proof.verifiedValues.map((v) => `<div class="sv-export-row"><span>${escapeHtml(v.summary)}</span></div>`).join("")}</div>` : "";
6580
- return `<aside class="sv-export-side"><div class="sv-export-card"><div class="sv-export-card-title">Proof</div><div class="sv-export-row"><span>Tickets referenced</span><b>${proof.ticketsReferenced.length}</b></div><div class="sv-export-row"><span>Signatures valid</span><b>${escapeHtml(countLine(proof.signaturesValid))}</b></div><div class="sv-export-row"><span>Records checked</span><b>${escapeHtml(countLine(proof.recordsChecked))}</b></div><div class="sv-export-row"><span>Not verifiable</span><b>${proof.unverifiableCount}</b></div></div><div class="sv-export-card"><div class="sv-export-card-title">Coverage</div><div class="sv-export-row"><span>Cases</span><b>${casesLine}</b></div><div class="sv-export-row"><span>Tickets in test period</span><b>${coverage.ticketsReferenced.length} of ${coverage.ticketsInPeriod.length}</b></div>` + periodNote + missingLine + `</div>` + checkedValues + `</aside>`;
6581
- }
6582
- function ensureHtmlDocument(html) {
6583
- if (/<html[^>]*>/i.test(html)) return html;
6584
- return `<!doctype html><html><head><meta charset="utf-8"></head><body>${html}</body></html>`;
6585
- }
6586
- function injectIntoHead(html, insert) {
6587
- if (/<head[^>]*>/i.test(html)) return html.replace(/<head([^>]*)>/i, (m) => `${m}${insert}`);
6588
- if (/<html[^>]*>/i.test(html)) return html.replace(/<html([^>]*)>/i, (m) => `${m}<head>${insert}</head>`);
6589
- return `<head>${insert}</head>${html}`;
6590
- }
6591
- function injectAfterBodyOpen(html, insert) {
6592
- if (/<body[^>]*>/i.test(html)) return html.replace(/<body([^>]*)>/i, (m) => `${m}${insert}`);
6593
- return `${insert}${html}`;
6594
- }
6595
- function injectBeforeBodyClose(html, insert) {
6596
- if (/<\/body>/i.test(html)) return html.replace(/<\/body>/i, `${insert}</body>`);
6597
- return `${html}${insert}`;
6598
- }
6599
- var EXPORT_PAGE_STYLES = `
6600
- .sv-export-header { font:13px/1.5 system-ui, sans-serif; background:#f6f6f4; border-bottom:1px solid #e5e5e5; padding:14px 20px; }
6601
- .sv-export-meta { margin:0 0 6px 0; font-weight:600; }
6602
- .sv-export-howto { margin:0; color:#444; }
6603
- .sv-export-howto code { background:#eee; border-radius:4px; padding:1px 5px; font-size:12px; }
6604
- .sv-export-layout { display:flex; gap:20px; align-items:flex-start; padding:20px; font:13px/1.5 system-ui, sans-serif; }
6605
- .sv-export-main { flex:1 1 auto; min-width:0; }
6606
- .sv-export-side { flex:0 0 260px; }
6607
- .sv-export-card { border:1px solid #e5e5e5; border-radius:10px; padding:10px 12px; margin-bottom:12px; background:#fff; }
6608
- .sv-export-card-title { font-weight:700; margin-bottom:6px; }
6609
- .sv-export-row { display:flex; justify-content:space-between; gap:10px; margin:2px 0; }
6610
- .sv-export-note, .sv-export-missing { color:#b45309; font-size:12px; margin:4px 0 0 0; }
6611
- @media (max-width: 720px) {
6612
- .sv-export-layout { flex-direction:column; }
6613
- .sv-export-side { flex:1 1 auto; width:100%; }
6614
- }
6615
- `;
6616
- function formatDateTimeWithYear(value) {
6617
- const base = formatDateTime(value);
6618
- const year = new Date(value * 1e3).getFullYear();
6619
- return base.replace(",", ` ${year},`);
6620
- }
6621
- function utcOffsetLabel(value) {
6622
- const offsetMin = -new Date(value * 1e3).getTimezoneOffset();
6623
- const sign = offsetMin >= 0 ? "+" : "-";
6624
- const abs = Math.abs(offsetMin);
6625
- const hh = Math.floor(abs / 60);
6626
- const mm = abs % 60;
6627
- return `UTC${sign}${hh}${mm ? ":" + String(mm).padStart(2, "0") : ""}`;
6628
- }
6629
- function buildExportDocument(params) {
6630
- const { bundle, renderedHtml } = params;
6631
- const exportedDate = new Date(bundle.exportedAt * 1e3).toISOString().slice(0, 10);
6632
- const exportedLabel = formatDateTimeWithYear(bundle.exportedAt);
6633
- const checkedLabel = formatDateTime(bundle.report.checkedAt);
6634
- const tzLabel = utcOffsetLabel(bundle.exportedAt);
6635
- const headerHtml = `<div class="sv-export-header"><p class="sv-export-meta">Suveren Gateway ${escapeHtml(bundle.gatewayVersion)} \u2014 exported ${escapeHtml(exportedLabel)} &middot; checked ${escapeHtml(checkedLabel)} (times in ${escapeHtml(tzLabel)})</p><p class="sv-export-howto">How to check this report: each ticket below links to its public record on suveren.ai ("Check on suveren.ai \u2197"). To verify this entire file offline (including every signature), run <code>suveren-gateway verify-report ${escapeHtml(suggestedFilename(bundle))}</code> from a terminal with the Suveren gateway CLI installed.</p><p class="sv-export-legend" role="note" style="display:block !important;visibility:visible !important;opacity:1 !important;margin:6px 0 0 0 !important;color:#3f3f46 !important;font:12.5px/1.45 system-ui, sans-serif !important;"><b style="display:inline !important;visibility:visible !important;color:#18181b !important;">${escapeHtml(AI_ANALYSIS_LABEL)}:</b> ${escapeHtml(AI_LEGEND_TEXT)}</p></div>`;
6636
- const proofScriptJson = JSON.stringify(bundle).replace(/<\//g, "<\\/");
6637
- const proofScript = `<script type="application/json" id="suveren-proof">${proofScriptJson}</script>`;
6638
- let doc = ensureHtmlDocument(renderedHtml);
6639
- doc = injectIntoHead(doc, `<meta charset="utf-8"><title>Suveren report export \u2014 ${escapeHtml(exportedDate)}</title><style>${DRAWN_ELEMENT_STYLES}${EXPORT_PAGE_STYLES}</style>`);
6640
- doc = injectAfterBodyOpen(doc, headerHtml + `<div class="sv-export-layout"><div class="sv-export-main">`);
6641
- doc = injectBeforeBodyClose(doc, `</div>${renderProofCoveragePanels(bundle.proof, bundle.coverage)}</div>${proofScript}`);
6642
- return doc;
6643
- }
6644
- function suggestedFilename(bundle) {
6645
- const date = new Date(bundle.exportedAt * 1e3).toISOString().slice(0, 10);
6646
- return `suveren-report-${date}.html`;
6190
+ function isInWindow(time, window) {
6191
+ return time !== void 0 && Number.isFinite(time) && time >= window.start && time <= window.end + END_SKEW_SECONDS;
6192
+ }
6193
+ function ticketTime(entry) {
6194
+ const t = entry.receipt.timestamp;
6195
+ return typeof t === "number" && Number.isFinite(t) ? t : void 0;
6196
+ }
6197
+ var OUTSIDE_WINDOW_PREFIX = "not verifiable \u2014 outside the reporting window";
6198
+ function windowArchive(base, window) {
6199
+ const inWindow = (r) => isInWindow(ticketTime(r), window);
6200
+ return {
6201
+ getReceipts() {
6202
+ return base.getReceipts().filter(inWindow);
6203
+ },
6204
+ getAuthorizations() {
6205
+ const used = new Set(base.getReceipts().filter(inWindow).map((r) => r.authorizationId));
6206
+ return base.getAuthorizations().filter((a) => used.has(a.authorizationId));
6207
+ },
6208
+ outsideWindowReason(ticketId) {
6209
+ const entry = base.getReceipts().find((r) => r.receipt.id === ticketId);
6210
+ if (!entry || inWindow(entry)) return void 0;
6211
+ return `${OUTSIDE_WINDOW_PREFIX} (${window.label}).`;
6212
+ }
6213
+ };
6214
+ }
6215
+ var RECORD_TIME_COLUMNS = ["received_at", "created_at", "at", "date"];
6216
+ function recordTime(row, loadedAt, ticketTimes) {
6217
+ if (!row || typeof row !== "object") return void 0;
6218
+ const r = row;
6219
+ if (typeof r.receipt_id === "string" && ticketTimes.has(r.receipt_id)) return ticketTimes.get(r.receipt_id);
6220
+ for (const col of RECORD_TIME_COLUMNS) {
6221
+ const t = parseTimestampSeconds(r[col]);
6222
+ if (t !== void 0) {
6223
+ return loadedAt !== null ? Math.max(t, loadedAt) : t;
6224
+ }
6225
+ }
6226
+ return void 0;
6227
+ }
6228
+ function windowExport(exported, window, loadedAt, ticketTimes = /* @__PURE__ */ new Map()) {
6229
+ if (!exported || typeof exported !== "object" || Array.isArray(exported)) return exported;
6230
+ const out = {};
6231
+ for (const [key, value] of Object.entries(exported)) {
6232
+ out[key] = Array.isArray(value) ? value.filter((row) => isInWindow(recordTime(row, loadedAt, ticketTimes), window)) : value;
6233
+ }
6234
+ return out;
6235
+ }
6236
+ async function readLoadedAt(runExport) {
6237
+ try {
6238
+ const exp = await runExport("email");
6239
+ if (!isEmailExport(exp)) return null;
6240
+ return parseTimestampSeconds(exp.simulation_load?.loaded_at) ?? null;
6241
+ } catch {
6242
+ return null;
6243
+ }
6244
+ }
6245
+ async function scopeReportSources(base, opts) {
6246
+ const now = opts.now ?? Math.floor(Date.now() / 1e3);
6247
+ const hasReporting = opts.authorizations.some((a) => a.complete && profileMatches(a.profileId, REPORTING_PROFILE));
6248
+ const loadedAt = hasReporting && opts.simulation ? await readLoadedAt(base.runExport) : null;
6249
+ const resolved = resolveReportWindow({
6250
+ authorizations: opts.authorizations,
6251
+ simulation: opts.simulation,
6252
+ loadedAt,
6253
+ now,
6254
+ lookupProfile: opts.lookupProfile
6255
+ });
6256
+ if (!resolved.ok) return resolved;
6257
+ return { ok: true, window: resolved.window, sources: scopedSources(base, resolved.window, loadedAt) };
6258
+ }
6259
+ function scopedSources(base, window, loadedAt) {
6260
+ const runExport = async (system) => {
6261
+ const ticketTimes = /* @__PURE__ */ new Map();
6262
+ for (const r of base.archive.getReceipts()) {
6263
+ const id = r.receipt.id;
6264
+ const t = ticketTime(r);
6265
+ if (typeof id === "string" && t !== void 0) ticketTimes.set(id, t);
6266
+ }
6267
+ return windowExport(await base.runExport(system), window, loadedAt, ticketTimes);
6268
+ };
6269
+ return { archive: windowArchive(base.archive, window), runExport, window };
6270
+ }
6271
+ async function scopeReportSourcesToStoredWindow(base, stored, opts) {
6272
+ const now = opts.now ?? Math.floor(Date.now() / 1e3);
6273
+ const loadedAt = opts.simulation ? await readLoadedAt(base.runExport) : null;
6274
+ const window = { start: stored.start, end: now, days: stored.days, loadedAt: null, label: stored.label };
6275
+ return { ok: true, window, sources: scopedSources(base, window, loadedAt) };
6647
6276
  }
6648
6277
 
6649
6278
  // src/lib/profile-loader.ts
@@ -6714,10 +6343,19 @@ var CRM_RECORD_KINDS = ["contacts", "deals", "tasks", "activities", "changes", "
6714
6343
  function errorText(text) {
6715
6344
  return { content: [{ type: "text", text }], isError: true };
6716
6345
  }
6346
+ function scope(deps) {
6347
+ return scopeReportSources(deps.reportSources, {
6348
+ authorizations: deps.state.getEnrichedAuthorizations(),
6349
+ simulation: isSimulationMode()
6350
+ });
6351
+ }
6352
+ function windowInfo(w) {
6353
+ return { since: w.label, start: w.start, end: w.end };
6354
+ }
6717
6355
  function listTicketsTool(deps) {
6718
6356
  return {
6719
6357
  name: "list_tickets",
6720
- description: "List tickets (signed executions) from the local receipt archive: id, time, action, action type, profile, authorizationId, the limits used, and whether an approval was archived for it. Optionally filter by since/until (unix seconds). Use the ids with get_ticket for full detail, and with <sv-ticket>, <sv-approval>, <sv-mandate> and <sv-case> in the report.",
6358
+ description: "List tickets (signed executions) from the local receipt archive inside your reporting window (the number of days back your reporting mandate allows): id, time, action, action type, profile, the limits used, and whether an approval was archived for it. Older tickets are not shown and cannot be used in the report. Optionally narrow further by since/until (unix seconds). Use the ids with get_ticket for full detail, and with <sv-ticket>, <sv-approval>, <sv-mandate> and <sv-case> in the report.",
6721
6359
  inputSchema: {
6722
6360
  type: "object",
6723
6361
  properties: {
@@ -6726,30 +6364,22 @@ function listTicketsTool(deps) {
6726
6364
  }
6727
6365
  },
6728
6366
  handler: async (args) => {
6367
+ const scoped = await scope(deps);
6368
+ if (!scoped.ok) return errorText(scoped.reason);
6729
6369
  const since = typeof args.since === "number" ? args.since : void 0;
6730
6370
  const until = typeof args.until === "number" ? args.until : void 0;
6731
- const tickets = deps.reportSources.archive.getReceipts().map((entry) => {
6371
+ const tickets = scoped.sources.archive.getReceipts().map((entry) => {
6732
6372
  const r = entry.receipt;
6733
- const time = typeof r.timestamp === "number" ? r.timestamp : void 0;
6734
- return {
6735
- id: typeof r.id === "string" ? r.id : String(r.id ?? ""),
6736
- time,
6737
- action: r.action,
6738
- actionType: r.actionType ?? null,
6739
- profile: r.profileId,
6740
- authorizationId: r.authorizationId,
6741
- limitsUsed: r.limits ?? r.executionContext ?? {},
6742
- hasApproval: Boolean(entry.proposal)
6743
- };
6744
- }).filter((t) => t.id !== "").filter((t) => since === void 0 || t.time !== void 0 && t.time >= since).filter((t) => until === void 0 || t.time !== void 0 && t.time <= until).sort((a, b) => (a.time ?? 0) - (b.time ?? 0));
6745
- return builtinText(JSON.stringify({ tickets }, null, 2));
6373
+ return agentTicketRow(r, Boolean(entry.proposal), profileShortLabel(typeof r.profileId === "string" ? r.profileId : void 0));
6374
+ }).filter((t) => t.id !== "").filter((t) => since === void 0 || typeof t.time === "number" && t.time >= since).filter((t) => until === void 0 || typeof t.time === "number" && t.time <= until).sort((a, b) => (a.time ?? 0) - (b.time ?? 0));
6375
+ return builtinText(JSON.stringify({ window: windowInfo(scoped.window), tickets }, null, 2));
6746
6376
  }
6747
6377
  };
6748
6378
  }
6749
6379
  function getTicketTool(deps) {
6750
6380
  return {
6751
6381
  name: "get_ticket",
6752
- description: 'Full detail for one ticket: the ticket itself (action, time, limits used, public-check link), its archived approval (who approved it, when it was requested and decided, how long it waited), and the mandate it ran under (limits, commitment mode, intent). Each part reports "unverifiable" on its own if it could not be checked \u2014 e.g. an automatic-mode ticket has no approval. Use this to decide what a ticket actually proves before referencing it in the report.',
6382
+ description: 'Full detail for one ticket: the ticket itself (action, time, limits used, public-check link), its archived approval (who approved it, when it was requested and decided, how long it waited), and the mandate it ran under (limits, commitment mode, intent). Each part reports "unverifiable" on its own if it could not be checked \u2014 e.g. an automatic-mode ticket has no approval. Only tickets inside your reporting window can be opened. Use this to decide what a ticket actually proves before referencing it in the report.',
6753
6383
  inputSchema: {
6754
6384
  type: "object",
6755
6385
  properties: { id: { type: "string", description: "The ticket id, as listed by list_tickets." } },
@@ -6758,15 +6388,17 @@ function getTicketTool(deps) {
6758
6388
  handler: async (args) => {
6759
6389
  const id = typeof args.id === "string" ? args.id.trim() : "";
6760
6390
  if (!id) return errorText("id is required.");
6761
- const details = await buildTicketDetails(deps.reportSources.archive, [id]);
6391
+ const scoped = await scope(deps);
6392
+ if (!scoped.ok) return errorText(scoped.reason);
6393
+ const details = await buildTicketDetails(scoped.sources.archive, [id]);
6762
6394
  const detail = details[id];
6763
6395
  if (!detail || detail.ticket.status !== "verified") {
6764
6396
  return errorText(detail?.ticket.reason ?? `No ticket "${id}" in the local archive.`);
6765
6397
  }
6766
6398
  return builtinText(JSON.stringify({
6767
- ticket: detail.ticket.data,
6768
- approval: detail.approval.status === "verified" ? { verified: true, ...detail.approval.data } : { verified: false, reason: detail.approval.reason },
6769
- mandate: detail.mandate.status === "verified" ? { verified: true, ...detail.mandate.data } : { verified: false, reason: detail.mandate.reason }
6399
+ ticket: agentTicket(detail.ticket.data),
6400
+ approval: detail.approval.status === "verified" ? { verified: true, ...agentApproval(detail.approval.data) } : { verified: false, reason: detail.approval.reason },
6401
+ mandate: detail.mandate.status === "verified" ? { verified: true, ...agentMandate(detail.mandate.data) } : { verified: false, reason: detail.mandate.reason }
6770
6402
  }, null, 2));
6771
6403
  }
6772
6404
  };
@@ -6774,12 +6406,14 @@ function getTicketTool(deps) {
6774
6406
  function listCasesTool(deps) {
6775
6407
  return {
6776
6408
  name: "list_cases",
6777
- description: "Loaded business cases from the connected email system, plus every sent mail \u2014 so you can define sv-case elements without guessing ids. Returns: when the test data was loaded (testDataLoadedAt); cases (caseId, the starting inbox message id, sender, subject, received time); and sent mails (id, inReplyTo, the receiptId of the ticket that sent it, subject, sentAt) so you can confirm which ticket closed which case. Never includes the team's own reference replies \u2014 those are not part of this report.",
6409
+ description: "Loaded business cases from the connected email system, plus every sent mail \u2014 so you can define sv-case elements without guessing ids. Returns: when the test data was loaded (testDataLoadedAt); cases (caseId, the starting inbox message id, sender, subject, received time); and sent mails (id, inReplyTo, the receiptId of the ticket that sent it, subject, sentAt) so you can confirm which ticket closed which case. Only records inside your reporting window. Never includes the team's own reference replies \u2014 those are not part of this report.",
6778
6410
  inputSchema: { type: "object", properties: {} },
6779
6411
  handler: async () => {
6412
+ const scoped = await scope(deps);
6413
+ if (!scoped.ok) return errorText(scoped.reason);
6780
6414
  let exported;
6781
6415
  try {
6782
- exported = await deps.reportSources.runExport("email");
6416
+ exported = await scoped.sources.runExport("email");
6783
6417
  } catch (err) {
6784
6418
  return errorText(`Could not read email records: ${err instanceof Error ? err.message : String(err)}`);
6785
6419
  }
@@ -6800,6 +6434,7 @@ function listCasesTool(deps) {
6800
6434
  sentAt: m.received_at
6801
6435
  }));
6802
6436
  return builtinText(JSON.stringify({
6437
+ window: windowInfo(scoped.window),
6803
6438
  testDataLoadedAt: exp.simulation_load?.loaded_at ?? null,
6804
6439
  cases,
6805
6440
  sent
@@ -6815,7 +6450,7 @@ function recordKindsFor(system) {
6815
6450
  function getRecordsTool(deps) {
6816
6451
  return {
6817
6452
  name: "get_records",
6818
- description: `Rows from one connected system (email, erp or crm) that you may reference with <sv-record system="..." ref="...">. Optionally narrow to one table via "kind" (email: inbox/sent/changes/refusals; erp: quotes/orders/changes/refusals; crm: contacts/deals/tasks/activities/changes/refusals) \u2014 omit it to get every table for that system. Never returns the team's own reference replies.`,
6453
+ description: `Rows from one connected system (email, erp or crm) that you may reference with <sv-record system="..." ref="...">. Optionally narrow to one table via "kind" (email: inbox/sent/changes/refusals; erp: quotes/orders/changes/refusals; crm: contacts/deals/tasks/activities/changes/refusals) \u2014 omit it to get every table for that system. Only rows inside your reporting window. Never returns the team's own reference replies.`,
6819
6454
  inputSchema: {
6820
6455
  type: "object",
6821
6456
  properties: {
@@ -6829,9 +6464,11 @@ function getRecordsTool(deps) {
6829
6464
  if (system !== "email" && system !== "erp" && system !== "crm") {
6830
6465
  return errorText('system must be one of "email", "erp", "crm".');
6831
6466
  }
6467
+ const scoped = await scope(deps);
6468
+ if (!scoped.ok) return errorText(scoped.reason);
6832
6469
  let exported;
6833
6470
  try {
6834
- exported = await deps.reportSources.runExport(system);
6471
+ exported = await scoped.sources.runExport(system);
6835
6472
  } catch (err) {
6836
6473
  return errorText(`Could not read ${system} records: ${err instanceof Error ? err.message : String(err)}`);
6837
6474
  }
@@ -6848,11 +6485,11 @@ function getRecordsTool(deps) {
6848
6485
  for (const kind of kinds) {
6849
6486
  records[kind] = exp[kind] ?? [];
6850
6487
  }
6851
- return builtinText(JSON.stringify({ system, records }, null, 2));
6488
+ return builtinText(JSON.stringify({ system, window: windowInfo(scoped.window), records }, null, 2));
6852
6489
  }
6853
6490
  };
6854
6491
  }
6855
- function summarizeWrite(result) {
6492
+ function summarizeWrite(result, rawHtml) {
6856
6493
  const verified = result.elements.filter((e) => e.status === "verified");
6857
6494
  const warnings = result.elements.filter((e) => e.status === "warning");
6858
6495
  const unverifiable = result.elements.filter((e) => e.status === "unverifiable");
@@ -6868,7 +6505,20 @@ function summarizeWrite(result) {
6868
6505
  lines.push("Not verifiable \u2014 fix these references and write again:");
6869
6506
  for (const u of unverifiable) lines.push(` - ${u.kind} (${u.id}): ${u.reason ?? "unspecified"}`);
6870
6507
  }
6508
+ const n = result.sanitizeNotes;
6509
+ if (n) {
6510
+ if (n.droppedBlocks > 0) lines.push(`dropped: ${n.droppedBlocks} block(s) outside sv-ai \u2014 put your own content inside <sv-ai>\u2026</sv-ai>; only gateway elements, sv-row and one sv-glossary may stand outside it.`);
6511
+ if (n.droppedSvInsideAi > 0) lines.push(`dropped: ${n.droppedSvInsideAi} sv-* element(s) inside sv-ai \u2014 place gateway elements outside sv-ai blocks.`);
6512
+ if (n.droppedStyles > 0) lines.push(`dropped: ${n.droppedStyles} <style> block(s) \u2014 use inline style="\u2026" attributes inside sv-ai.`);
6513
+ if (n.extraGlossaries > 0) lines.push(`dropped: ${n.extraGlossaries} extra sv-glossary \u2014 one per report.`);
6514
+ }
6515
+ const g = glossaryUsage(rawHtml, result.elements);
6516
+ if (g) {
6517
+ lines.push(`Glossary: ${g.applied.length} term(s) shown as translation (when the reader switches it on).`);
6518
+ for (const r of g.rejected) lines.push(` - ignored "${r.key}": ${r.reason}`);
6519
+ }
6871
6520
  const cov = result.coverage;
6521
+ if (cov.window) lines.push(`Reporting window: ${cov.window.label}. Evidence from before it is not verifiable in this report.`);
6872
6522
  if (cov.emailExportError) {
6873
6523
  lines.push(`Coverage could not be checked: ${cov.emailExportError}`);
6874
6524
  } else {
@@ -6903,9 +6553,15 @@ function writeReportTool(deps) {
6903
6553
  },
6904
6554
  required: ["html"]
6905
6555
  },
6906
- // Empty/oversize html is refused here, BEFORE the gate requests a ticket
6907
- // (precheckBuiltin) — a refused write must not consume report_daily_max.
6908
- validate: writeReportRefusal,
6556
+ // Empty/oversize html, or no reporting window to check it against, is
6557
+ // refused here, BEFORE the gate requests a ticket (precheckBuiltin) — a
6558
+ // refused write must not consume report_daily_max.
6559
+ validate: async (args) => {
6560
+ const refusal = writeReportRefusal(args);
6561
+ if (refusal) return refusal;
6562
+ const scoped = await scope(deps);
6563
+ return scoped.ok ? void 0 : scoped.reason;
6564
+ },
6909
6565
  handler: async (args) => {
6910
6566
  const refusal = writeReportRefusal(args);
6911
6567
  if (refusal) return errorText(`Refused: ${refusal}`);
@@ -6913,9 +6569,11 @@ function writeReportTool(deps) {
6913
6569
  if (deps.state.reportStore.isLocked()) {
6914
6570
  return errorText("The report store is locked (the vault is not unlocked) \u2014 cannot save a report right now.");
6915
6571
  }
6572
+ const scoped = await scope(deps);
6573
+ if (!scoped.ok) return errorText(`Refused: ${scoped.reason}`);
6916
6574
  try {
6917
- const stored = await deps.state.reportStore.saveReport(html, deps.reportSources);
6918
- return builtinText(summarizeWrite(stored.result));
6575
+ const stored = await deps.state.reportStore.saveReport(html, scoped.sources);
6576
+ return builtinText(summarizeWrite(stored.result, html));
6919
6577
  } catch (err) {
6920
6578
  return errorText(`Could not verify and store the report: ${err instanceof Error ? err.message : String(err)}`);
6921
6579
  }
@@ -6932,6 +6590,11 @@ var reportBuiltin = (deps) => ({
6932
6590
  // read-only export CLI; writes only the local, vault-encrypted report file)
6933
6591
  // — safe during a simulation-mode test.
6934
6592
  simulation: true,
6593
+ // The same predicate the reporting window uses to refuse a mandate
6594
+ // (reporting@0.1, or no window set): the gate never selects such a mandate
6595
+ // for ANY report tool — so write_report is never ticketed under (and never
6596
+ // charges report_daily_max of) a mandate every read tool refuses (RR7).
6597
+ mandateRefusal: (auth) => reportingMandateRefusal(auth),
6935
6598
  toolGating: {
6936
6599
  overrides: {
6937
6600
  list_tickets: READ_GATE,
@@ -7767,12 +7430,36 @@ app.get("/internal/evidence", internalOnly, (_req, res) => {
7767
7430
  gates: state.gateStore.getAll()
7768
7431
  });
7769
7432
  });
7770
- async function reportResponsePayload(stored) {
7771
- const ticketDetails = await buildTicketDetails(state.receiptArchive, stored.result.proof.ticketsReferenced);
7433
+ function reportScope() {
7434
+ return scopeReportSources(reportSources, {
7435
+ authorizations: state.getEnrichedAuthorizations(),
7436
+ simulation: isSimulationMode()
7437
+ });
7438
+ }
7439
+ function storedReportScope(stored) {
7440
+ const w = stored?.result.coverage.window;
7441
+ if (w) return scopeReportSourcesToStoredWindow(reportSources, w, { simulation: isSimulationMode() });
7442
+ return reportScope();
7443
+ }
7444
+ async function reportResponsePayload(stored, archive) {
7445
+ let detailArchive = state.receiptArchive;
7446
+ if (archive) {
7447
+ detailArchive = archive;
7448
+ } else {
7449
+ const scoped = await storedReportScope(stored);
7450
+ if (scoped.ok) detailArchive = scoped.sources.archive;
7451
+ }
7452
+ const ticketDetails = await buildTicketDetails(detailArchive, stored.result.proof.ticketsReferenced);
7453
+ const glossary = glossaryUsage(stored.result.html, stored.result.elements);
7454
+ const dropped = sanitizeReport(stored.html).notes;
7455
+ const notShown = dropped.droppedBlocks + dropped.droppedSvInsideAi + dropped.droppedStyles;
7772
7456
  return {
7773
7457
  savedAt: stored.savedAt,
7774
7458
  checkedAt: stored.checkedAt,
7775
7459
  renderedHtml: renderReportHtml(stored.result.html, stored.result.elements),
7460
+ ...glossary && glossary.applied.length > 0 ? { renderedHtmlGloss: renderReportHtml(stored.result.html, stored.result.elements, { gloss: "on" }) } : {},
7461
+ ...glossary ? { glossary } : {},
7462
+ ...notShown > 0 ? { formatNotice: { blocksNotShown: notShown } } : {},
7776
7463
  proof: stored.result.proof,
7777
7464
  coverage: stored.result.coverage,
7778
7465
  elements: stored.result.elements,
@@ -7801,9 +7488,14 @@ app.post("/internal/report", internalOnly, async (req, res) => {
7801
7488
  res.status(400).json({ error: "Missing required field: html (non-empty string)" });
7802
7489
  return;
7803
7490
  }
7491
+ const scoped = await reportScope();
7492
+ if (!scoped.ok) {
7493
+ res.status(409).json({ error: scoped.reason });
7494
+ return;
7495
+ }
7804
7496
  try {
7805
- const stored = await state.reportStore.saveReport(html, reportSources);
7806
- res.json({ report: await reportResponsePayload(stored) });
7497
+ const stored = await state.reportStore.saveReport(html, scoped.sources);
7498
+ res.json({ report: await reportResponsePayload(stored, scoped.sources.archive) });
7807
7499
  } catch (err) {
7808
7500
  console.error("[Suveren MCP] /internal/report save failed:", err);
7809
7501
  res.status(500).json({ error: err instanceof Error ? err.message : "Report verification failed" });
@@ -7814,13 +7506,23 @@ app.post("/internal/report/recheck", internalOnly, async (_req, res) => {
7814
7506
  res.status(503).json({ error: "Vault locked \u2014 cannot recheck until sign-in." });
7815
7507
  return;
7816
7508
  }
7509
+ const current = state.reportStore.getReport();
7510
+ if (!current) {
7511
+ res.json({ report: null });
7512
+ return;
7513
+ }
7514
+ const scoped = await storedReportScope(current);
7515
+ if (!scoped.ok) {
7516
+ res.status(409).json({ error: scoped.reason });
7517
+ return;
7518
+ }
7817
7519
  try {
7818
- const stored = await state.reportStore.recheck(reportSources);
7520
+ const stored = await state.reportStore.recheck(scoped.sources);
7819
7521
  if (!stored) {
7820
7522
  res.json({ report: null });
7821
7523
  return;
7822
7524
  }
7823
- res.json({ report: await reportResponsePayload(stored) });
7525
+ res.json({ report: await reportResponsePayload(stored, scoped.sources.archive) });
7824
7526
  } catch (err) {
7825
7527
  console.error("[Suveren MCP] /internal/report/recheck failed:", err);
7826
7528
  res.status(500).json({ error: err instanceof Error ? err.message : "Recheck failed" });
@@ -7838,9 +7540,19 @@ app.get("/internal/report/export", internalOnly, async (req, res) => {
7838
7540
  res.status(503).json({ error: "Vault locked \u2014 the report is encrypted until sign-in." });
7839
7541
  return;
7840
7542
  }
7543
+ const current = state.reportStore.getReport();
7544
+ if (!current) {
7545
+ res.status(404).json({ error: "No report to export yet \u2014 the AI writes it with the Reporting mandate." });
7546
+ return;
7547
+ }
7548
+ const scoped = await storedReportScope(current);
7549
+ if (!scoped.ok) {
7550
+ res.status(409).json({ error: scoped.reason });
7551
+ return;
7552
+ }
7841
7553
  let stored;
7842
7554
  try {
7843
- stored = await state.reportStore.recheck(reportSources);
7555
+ stored = await state.reportStore.recheck(scoped.sources);
7844
7556
  } catch (err) {
7845
7557
  console.error("[Suveren MCP] /internal/report/export recheck failed:", err);
7846
7558
  res.status(500).json({ error: err instanceof Error ? err.message : "Recheck failed" });
@@ -7860,12 +7572,11 @@ app.get("/internal/report/export", internalOnly, async (req, res) => {
7860
7572
  const gatewayVersion = typeof req.query.gatewayVersion === "string" ? req.query.gatewayVersion : "unknown";
7861
7573
  const bundle = buildExportBundle({
7862
7574
  stored,
7863
- archive: state.receiptArchive,
7575
+ archive: scoped.sources.archive,
7864
7576
  authorityServer,
7865
7577
  gatewayVersion
7866
7578
  });
7867
- const renderedHtml = renderReportHtml(stored.result.html, stored.result.elements);
7868
- const document = buildExportDocument({ bundle, renderedHtml });
7579
+ const document = buildExportDocument({ bundle });
7869
7580
  res.setHeader("Content-Type", "text/html; charset=utf-8").setHeader("Content-Disposition", `attachment; filename="${suggestedFilename(bundle)}"`).setHeader("Cache-Control", "no-store").send(document);
7870
7581
  });
7871
7582
  app.get("/internal/brief", internalOnly, (_req, res) => {