@zanii/blackbox 0.3.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.
Files changed (82) hide show
  1. package/README.md +26 -1
  2. package/dist/a2a/index.d.ts +27 -0
  3. package/dist/a2a/index.js +104 -1
  4. package/dist/agents/index.d.ts +8 -0
  5. package/dist/analysis/accuracy.d.ts +24 -0
  6. package/dist/analysis/accuracy.js +45 -0
  7. package/dist/analysis/credential.d.ts +101 -0
  8. package/dist/analysis/credential.js +142 -0
  9. package/dist/analysis/faults.js +115 -0
  10. package/dist/analysis/grounding.d.ts +122 -0
  11. package/dist/analysis/grounding.js +445 -0
  12. package/dist/analysis/hallucination.d.ts +32 -0
  13. package/dist/analysis/hallucination.js +357 -0
  14. package/dist/analysis/index.d.ts +23 -0
  15. package/dist/analysis/index.js +93 -0
  16. package/dist/analysis/memory.d.ts +8 -0
  17. package/dist/analysis/memory.js +35 -8
  18. package/dist/analysis/reference.d.ts +49 -0
  19. package/dist/analysis/reference.js +164 -0
  20. package/dist/analysis/taxonomy.js +1 -0
  21. package/dist/approvals/index.d.ts +23 -0
  22. package/dist/approvals/index.js +48 -0
  23. package/dist/archive/parquet.d.ts +2 -0
  24. package/dist/archive/parquet.js +185 -0
  25. package/dist/badge/index.d.ts +16 -0
  26. package/dist/badge/index.js +48 -0
  27. package/dist/bom/index.js +20 -0
  28. package/dist/cli.js +114 -10
  29. package/dist/compliance/art12.js +36 -9
  30. package/dist/compliance/index.d.ts +36 -2
  31. package/dist/compliance/index.js +78 -11
  32. package/dist/compliance/zanii.d.ts +29 -0
  33. package/dist/compliance/zanii.js +84 -0
  34. package/dist/constitution/index.d.ts +57 -0
  35. package/dist/constitution/index.js +131 -0
  36. package/dist/cv/index.d.ts +39 -0
  37. package/dist/cv/index.js +108 -0
  38. package/dist/disclosure/index.d.ts +31 -0
  39. package/dist/disclosure/index.js +113 -0
  40. package/dist/encryption/index.d.ts +9 -0
  41. package/dist/encryption/index.js +31 -0
  42. package/dist/evidence/index.d.ts +60 -0
  43. package/dist/evidence/index.js +151 -0
  44. package/dist/federation/index.d.ts +35 -0
  45. package/dist/federation/index.js +102 -0
  46. package/dist/finance/index.d.ts +126 -0
  47. package/dist/finance/index.js +320 -0
  48. package/dist/fleet/index.js +9 -0
  49. package/dist/gov/index.d.ts +108 -0
  50. package/dist/gov/index.js +225 -0
  51. package/dist/health/index.d.ts +120 -0
  52. package/dist/health/index.js +233 -0
  53. package/dist/index.d.ts +28 -6
  54. package/dist/index.js +28 -6
  55. package/dist/memory/index.d.ts +36 -0
  56. package/dist/memory/index.js +85 -0
  57. package/dist/occurrence/index.d.ts +11 -0
  58. package/dist/occurrence/index.js +18 -0
  59. package/dist/otlp/index.js +28 -1
  60. package/dist/packs/index.js +44 -4
  61. package/dist/policy/delta.js +7 -1
  62. package/dist/policy/index.d.ts +23 -6
  63. package/dist/policy/index.js +151 -8
  64. package/dist/policy/zanii.d.ts +31 -0
  65. package/dist/policy/zanii.js +87 -0
  66. package/dist/pq/index.d.ts +23 -0
  67. package/dist/pq/index.js +104 -0
  68. package/dist/search/index.d.ts +23 -0
  69. package/dist/search/index.js +69 -0
  70. package/dist/session/index.d.ts +89 -1
  71. package/dist/session/index.js +143 -11
  72. package/dist/sla/index.d.ts +61 -0
  73. package/dist/sla/index.js +197 -0
  74. package/dist/succession/index.d.ts +50 -0
  75. package/dist/succession/index.js +123 -0
  76. package/dist/tokens/index.d.ts +6 -0
  77. package/dist/tokens/index.js +46 -0
  78. package/dist/version.d.ts +1 -1
  79. package/dist/version.js +1 -1
  80. package/dist/walls/index.d.ts +31 -0
  81. package/dist/walls/index.js +119 -0
  82. package/package.json +1 -1
@@ -0,0 +1,320 @@
1
+ // Finance (spec/finance.md): exact money and payments (@zanii/payments), counterparty screening
2
+ // (@zanii/kya), tax filings prepared, never filed (@zanii/fta), the owner behind a counterparty
3
+ // (@zanii/kyb), and the findings over them. Pure; mirrors sdks/python/src/zanii_blackbox/finance.py;
4
+ // pinned by spec/vectors/finance.json.
5
+ import { canonicalBytes, publicKeyFromDid } from "@zanii/core";
6
+ import { ed25519Sign, ed25519Verify } from "../transparency/index.js";
7
+ /** Minor-unit precision: Zanii's table, plus the Gulf's currencies. */
8
+ export const CURRENCIES = {
9
+ USD: 2,
10
+ EUR: 2,
11
+ GBP: 2,
12
+ CHF: 2,
13
+ CAD: 2,
14
+ AUD: 2,
15
+ CNY: 2,
16
+ INR: 2,
17
+ JPY: 0,
18
+ KRW: 0,
19
+ USDC: 6,
20
+ USDT: 6,
21
+ BTC: 8,
22
+ ZAN: 9,
23
+ ETH: 18,
24
+ AED: 2,
25
+ SAR: 2,
26
+ QAR: 2,
27
+ KWD: 3,
28
+ BHD: 3,
29
+ OMR: 3,
30
+ };
31
+ function decimalsOf(currency) {
32
+ const d = CURRENCIES[currency.toUpperCase()];
33
+ if (d === undefined)
34
+ throw new Error(`unknown currency '${currency}'`);
35
+ return d;
36
+ }
37
+ function formatMinorUnits(minor, decimals) {
38
+ const neg = minor < 0n;
39
+ const m = neg ? -minor : minor;
40
+ const base = 10n ** BigInt(decimals);
41
+ const whole = m / base;
42
+ if (decimals === 0)
43
+ return `${neg ? "-" : ""}${whole}`;
44
+ return `${neg ? "-" : ""}${whole}.${(m % base).toString().padStart(decimals, "0")}`;
45
+ }
46
+ /** Zanii's exact parse: a decimal string to minor units (a string), never a float. */
47
+ export function parseMoney(amount, currency) {
48
+ const decimals = decimalsOf(currency);
49
+ const s = String(amount).trim();
50
+ if (!/^\d+(\.\d+)?$/.test(s))
51
+ throw new Error(`invalid amount '${s}' (expected a non-negative decimal)`);
52
+ const [whole = "0", frac = ""] = s.split(".");
53
+ if (frac.length > decimals)
54
+ throw new Error(`amount '${s}' has more precision than ${currency} allows (${decimals} dp)`);
55
+ const minor = BigInt(whole) * 10n ** BigInt(decimals) + BigInt(frac.padEnd(decimals, "0") || "0");
56
+ if (minor <= 0n)
57
+ throw new Error("amount must be > 0");
58
+ const cur = currency.toUpperCase();
59
+ return {
60
+ currency: cur,
61
+ minor: minor.toString(),
62
+ display: `${formatMinorUnits(minor, decimals)} ${cur}`,
63
+ };
64
+ }
65
+ export const formatMoney = (minor, currency) => formatMinorUnits(BigInt(minor), decimalsOf(currency));
66
+ /** Zanii's payment receipt payload. No settlement reference, no payment. */
67
+ export function buildPayment(input) {
68
+ if (!input.rail)
69
+ throw new Error("rail is required");
70
+ if (!input.reference)
71
+ throw new Error("reference is required (a payment with no settlement reference is unprovable)");
72
+ return {
73
+ target: `pay.${input.rail.toLowerCase()}`,
74
+ rail: input.rail,
75
+ reference: input.reference,
76
+ money: parseMoney(input.amount, input.currency),
77
+ from: input.from ?? null,
78
+ to: input.to ?? null,
79
+ memo: input.memo ?? null,
80
+ extra: input.extra ?? null,
81
+ };
82
+ }
83
+ /** Zanii's KYA screen, offline: the DID or its owner on a deny list is a hit. */
84
+ export function screenCounterparty(did, opts = {}) {
85
+ if (!did)
86
+ throw new Error("did is required");
87
+ const deny = new Set(opts.denyList ?? []);
88
+ const hits = [];
89
+ if (deny.has(did))
90
+ hits.push({ source: "deny-list", detail: did });
91
+ if (opts.owner && deny.has(opts.owner))
92
+ hits.push({ source: "deny-list", detail: opts.owner });
93
+ return { did, owner: opts.owner ?? null, ok: hits.length === 0, hits, history: null };
94
+ }
95
+ /** Zanii's kya.screening payload for a screening result. */
96
+ export function screeningPayload(result, ts, screenedBy = null) {
97
+ const sources = [...new Set(result.hits.map((h) => h.source))].sort();
98
+ return {
99
+ _zr_kind: "kya",
100
+ v: 1,
101
+ did: result.did,
102
+ owner: result.owner,
103
+ ok: result.ok,
104
+ hits: result.hits,
105
+ sources,
106
+ screened_by: screenedBy,
107
+ ts,
108
+ };
109
+ }
110
+ export const FTA_WALL_ID = "fta-prepares-never-files";
111
+ /** Zanii's FTA payloads: a filing prepared (never filed), and its hand-off to a Tax Agent. */
112
+ export function ftaPayload(input) {
113
+ if (!input.filingType)
114
+ throw new Error("filing_type is required");
115
+ if (!input.period)
116
+ throw new Error("period is required");
117
+ if (input.action === "prepared")
118
+ return {
119
+ _zr_kind: "fta",
120
+ v: 1,
121
+ action: "prepared",
122
+ filing_type: input.filingType,
123
+ period: input.period,
124
+ prepared_by: input.by,
125
+ filing_ref: input.filingRef ?? null,
126
+ manifest_hash: input.manifestHash ?? null,
127
+ wall: FTA_WALL_ID,
128
+ };
129
+ if (!input.toTaxAgent)
130
+ throw new Error("to_tax_agent (the licensed Tax Agent) is required");
131
+ return {
132
+ _zr_kind: "fta",
133
+ v: 1,
134
+ action: "handoff",
135
+ filing_type: input.filingType,
136
+ period: input.period,
137
+ from_agent: input.by,
138
+ to_tax_agent: input.toTaxAgent,
139
+ filing_ref: input.filingRef ?? null,
140
+ wall: FTA_WALL_ID,
141
+ };
142
+ }
143
+ // ---------------------------------------------------------------- KYB: the owner behind a party
144
+ /** Zanii's kyb.attestation: an owner DID signs which domain and legal entity it is. */
145
+ export function buildOwnerAttestation(input) {
146
+ if (!input.did)
147
+ throw new Error("did is required");
148
+ if (!input.domain)
149
+ throw new Error("domain is required");
150
+ if (!input.entity?.name)
151
+ throw new Error("entity.name is required");
152
+ if (Number.isNaN(Date.parse(input.ts)))
153
+ throw new Error("ts must be an ISO timestamp");
154
+ const unsigned = {
155
+ v: 1,
156
+ type: "kyb.attestation",
157
+ did: input.did,
158
+ domain: input.domain.toLowerCase(),
159
+ entity: input.entity,
160
+ ts: input.ts,
161
+ };
162
+ const sig = Buffer.from(ed25519Sign(input.ownerPrivateKey, canonicalBytes(unsigned))).toString("hex");
163
+ return { ...unsigned, sig: `ed25519:${sig}` };
164
+ }
165
+ export function verifyOwnerAttestation(a) {
166
+ const p = (a ?? {});
167
+ if (p.v !== 1 || p.type !== "kyb.attestation" || !p.did || !p.domain)
168
+ return false;
169
+ const entity = p.entity;
170
+ if (!entity || typeof entity !== "object" || !entity.name)
171
+ return false;
172
+ if (typeof p.ts !== "string" || Number.isNaN(Date.parse(p.ts)))
173
+ return false;
174
+ const pub = publicKeyFromDid(String(p.did));
175
+ const m = typeof p.sig === "string" ? /^ed25519:([0-9a-f]{128})$/.exec(p.sig) : null;
176
+ if (!pub || !m)
177
+ return false;
178
+ const { sig: _sig, ...unsigned } = p;
179
+ return ed25519Verify(pub, canonicalBytes(unsigned), Buffer.from(m[1], "hex"));
180
+ }
181
+ /** Zanii's two-way check: the owner signed it, and the domain's own doc lists that owner. */
182
+ export function verifyOwnerBinding(attestation, doc) {
183
+ const a = (attestation ?? {});
184
+ if (!verifyOwnerAttestation(attestation))
185
+ return { ok: false, reasons: ["attestation is not validly signed by its DID"], entity: null };
186
+ const d = (doc ?? {});
187
+ if (d.v !== 1 ||
188
+ typeof d.domain !== "string" ||
189
+ typeof d.entity !== "object" ||
190
+ d.entity === null ||
191
+ !d.entity.name ||
192
+ !Array.isArray(d.owner_dids))
193
+ return {
194
+ ok: false,
195
+ reasons: [
196
+ `${a.domain} publishes no valid owner doc — the domain side of the binding is missing`,
197
+ ],
198
+ entity: null,
199
+ };
200
+ const reasons = [];
201
+ if (d.domain.toLowerCase() !== a.domain.toLowerCase())
202
+ reasons.push(`attestation names ${a.domain} but the doc is for ${d.domain}`);
203
+ if (!d.owner_dids.includes(a.did))
204
+ reasons.push(`the domain does not list ${a.did} — anyone can sign a claim; only the domain's list makes it mutual`);
205
+ if (d.entity.name !== a.entity.name)
206
+ reasons.push(`entity name mismatch: domain says '${String(d.entity.name)}', owner claims '${String(a.entity.name)}'`);
207
+ for (const field of ["jurisdiction", "registration"]) {
208
+ const x = a.entity[field];
209
+ const y = d.entity[field];
210
+ if (x !== undefined && y !== undefined && x !== y)
211
+ reasons.push(`entity ${field} mismatch: domain says '${String(y)}', owner claims '${String(x)}'`);
212
+ }
213
+ return { ok: reasons.length === 0, reasons, entity: reasons.length === 0 ? d.entity : null };
214
+ }
215
+ const decoder = new TextDecoder();
216
+ export function financeFindings(lines, bodies, closing = false) {
217
+ let closed = closing;
218
+ const out = [];
219
+ const screened = new Map();
220
+ const prepared = [];
221
+ const handedOff = new Set();
222
+ for (const l of lines) {
223
+ const e = JSON.parse(l);
224
+ if (e.kind === "session.close")
225
+ closed = true;
226
+ if (e.kind !== "sdk.event")
227
+ continue;
228
+ const type = e.meta.type;
229
+ if (type !== "payment" &&
230
+ type !== "kya.screening" &&
231
+ type !== "fta.prepared" &&
232
+ type !== "fta.handoff")
233
+ continue;
234
+ const body = bodies(e.body_hash);
235
+ let data = {};
236
+ try {
237
+ data = body ? JSON.parse(decoder.decode(body)) : {};
238
+ }
239
+ catch { }
240
+ if (type === "kya.screening" && typeof data.did === "string")
241
+ screened.set(data.did, { ok: data.ok === true, seq: e.seq });
242
+ else if (type === "payment" && typeof data.to === "string" && data.to) {
243
+ const s = screened.get(data.to);
244
+ if (!s)
245
+ out.push({
246
+ code: "UNSCREENED_COUNTERPARTY",
247
+ source: "detectors",
248
+ severity: "warning",
249
+ ref: { seq: e.seq, to: data.to },
250
+ detail: "A payment to a counterparty this session never screened first.",
251
+ });
252
+ else if (!s.ok)
253
+ out.push({
254
+ code: "SCREENING_HIT_PAID",
255
+ source: "detectors",
256
+ severity: "warning",
257
+ ref: { seq: e.seq, to: data.to, screening_seq: s.seq },
258
+ detail: "A payment to a counterparty whose screening had hits.",
259
+ });
260
+ }
261
+ else if (type === "fta.prepared" || type === "fta.handoff") {
262
+ const key = `${String(data.filing_type)}\u0000${String(data.period)}`;
263
+ if (type === "fta.handoff")
264
+ handedOff.add(key);
265
+ else
266
+ prepared.push({
267
+ seq: e.seq,
268
+ key,
269
+ filing_type: String(data.filing_type),
270
+ period: String(data.period),
271
+ });
272
+ }
273
+ }
274
+ if (closed)
275
+ for (const p of prepared)
276
+ if (!handedOff.has(p.key))
277
+ out.push({
278
+ code: "FTA_NO_HANDOFF",
279
+ source: "detectors",
280
+ severity: "advisory",
281
+ ref: { seq: p.seq, filing_type: p.filing_type, period: p.period },
282
+ detail: "A tax filing was prepared and never handed to a licensed Tax Agent.",
283
+ });
284
+ return out;
285
+ }
286
+ /** spec/finance.md §2: the session's payments, and exact totals per currency (minor units). */
287
+ export function paymentsOf(lines, bodies) {
288
+ const payments = [];
289
+ const totals = new Map();
290
+ for (const l of lines) {
291
+ const e = JSON.parse(l);
292
+ if (e.kind !== "sdk.event" || e.meta.type !== "payment")
293
+ continue;
294
+ const body = bodies(e.body_hash);
295
+ let p;
296
+ try {
297
+ p = body ? JSON.parse(decoder.decode(body)) : {};
298
+ }
299
+ catch {
300
+ continue;
301
+ }
302
+ const m = p.money;
303
+ if (!m || typeof m !== "object")
304
+ continue;
305
+ payments.push({
306
+ seq: e.seq,
307
+ rail: p.rail ?? null,
308
+ reference: p.reference ?? null,
309
+ money: m,
310
+ to: p.to ?? null,
311
+ });
312
+ const cur = String(m.currency);
313
+ totals.set(cur, (totals.get(cur) ?? 0n) + BigInt(String(m.minor)));
314
+ }
315
+ const sorted = [...totals.keys()].sort();
316
+ return {
317
+ payments,
318
+ totals: Object.fromEntries(sorted.map((k) => [k, String(totals.get(k))])),
319
+ };
320
+ }
@@ -6,6 +6,7 @@ import { toolCallsOf } from "../analysis/detectors.js";
6
6
  import { FAULTS } from "../analysis/faults.js";
7
7
  import { eventsOf } from "../analysis/index.js";
8
8
  import { landing } from "../analysis/landing.js";
9
+ import { authorityTimeline } from "../authority/index.js";
9
10
  import { formatAed, sessionCost, toAedFils } from "../cost/index.js";
10
11
  import { callsOf } from "../reconcile/record.js";
11
12
  import { canonical } from "../reconcile/shared.js";
@@ -60,10 +61,18 @@ export function sessionSummary(lines, bodies, prices) {
60
61
  const claims = { satisfied: 0, failed: 0, unverified: 0 };
61
62
  for (const c of landing(eventsOf(lines, bodies), lines, bodies).claims)
62
63
  claims[c.verdict]++;
64
+ const inControl = authorityTimeline(lines).at(-1)?.mode ?? "agent";
63
65
  return {
64
66
  session_id: first.session_id,
65
67
  label: typeof first.meta.label === "string" ? first.meta.label : null,
66
68
  tenant: typeof first.meta.tenant === "string" ? first.meta.tenant : null,
69
+ // spec/api.md: only when the session has one, so older summaries read the same
70
+ ...(typeof first.meta.environment === "string" ? { environment: first.meta.environment } : {}),
71
+ // spec/fleet.md §2: the budget, when there is one; who flies, when it isn't the agent
72
+ ...(typeof first.meta.budget_micro_usd === "number"
73
+ ? { budget_micro_usd: first.meta.budget_micro_usd }
74
+ : {}),
75
+ ...(inControl !== "agent" ? { in_control: inControl } : {}),
67
76
  objective: typeof plan?.objective === "string" ? plan.objective : null,
68
77
  started_at: first.ts,
69
78
  last_event_at: last.ts,
@@ -0,0 +1,108 @@
1
+ type Text = {
2
+ en: string;
3
+ ar: string;
4
+ };
5
+ type Obj = Record<string, unknown>;
6
+ export declare const GOV_DECISION_KINDS: readonly ["benefit.grant", "benefit.deny", "visa.decide", "fine.issue", "licence.decide"];
7
+ /** Zanii's pseudonymous, authority-scoped tag for a person's did:key. */
8
+ export declare function subjectTag(subjectDid: string, authority: string): string;
9
+ /** Zanii's subject claim: "this is my slice", signed with the person's own key. */
10
+ export declare function signSubjectClaim(input: {
11
+ platform: string;
12
+ subjectDid: string;
13
+ ts: string;
14
+ subjectPrivateKey: Uint8Array;
15
+ }): {
16
+ v: number;
17
+ type: string;
18
+ platform: string;
19
+ subject: string;
20
+ tag: string;
21
+ ts: string;
22
+ sig: string;
23
+ };
24
+ /** The subject signed it, and the tag derives from that subject at that platform. */
25
+ export declare function verifySubjectClaim(claim: unknown): boolean;
26
+ /** Zanii's decision payload, v1, for a government decision. No rulebook, no decision. */
27
+ export declare function decisionPayload(input: {
28
+ kind: string;
29
+ manifestHash: string;
30
+ outcome: Obj;
31
+ ts: string;
32
+ factors?: Obj;
33
+ appealBy?: string;
34
+ nonce?: string;
35
+ }): {
36
+ payload: Obj;
37
+ nonce: string;
38
+ };
39
+ /** The disclosed factors and nonce match the commitment made at decision time. */
40
+ export declare const verifyFactors: (factors: unknown, nonce: string, commitment: string) => boolean;
41
+ /** Zanii's structural check of a decision payload. */
42
+ export declare function verifyDecision(payload: unknown): {
43
+ ok: boolean;
44
+ reasons: string[];
45
+ };
46
+ /** Zanii's rulebook windows over time; interleaving is the red flag. */
47
+ export declare function ruleConsistency(payloads: readonly unknown[]): {
48
+ rulebooks: {
49
+ manifest_hash: string;
50
+ count: number;
51
+ first_ts: string;
52
+ last_ts: string;
53
+ }[];
54
+ consistent: boolean;
55
+ reasons: string[];
56
+ };
57
+ export interface AppealItem {
58
+ session_id: string;
59
+ seq: number;
60
+ line: string;
61
+ event_hash: string;
62
+ anchored: boolean;
63
+ decision: unknown;
64
+ }
65
+ /** spec/gov.md §3: the citizen's appeal pack over the decisions recorded about their tag. */
66
+ export declare function appealPack(items: readonly AppealItem[], opts: {
67
+ tag: string;
68
+ authority: string;
69
+ generated_at: string;
70
+ }): {
71
+ v: 1;
72
+ legal_basis: Text;
73
+ subject_tag: string;
74
+ authority: string;
75
+ decisions: number;
76
+ rules: {
77
+ rulebooks: {
78
+ manifest_hash: string;
79
+ count: number;
80
+ first_ts: string;
81
+ last_ts: string;
82
+ }[];
83
+ consistent: boolean;
84
+ reasons: string[];
85
+ };
86
+ unruled: number;
87
+ period: {
88
+ first: string | null;
89
+ last: string | null;
90
+ };
91
+ items: {
92
+ session_id: string;
93
+ seq: number;
94
+ line: string;
95
+ event_hash: string;
96
+ anchored: boolean;
97
+ }[];
98
+ steps: {
99
+ en: string;
100
+ ar: string;
101
+ n: number;
102
+ }[];
103
+ generated_at: string;
104
+ };
105
+ export type AppealPack = ReturnType<typeof appealPack>;
106
+ /** The reading copy for an administrative judge: `en`, `ar` or `both` (headers always bilingual). */
107
+ export declare function renderAppealPack(pack: AppealPack, lang?: "en" | "ar" | "both"): string;
108
+ export {};
@@ -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
+ }