@zanii/blackbox 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -1
- package/dist/a2a/index.d.ts +77 -0
- package/dist/a2a/index.js +305 -0
- package/dist/agents/index.d.ts +8 -0
- package/dist/analysis/accuracy.d.ts +24 -0
- package/dist/analysis/accuracy.js +45 -0
- package/dist/analysis/credential.d.ts +101 -0
- package/dist/analysis/credential.js +142 -0
- package/dist/analysis/faults.js +115 -0
- package/dist/analysis/grounding.d.ts +122 -0
- package/dist/analysis/grounding.js +445 -0
- package/dist/analysis/hallucination.d.ts +32 -0
- package/dist/analysis/hallucination.js +357 -0
- package/dist/analysis/index.d.ts +23 -0
- package/dist/analysis/index.js +93 -0
- package/dist/analysis/memory.d.ts +8 -0
- package/dist/analysis/memory.js +35 -8
- package/dist/analysis/reference.d.ts +49 -0
- package/dist/analysis/reference.js +164 -0
- package/dist/analysis/taxonomy.d.ts +18 -0
- package/dist/analysis/taxonomy.js +66 -0
- package/dist/approvals/index.d.ts +23 -0
- package/dist/approvals/index.js +48 -0
- package/dist/archive/index.d.ts +39 -0
- package/dist/archive/index.js +96 -0
- package/dist/archive/parquet.d.ts +2 -0
- package/dist/archive/parquet.js +185 -0
- package/dist/badge/index.d.ts +16 -0
- package/dist/badge/index.js +48 -0
- package/dist/bom/index.d.ts +14 -0
- package/dist/bom/index.js +152 -0
- package/dist/cli.js +114 -10
- package/dist/compliance/art12.d.ts +35 -0
- package/dist/compliance/art12.js +190 -0
- package/dist/compliance/index.d.ts +36 -2
- package/dist/compliance/index.js +78 -11
- package/dist/compliance/zanii.d.ts +29 -0
- package/dist/compliance/zanii.js +84 -0
- package/dist/constitution/index.d.ts +57 -0
- package/dist/constitution/index.js +131 -0
- package/dist/cv/index.d.ts +39 -0
- package/dist/cv/index.js +108 -0
- package/dist/disclosure/index.d.ts +31 -0
- package/dist/disclosure/index.js +113 -0
- package/dist/encryption/index.d.ts +9 -0
- package/dist/encryption/index.js +31 -0
- package/dist/evidence/index.d.ts +60 -0
- package/dist/evidence/index.js +151 -0
- package/dist/federation/index.d.ts +35 -0
- package/dist/federation/index.js +102 -0
- package/dist/finance/index.d.ts +126 -0
- package/dist/finance/index.js +320 -0
- package/dist/fleet/index.js +9 -0
- package/dist/gov/index.d.ts +108 -0
- package/dist/gov/index.js +225 -0
- package/dist/health/index.d.ts +120 -0
- package/dist/health/index.js +233 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.js +34 -5
- package/dist/memory/index.d.ts +36 -0
- package/dist/memory/index.js +85 -0
- package/dist/occurrence/index.d.ts +11 -0
- package/dist/occurrence/index.js +18 -0
- package/dist/ocsf/index.d.ts +1 -1
- package/dist/ocsf/index.js +36 -3
- package/dist/otlp/index.d.ts +8 -1
- package/dist/otlp/index.js +258 -1
- package/dist/packs/index.js +44 -4
- package/dist/policy/delta.js +7 -1
- package/dist/policy/index.d.ts +40 -6
- package/dist/policy/index.js +186 -8
- package/dist/policy/zanii.d.ts +31 -0
- package/dist/policy/zanii.js +87 -0
- package/dist/pq/index.d.ts +23 -0
- package/dist/pq/index.js +104 -0
- package/dist/search/index.d.ts +23 -0
- package/dist/search/index.js +69 -0
- package/dist/session/index.d.ts +107 -1
- package/dist/session/index.js +189 -11
- package/dist/sla/index.d.ts +61 -0
- package/dist/sla/index.js +197 -0
- package/dist/succession/index.d.ts +50 -0
- package/dist/succession/index.js +123 -0
- package/dist/timestamp/index.d.ts +24 -0
- package/dist/timestamp/index.js +274 -0
- package/dist/tokens/index.d.ts +6 -0
- package/dist/tokens/index.js +46 -0
- package/dist/transparency/index.d.ts +188 -0
- package/dist/transparency/index.js +712 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/walls/index.d.ts +31 -0
- package/dist/walls/index.js +119 -0
- package/package.json +1 -1
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// Government decisions about people, and the citizen's appeal pack (spec/gov.md), on Zanii's
|
|
2
|
+
// subject tags, subject claims and decision payloads (@zanii/subject, @zanii/decisions, @zanii/gov).
|
|
3
|
+
// Pure; mirrors sdks/python/src/zanii_blackbox/gov.py; pinned by spec/vectors/gov.json.
|
|
4
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
5
|
+
import { canonicalBytes, jcsHash, publicKeyFromDid } from "@zanii/core";
|
|
6
|
+
import { canonical } from "../reconcile/shared.js";
|
|
7
|
+
import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
|
|
8
|
+
export const GOV_DECISION_KINDS = [
|
|
9
|
+
"benefit.grant",
|
|
10
|
+
"benefit.deny",
|
|
11
|
+
"visa.decide",
|
|
12
|
+
"fine.issue",
|
|
13
|
+
"licence.decide",
|
|
14
|
+
];
|
|
15
|
+
const KIND = /^[a-z][a-z0-9.]{0,63}$/;
|
|
16
|
+
const validTs = (ts) => typeof ts === "string" && !Number.isNaN(Date.parse(ts));
|
|
17
|
+
/** Zanii's pseudonymous, authority-scoped tag for a person's did:key. */
|
|
18
|
+
export function subjectTag(subjectDid, authority) {
|
|
19
|
+
if (!subjectDid || !authority)
|
|
20
|
+
throw new Error("subject_did and platform_id are required");
|
|
21
|
+
return jcsHash({ v: 1, type: "subject_tag", platform: authority, subject: subjectDid });
|
|
22
|
+
}
|
|
23
|
+
/** Zanii's subject claim: "this is my slice", signed with the person's own key. */
|
|
24
|
+
export function signSubjectClaim(input) {
|
|
25
|
+
if (!validTs(input.ts))
|
|
26
|
+
throw new Error("ts must be an ISO timestamp");
|
|
27
|
+
const unsigned = {
|
|
28
|
+
v: 1,
|
|
29
|
+
type: "subject_claim",
|
|
30
|
+
platform: input.platform,
|
|
31
|
+
subject: input.subjectDid,
|
|
32
|
+
tag: subjectTag(input.subjectDid, input.platform),
|
|
33
|
+
ts: input.ts,
|
|
34
|
+
};
|
|
35
|
+
const sig = Buffer.from(ed25519Sign(input.subjectPrivateKey, canonicalBytes(unsigned))).toString("hex");
|
|
36
|
+
return { ...unsigned, sig: `ed25519:${sig}` };
|
|
37
|
+
}
|
|
38
|
+
/** The subject signed it, and the tag derives from that subject at that platform. */
|
|
39
|
+
export function verifySubjectClaim(claim) {
|
|
40
|
+
const c = (claim ?? {});
|
|
41
|
+
if (c.v !== 1 || c.type !== "subject_claim")
|
|
42
|
+
return false;
|
|
43
|
+
try {
|
|
44
|
+
if (subjectTag(String(c.subject ?? ""), String(c.platform ?? "")) !== c.tag)
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const pub = publicKeyFromDid(String(c.subject));
|
|
51
|
+
const m = typeof c.sig === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(c.sig) : null;
|
|
52
|
+
if (!pub || !m)
|
|
53
|
+
return false;
|
|
54
|
+
const { sig: _sig, ...unsigned } = c;
|
|
55
|
+
return ed25519Verify(pub, canonicalBytes(unsigned), Buffer.from(m[1], "hex"));
|
|
56
|
+
}
|
|
57
|
+
const commit = (factors, nonce) => `sha256:${createHash("sha256")
|
|
58
|
+
.update(Buffer.from(nonce, "hex"))
|
|
59
|
+
.update(canonical(factors))
|
|
60
|
+
.digest("hex")}`;
|
|
61
|
+
/** Zanii's decision payload, v1, for a government decision. No rulebook, no decision. */
|
|
62
|
+
export function decisionPayload(input) {
|
|
63
|
+
if (!KIND.test(input.kind))
|
|
64
|
+
throw new Error("kind must be lowercase, 1-64 characters, a-z 0-9 .");
|
|
65
|
+
if (!input.manifestHash)
|
|
66
|
+
throw new Error("manifest_hash is required: a state decision with no governing rulebook on record is not auditable, and must not be issued");
|
|
67
|
+
if (!validTs(input.ts))
|
|
68
|
+
throw new Error("ts must be an ISO timestamp");
|
|
69
|
+
if (input.appealBy !== undefined && !validTs(input.appealBy))
|
|
70
|
+
throw new Error("appeal_by must be an ISO date");
|
|
71
|
+
const nonce = input.factors !== undefined ? (input.nonce ?? randomBytes(16).toString("hex")) : "";
|
|
72
|
+
const payload = {
|
|
73
|
+
_zr_kind: "decision",
|
|
74
|
+
v: 1,
|
|
75
|
+
kind: input.kind,
|
|
76
|
+
manifest_hash: input.manifestHash,
|
|
77
|
+
outcome: input.outcome,
|
|
78
|
+
ts: input.ts,
|
|
79
|
+
...(input.factors !== undefined ? { factors_commitment: commit(input.factors, nonce) } : {}),
|
|
80
|
+
...(input.appealBy !== undefined ? { appeal_by: input.appealBy } : {}),
|
|
81
|
+
_zr_domain: "gov",
|
|
82
|
+
};
|
|
83
|
+
return { payload, nonce };
|
|
84
|
+
}
|
|
85
|
+
/** The disclosed factors and nonce match the commitment made at decision time. */
|
|
86
|
+
export const verifyFactors = (factors, nonce, commitment) => /^[0-9a-f]{32}$/.test(nonce) && commit(factors, nonce) === commitment;
|
|
87
|
+
/** Zanii's structural check of a decision payload. */
|
|
88
|
+
export function verifyDecision(payload) {
|
|
89
|
+
const p = (payload ?? {});
|
|
90
|
+
const reasons = [];
|
|
91
|
+
if (p._zr_kind !== "decision" || p.v !== 1)
|
|
92
|
+
reasons.push("not a decision receipt (v1)");
|
|
93
|
+
if (!p.kind)
|
|
94
|
+
reasons.push("missing kind");
|
|
95
|
+
if (typeof p.manifest_hash !== "string" || !p.manifest_hash)
|
|
96
|
+
reasons.push("missing manifest_hash (no governing rulebook)");
|
|
97
|
+
if (!validTs(p.ts))
|
|
98
|
+
reasons.push("missing/invalid ts");
|
|
99
|
+
return { ok: reasons.length === 0, reasons };
|
|
100
|
+
}
|
|
101
|
+
/** Zanii's rulebook windows over time; interleaving is the red flag. */
|
|
102
|
+
export function ruleConsistency(payloads) {
|
|
103
|
+
const reasons = [];
|
|
104
|
+
const windows = new Map();
|
|
105
|
+
const sorted = [...payloads].sort((a, b) => {
|
|
106
|
+
const x = String(a?.ts);
|
|
107
|
+
const y = String(b?.ts);
|
|
108
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
109
|
+
});
|
|
110
|
+
for (const p of sorted) {
|
|
111
|
+
const check = verifyDecision(p);
|
|
112
|
+
if (!check.ok) {
|
|
113
|
+
reasons.push(`invalid decision: ${check.reasons.join("; ")}`);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const h = String(p.manifest_hash);
|
|
117
|
+
const ts = String(p.ts);
|
|
118
|
+
const w = windows.get(h);
|
|
119
|
+
if (w) {
|
|
120
|
+
w.count++;
|
|
121
|
+
w.last_ts = ts;
|
|
122
|
+
}
|
|
123
|
+
else
|
|
124
|
+
windows.set(h, { manifest_hash: h, count: 1, first_ts: ts, last_ts: ts });
|
|
125
|
+
}
|
|
126
|
+
const rulebooks = [...windows.values()].sort((a, b) => a.first_ts < b.first_ts ? -1 : a.first_ts > b.first_ts ? 1 : 0);
|
|
127
|
+
for (let i = 1; i < rulebooks.length; i++) {
|
|
128
|
+
const prev = rulebooks[i - 1];
|
|
129
|
+
const cur = rulebooks[i];
|
|
130
|
+
if (cur.first_ts < prev.last_ts)
|
|
131
|
+
reasons.push(`rulebooks ${prev.manifest_hash} and ${cur.manifest_hash} interleave — rules differed between people at the same time`);
|
|
132
|
+
}
|
|
133
|
+
return { rulebooks, consistent: reasons.length === 0, reasons };
|
|
134
|
+
}
|
|
135
|
+
const LEGAL_BASIS = {
|
|
136
|
+
en: "This pack is a self-contained, independently verifiable record of the automated decisions taken about the holder by the issuing authority. Each decision names the rulebook that governed it, and the pack is signed by the gateway that recorded it. A court or appointed expert can verify every element offline, without the cooperation of the authority that produced it.",
|
|
137
|
+
ar: "هذه الحزمة سجلّ قائم بذاته وقابل للتحقّق المستقل للقرارات الآلية التي اتُّخذت بشأن حاملها من قبل الجهة المُصدِرة. يذكر كل قرار القواعد التي حكمته، والحزمة موقّعة من البوابة التي سجّلته. ويمكن للمحكمة أو الخبير المعيّن التحقّق من كل عنصر دون اتصال ودون تعاون الجهة التي أصدرته.",
|
|
138
|
+
};
|
|
139
|
+
const STEPS = [
|
|
140
|
+
{
|
|
141
|
+
en: "Verify the pack's signature against the gateway's published log key (did:key).",
|
|
142
|
+
ar: "تحقّق من توقيع الحزمة مقابل مفتاح السجل المنشور للبوابة (did:key).",
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
en: "Verify each decision line hashes (SHA-256) to the event hash given for it: the line is exactly what was recorded.",
|
|
146
|
+
ar: "تحقّق من أن تجزئة كل سطر قرار (SHA-256) تساوي تجزئة الحدث المذكورة له: السطر هو ما سُجّل بالضبط.",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
en: "Confirm every decision names the rulebook that governed it (manifest_hash): a decision without one is not evidence of a lawful process.",
|
|
150
|
+
ar: "تأكّد من أنّ كل قرار يذكر القواعد الحاكمة له (بصمة القواعد): فالقرار الخالي منها ليس دليلاً على إجراء سليم.",
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
en: "Check rule-consistency: the same rulebook decided everyone in the period, and any change of rules is visible and dated.",
|
|
154
|
+
ar: "تحقّق من اتساق القواعد: أنّ القواعد نفسها طُبِّقت على الجميع في المدة، وأنّ أي تغيير فيها ظاهر ومؤرَّخ.",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
en: "With the session records the authority discloses, verify each line is in its session's hash chain and under its anchors: it was not added afterwards.",
|
|
158
|
+
ar: "مع سجلات الجلسات التي تفصح عنها الجهة، تحقّق من أن كل سطر ضمن سلسلة تجزئة جلسته وتحت تثبيتاتها: لم يُضَف لاحقًا.",
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
en: "On dispute, the authority discloses the committed decision factors and their nonce; verify they match the commitment made at decision time (no reasoning added afterwards).",
|
|
162
|
+
ar: "عند النزاع، تفصح الجهة عن عوامل القرار المُلتزَم بها ومعامل الملح؛ تحقّق من مطابقتها للالتزام وقت اتخاذ القرار (لا تبرير لاحق).",
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
/** spec/gov.md §3: the citizen's appeal pack over the decisions recorded about their tag. */
|
|
166
|
+
export function appealPack(items, opts) {
|
|
167
|
+
const payloads = items.map((i) => i.decision);
|
|
168
|
+
const times = payloads
|
|
169
|
+
.map((p) => p?.ts)
|
|
170
|
+
.filter((t) => typeof t === "string")
|
|
171
|
+
.sort();
|
|
172
|
+
return {
|
|
173
|
+
v: 1,
|
|
174
|
+
legal_basis: LEGAL_BASIS,
|
|
175
|
+
subject_tag: opts.tag,
|
|
176
|
+
authority: opts.authority,
|
|
177
|
+
decisions: items.length,
|
|
178
|
+
rules: ruleConsistency(payloads),
|
|
179
|
+
unruled: payloads.filter((p) => !verifyDecision(p).ok).length,
|
|
180
|
+
period: { first: times[0] ?? null, last: times.at(-1) ?? null },
|
|
181
|
+
items: items.map(({ session_id, seq, line, event_hash, anchored }) => ({
|
|
182
|
+
session_id,
|
|
183
|
+
seq,
|
|
184
|
+
line,
|
|
185
|
+
event_hash,
|
|
186
|
+
anchored,
|
|
187
|
+
})),
|
|
188
|
+
steps: STEPS.map((s, i) => ({ n: i + 1, ...s })),
|
|
189
|
+
generated_at: opts.generated_at,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
/** The reading copy for an administrative judge: `en`, `ar` or `both` (headers always bilingual). */
|
|
193
|
+
export function renderAppealPack(pack, lang = "both") {
|
|
194
|
+
const show = (b) => (lang === "en" ? b.en : lang === "ar" ? b.ar : `${b.en}\n\n> ${b.ar}`);
|
|
195
|
+
const steps = pack.steps.map((s) => `${s.n}. ${show(s)}`).join("\n");
|
|
196
|
+
const rulebooks = pack.rules.rulebooks
|
|
197
|
+
.map((w) => `- \`${w.manifest_hash}\` — ${w.count} decision(s), ${w.first_ts} → ${w.last_ts}`)
|
|
198
|
+
.join("\n") || "- (none)";
|
|
199
|
+
const flags = [];
|
|
200
|
+
if (!pack.rules.consistent)
|
|
201
|
+
flags.push("⚠️ RULES INTERLEAVED — different rulebooks decided people at the same time");
|
|
202
|
+
if (pack.unruled > 0)
|
|
203
|
+
flags.push(`❌ ${pack.unruled} decision(s) carry NO governing rulebook`);
|
|
204
|
+
const findings = flags.length
|
|
205
|
+
? `## Findings / النتائج\n\n${flags.map((f) => `- ${f}`).join("\n")}\n\n`
|
|
206
|
+
: "";
|
|
207
|
+
const p = pack.period;
|
|
208
|
+
return ("# Appeal Pack / حزمة الطعن\n\n" +
|
|
209
|
+
`- **Subject (pseudonymous) / الشخص المعني (مستعار):** \`${pack.subject_tag}\`\n` +
|
|
210
|
+
`- **Authority / الجهة:** ${pack.authority || "—"}\n` +
|
|
211
|
+
`- **Decisions / القرارات:** ${pack.decisions}\n` +
|
|
212
|
+
`- **Period / المدة:** ${p.first ?? "—"} → ${p.last ?? "—"}\n\n` +
|
|
213
|
+
findings +
|
|
214
|
+
`## Legal basis / الأساس القانوني\n\n${show(pack.legal_basis)}\n\n` +
|
|
215
|
+
`## Rulebooks applied / القواعد المطبَّقة\n\n${rulebooks}\n\n` +
|
|
216
|
+
`## Verification steps / خطوات التحقّق\n\n${steps}\n\n` +
|
|
217
|
+
"## What this does and does not prove / ما تثبته هذه الحزمة وما لا تثبته\n\n" +
|
|
218
|
+
"It proves the **process**: the same rules decided everyone, the record is complete and " +
|
|
219
|
+
"unaltered, and the inputs were committed at decision time. It does **not** prove the " +
|
|
220
|
+
"decision was *just* — a rule applied consistently may still be unlawful. That judgment " +
|
|
221
|
+
"is the court's.\n\n" +
|
|
222
|
+
"> تُثبت هذه الحزمة **سلامة الإجراء**: أنّ القواعد نفسها طُبِّقت على الجميع، وأنّ السجل كامل " +
|
|
223
|
+
"وغير مُعدَّل، وأنّ المعطيات التُزم بها وقت القرار. ولا تُثبت **عدالة** القرار — فالقاعدة " +
|
|
224
|
+
"المطبَّقة باتساق قد تظل مخالفة للقانون، وهذا تقدير المحكمة.\n");
|
|
225
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
type Obj = Record<string, unknown>;
|
|
2
|
+
/** The patient's seed, for the patient's own app. It never leaves the patient. */
|
|
3
|
+
export declare function episodeSeed(secretHex?: string): {
|
|
4
|
+
v: 1;
|
|
5
|
+
secret: string;
|
|
6
|
+
};
|
|
7
|
+
/** Zanii's tag for encounter n, derived by the patient; two tags can't be linked without the seed. */
|
|
8
|
+
export declare function episodeTag(seed: {
|
|
9
|
+
secret: string;
|
|
10
|
+
}, n: number): string;
|
|
11
|
+
/** The tag, or an error: the patient supplies the tag, never the seed. */
|
|
12
|
+
export declare function checkEpisodeTag(tag: unknown): string;
|
|
13
|
+
/** A disclosed value and nonce match the commitment made at the time. */
|
|
14
|
+
export declare const verifyCommitment: (value: unknown, nonce: string, commitment: string) => boolean;
|
|
15
|
+
/** Zanii's health.access payload: who accessed the record, and why. */
|
|
16
|
+
export declare function accessPayload(input: {
|
|
17
|
+
actor: string;
|
|
18
|
+
purpose: string;
|
|
19
|
+
ts: string;
|
|
20
|
+
consentRef?: string;
|
|
21
|
+
details?: Obj;
|
|
22
|
+
nonce?: string;
|
|
23
|
+
}): {
|
|
24
|
+
payload: Obj;
|
|
25
|
+
nonce: string;
|
|
26
|
+
};
|
|
27
|
+
/** What the clinician signs: jcsHash of the recommendation payload. */
|
|
28
|
+
export declare const recommendationHash: (payload: Obj) => string;
|
|
29
|
+
/** Zanii's health.recommendation payload. No protocol, no recommendation; a deviation is recorded, never refused. */
|
|
30
|
+
export declare function recommendationPayload(input: {
|
|
31
|
+
modelId: string;
|
|
32
|
+
manifestHash: string;
|
|
33
|
+
outcome: Obj;
|
|
34
|
+
ts: string;
|
|
35
|
+
runtimeHash?: string;
|
|
36
|
+
deviation?: Obj;
|
|
37
|
+
nonce?: string;
|
|
38
|
+
}): {
|
|
39
|
+
payload: Obj;
|
|
40
|
+
nonce: string;
|
|
41
|
+
hash: string;
|
|
42
|
+
};
|
|
43
|
+
/** Zanii's health.confirmation payload, signed on the clinician's own device. */
|
|
44
|
+
export declare function clinicianConfirmation(input: {
|
|
45
|
+
episodeTag: string;
|
|
46
|
+
clinicianDid: string;
|
|
47
|
+
recommendationHash: string;
|
|
48
|
+
confirmedAt: string;
|
|
49
|
+
clinicianPrivateKey: Uint8Array;
|
|
50
|
+
}): Obj;
|
|
51
|
+
/** Zanii's health.break_glass payload: emergency access, signed personally. Kept open on purpose, and loud. */
|
|
52
|
+
export declare function breakGlass(input: {
|
|
53
|
+
episodeTag: string;
|
|
54
|
+
clinicianDid: string;
|
|
55
|
+
reason: string;
|
|
56
|
+
ts: string;
|
|
57
|
+
clinicianPrivateKey: Uint8Array;
|
|
58
|
+
}): Obj;
|
|
59
|
+
/** A confirmation or break-glass is signed by the clinician it names, over the action it names. */
|
|
60
|
+
export declare function verifyHealthSignature(payload: unknown, tag: string): boolean;
|
|
61
|
+
/** Zanii's protocol trail. No verdict on uniformity (individualised care is correct medicine). */
|
|
62
|
+
export declare function protocolTrail(payloads: readonly unknown[]): {
|
|
63
|
+
protocols: {
|
|
64
|
+
manifest_hash: unknown;
|
|
65
|
+
count: number;
|
|
66
|
+
first_ts: unknown;
|
|
67
|
+
last_ts: unknown;
|
|
68
|
+
}[];
|
|
69
|
+
deviations: number;
|
|
70
|
+
unprotocolled: number;
|
|
71
|
+
};
|
|
72
|
+
export interface EpisodeItem {
|
|
73
|
+
session_id: string;
|
|
74
|
+
seq: number;
|
|
75
|
+
line: string;
|
|
76
|
+
event_hash: string;
|
|
77
|
+
anchored: boolean;
|
|
78
|
+
payload: Obj;
|
|
79
|
+
tag: string;
|
|
80
|
+
}
|
|
81
|
+
/** spec/health.md §4: the patient's episodes; `reviewed` holds "<session_id>:<seq>" of reviewed break-glass. */
|
|
82
|
+
export declare function episodesPack(items: readonly EpisodeItem[], o: {
|
|
83
|
+
tags: readonly string[];
|
|
84
|
+
reviewed: readonly string[];
|
|
85
|
+
generated_at: string;
|
|
86
|
+
}): {
|
|
87
|
+
v: number;
|
|
88
|
+
episodes: {
|
|
89
|
+
tag: string;
|
|
90
|
+
items: {
|
|
91
|
+
session_id: string;
|
|
92
|
+
seq: number;
|
|
93
|
+
line: string;
|
|
94
|
+
event_hash: string;
|
|
95
|
+
anchored: boolean;
|
|
96
|
+
payload: Obj;
|
|
97
|
+
}[];
|
|
98
|
+
}[];
|
|
99
|
+
total: number;
|
|
100
|
+
pending_break_glass: number;
|
|
101
|
+
protocols: {
|
|
102
|
+
protocols: {
|
|
103
|
+
manifest_hash: unknown;
|
|
104
|
+
count: number;
|
|
105
|
+
first_ts: unknown;
|
|
106
|
+
last_ts: unknown;
|
|
107
|
+
}[];
|
|
108
|
+
deviations: number;
|
|
109
|
+
unprotocolled: number;
|
|
110
|
+
};
|
|
111
|
+
generated_at: string;
|
|
112
|
+
};
|
|
113
|
+
export declare function healthFindings(lines: readonly string[], bodies: (hash: string) => Uint8Array | undefined, closing?: boolean): {
|
|
114
|
+
code: string;
|
|
115
|
+
source: string;
|
|
116
|
+
severity: "warning" | "advisory";
|
|
117
|
+
ref: Record<string, string | number>;
|
|
118
|
+
detail: string;
|
|
119
|
+
}[];
|
|
120
|
+
export {};
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Health (spec/health.md): unlinkable per-episode patient tags, access and recommendation payloads,
|
|
2
|
+
// clinician confirmations, loud break-glass, the protocol trail and the patient's pack, on Zanii's
|
|
3
|
+
// health payloads (@zanii/health). Pure; mirrors sdks/python/src/zanii_blackbox/health.py; pinned
|
|
4
|
+
// by spec/vectors/health.json.
|
|
5
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
6
|
+
import { createConfirmation, jcsHash } from "@zanii/core";
|
|
7
|
+
import { verifyConfirmation } from "../approvals/index.js";
|
|
8
|
+
import { canonical } from "../reconcile/shared.js";
|
|
9
|
+
const TAG = /^sha256:[0-9a-f]{64}$/;
|
|
10
|
+
const HEX64 = /^[0-9a-f]{64}$/;
|
|
11
|
+
/** The patient's seed, for the patient's own app. It never leaves the patient. */
|
|
12
|
+
export function episodeSeed(secretHex) {
|
|
13
|
+
const secret = secretHex ?? randomBytes(32).toString("hex");
|
|
14
|
+
if (!HEX64.test(secret))
|
|
15
|
+
throw new Error("seed secret must be 32 bytes of hex");
|
|
16
|
+
return { v: 1, secret };
|
|
17
|
+
}
|
|
18
|
+
/** Zanii's tag for encounter n, derived by the patient; two tags can't be linked without the seed. */
|
|
19
|
+
export function episodeTag(seed, n) {
|
|
20
|
+
if (!Number.isSafeInteger(n) || n < 0)
|
|
21
|
+
throw new Error("n must be a non-negative integer");
|
|
22
|
+
return jcsHash({ v: 1, type: "episode_tag", secret: seed.secret, n });
|
|
23
|
+
}
|
|
24
|
+
/** The tag, or an error: the patient supplies the tag, never the seed. */
|
|
25
|
+
export function checkEpisodeTag(tag) {
|
|
26
|
+
if ((typeof tag === "object" && tag !== null) || (typeof tag === "string" && HEX64.test(tag)))
|
|
27
|
+
throw new Error("pass the patient's episode tag, never the seed: whoever holds the seed can link every encounter");
|
|
28
|
+
if (typeof tag !== "string" || !TAG.test(tag))
|
|
29
|
+
throw new Error("episode_tag must be sha256: and 64 hex characters");
|
|
30
|
+
return tag;
|
|
31
|
+
}
|
|
32
|
+
const validTs = (ts) => typeof ts === "string" && !Number.isNaN(Date.parse(ts));
|
|
33
|
+
const commit = (value, nonce) => `sha256:${createHash("sha256").update(Buffer.from(nonce, "hex")).update(canonical(value)).digest("hex")}`;
|
|
34
|
+
/** A disclosed value and nonce match the commitment made at the time. */
|
|
35
|
+
export const verifyCommitment = (value, nonce, commitment) => /^[0-9a-f]{32}$/.test(nonce) && commit(value, nonce) === commitment;
|
|
36
|
+
const base = (kind) => ({ _zr_kind: "health", _zr_domain: "health", v: 1, kind });
|
|
37
|
+
/** Zanii's health.access payload: who accessed the record, and why. */
|
|
38
|
+
export function accessPayload(input) {
|
|
39
|
+
if (!input.actor)
|
|
40
|
+
throw new Error("actor is required");
|
|
41
|
+
if (!input.purpose)
|
|
42
|
+
throw new Error("purpose is required: purpose limitation is not optional");
|
|
43
|
+
if (!validTs(input.ts))
|
|
44
|
+
throw new Error("ts must be an ISO timestamp");
|
|
45
|
+
const nonce = input.nonce ?? randomBytes(16).toString("hex");
|
|
46
|
+
return {
|
|
47
|
+
payload: {
|
|
48
|
+
...base("access"),
|
|
49
|
+
actor: input.actor,
|
|
50
|
+
purpose: input.purpose,
|
|
51
|
+
consent_ref: input.consentRef ?? null,
|
|
52
|
+
details_commitment: commit(input.details ?? {}, nonce),
|
|
53
|
+
ts: input.ts,
|
|
54
|
+
},
|
|
55
|
+
nonce,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** What the clinician signs: jcsHash of the recommendation payload. */
|
|
59
|
+
export const recommendationHash = (payload) => jcsHash(payload);
|
|
60
|
+
/** Zanii's health.recommendation payload. No protocol, no recommendation; a deviation is recorded, never refused. */
|
|
61
|
+
export function recommendationPayload(input) {
|
|
62
|
+
if (!input.modelId)
|
|
63
|
+
throw new Error("model_id is required");
|
|
64
|
+
if (!input.manifestHash)
|
|
65
|
+
throw new Error("manifest_hash is required: a clinical recommendation with no governing protocol on record is not auditable");
|
|
66
|
+
if (!validTs(input.ts))
|
|
67
|
+
throw new Error("ts must be an ISO timestamp");
|
|
68
|
+
const nonce = input.nonce ?? randomBytes(16).toString("hex");
|
|
69
|
+
const payload = {
|
|
70
|
+
...base("recommendation"),
|
|
71
|
+
model_id: input.modelId,
|
|
72
|
+
runtime_hash: input.runtimeHash ?? null,
|
|
73
|
+
manifest_hash: input.manifestHash,
|
|
74
|
+
outcome_commitment: commit(input.outcome, nonce),
|
|
75
|
+
deviation: input.deviation ?? null,
|
|
76
|
+
ts: input.ts,
|
|
77
|
+
};
|
|
78
|
+
return { payload, nonce, hash: recommendationHash(payload) };
|
|
79
|
+
}
|
|
80
|
+
/** Zanii's health.confirmation payload, signed on the clinician's own device. */
|
|
81
|
+
export function clinicianConfirmation(input) {
|
|
82
|
+
checkEpisodeTag(input.episodeTag);
|
|
83
|
+
const confirmation = createConfirmation({
|
|
84
|
+
ownerId: input.clinicianDid,
|
|
85
|
+
actionHash: input.recommendationHash,
|
|
86
|
+
confirmedAt: input.confirmedAt,
|
|
87
|
+
}, input.clinicianPrivateKey);
|
|
88
|
+
return {
|
|
89
|
+
...base("confirmation"),
|
|
90
|
+
clinician: input.clinicianDid,
|
|
91
|
+
recommendation_hash: input.recommendationHash,
|
|
92
|
+
confirmation,
|
|
93
|
+
ts: input.confirmedAt,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const breakGlassAction = (tag, ts) => jcsHash({ v: 1, type: "break_glass", tag, ts });
|
|
97
|
+
/** Zanii's health.break_glass payload: emergency access, signed personally. Kept open on purpose, and loud. */
|
|
98
|
+
export function breakGlass(input) {
|
|
99
|
+
checkEpisodeTag(input.episodeTag);
|
|
100
|
+
if (!input.reason)
|
|
101
|
+
throw new Error("reason is required: an unexplained break-glass is exactly the abuse this exists to catch");
|
|
102
|
+
const confirmation = createConfirmation({
|
|
103
|
+
ownerId: input.clinicianDid,
|
|
104
|
+
actionHash: breakGlassAction(input.episodeTag, input.ts),
|
|
105
|
+
confirmedAt: input.ts,
|
|
106
|
+
}, input.clinicianPrivateKey);
|
|
107
|
+
return {
|
|
108
|
+
...base("break_glass"),
|
|
109
|
+
clinician: input.clinicianDid,
|
|
110
|
+
reason: input.reason,
|
|
111
|
+
confirmation,
|
|
112
|
+
reviewed: false,
|
|
113
|
+
ts: input.ts,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** A confirmation or break-glass is signed by the clinician it names, over the action it names. */
|
|
117
|
+
export function verifyHealthSignature(payload, tag) {
|
|
118
|
+
const p = (payload ?? {});
|
|
119
|
+
const c = p.confirmation;
|
|
120
|
+
if (!c || typeof c !== "object")
|
|
121
|
+
return false;
|
|
122
|
+
if (c.owner_id !== p.clinician || !verifyConfirmation(c))
|
|
123
|
+
return false;
|
|
124
|
+
if (p.kind === "confirmation")
|
|
125
|
+
return c.action_hash === p.recommendation_hash;
|
|
126
|
+
if (p.kind === "break_glass")
|
|
127
|
+
return c.action_hash === breakGlassAction(tag, p.ts);
|
|
128
|
+
return false;
|
|
129
|
+
}
|
|
130
|
+
/** Zanii's protocol trail. No verdict on uniformity (individualised care is correct medicine). */
|
|
131
|
+
export function protocolTrail(payloads) {
|
|
132
|
+
const recs = payloads
|
|
133
|
+
.filter((p) => typeof p === "object" && p !== null)
|
|
134
|
+
.filter((p) => p._zr_kind === "health" && p.kind === "recommendation")
|
|
135
|
+
.sort((a, b) => (String(a.ts) < String(b.ts) ? -1 : String(a.ts) > String(b.ts) ? 1 : 0));
|
|
136
|
+
const windows = new Map();
|
|
137
|
+
let deviations = 0;
|
|
138
|
+
let unprotocolled = 0;
|
|
139
|
+
for (const p of recs) {
|
|
140
|
+
if (p.deviation)
|
|
141
|
+
deviations++;
|
|
142
|
+
const h = p.manifest_hash;
|
|
143
|
+
if (!h) {
|
|
144
|
+
unprotocolled++;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const w = windows.get(String(h));
|
|
148
|
+
if (w) {
|
|
149
|
+
w.count++;
|
|
150
|
+
w.last_ts = p.ts;
|
|
151
|
+
}
|
|
152
|
+
else
|
|
153
|
+
windows.set(String(h), { manifest_hash: h, count: 1, first_ts: p.ts, last_ts: p.ts });
|
|
154
|
+
}
|
|
155
|
+
const protocols = [...windows.values()].sort((a, b) => String(a.first_ts) < String(b.first_ts) ? -1 : String(a.first_ts) > String(b.first_ts) ? 1 : 0);
|
|
156
|
+
return { protocols, deviations, unprotocolled };
|
|
157
|
+
}
|
|
158
|
+
/** spec/health.md §4: the patient's episodes; `reviewed` holds "<session_id>:<seq>" of reviewed break-glass. */
|
|
159
|
+
export function episodesPack(items, o) {
|
|
160
|
+
const done = new Set(o.reviewed);
|
|
161
|
+
const episodes = o.tags
|
|
162
|
+
.map((tag) => ({
|
|
163
|
+
tag,
|
|
164
|
+
items: items.filter((i) => i.tag === tag).map(({ tag: _t, ...rest }) => rest),
|
|
165
|
+
}))
|
|
166
|
+
.filter((e) => e.items.length > 0);
|
|
167
|
+
const pending = items.filter((i) => i.payload.kind === "break_glass" && !done.has(`${i.session_id}:${i.seq}`));
|
|
168
|
+
return {
|
|
169
|
+
v: 1,
|
|
170
|
+
episodes,
|
|
171
|
+
total: items.length,
|
|
172
|
+
pending_break_glass: pending.length,
|
|
173
|
+
protocols: protocolTrail(items.map((i) => i.payload)),
|
|
174
|
+
generated_at: o.generated_at,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const decoder = new TextDecoder();
|
|
178
|
+
export function healthFindings(lines, bodies, closing = false) {
|
|
179
|
+
const out = [];
|
|
180
|
+
const recommendations = [];
|
|
181
|
+
const confirmed = new Set();
|
|
182
|
+
let closed = closing;
|
|
183
|
+
for (const l of lines) {
|
|
184
|
+
const e = JSON.parse(l);
|
|
185
|
+
if (e.kind === "session.close")
|
|
186
|
+
closed = true;
|
|
187
|
+
if (e.kind !== "sdk.event" || !String(e.meta.type).startsWith("health."))
|
|
188
|
+
continue;
|
|
189
|
+
const body = bodies(e.body_hash);
|
|
190
|
+
let data = {};
|
|
191
|
+
try {
|
|
192
|
+
data = body ? JSON.parse(decoder.decode(body)) : {};
|
|
193
|
+
}
|
|
194
|
+
catch { }
|
|
195
|
+
const payload = data?.payload;
|
|
196
|
+
if (!payload || typeof payload !== "object")
|
|
197
|
+
continue;
|
|
198
|
+
const kind = payload.kind;
|
|
199
|
+
if (kind === "recommendation")
|
|
200
|
+
recommendations.push([e.seq, recommendationHash(payload)]);
|
|
201
|
+
else if (kind === "confirmation" || kind === "break_glass") {
|
|
202
|
+
if (!verifyHealthSignature(payload, String(data.episode_tag)))
|
|
203
|
+
out.push({
|
|
204
|
+
code: "HEALTH_SIGNATURE_INVALID",
|
|
205
|
+
source: "detectors",
|
|
206
|
+
severity: "warning",
|
|
207
|
+
ref: { seq: e.seq, kind },
|
|
208
|
+
detail: "A clinician signature that isn't theirs, or signs another action.",
|
|
209
|
+
});
|
|
210
|
+
else if (kind === "confirmation")
|
|
211
|
+
confirmed.add(String(payload.recommendation_hash));
|
|
212
|
+
else
|
|
213
|
+
out.push({
|
|
214
|
+
code: "BREAK_GLASS",
|
|
215
|
+
source: "detectors",
|
|
216
|
+
severity: "warning",
|
|
217
|
+
ref: { seq: e.seq, clinician: String(payload.clinician) },
|
|
218
|
+
detail: "Emergency access to a record, signed by the clinician.",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (closed)
|
|
223
|
+
for (const [seq, h] of recommendations)
|
|
224
|
+
if (!confirmed.has(h))
|
|
225
|
+
out.push({
|
|
226
|
+
code: "UNCONFIRMED_RECOMMENDATION",
|
|
227
|
+
source: "detectors",
|
|
228
|
+
severity: "advisory",
|
|
229
|
+
ref: { seq, recommendation_hash: h },
|
|
230
|
+
detail: "A model's recommendation no clinician confirmed.",
|
|
231
|
+
});
|
|
232
|
+
return out;
|
|
233
|
+
}
|