@slicedearth/whoisleuth-cli 1.47.4 → 2.0.1

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.
Files changed (236) hide show
  1. package/README.md +74 -177
  2. package/SECURITY.md +6 -3
  3. package/bin/whoisleuth.mjs +2 -1
  4. package/cli/archive-inspect.mjs +28 -10
  5. package/cli/arguments.mjs +204 -174
  6. package/cli/artifact-structure.mjs +837 -163
  7. package/cli/artifact-verify.mjs +90 -61
  8. package/cli/bounded-json.mjs +1 -1
  9. package/cli/bulk-checkpoint.mjs +33 -20
  10. package/cli/bulk.mjs +9 -2
  11. package/cli/case-pack.mjs +180 -576
  12. package/cli/ci-report.mjs +6 -1
  13. package/cli/collection-preflight.mjs +2 -2
  14. package/cli/command-catalogue.mjs +12 -3
  15. package/cli/command-reference.mjs +910 -92
  16. package/cli/completion.mjs +504 -226
  17. package/cli/config-profile.mjs +13 -3
  18. package/cli/discover.mjs +7 -1
  19. package/cli/discovery-observation-snapshot.mjs +24 -28
  20. package/cli/discovery-scan-command-runner.mjs +9 -9
  21. package/cli/discovery-scan.mjs +17 -3
  22. package/cli/discovery-snapshot.mjs +15 -7
  23. package/cli/domain-control-monitor.mjs +203 -30
  24. package/cli/domain-control-observations.mjs +15 -21
  25. package/cli/errors.mjs +8 -3
  26. package/cli/evidence-signing.mjs +10 -20
  27. package/cli/external-observation-mapping.mjs +3 -3
  28. package/cli/formatters/json.mjs +20 -32
  29. package/cli/formatters/terminal.mjs +583 -22
  30. package/cli/input.mjs +7 -2
  31. package/cli/interactive-launcher.mjs +226 -0
  32. package/cli/interchange-report.mjs +19 -19
  33. package/cli/investigation-manifest.mjs +1 -1
  34. package/cli/investigation-plan.mjs +167 -16
  35. package/cli/investigation-run.mjs +9 -1
  36. package/cli/lookalike-calibration.mjs +3 -1
  37. package/cli/lookup-brief.mjs +13 -8
  38. package/cli/lookup-browser.mjs +844 -0
  39. package/cli/lookup-command-runner.mjs +94 -31
  40. package/cli/lookup-diff.mjs +2 -2
  41. package/cli/lookup-plan.mjs +1 -0
  42. package/cli/mail-review.mjs +206 -94
  43. package/cli/manual.mjs +5 -5
  44. package/cli/offline-evidence-review.mjs +18 -7
  45. package/cli/open-asset-model-bridge.mjs +1 -1
  46. package/cli/output-file.mjs +70 -14
  47. package/cli/page-compare.mjs +11 -6
  48. package/cli/progress-events.mjs +2 -1
  49. package/cli/progress.mjs +3 -3
  50. package/cli/registry-cohort.mjs +9 -51
  51. package/cli/registry-doctor.mjs +67 -28
  52. package/cli/retained-artifact-diff.mjs +7 -8
  53. package/cli/risk-calibration.mjs +85 -38
  54. package/cli/runner.mjs +189 -75
  55. package/cli/saved-lookup.mjs +424 -57
  56. package/cli/sharing-review.mjs +6 -1
  57. package/cli/source-reliability.mjs +75 -36
  58. package/cli/terminal-presentation.mjs +43 -18
  59. package/docs/cli-reference.md +125 -1708
  60. package/docs/cli.md +86 -254
  61. package/frontend/src/lib/analysis/domain-control-manifest-core.js +3 -145
  62. package/frontend/src/lib/analysis/domain-control-records.js +3 -88
  63. package/lib/availability.mjs +58 -40
  64. package/lib/bounded-contract-normalizers.mjs +2 -4
  65. package/lib/bounded-file.mjs +78 -4
  66. package/lib/bounded-json.mjs +51 -13
  67. package/lib/browser-library-profile.mjs +1 -1
  68. package/lib/classify.mjs +56 -5
  69. package/lib/client-behavior-profile.mjs +1 -1
  70. package/lib/credential-surface-profile.mjs +1 -1
  71. package/lib/cryptographic-assurance.mjs +35 -17
  72. package/lib/ct-query.mjs +3 -3
  73. package/lib/ct-search.mjs +37 -21
  74. package/lib/distributed-operation-budget.mjs +4 -2
  75. package/lib/dns-delegation-health.mjs +9 -6
  76. package/lib/dns-intelligence.mjs +1 -1
  77. package/lib/dnssec-chain-validation.mjs +2 -3
  78. package/lib/dnssec-evidence-validation.mjs +13 -7
  79. package/lib/domain-assurance.mjs +12 -12
  80. package/lib/domain-change-packet.mjs +1 -1
  81. package/lib/domain-control-flight-recorder.mjs +442 -78
  82. package/lib/domain-control-manifest.mjs +314 -59
  83. package/lib/domain-posture-analysis.mjs +5 -2
  84. package/lib/encrypted-dns-contract.mjs +2 -3
  85. package/lib/evidence-export.mjs +518 -36
  86. package/lib/external-intelligence-risk.mjs +26 -10
  87. package/lib/feature-policy.mjs +1 -1
  88. package/lib/homepage-metadata-contract.mjs +290 -0
  89. package/lib/hostname.mjs +1 -1
  90. package/lib/html-signals.mjs +89 -5
  91. package/lib/http-intelligence.mjs +331 -2
  92. package/lib/interchange-fidelity-registry.mjs +28 -26
  93. package/lib/lookup-cache.mjs +6 -4
  94. package/lib/lookup.mjs +25 -2
  95. package/lib/observed-network-context.mjs +1 -1
  96. package/lib/page-fingerprints.mjs +2 -2
  97. package/lib/page-role-profile.mjs +1 -1
  98. package/lib/portable-generator.mjs +8 -0
  99. package/lib/project-metadata.mjs +1 -1
  100. package/lib/provider-policy-admission.mjs +3 -1
  101. package/lib/rdap-bootstrap.mjs +76 -16
  102. package/lib/rdap-client.mjs +20 -12
  103. package/lib/rdap-normalization.mjs +306 -75
  104. package/lib/rdap-transport.mjs +2 -2
  105. package/lib/rdap-validation.mjs +65 -14
  106. package/lib/rdap.mjs +2 -2
  107. package/lib/registrable-domain.mjs +36 -0
  108. package/lib/registry-insights.mjs +7 -0
  109. package/lib/response-policy.mjs +27 -4
  110. package/lib/risk-calibration-summary.mjs +35 -33
  111. package/lib/risk-scoring.mjs +4 -3
  112. package/lib/safe-fetch.mjs +62 -40
  113. package/lib/security-txt.mjs +1 -1
  114. package/lib/smtp-transport-review.mjs +8 -5
  115. package/lib/sslbl-intelligence.mjs +6 -8
  116. package/lib/static-html-analysis.mjs +301 -4
  117. package/lib/structured-data-identity.mjs +51 -19
  118. package/lib/threat-intelligence-fixtures.mjs +2 -4
  119. package/lib/threat-intelligence-manifests.mjs +2 -7
  120. package/lib/threat-intelligence-runtime.mjs +3 -5
  121. package/lib/threat-intelligence-types.mjs +3 -0
  122. package/lib/threatfox-intelligence.mjs +7 -3
  123. package/lib/tls-intelligence.mjs +4 -4
  124. package/lib/urlhaus-intelligence.mjs +4 -2
  125. package/lib/urlscan-intelligence.mjs +6 -5
  126. package/lib/website-security-posture.mjs +1 -1
  127. package/lib/website-technology.mjs +1 -1
  128. package/lib/whois-authority.mjs +43 -8
  129. package/lib/whois-chain.mjs +25 -3
  130. package/lib/whois-normalization.mjs +3 -1
  131. package/lib/whois-transport.mjs +12 -3
  132. package/lib/zone-intent-review.mjs +1 -1
  133. package/package.json +13 -5
  134. package/{frontend/src/lib/analysis/case-brand-profile-references.js → packages/cases/case-brand-profile-references.mjs} +2 -2
  135. package/{frontend/src/lib/analysis/case-evidence-model.js → packages/cases/case-evidence-model.mjs} +35 -14
  136. package/{frontend/src/lib/analysis/case-investigation-branch-model.js → packages/cases/case-investigation-branch-model.mjs} +10 -12
  137. package/packages/cases/case-migration-model.mjs +476 -0
  138. package/{frontend/src/lib/analysis/case-model.js → packages/cases/case-model.mjs} +3 -3
  139. package/{frontend/src/lib/analysis/case-record-contracts.js → packages/cases/case-record-contracts.mjs} +3 -21
  140. package/{frontend/src/lib/analysis/case-record-core.js → packages/cases/case-record-core.mjs} +58 -31
  141. package/packages/cases/case-record-model.mjs +9 -0
  142. package/{frontend/src/lib/analysis/case-record-operations.js → packages/cases/case-record-operations.mjs} +72 -29
  143. package/{frontend/src/lib/analysis/case-report.js → packages/cases/case-report.mjs} +108 -14
  144. package/packages/cases/case-response-model.mjs +1604 -0
  145. package/packages/cases/case-response-packet.mjs +1312 -0
  146. package/{frontend/src/lib/analysis/case-storage-model.js → packages/cases/case-storage-model.mjs} +2 -2
  147. package/{frontend/src/lib/analysis/http-summary.js → packages/cases/http-summary.mjs} +1 -1
  148. package/{frontend/src/lib/analysis/comparison-ledger-bulk.js → packages/comparison/comparison-ledger-bulk.mjs} +2 -2
  149. package/{frontend/src/lib/analysis/comparison-ledger-contract.js → packages/comparison/comparison-ledger-contract.mjs} +4 -4
  150. package/{frontend/src/lib/analysis/comparison-ledger-json.js → packages/comparison/comparison-ledger-json.mjs} +1 -0
  151. package/{frontend/src/lib/analysis/comparison-ledger-serialization.js → packages/comparison/comparison-ledger-serialization.mjs} +1 -0
  152. package/packages/comparison/favicon-similarity.mjs +78 -0
  153. package/{frontend/src/lib/analysis/page-similarity.js → packages/comparison/page-similarity.mjs} +4 -3
  154. package/{frontend/src/lib/analysis/relationship-evidence.js → packages/comparison/relationship-evidence.mjs} +6 -7
  155. package/packages/contracts/analyst-review-state-contract.mjs +61 -0
  156. package/packages/contracts/analyst-review-state.mjs +50 -0
  157. package/packages/contracts/case-portability.mjs +1310 -0
  158. package/packages/contracts/cli-command-catalogue.mjs +2 -0
  159. package/packages/contracts/cli-lookup.mjs +394 -0
  160. package/packages/contracts/domain-control-flight-recorder.mjs +461 -0
  161. package/packages/contracts/domain-control-manifest.mjs +1027 -0
  162. package/packages/contracts/domain-control-monitor.mjs +310 -0
  163. package/packages/contracts/domain-control-review.mjs +828 -0
  164. package/packages/contracts/domain-name.mjs +4 -0
  165. package/packages/contracts/external-observation-interchange.mjs +224 -0
  166. package/packages/contracts/extracted-domain-lifecycle.mjs +218 -0
  167. package/packages/contracts/investigation-portability.mjs +331 -0
  168. package/packages/contracts/offline-comparison.mjs +160 -0
  169. package/packages/contracts/risk-calibration.mjs +1288 -0
  170. package/packages/contracts/schema-compatibility.mjs +71 -0
  171. package/packages/contracts/schema-lifecycle.mjs +1702 -0
  172. package/packages/contracts/sslbl-snapshot.mjs +17 -0
  173. package/packages/contracts/workspace-portability.mjs +1347 -0
  174. package/{frontend/src/lib/analysis/artifact-integrity.js → packages/evidence/artifact-integrity.mjs} +13 -11
  175. package/packages/evidence/decision-fact.mjs +735 -0
  176. package/packages/evidence/domain-control-runtime.mjs +547 -0
  177. package/packages/evidence/domain-name.mjs +41 -0
  178. package/packages/evidence/observation.mjs +384 -0
  179. package/{frontend/src/lib/analysis/external-findings-import.js → packages/interchange/external-findings-import.mjs} +24 -22
  180. package/packages/interchange/retained-text.mjs +8 -0
  181. package/{frontend/src/lib/analysis/acquisition-decision-packet.js → packages/investigation/acquisition-decision-packet.mjs} +9 -6
  182. package/{frontend/src/lib/analysis/bulk-domain-comparison.js → packages/investigation/bulk-domain-comparison.mjs} +5 -6
  183. package/{frontend/src/lib/analysis/bulk-mail-exposure.js → packages/investigation/bulk-mail-exposure.mjs} +4 -6
  184. package/{frontend/src/lib/analysis/bulk-review-export.js → packages/investigation/bulk-review-export.mjs} +5 -5
  185. package/packages/investigation/bulk-review-policy.mjs +1 -0
  186. package/{frontend/src/lib/analysis/investigation-capsule.js → packages/investigation/investigation-capsule.mjs} +30 -32
  187. package/packages/investigation/lookup-artefact-inputs.mjs +1 -0
  188. package/{frontend/src/lib/analysis/lookup-asset-graph.js → packages/investigation/lookup-asset-graph.mjs} +4 -2
  189. package/{frontend/src/lib/analysis/lookup-claim-passport.js → packages/investigation/lookup-claim-passport.mjs} +6 -10
  190. package/{frontend/src/lib/analysis/lookup-claim-readiness.js → packages/investigation/lookup-claim-readiness.mjs} +9 -2
  191. package/packages/investigation/lookup-investigation-brief.mjs +172 -0
  192. package/packages/monitoring/analyst-review-state.mjs +680 -0
  193. package/{frontend/src/lib/analysis/brand-profile-model.js → packages/workspace/brand-profile-model.mjs} +71 -48
  194. package/{frontend/src/lib/analysis/bulk-review-model.js → packages/workspace/bulk-review-model.mjs} +13 -14
  195. package/{frontend/src/lib/analysis/bulk-session-model.js → packages/workspace/bulk-session-model.mjs} +33 -59
  196. package/{frontend/src/lib/analysis/bulk-sort.js → packages/workspace/bulk-sort.mjs} +8 -5
  197. package/{frontend/src/lib/analysis/campaign-model.js → packages/workspace/campaign-model.mjs} +20 -22
  198. package/{frontend/src/lib/analysis/detection-rule-model.js → packages/workspace/detection-rule-model.mjs} +15 -17
  199. package/packages/workspace/hostile-input.mjs +172 -0
  200. package/{frontend/src/lib/analysis/investigation-guide.js → packages/workspace/investigation-guide.mjs} +8 -45
  201. package/{frontend/src/lib/analysis/investigation-template-model.js → packages/workspace/investigation-template-model.mjs} +18 -21
  202. package/{frontend/src/lib/analysis/page-baseline.js → packages/workspace/page-baseline.mjs} +6 -13
  203. package/{frontend/src/lib/analysis/relationship-observation-model.js → packages/workspace/relationship-observation-model.mjs} +13 -17
  204. package/{frontend/src/lib/analysis/shortlist-model.js → packages/workspace/shortlist-model.mjs} +20 -18
  205. package/{frontend/src/lib/analysis/watchlist-history.js → packages/workspace/watchlist-history.mjs} +9 -16
  206. package/{frontend/src/lib/analysis/watchlist-store.js → packages/workspace/watchlist-store.mjs} +24 -17
  207. package/{frontend/src/lib/analysis/website-snapshot-model.js → packages/workspace/website-snapshot-model.mjs} +13 -13
  208. package/{frontend/src/lib/analysis/workspace-archive-crypto.js → packages/workspace/workspace-archive-crypto.mjs} +49 -19
  209. package/{frontend/src/lib/analysis/workspace-archive.js → packages/workspace/workspace-archive.mjs} +110 -89
  210. package/third-party-notices.txt +14 -14
  211. package/frontend/src/lib/analysis/bulk-retry-plan.js +0 -79
  212. package/frontend/src/lib/analysis/bulk-source-coverage.js +0 -76
  213. package/frontend/src/lib/analysis/case-migration-model.js +0 -305
  214. package/frontend/src/lib/analysis/case-record-model.js +0 -9
  215. package/frontend/src/lib/analysis/case-response-model.js +0 -728
  216. package/frontend/src/lib/analysis/case-response-packet.js +0 -583
  217. package/frontend/src/lib/analysis/lookup-decision-support.js +0 -542
  218. package/frontend/src/lib/analysis/lookup-investigation-brief.js +0 -153
  219. package/frontend/src/lib/analysis/lookup-presentation.js +0 -120
  220. package/frontend/src/lib/analysis/lookup-response.js +0 -2
  221. package/frontend/src/lib/analysis/lookup-source-refresh.js +0 -277
  222. package/frontend/src/lib/analysis/lookup-summary-model.js +0 -398
  223. package/frontend/src/lib/analysis/scoring.js +0 -55
  224. package/frontend/src/lib/analysis/utils.js +0 -255
  225. package/frontend/src/lib/analysis/visualization-bounds.js +0 -26
  226. package/frontend/src/lib/analysis/visualization-force-graph.js +0 -352
  227. package/frontend/src/lib/analysis/visualization-matrix.js +0 -131
  228. package/frontend/src/lib/analysis/visualization-models.js +0 -338
  229. package/frontend/src/lib/analysis/visualization-time-series.js +0 -185
  230. package/lib/bounded-json-response.mjs +0 -170
  231. package/lib/lookup-response-contract.mjs +0 -1034
  232. package/lib/observation.mjs +0 -115
  233. /package/{frontend/src/lib/analysis/opaque-reference-id.js → packages/cases/opaque-reference-id.mjs} +0 -0
  234. /package/{frontend/src/lib/analysis/acquisition-due-diligence.js → packages/investigation/acquisition-due-diligence.mjs} +0 -0
  235. /package/{frontend/src/lib/analysis/evidence-coverage-ledger.js → packages/investigation/evidence-coverage-ledger.mjs} +0 -0
  236. /package/{frontend/src/lib/analysis/dns-record-normalization.js → packages/workspace/dns-record-normalization.mjs} +0 -0
@@ -1,6 +1,8 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { CASE_DISPOSITIONS, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.js";
3
+ import { CASE_DISPOSITIONS, CASE_SCHEMA_VERSION, CASE_REVIEW_REASONS, CASE_SOURCES, CASE_STATUSES, DEFAULT_DISPOSITION, DEFAULT_SOURCE, DEFAULT_STATUS, EVIDENCE_SOURCES, MAX_DOMAIN_LENGTH, MAX_NOTES_PER_CASE, MAX_NOTE_LENGTH, MAX_TAGS_PER_CASE, MAX_TAG_LENGTH, } from "./case-record-contracts.mjs";
4
+ import { normalizeExplicitIsoTimestamp, normalizeLegacyIsoTimestamp } from "../evidence/observation.mjs";
5
+ import { canonicalRegistrableDomain } from "../../lib/registrable-domain.mjs";
4
6
  // Forward-version policy (two distinct guarantees):
5
7
  // - A locally-stored envelope that declares a version greater than this is
6
8
  // still read best-effort on load (known fields kept, unknown dropped), but
@@ -47,6 +49,11 @@ export const REGISTERED_LIKE = new Set([
47
49
  // URL/DOM/query-string-safe id shape. UUIDs satisfy this; anything else is
48
50
  // treated as untrusted and deterministically repaired.
49
51
  const SAFE_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
52
+ // Unicode U-labels can be substantially longer than their canonical A-label
53
+ // representation (for example, decomposed combining sequences). Keep the raw
54
+ // work bound finite, then enforce DNS' 253/63-byte limits on the URL parser's
55
+ // canonical ASCII hostname below.
56
+ export const MAX_EVIDENCE_DOMAIN_INPUT_LENGTH = 1024;
50
57
  export function objectRecord(value) {
51
58
  return value && typeof value === 'object' && !Array.isArray(value)
52
59
  ? value
@@ -89,47 +96,56 @@ export function hashString(value) {
89
96
  export function deterministicId(domain) {
90
97
  return `c-${hashString(domain)}`;
91
98
  }
99
+ export { normalizeDomain } from "../evidence/domain-name.mjs";
92
100
  /**
93
- * Strict, canonical domain normalization. Parses through the WHATWG URL host
94
- * (which strips scheme/path/port/userinfo and applies IDNA/punycode so Unicode
95
- * and its punycode form collapse to one value), lowercases, drops a single
96
- * terminal root dot, and validates LDH hostname labels. Rejects IPs, ASNs,
97
- * whitespace/control characters, underscores, empty/overlong/hyphen-edged
98
- * labels, and undotted names. Returns '' for anything unusable.
99
- * @param {unknown} value
100
- * @returns {string}
101
+ * Canonicalizes a hostname supplied as source evidence without applying the
102
+ * analyst-input conveniences in normalizeDomain. URI syntax, credentials,
103
+ * ports, paths, queries, fragments, separators, whitespace, and controls make
104
+ * the observation invalid rather than being silently discarded.
101
105
  */
102
- export function normalizeDomain(value) {
103
- const raw = String(value == null ? '' : value).trim();
104
- if (!raw || /[\s\x00-\x1f\x7f]/.test(raw))
106
+ export function normalizeEvidenceDomain(value) {
107
+ if (typeof value !== 'string' || !value || value !== value.trim())
108
+ return '';
109
+ if (value.length > MAX_EVIDENCE_DOMAIN_INPUT_LENGTH)
110
+ return '';
111
+ if (/[\s\x00-\x1f\x7f%@/:?#\\]/u.test(value))
105
112
  return '';
106
- let hostname;
113
+ const withoutRootDot = value.endsWith('.') ? value.slice(0, -1) : value;
114
+ if (!withoutRootDot || withoutRootDot.endsWith('.'))
115
+ return '';
116
+ let hostname = '';
107
117
  try {
108
- const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
109
- hostname = new URL(hasScheme ? raw : `http://${raw}`).hostname;
118
+ hostname = new URL(`http://${withoutRootDot}`).hostname.toLowerCase();
110
119
  }
111
120
  catch {
112
121
  return '';
113
122
  }
114
- hostname = hostname.toLowerCase().replace(/\.$/, '');
115
- if (!hostname || hostname.length > MAX_DOMAIN_LENGTH)
116
- return '';
117
- // A leftover ':' or '[' means a port/IPv6 the host parser preserved.
118
- if (hostname.includes(':') || hostname.startsWith('['))
123
+ if (!hostname || hostname.length > MAX_DOMAIN_LENGTH || hostname !== hostname.replace(/\.$/u, ''))
119
124
  return '';
120
125
  const labels = hostname.split('.');
121
126
  if (labels.length < 2)
122
127
  return '';
123
- const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
124
- for (const label of labels) {
125
- if (!label || label.length > 63 || !labelPattern.test(label))
126
- return '';
127
- }
128
- // An all-numeric final label is an IPv4 address, never a hostname TLD.
129
- if (/^[0-9]+$/.test(labels.at(-1) ?? ''))
128
+ const labelPattern = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/u;
129
+ if (labels.some((label) => !label || label.length > 63 || !labelPattern.test(label)))
130
+ return '';
131
+ if (/^[0-9]+$/u.test(labels.at(-1) ?? ''))
130
132
  return '';
131
133
  return hostname;
132
134
  }
135
+ /**
136
+ * Retains an exact submitted hostname only when it is strict evidence input
137
+ * for the Case's canonical registrable identity. This boundary deliberately
138
+ * does not repair URI syntax or infer a hostname from other evidence.
139
+ */
140
+ export function normalizeEvidenceHostnameForCase(value, caseDomain) {
141
+ const hostname = normalizeEvidenceDomain(value);
142
+ const canonicalCase = normalizeEvidenceDomain(caseDomain);
143
+ if (!hostname || !canonicalCase)
144
+ return null;
145
+ if (canonicalRegistrableDomain(canonicalCase) !== canonicalCase)
146
+ return null;
147
+ return canonicalRegistrableDomain(hostname) === canonicalCase ? hostname : null;
148
+ }
133
149
  export function normalizeStatus(value) {
134
150
  return typeof value === 'string' && STATUS_VALUES.has(value) ? value : DEFAULT_STATUS;
135
151
  }
@@ -151,6 +167,17 @@ export function isoOrNull(value) {
151
167
  }
152
168
  return null;
153
169
  }
170
+ /** Canonical instant for versioned case data; current schemas require a zone. */
171
+ export function caseTimestampOrNull(value, sourceVersion) {
172
+ const explicit = normalizeExplicitIsoTimestamp(value);
173
+ if (explicit)
174
+ return explicit;
175
+ return sourceVersion !== undefined
176
+ && sourceVersion !== null
177
+ && sourceVersion < CASE_SCHEMA_VERSION
178
+ ? normalizeLegacyIsoTimestamp(value)
179
+ : null;
180
+ }
154
181
  export function isoOrNow(value, fallback) {
155
182
  return isoOrNull(value) || fallback;
156
183
  }
@@ -194,12 +221,12 @@ export function normalizeNoteBody(body) {
194
221
  * @param {string | null} fallback
195
222
  * @returns {CaseNote | null}
196
223
  */
197
- function normalizeNote(raw, fallback) {
224
+ function normalizeNote(raw, fallback, sourceVersion) {
198
225
  const record = objectRecord(raw);
199
226
  const body = normalizeNoteBody(record.body);
200
227
  if (!body)
201
228
  return null;
202
- const createdAt = isoOrNull(record.createdAt) || fallback;
229
+ const createdAt = caseTimestampOrNull(record.createdAt, sourceVersion) || fallback;
203
230
  if (!createdAt)
204
231
  return null;
205
232
  return {
@@ -215,13 +242,13 @@ function normalizeNote(raw, fallback) {
215
242
  * updatedAt for imports, never the current time)
216
243
  * @returns {CaseNote[]}
217
244
  */
218
- export function normalizeNotes(value, fallback) {
245
+ export function normalizeNotes(value, fallback, sourceVersion) {
219
246
  if (!Array.isArray(value))
220
247
  return [];
221
248
  const seen = new Set();
222
249
  const notes = [];
223
250
  for (const raw of value) {
224
- const note = normalizeNote(raw, fallback);
251
+ const note = normalizeNote(raw, fallback, sourceVersion);
225
252
  if (!note || seen.has(note.id))
226
253
  continue;
227
254
  seen.add(note.id);
@@ -0,0 +1,9 @@
1
+ // Stable case-record facade. Core validation, evidence history/comparison,
2
+ // and analyst-record operations remain framework-neutral and independently
3
+ // testable while existing consumers keep one compatibility import.
4
+ export * from "./case-record-contracts.mjs";
5
+ export * from "./case-record-core.mjs";
6
+ export * from "./case-brand-profile-references.mjs";
7
+ export * from "./case-evidence-model.mjs";
8
+ export * from "./case-record-operations.mjs";
9
+ export * from "./case-investigation-branch-model.mjs";
@@ -1,11 +1,11 @@
1
1
  // Pure, framework-neutral analyst-case records, evidence histories, bounded
2
2
  // record normalization, and analyst updates.
3
- import { appendCaseAction, appendCaseAssertion, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseSighting, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.js";
4
- import { MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.js";
5
- import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, deterministicId, isoOrNow, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.js";
6
- import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.js";
7
- import { normalizeEvidenceHistory, } from "./case-evidence-model.js";
8
- import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.js";
3
+ import { appendCaseAction, appendCaseAssertion, appendCaseClosure, appendCaseDecision, appendCaseEvidencePin, appendCaseEvidencePins, appendCaseManualTrailEvent, appendCaseObservedEffectReview, appendCaseSighting, buildCaseClosureLinkContext, normalizeCaseActions, normalizeCaseAssertions, normalizeCaseClosureHistory, normalizeCaseDecisions, normalizeCaseEvidencePins, normalizeCaseManualTrail, normalizeCaseObservedEffectHistory, normalizeCaseSightings, updateCaseAction, updateCaseAssertion, } from "./case-response-model.mjs";
4
+ import { CASE_SCHEMA_VERSION, MAX_CASES, MAX_NOTES_PER_CASE, } from "./case-record-contracts.mjs";
5
+ import { DEFAULT_EVIDENCE_SOURCE, EVIDENCE_SOURCE_SET, caseTimestampOrNull, deterministicId, makeId, normalizeDisposition, normalizeDomain, normalizeNoteBody, normalizeReviewReasonCode, normalizeNotes, normalizeSource, normalizeStatus, normalizeTags, objectRecord, safeId, } from "./case-record-core.mjs";
6
+ import { assertCaseBrandProfileIds, normalizeCaseBrandProfileIds, } from "./case-brand-profile-references.mjs";
7
+ import { normalizeEvidenceHistory, } from "./case-evidence-model.mjs";
8
+ import { appendCaseInvestigationBranch, caseInvestigationBranchReferences, normalizeCaseInvestigationBranches, updateCaseInvestigationBranch, } from "./case-investigation-branch-model.mjs";
9
9
  // ---------------------------------------------------------------------------
10
10
  // Case normalization
11
11
  // ---------------------------------------------------------------------------
@@ -18,12 +18,13 @@ function inferCaptureSource(caseSource) {
18
18
  }
19
19
  // Builds a case's bounded current-schema evidence history. Uses a lenient
20
20
  // local fallback timestamp so recoverable current data always loads.
21
- function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion) {
21
+ function normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion) {
22
22
  const localFallback = updatedAt || createdAt || now;
23
23
  if (Array.isArray(record.evidenceHistory)) {
24
24
  return normalizeEvidenceHistory(record.evidenceHistory, {
25
25
  source: DEFAULT_EVIDENCE_SOURCE,
26
26
  fallback: localFallback,
27
+ caseDomain: domain,
27
28
  ...(sourceVersion === undefined ? {} : { sourceVersion }),
28
29
  });
29
30
  }
@@ -41,36 +42,50 @@ function normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion)
41
42
  * @returns {CaseRecord | null}
42
43
  */
43
44
  export function normalizeCase(raw, existing, nowIso, sourceVersion) {
44
- const now = nowIso || new Date().toISOString();
45
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
45
46
  const record = objectRecord(raw);
46
47
  const domain = normalizeDomain(existing ? existing.domain : record.domain);
47
48
  if (!domain)
48
49
  return null;
49
- const createdAt = existing ? existing.createdAt : isoOrNow(record.createdAt, now);
50
- const updatedAt = isoOrNow(record.updatedAt, now);
51
- const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt);
50
+ const createdAt = existing ? existing.createdAt : caseTimestampOrNull(record.createdAt, sourceVersion) || now;
51
+ const updatedAt = caseTimestampOrNull(record.updatedAt, sourceVersion) || createdAt;
52
+ const timestampOptions = {
53
+ legacyTimestamps: sourceVersion != null && sourceVersion < CASE_SCHEMA_VERSION,
54
+ ...(sourceVersion === undefined ? {} : { sourceVersion }),
55
+ };
56
+ const evidencePins = normalizeCaseEvidencePins(record.evidencePins, updatedAt, timestampOptions);
52
57
  const pinIds = new Set(evidencePins.map((item) => item.id));
53
- const actions = normalizeCaseActions(record.actions, updatedAt);
54
- const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds);
58
+ const actions = normalizeCaseActions(record.actions, updatedAt, { ...timestampOptions, validEvidencePinIds: pinIds });
59
+ const assertions = normalizeCaseAssertions(record.assertions, updatedAt, pinIds, timestampOptions);
60
+ const sightings = normalizeCaseSightings(record.sightings, updatedAt, pinIds, timestampOptions);
61
+ const sightingIds = new Set(sightings.map((item) => item.id));
62
+ const observedEffects = normalizeCaseObservedEffectHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.observedEffects, updatedAt, pinIds, sightingIds, timestampOptions);
63
+ const closures = normalizeCaseClosureHistory(sourceVersion != null && sourceVersion < 13 ? undefined : record.closures, updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), timestampOptions, buildCaseClosureLinkContext(observedEffects, actions));
64
+ const normalizedStatus = normalizeStatus(record.status);
55
65
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
56
66
  return {
57
67
  id: existing ? existing.id : safeId(record.id) || deterministicId(domain),
58
68
  domain,
59
- status: normalizeStatus(record.status),
69
+ status: normalizedStatus === 'resolved'
70
+ && closures.records.length === 0 && !closures.preV13HistoryUnavailable
71
+ ? 'reviewing'
72
+ : normalizedStatus,
60
73
  disposition: normalizeDisposition(record.disposition),
61
74
  reviewReasonCode: normalizeReviewReasonCode(record.reviewReasonCode),
62
75
  brandProfileIds: normalizeCaseBrandProfileIds(record.brandProfileIds),
63
76
  tags: normalizeTags(record.tags),
64
- notes: normalizeNotes(record.notes, now),
77
+ notes: normalizeNotes(record.notes, now, sourceVersion),
65
78
  source: normalizeSource(record.source),
66
- evidenceHistory: normalizeCaseEvidence(record, createdAt, updatedAt, now, sourceVersion),
79
+ evidenceHistory: normalizeCaseEvidence(record, domain, createdAt, updatedAt, now, sourceVersion),
67
80
  evidencePins,
68
- decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds),
81
+ decisions: normalizeCaseDecisions(record.decisions, updatedAt, pinIds, timestampOptions),
69
82
  actions,
70
83
  assertions,
71
- manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt),
72
- sightings: normalizeCaseSightings(record.sightings, updatedAt, pinIds),
73
- branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences),
84
+ manualTrail: normalizeCaseManualTrail(record.manualTrail, updatedAt, timestampOptions),
85
+ sightings,
86
+ observedEffects,
87
+ closures,
88
+ branches: normalizeCaseInvestigationBranches(record.branches, updatedAt, branchReferences, timestampOptions),
74
89
  createdAt,
75
90
  updatedAt,
76
91
  };
@@ -81,7 +96,7 @@ export function normalizeCase(raw, existing, nowIso, sourceVersion) {
81
96
  * @returns {CaseRecord}
82
97
  */
83
98
  export function createCase(input, nowIso) {
84
- const now = nowIso || new Date().toISOString();
99
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
85
100
  const domain = normalizeDomain(input.domain);
86
101
  if (!domain)
87
102
  throw new Error('A valid domain is required to open a case.');
@@ -100,10 +115,22 @@ export function createCase(input, nowIso) {
100
115
  ? appendCaseAssertion([], input.assertion, now)
101
116
  : [];
102
117
  const branchReferences = caseInvestigationBranchReferences({ evidencePins, actions, assertions });
118
+ const sightings = input.sighting !== undefined
119
+ ? appendCaseSighting([], input.sighting, now, pinIds)
120
+ : [];
121
+ const observedEffects = input.observedEffectReview !== undefined
122
+ ? appendCaseObservedEffectReview(normalizeCaseObservedEffectHistory(undefined, now), input.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)))
123
+ : normalizeCaseObservedEffectHistory(undefined, now);
124
+ const closures = input.closure !== undefined
125
+ ? appendCaseClosure(normalizeCaseClosureHistory(undefined, now), input.closure, now, observedEffects, actions)
126
+ : normalizeCaseClosureHistory(undefined, now);
127
+ if (normalizeStatus(input.status) === 'resolved' && input.closure === undefined) {
128
+ throw new Error('Opening a resolved case requires a deliberate closure reason and its linked review context.');
129
+ }
103
130
  return {
104
131
  id: makeId(),
105
132
  domain,
106
- status: normalizeStatus(input.status),
133
+ status: input.closure !== undefined ? 'resolved' : normalizeStatus(input.status),
107
134
  disposition: normalizeDisposition(input.disposition),
108
135
  reviewReasonCode: normalizeReviewReasonCode(input.reviewReasonCode),
109
136
  brandProfileIds: input.brandProfileIds === undefined ? [] : assertCaseBrandProfileIds(input.brandProfileIds),
@@ -113,6 +140,7 @@ export function createCase(input, nowIso) {
113
140
  evidenceHistory: normalizeEvidenceHistory(input.evidence ? [input.evidence] : [], {
114
141
  source: inferCaptureSource(source),
115
142
  fallback: now,
143
+ caseDomain: domain,
116
144
  }),
117
145
  evidencePins,
118
146
  decisions: input.decision !== undefined
@@ -123,9 +151,9 @@ export function createCase(input, nowIso) {
123
151
  manualTrail: input.trailEvent !== undefined
124
152
  ? appendCaseManualTrailEvent([], input.trailEvent, now)
125
153
  : [],
126
- sightings: input.sighting !== undefined
127
- ? appendCaseSighting([], input.sighting, now, pinIds)
128
- : [],
154
+ sightings,
155
+ observedEffects,
156
+ closures,
129
157
  branches: input.branch !== undefined
130
158
  ? appendCaseInvestigationBranch([], input.branch, now, branchReferences)
131
159
  : [],
@@ -166,7 +194,7 @@ export function openOrCreateCase(cases, input, nowIso) {
166
194
  * @returns {{ cases: CaseRecord[], record: CaseRecord }}
167
195
  */
168
196
  export function updateCase(cases, id, patch, nowIso) {
169
- const now = nowIso || new Date().toISOString();
197
+ const now = caseTimestampOrNull(nowIso) || new Date().toISOString();
170
198
  const index = cases.findIndex((item) => item.id === id);
171
199
  if (index < 0)
172
200
  throw new Error('That case no longer exists.');
@@ -186,7 +214,7 @@ export function updateCase(cases, id, patch, nowIso) {
186
214
  const source = patch.source !== undefined ? normalizeSource(patch.source) : current.source;
187
215
  let evidenceHistory = current.evidenceHistory;
188
216
  if (patch.evidence !== undefined) {
189
- evidenceHistory = normalizeEvidenceHistory([...current.evidenceHistory, ...(patch.evidence ? [patch.evidence] : [])], { source: inferCaptureSource(source), fallback: now });
217
+ evidenceHistory = normalizeEvidenceHistory([...current.evidenceHistory, ...(patch.evidence ? [patch.evidence] : [])], { source: inferCaptureSource(source), fallback: now, caseDomain: current.domain });
190
218
  }
191
219
  let evidencePins = current.evidencePins;
192
220
  if (patch.evidencePins !== undefined) {
@@ -205,7 +233,7 @@ export function updateCase(cases, id, patch, nowIso) {
205
233
  actions = appendCaseAction(current.actions, patch.action, now);
206
234
  }
207
235
  if (patch.actionUpdate !== undefined) {
208
- actions = updateCaseAction(actions, patch.actionUpdate, now);
236
+ actions = updateCaseAction(actions, patch.actionUpdate, now, pinIds);
209
237
  }
210
238
  let assertions = current.assertions;
211
239
  if (patch.assertion !== undefined) {
@@ -230,9 +258,22 @@ export function updateCase(cases, id, patch, nowIso) {
230
258
  if (patch.sighting !== undefined) {
231
259
  sightings = appendCaseSighting(current.sightings, patch.sighting, now, pinIds);
232
260
  }
261
+ let observedEffects = normalizeCaseObservedEffectHistory(current.observedEffects, current.updatedAt, pinIds, new Set(sightings.map((item) => item.id)));
262
+ if (patch.observedEffectReview !== undefined) {
263
+ observedEffects = appendCaseObservedEffectReview(observedEffects, patch.observedEffectReview, now, pinIds, new Set(sightings.map((item) => item.id)));
264
+ }
265
+ let closures = normalizeCaseClosureHistory(current.closures, current.updatedAt, new Set(observedEffects.reviews.map((item) => item.id)), new Set(actions.map((item) => item.id)), {}, buildCaseClosureLinkContext(observedEffects, actions));
266
+ if (patch.closure !== undefined) {
267
+ closures = appendCaseClosure(closures, patch.closure, now, observedEffects, actions);
268
+ }
269
+ if (patch.status === 'resolved' && patch.closure === undefined) {
270
+ throw new Error('Resolve this case through the deliberate closure review so the reason and evidence state remain explicit.');
271
+ }
233
272
  const record = {
234
273
  ...current,
235
- status: patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
274
+ status: patch.closure !== undefined
275
+ ? 'resolved'
276
+ : patch.status !== undefined ? normalizeStatus(patch.status) : current.status,
236
277
  disposition: patch.disposition !== undefined ? normalizeDisposition(patch.disposition) : current.disposition,
237
278
  reviewReasonCode: patch.reviewReasonCode !== undefined
238
279
  ? normalizeReviewReasonCode(patch.reviewReasonCode)
@@ -249,6 +290,8 @@ export function updateCase(cases, id, patch, nowIso) {
249
290
  assertions,
250
291
  manualTrail,
251
292
  sightings,
293
+ observedEffects,
294
+ closures,
252
295
  branches,
253
296
  notes,
254
297
  updatedAt: now,
@@ -8,17 +8,17 @@
8
8
  // contains raw RDAP/WHOIS material. This module deliberately excludes all raw
9
9
  // registry/web responses, contacts, cookies, screenshots, and authentication
10
10
  // data. Reports contain only the normalized case record.
11
- import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.js";
12
- import { httpSecurityHeaderLabel } from "./http-summary.js";
13
- import { analystInteroperabilityTags } from "../../../../lib/analyst-taxonomy.mjs";
14
- import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../../../lib/portable-generator.mjs";
15
- import { CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.js";
16
- import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.js";
11
+ import { caseEvidenceIncomparableReasons, compareCaseEvidence, latestCaseEvidence } from "./case-model.mjs";
12
+ import { httpSecurityHeaderLabel } from "./http-summary.mjs";
13
+ import { analystInteroperabilityTags } from "../../lib/analyst-taxonomy.mjs";
14
+ import { buildPortableGeneratorMetadata, portableGeneratorAttribution, } from "../../lib/portable-generator.mjs";
15
+ import { buildCaseResponseLifecycleSummary, CASE_EVIDENCE_RELATION_STANCES } from "./case-response-model.mjs";
16
+ import { normalizeCaseBrandProfileIds } from "./case-brand-profile-references.mjs";
17
+ import { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION, } from "../contracts/case-portability.mjs";
17
18
  // ---------------------------------------------------------------------------
18
19
  // Constants
19
20
  // ---------------------------------------------------------------------------
20
- export const CASE_REPORT_SCHEMA = 'whoisleuth.case-report';
21
- export const CASE_REPORT_SCHEMA_VERSION = 8;
21
+ export { CASE_REPORT_SCHEMA, CASE_REPORT_SCHEMA_VERSION };
22
22
  const LIMITATIONS_TEXT = [
23
23
  'This report contains normalised browser-local observations from WHOISleuth analyst cases.',
24
24
  'It is not a live lookup and does not contain raw WHOIS, RDAP, DNS, HTML, or responses collected during website checks.',
@@ -26,6 +26,7 @@ const LIMITATIONS_TEXT = [
26
26
  'Snapshot fingerprints are deduplication identifiers, not cryptographic evidence hashes.',
27
27
  'Scan-depth and scoring-model gates prevent misleading comparisons; "incomparable" means observations differ materially but one or more fields cannot be compared reliably.',
28
28
  'Brand Profile references record an explicit analyst-selected association only; they do not establish ownership, attribution, intent, safety, or maliciousness.',
29
+ 'Provider workflow outcomes and independently observed technical effects remain separately attributed. A provider outcome does not establish independent remediation, absence, or safety; neither evidence family establishes legal sufficiency or provider performance.',
29
30
  'Generated locally in the browser. Review the package before sharing it.',
30
31
  ].join(' ');
31
32
  function escapeMarkdownInline(text) {
@@ -181,7 +182,9 @@ export function buildCaseReport(caseRecord, options = {}) {
181
182
  if (!previous)
182
183
  continue;
183
184
  const rawChanges = compareCaseEvidence(previous, snapshot);
184
- incomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
185
+ const allIncomparableReasons = caseEvidenceIncomparableReasons(previous, snapshot);
186
+ const hostnameContextChanged = allIncomparableReasons.includes('observation-context');
187
+ incomparableReasons = allIncomparableReasons.filter((reason) => reason !== 'observation-context');
185
188
  if (rawChanges.length > 0) {
186
189
  changes = rawChanges.map((change) => ({
187
190
  field: change.field,
@@ -191,7 +194,9 @@ export function buildCaseReport(caseRecord, options = {}) {
191
194
  tone: change.tone,
192
195
  }));
193
196
  }
194
- else if (snapshot.fingerprint !== previous.fingerprint && incomparableReasons.length === 0) {
197
+ else if (snapshot.fingerprint !== previous.fingerprint
198
+ && incomparableReasons.length === 0
199
+ && !hostnameContextChanged) {
195
200
  incomparableReasons = ['other'];
196
201
  }
197
202
  hasIncomparableChange = incomparableReasons.length > 0;
@@ -208,6 +213,19 @@ export function buildCaseReport(caseRecord, options = {}) {
208
213
  }
209
214
  const latest = latestCaseEvidence({ evidenceHistory: caseRecord.evidenceHistory ?? undefined });
210
215
  const currentAssessment = latest ? pickKnownSnapshotFields(latest) : null;
216
+ const responseLifecycle = buildCaseResponseLifecycleSummary(caseRecord);
217
+ const observedEffects = caseRecord.observedEffects ?? {
218
+ reviews: [],
219
+ omitted: 0,
220
+ preV13HistoryUnavailable: false,
221
+ limitations: ['Observed-effect reviews are independent point-in-time records; provider workflow events do not create or replace them.'],
222
+ };
223
+ const closures = caseRecord.closures ?? {
224
+ records: [],
225
+ omitted: 0,
226
+ preV13HistoryUnavailable: false,
227
+ limitations: ['Closure records are deliberate analyst actions and do not establish absence, safety, provider performance, or legal sufficiency.'],
228
+ };
211
229
  const json = {
212
230
  schema: CASE_REPORT_SCHEMA,
213
231
  schemaVersion: CASE_REPORT_SCHEMA_VERSION,
@@ -233,7 +251,12 @@ export function buildCaseReport(caseRecord, options = {}) {
233
251
  analystResponse: {
234
252
  evidencePins: caseRecord.evidencePins.map((item) => ({ ...item, limitations: [...item.limitations] })),
235
253
  decisions: caseRecord.decisions.map((item) => ({ ...item, evidencePinIds: [...item.evidencePinIds] })),
236
- actions: caseRecord.actions.map((item) => ({ ...item, contactLimitations: [...item.contactLimitations] })),
254
+ actions: caseRecord.actions.map((item) => ({
255
+ ...item,
256
+ contactLimitations: [...item.contactLimitations],
257
+ history: item.history.map((event) => ({ ...event, limitations: [...event.limitations] })),
258
+ historyLimitations: [...item.historyLimitations],
259
+ })),
237
260
  assertions: caseRecord.assertions.map((item) => ({
238
261
  ...item,
239
262
  evidencePinIds: [...item.evidencePinIds],
@@ -248,6 +271,18 @@ export function buildCaseReport(caseRecord, options = {}) {
248
271
  })),
249
272
  manualTrail: caseRecord.manualTrail.map((item) => ({ ...item })),
250
273
  sightings: caseRecord.sightings.map((item) => ({ ...item, limitations: [...item.limitations] })),
274
+ observedEffects: {
275
+ reviews: observedEffects.reviews.map((item) => ({ ...item, limitations: [...item.limitations] })),
276
+ omitted: observedEffects.omitted,
277
+ preV13HistoryUnavailable: observedEffects.preV13HistoryUnavailable,
278
+ limitations: [...observedEffects.limitations],
279
+ },
280
+ closures: {
281
+ records: closures.records.map((item) => ({ ...item, limitations: [...item.limitations] })),
282
+ omitted: closures.omitted,
283
+ preV13HistoryUnavailable: closures.preV13HistoryUnavailable,
284
+ limitations: [...closures.limitations],
285
+ },
251
286
  branches: (caseRecord.branches ?? []).map((item) => ({
252
287
  ...item,
253
288
  evidencePinIds: [...item.evidencePinIds],
@@ -256,6 +291,7 @@ export function buildCaseReport(caseRecord, options = {}) {
256
291
  actionIds: [...item.actionIds],
257
292
  })),
258
293
  },
294
+ responseLifecycle,
259
295
  limitations: LIMITATIONS_TEXT,
260
296
  };
261
297
  // --- Build Markdown ---
@@ -431,8 +467,8 @@ function buildMarkdown(report, includeAttribution) {
431
467
  lines.push('## Analyst Decision Packet');
432
468
  lines.push('');
433
469
  const response = report.analystResponse;
434
- if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.branches.length) {
435
- lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, case actions, or manual investigation steps recorded.');
470
+ if (!response.evidencePins.length && !response.sightings.length && !response.decisions.length && !response.actions.length && !response.assertions.length && !response.manualTrail.length && !response.observedEffects.reviews.length && !response.closures.records.length && !response.branches.length) {
471
+ lines.push('No evidence pins, source-qualified sightings, structured assertions, decision records, response actions, independent effect reviews, closures, or manual investigation steps recorded.');
436
472
  lines.push('');
437
473
  }
438
474
  else {
@@ -519,16 +555,35 @@ function buildMarkdown(report, includeAttribution) {
519
555
  for (const action of response.actions) {
520
556
  lines.push(`- **${escapeMarkdownInline(action.type.replaceAll('_', ' '))}:** ${escapeMarkdownInline(action.recipient)} (${escapeMarkdownInline(action.state)})`);
521
557
  lines.push(` Contact source: ${escapeMarkdownInline(action.contactSource)}; updated ${escapeMarkdownInline(action.updatedAt)}.`);
558
+ if (action.originActionId)
559
+ lines.push(` Originating action: ${escapeMarkdownInline(action.originActionId)}`);
522
560
  if (action.dueAt)
523
561
  lines.push(` Due: ${escapeMarkdownInline(action.dueAt)}`);
524
562
  if (action.followUpAt)
525
563
  lines.push(` Follow-up: ${escapeMarkdownInline(action.followUpAt)}`);
526
564
  if (action.reference)
527
565
  lines.push(` Reference: ${escapeMarkdownInline(action.reference)}`);
566
+ if (action.providerOutcome)
567
+ lines.push(` Latest typed provider outcome: ${escapeMarkdownInline(action.providerOutcome.replaceAll('_', ' '))}`);
528
568
  if (action.outcome)
529
- lines.push(` Outcome: ${escapeMarkdownInline(action.outcome)}`);
569
+ lines.push(` ${action.providerOutcome ? 'Provider' : 'Legacy recorded'} outcome detail: ${escapeMarkdownInline(action.outcome)}`);
530
570
  if (action.contactLimitations.length)
531
571
  lines.push(` Contact limitations: ${escapeMarkdownInline(action.contactLimitations.join('; '))}`);
572
+ for (const event of action.history) {
573
+ lines.push(` - ${escapeMarkdownInline(event.occurredAt)} · ${escapeMarkdownInline(event.previousState ?? 'none')} → ${escapeMarkdownInline(event.nextState)} · ${escapeMarkdownInline(event.sourceClass)} · ${event.applied ? 'applied' : 'retained conflict'}`);
574
+ if (event.providerOutcome)
575
+ lines.push(` Provider outcome at this time: ${escapeMarkdownInline(event.providerOutcome.replaceAll('_', ' '))}`);
576
+ if (event.reference)
577
+ lines.push(` Event reference: ${escapeMarkdownInline(event.reference)}`);
578
+ if (event.evidencePinId)
579
+ lines.push(` Evidence pin: ${escapeMarkdownInline(event.evidencePinId)}`);
580
+ if (event.limitations.length)
581
+ lines.push(` Limitations: ${escapeMarkdownInline(event.limitations.join('; '))}`);
582
+ }
583
+ if (action.historyOmitted)
584
+ lines.push(` Earlier transition events omitted by bound: ${action.historyOmitted}`);
585
+ if (action.historyLimitations.length)
586
+ lines.push(` History limitations: ${escapeMarkdownInline(action.historyLimitations.join('; '))}`);
532
587
  }
533
588
  lines.push('');
534
589
  lines.push('### Investigation branches');
@@ -551,6 +606,45 @@ function buildMarkdown(report, includeAttribution) {
551
606
  }
552
607
  lines.push('');
553
608
  }
609
+ lines.push('### Independent remediation review and closure');
610
+ lines.push('');
611
+ const providerOutcome = report.responseLifecycle.latestProviderOutcome;
612
+ const observedEffect = report.responseLifecycle.latestObservedEffect;
613
+ lines.push(providerOutcome
614
+ ? `- Latest provider outcome time: ${escapeMarkdownInline(providerOutcome.occurredAt)} (${escapeMarkdownInline(providerOutcome.outcome.replaceAll('_', ' '))})`
615
+ : `- Latest provider outcome time: Withheld because the typed event state is ${escapeMarkdownInline(report.responseLifecycle.providerOutcomeState)}.`);
616
+ lines.push(report.responseLifecycle.latestObservedChangeAt
617
+ ? `- Latest independently observed change time: ${escapeMarkdownInline(report.responseLifecycle.latestObservedChangeAt)}`
618
+ : `- Latest independently observed change time: Withheld because the independent change state is ${escapeMarkdownInline(report.responseLifecycle.observedChangeState)}.`);
619
+ if (observedEffect)
620
+ lines.push(`- Latest independent review: ${escapeMarkdownInline(observedEffect.state.replaceAll('_', ' '))} at ${escapeMarkdownInline(observedEffect.observedAt)} from ${escapeMarkdownInline(observedEffect.source)}.`);
621
+ if (!response.observedEffects.reviews.length)
622
+ lines.push('- No independent observed-effect review recorded.');
623
+ for (const review of response.observedEffects.reviews) {
624
+ lines.push(`- **${escapeMarkdownInline(review.state.replaceAll('_', ' '))}** (${escapeMarkdownInline(review.observedAt)}): ${escapeMarkdownInline(review.source)}; class ${escapeMarkdownInline(review.sourceClass)}; completeness ${escapeMarkdownInline(review.completeness)}.`);
625
+ if (review.evidencePinId)
626
+ lines.push(` Evidence pin: ${escapeMarkdownInline(review.evidencePinId)}`);
627
+ if (review.sightingId)
628
+ lines.push(` Sighting: ${escapeMarkdownInline(review.sightingId)}`);
629
+ if (review.followUpAt)
630
+ lines.push(` Scheduled local follow-up: ${escapeMarkdownInline(review.followUpAt)}`);
631
+ if (review.limitations.length)
632
+ lines.push(` Limitations: ${escapeMarkdownInline(review.limitations.join('; '))}`);
633
+ }
634
+ if (response.observedEffects.omitted)
635
+ lines.push(`- Earlier independent reviews omitted by bound: ${response.observedEffects.omitted}`);
636
+ if (!response.closures.records.length)
637
+ lines.push('- No deliberate analyst closure recorded.');
638
+ for (const closure of response.closures.records) {
639
+ lines.push(`- **Closure · ${escapeMarkdownInline(closure.reason.replaceAll('_', ' '))}** (${escapeMarkdownInline(closure.createdAt)}): ${escapeMarkdownInline(closure.summary)}`);
640
+ if (closure.observedEffectReviewId)
641
+ lines.push(` Independent review: ${escapeMarkdownInline(closure.observedEffectReviewId)}`);
642
+ if (closure.actionId)
643
+ lines.push(` Provider action: ${escapeMarkdownInline(closure.actionId)}`);
644
+ if (closure.limitations.length)
645
+ lines.push(` Limitations: ${escapeMarkdownInline(closure.limitations.join('; '))}`);
646
+ }
647
+ lines.push('');
554
648
  // Analyst notes (only when included)
555
649
  if (report.case.notesIncluded && Array.isArray(report.case.notes) && report.case.notes.length > 0) {
556
650
  lines.push('## Analyst Notes');