1(function (global) { 2 "use strict"; 3 4 var CASE_FORMAT = "palo-case-file"; 5 var BUNDLE_FORMAT = "palo-evidence-bundle"; 6 var VERSION = "1.0.0"; 7 var CASE_KEY = "palo.case-file.v1"; 8 var HANDOFF_KEY = "palo.handoff.v1"; 9 var DISCLAIMER = "PALO provides educational governance support, not certification or legal advice. Verify decisions against current official sources and qualified human review."; 10 var ARRAY_IDS = { assessments: "assessmentId", evidence: "evidenceId", sources: "sourceId", incidents: "incidentId", handoffs: "handoffId" }; 11 12 function clone(value) { 13 return value == null ? value : JSON.parse(JSON.stringify(value)); 14 } 15 16 function slug(value) { 17 return String(value || "case").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 42) || "case"; 18 } 19 20 function token() { 21 if (global.crypto && global.crypto.getRandomValues) { 22 var values = new Uint32Array(2); 23 global.crypto.getRandomValues(values); 24 return Array.prototype.map.call(values, function (value) { return value.toString(36); }).join("").slice(0, 10); 25 } 26 return Math.random().toString(36).slice(2, 12); 27 } 28 29 function id(prefix, label) { 30 return prefix + "-" + slug(label) + "-" + token(); 31 } 32 33 function now() { return new Date().toISOString(); } 34 function isObject(value) { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } 35 function isDate(value) { 36 return typeof value === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/.test(value) && !Number.isNaN(Date.parse(value)); 37 } 38 function error(path, message) { return { path: path, message: message }; } 39 40 function validateSource(source, path, errors) { 41 if (!isObject(source)) { errors.push(error(path, "must be an object")); return; } 42 ["sourceId", "title", "url", "sourceType", "publisher"].forEach(function (key) { 43 if (typeof source[key] !== "string" || !source[key]) errors.push(error(path + "." + key, "must be a non-empty string")); 44 }); 45 if (typeof source.url === "string") { 46 try { new URL(source.url); } catch (urlError) { errors.push(error(path + ".url", "must be an absolute URI")); } 47 } 48 if (["official", "standard", "organizational", "research", "monitoring-signal"].indexOf(source.sourceType) === -1) errors.push(error(path + ".sourceType", "is not supported")); 49 if (!isDate(source.checkedAt)) errors.push(error(path + ".checkedAt", "must be an ISO date-time")); 50 if (!isObject(source.freshness)) errors.push(error(path + ".freshness", "must be an object")); 51 else { 52 if (["current", "review-due", "stale", "superseded", "unknown"].indexOf(source.freshness.status) === -1) errors.push(error(path + ".freshness.status", "is not supported")); 53 if (!Number.isInteger(source.freshness.reviewIntervalDays) || source.freshness.reviewIntervalDays < 1) errors.push(error(path + ".freshness.reviewIntervalDays", "must be a positive integer")); 54 if (!isDate(source.freshness.nextReviewAt)) errors.push(error(path + ".freshness.nextReviewAt", "must be an ISO date-time")); 55 } 56 } 57 58 function validateCase(value) { 59 var errors = []; 60 if (!isObject(value)) return { valid: false, type: null, errors: [error("$", "must be an object")] }; 61 if (value.format !== CASE_FORMAT) errors.push(error("$.format", "must equal " + CASE_FORMAT)); 62 if (value.schemaVersion !== VERSION) errors.push(error("$.schemaVersion", "must equal " + VERSION)); 63 if (!/^case-[a-z0-9][a-z0-9-]{5,63}$/.test(value.caseId || "")) errors.push(error("$.caseId", "must be a valid case id")); 64 if (typeof value.title !== "string" || !value.title.trim()) errors.push(error("$.title", "must be a non-empty string")); 65 if (["draft", "active", "review", "closed", "reopened"].indexOf(value.status) === -1) errors.push(error("$.status", "is not supported")); 66 ["createdAt", "updatedAt"].forEach(function (key) { if (!isDate(value[key])) errors.push(error("$." + key, "must be an ISO date-time")); }); 67 if (!isObject(value.context)) errors.push(error("$.context", "must be an object"));
68 ["assessments", "evidence", "sources", "incidents", "handoffs"].forEach(function (key) { if (!Array.isArray(value[key])) errors.push(error("$." + key, "must be an array")); }); 69 (value.assessments || []).forEach(function (item, index) { 70 if (!isObject(item) || !item.assessmentId || !item.module || !isDate(item.recordedAt) || !isObject(item.data)) errors.push(error("$.assessments[" + index + "]", "requires assessmentId, module, recordedAt, and object data")); 71 }); 72 (value.evidence || []).forEach(function (item, index) { 73 if (!isObject(item) || !item.evidenceId || !item.title || !item.kind || ["open", "draft", "ready", "verified", "superseded"].indexOf(item.status) === -1 || !isDate(item.recordedAt)) errors.push(error("$.evidence[" + index + "]", "requires evidenceId, title, kind, supported status, and recordedAt")); 74 }); 75 (value.sources || []).forEach(function (item, index) { validateSource(item, "$.sources[" + index + "]", errors); }); 76 (value.incidents || []).forEach(function (item, index) { 77 if (!isObject(item) || !item.incidentId || !item.triggerId || !isDate(item.occurredAt) || !item.summary || !Array.isArray(item.reopenGates) || !item.reopenGates.length) errors.push(error("$.incidents[" + index + "]", "requires incidentId, triggerId, occurredAt, summary, and reopenGates")); 78 }); 79 (value.handoffs || []).forEach(function (item, index) { 80 if (!isObject(item) || !item.handoffId || !item.from || !item.to || !isDate(item.createdAt)) errors.push(error("$.handoffs[" + index + "]", "requires handoffId, from, to, and createdAt")); 81 }); 82 return { valid: errors.length === 0, type: CASE_FORMAT, errors: errors }; 83 } 84 85 function validateBundle(value) { 86 var errors = []; 87 if (!isObject(value)) return { valid: false, type: null, errors: [error("$", "must be an object")] }; 88 if (value.format !== BUNDLE_FORMAT) errors.push(error("$.format", "must equal " + BUNDLE_FORMAT)); 89 if (value.schemaVersion !== VERSION) errors.push(error("$.schemaVersion", "must equal " + VERSION)); 90 if (!/^bundle-[a-z0-9][a-z0-9-]{5,63}$/.test(value.bundleId || "")) errors.push(error("$.bundleId", "must be a valid bundle id")); 91 if (!isDate(value.generatedAt)) errors.push(error("$.generatedAt", "must be an ISO date-time")); 92 if (!Array.isArray(value.artifacts)) errors.push(error("$.artifacts", "must be an array")); 93 (value.artifacts || []).forEach(function (item, index) { 94 if (!isObject(item) || !item.artifactId || !item.title || !item.kind || ["open", "draft", "ready", "verified", "superseded"].indexOf(item.status) === -1) errors.push(error("$.artifacts[" + index + "]", "requires artifactId, title, kind, and a supported status")); 95 }); 96 if (!Array.isArray(value.sourceRegistry)) errors.push(error("$.sourceRegistry", "must be an array")); 97 (value.sourceRegistry || []).forEach(function (item, index) { validateSource(item, "$.sourceRegistry[" + index + "]", errors); }); 98 if (!isObject(value.freshness) || !isDate(value.freshness.evaluatedAt) || ["current", "review-due", "stale", "unknown"].indexOf(value.freshness.status) === -1) errors.push(error("$.freshness", "requires evaluatedAt and a supported status")); 99 return { valid: errors.length === 0, type: BUNDLE_FORMAT, errors: errors }; 100 } 101 102 function validate(value) { 103 if (value && value.format === CASE_FORMAT) return validateCase(value); 104 if (value && value.format === BUNDLE_FORMAT) return validateBundle(value); 105 return { valid: false, type: null, errors: [error("$.format", "unsupported or missing format")] }; 106 } 107 108 function createCase(options) { 109 options = options || {}; 110 var stamp = options.createdAt || now(); 111 return Object.assign({}, clone(options.extensions || {}), { 112 format: CASE_FORMAT, 113 schemaVersion: VERSION, 114 caseId: options.caseId || id("case", options.title), 115 title: options.title || "Untitled PALO case", 116 status: options.status || "draft", 117 createdAt: stamp, 118 updatedAt: options.updatedAt || stamp, 119 owner: options.owner || "", 120 context: clone(options.context || {}), 121 assessments: clone(options.assessments || []), 122 evidence: clone(options.evidence || []), 123 sources: clone(options.sources || []), 124 incidents: clone(options.incidents || []), 125 handoffs: clone(options.handoffs || []) 126 }); 127 } 128 129 function sourceFromUrl(url, index, stamp) { 130 var host = "Imported source"; 131 try { host = new URL(url).hostname; } catch (urlError) { /* validation will flag the URL */ } 132 var checked = stamp || now(); 133 var next = new Date(Date.parse(checked) + 90 * 86400000).toISOString(); 134 return { sourceId: "legacy-source-" + (index + 1), title: host, url: url, sourceType: "official", publisher: host, checkedAt: checked, freshness: { status: "unknown", reviewIntervalDays: 90, nextReviewAt: next } }; 135 } 136 137 function migrateLegacyBundle(value) { 138 var stamp = isDate(value.generatedAt) ? value.generatedAt : now();
139 var title = value.assessment && value.assessment.systemName ? value.assessment.systemName : "Imported assessment"; 140 return Object.assign({}, clone(value), { 141 format: BUNDLE_FORMAT, 142 schemaVersion: VERSION, 143 bundleId: value.bundleId || id("bundle", title), 144 generatedAt: stamp, 145 artifacts: Array.isArray(value.artifacts) ? clone(value.artifacts) : [{ artifactId: "legacy-assessment-route", title: "Imported assessment route", kind: "assessment-route", status: "ready", content: { route: clone(value.route || []), evidenceReadiness: clone(value.evidenceReadiness || {}) } }], 146 sourceRegistry: Array.isArray(value.sourceRegistry) ? clone(value.sourceRegistry) : (value.sources || []).map(function (url, index) { return sourceFromUrl(url, index, stamp); }), 147 freshness: isObject(value.freshness) ? clone(value.freshness) : { evaluatedAt: stamp, status: "unknown" }, 148 disclaimer: value.disclaimer || DISCLAIMER 149 }); 150 } 151 152 function normalize(value) { 153 if (value && value.formatVersion === "PALO Evidence Bundle 0.1") return migrateLegacyBundle(value); 154 return clone(value); 155 } 156 157 function importJSON(input) { 158 function finish(value) { 159 var normalized = normalize(value); 160 var result = validate(normalized); 161 if (!result.valid) { 162 var importError = new Error("Invalid PALO file: " + result.errors.map(function (item) { return item.path + " " + item.message; }).join("; ")); 163 importError.validation = result; 164 throw importError; 165 } 166 return { document: normalized, type: result.type, migrated: Boolean(value && value.formatVersion === "PALO Evidence Bundle 0.1"), validation: result }; 167 } 168 if (typeof input === "string") return Promise.resolve(finish(JSON.parse(input))); 169 if (input && typeof input.text === "function") return input.text().then(function (text) { return finish(JSON.parse(text)); }); 170 return Promise.resolve(finish(input)); 171 } 172 173 function mergeValue(base, incoming, key) { 174 if (Array.isArray(base) && Array.isArray(incoming)) { 175 var identity = ARRAY_IDS[key]; 176 if (!identity) return clone(base).concat(clone(incoming)); 177 var output = clone(base); 178 var positions = new Map(); 179 output.forEach(function (item, index) { if (item && item[identity] != null) positions.set(item[identity], index); }); 180 incoming.forEach(function (item) { 181 var index = item && positions.has(item[identity]) ? positions.get(item[identity]) : -1; 182 if (index === -1) output.push(clone(item)); 183 else output[index] = mergeValue(output[index], item, ""); 184 if (index === -1 && item && item[identity] != null) positions.set(item[identity], output.length - 1); 185 }); 186 return output; 187 } 188 if (isObject(base) && isObject(incoming)) { 189 var object = clone(base); 190 Object.keys(incoming).forEach(function (childKey) { object[childKey] = childKey in object ? mergeValue(object[childKey], incoming[childKey], childKey) : clone(incoming[childKey]); }); 191 return object; 192 } 193 return clone(incoming); 194 } 195 196 function merge(base, incoming) { 197 var left = clone(base); 198 var right = clone(incoming); 199 if (right && right.format === BUNDLE_FORMAT) right = bundleToCase(right); 200 if (!left) left = createCase({ title: right && right.title }); 201 if (right && right.caseId && left.caseId && right.caseId !== left.caseId) throw new Error("Cannot merge different PALO cases (" + left.caseId + " and " + right.caseId + ")"); 202 var result = mergeValue(left, right, ""); 203 delete result.mobileIntegrity; // Any edit invalidates the imported transport digest. 204 result.updatedAt = now(); 205 var validation = validateCase(result); 206 if (!validation.valid) throw new Error("Merged case is invalid: " + validation.errors.map(function (item) { return item.path + " " + item.message; }).join("; ")); 207 return result; 208 } 209 210 function bundleToCase(bundle) { 211 var stamp = bundle.generatedAt || now(); 212 var assessment = clone(bundle.assessment || {}); 213 var title = assessment.systemName || "Imported PALO assessment"; 214 return createCase({ 215 caseId: bundle.caseId || undefined, 216 title: title, 217 createdAt: stamp, 218 updatedAt: stamp, 219 context: { organization: assessment.organization || "", sector: assessment.sector || "", intendedUse: assessment.useCase || "" }, 220 assessments: [{ assessmentId: "assessment-path-" + slug(bundle.bundleId), module: "assessment-path", recordedAt: stamp, data: { assessment: assessment, route: clone(bundle.route || []), evidenceReadiness: clone(bundle.evidenceReadiness || {}) } }], 221 evidence: (bundle.artifacts || []).map(function (artifact) { return Object.assign({}, clone(artifact), { evidenceId: artifact.evidenceId || artifact.artifactId, recordedAt: artifact.recordedAt || stamp }); }), 222 sources: clone(bundle.sourceRegistry || []), 223 extensions: { importedBundle: clone(bundle) } 224 }); 225 } 226 227 function save(caseFile) { 228 var result = validateCase(caseFile); 229 if (!result.valid) return { ok: false, errors: result.errors }; 230 try { global.localStorage.setItem(CASE_KEY, JSON.stringify(caseFile)); return { ok: true, caseFile: clone(caseFile) }; } 231 catch (storageError) { return { ok: false, errors: [error("storage", storageError.message)] }; } 232 } 233 234 function load() { 235 try { 236 var stored = global.localStorage.getItem(CASE_KEY); 237 if (!stored) return null; 238 var parsed = JSON.parse(stored); 239 return validateCase(parsed).valid ? parsed : null; 240 } catch (storageError) { return null; } 241 } 242 243 function clear() { 244 try { global.localStorage.removeItem(CASE_KEY); global.sessionStorage.removeItem(HANDOFF_KEY); return true; } 245 catch (storageError) { return false; } 246 } 247 248 function handoff(caseFile, from, to, detail) { 249 var record = { handoffId: id("handoff", from + "-" + to), from: from, to: to, createdAt: now(), detail: clone(detail || {}) }; 250 var updated = merge(caseFile, { handoffs: [record] }); 251 var payload = { contractVersion: VERSION, from: from, to: to, createdAt: record.createdAt,
251caseFile: updated }; 252 var saved = save(updated); 253 if (!saved.ok) return { ok: false, caseFile: updated, errors: saved.errors }; 254 try { global.sessionStorage.setItem(HANDOFF_KEY, JSON.stringify(payload)); } 255 catch (storageError) { return { ok: false, caseFile: updated, errors: [error("storage", storageError.message)] }; } 256 return { ok: true, caseFile: updated, payload: payload }; 257 } 258 259 function consumeHandoff(target) { 260 try { 261 var raw = global.sessionStorage.getItem(HANDOFF_KEY); 262 if (!raw) return null; 263 var payload = JSON.parse(raw); 264 if (payload.contractVersion !== VERSION || (target && payload.to !== target) || !validateCase(payload.caseFile).valid) return null; 265 var saved = save(payload.caseFile); 266 if (!saved.ok) return null; 267 global.sessionStorage.removeItem(HANDOFF_KEY); 268 return payload; 269 } catch (storageError) { return null; } 270 } 271 272 function markdownValue(value) { return String(value == null || value === "" ? "Not provided" : value).replace(/\r?\n/g, " "); } 273 274 function caseMarkdown(caseFile) { 275 var lines = ["# PALO case file", "", "- Case ID: " + caseFile.caseId, "- Title: " + caseFile.title, "- Status: " + caseFile.status, "- Updated: " + caseFile.updatedAt, "- Owner: " + markdownValue(caseFile.owner), "", "## Context", ""]; 276 Object.keys(caseFile.context || {}).sort().forEach(function (key) { lines.push("- " + key + ": " + markdownValue(caseFile.context[key])); }); 277 lines.push("", "## Assessments", ""); 278 caseFile.assessments.forEach(function (item) { lines.push("- " + item.module + " (" + item.recordedAt + ")"); }); 279 lines.push("", "## Evidence", ""); 280 caseFile.evidence.forEach(function (item) { lines.push("- " + item.title + " | " + item.kind + " | " + item.status); }); 281 lines.push("", "## Sources and freshness", ""); 282 caseFile.sources.forEach(function (item) { lines.push("- [" + item.title + "](" + item.url + ") | " + item.freshness.status + " | checked " + item.checkedAt + " | next review " + item.freshness.nextReviewAt); }); 283 lines.push("", "## Incidents and reopened gates", ""); 284 caseFile.incidents.forEach(function (item) { lines.push("- " + item.summary + " | " + item.reopenGates.join(", ")); }); 285 lines.push("", "## Disclaimer", "", DISCLAIMER, ""); 286 return lines.join("\n"); 287 } 288 289 function boardPack(caseFile) { 290 var latest = caseFile.assessments[caseFile.assessments.length - 1]; 291 var openEvidence = caseFile.evidence.filter(function (item) { return ["ready", "verified"].indexOf(item.status) === -1; }); 292 var dueSources = caseFile.sources.filter(function (item) { return item.freshness.status !== "current"; }); 293 var lines = ["# PALO board review pack", "", "## Decision header", "", "- Case: " + caseFile.title + " (" + caseFile.caseId + ")", "- Status: " + caseFile.status, "- Accountable owner: " + markdownValue(caseFile.owner), "- Last updated: " + caseFile.updatedAt, "", "## Decision requested", "", markdownValue(caseFile.context.decisionRequested || "Record a proceed, conditional-proceed, pause, or stop decision with rationale and named owner."), "", "## Operating context", ""]; 294 ["organization", "sector", "intendedUse", "affectedPeople"].forEach(function (key) { if (caseFile.context[key]) lines.push("- " + key + ": " + markdownValue(caseFile.context[key])); }); 295 lines.push("", "## Latest assessment", "", latest ? "- " + latest.module + " recorded " + latest.recordedAt : "- No assessment recorded", "", "## Evidence readiness", "", "- Total artifacts: " + caseFile.evidence.length, "- Open or draft artifacts: " + openEvidence.length, "", "## Source freshness", "", "- Registered sources: " + caseFile.sources.length, "- Sources requiring review: " + dueSources.length); 296 dueSources.forEach(function (item) { lines.push("- Review " + item.title + " (" + item.freshness.status + ")"); }); 297 lines.push("", "## Incidents and material changes", "", "- Recorded triggers: " + caseFile.incidents.length); 298 caseFile.incidents.forEach(function (item) { lines.push("- " + item.summary + "; reopen " + item.reopenGates.join(", ")); }); 299 lines.push("", "## Board record", "", "- Decision: [record decision]", "- Conditions: [record conditions]", "- Residual risk owner: [name role]", "- Next review: [date or trigger]", "", "## Disclaimer", "", DISCLAIMER, ""); 300 return lines.join("\n"); 301 } 302 303 function exportJSON(document) { var copy = clone(document); delete copy.mobileIntegrity; return JSON.stringify(copy, null, 2) + "\n"; } 304 305 global.PALOCaseFile = Object.freeze({ 306 version: VERSION, 307 formats: Object.freeze({ caseFile: CASE_FORMAT, evidenceBundle: BUNDLE_FORMAT }), 308 storageKeys: Object.freeze({ caseFile: CASE_KEY, handoff: HANDOFF_KEY }), 309 create: createCase, 310 validate: validate, 311 import: importJSON, 312 exportJSON: exportJSON, 313 exportMarkdown: caseMarkdown, 314 merge: merge, 315 bundleToCase: bundleToCase, 316 save: save, 317 load: load, 318 clear: clear, 319 handoff: handoff, 320 consumeHandoff: consumeHandoff, 321 boardPack: boardPack, 322 disclaimer: DISCLAIMER 323 }); 324}(window));
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.