@zanii/ai-register 0.0.0-stage → 0.2.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/LICENSE +159 -0
- package/README.md +72 -2
- package/dist/index.d.ts +223 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +438 -0
- package/dist/index.js.map +1 -0
- package/package.json +40 -3
- package/src/index.ts +501 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zanii/ai-register: a regulator's register of AI systems, built from Ledger and Certify
|
|
3
|
+
* records, exported in the shapes public registers already use.
|
|
4
|
+
*
|
|
5
|
+
* One entry per AI system: its agent DIDs, the owner DID, a `zanii.jurisdiction` binding,
|
|
6
|
+
* an optional Zanii Certify certificate (the public `CertificateView` or the credential),
|
|
7
|
+
* an optional `@zanii/kyb` owner attestation and caller-supplied descriptive fields.
|
|
8
|
+
* Every export is a versioned field map in which each field either carries a value and its
|
|
9
|
+
* source or is null with the reason the inputs do not support it. Nothing is guessed.
|
|
10
|
+
*
|
|
11
|
+
* Exports:
|
|
12
|
+
* - EU AI Act Art. 49 / Annex VIII sections A, B and C (guidance: the EU database format
|
|
13
|
+
* is still subject to Commission implementing acts and to the Digital Omnibus changes),
|
|
14
|
+
* - UK Algorithmic Transparency Recording Standard (ATRS) template 4.0,
|
|
15
|
+
* - Algorithmic Transparency Standard 0.3.1 (Eurocities; Amsterdam, Helsinki registers).
|
|
16
|
+
*
|
|
17
|
+
* The native `zanii.ai_register` snapshot is signed by the regulator or the operator.
|
|
18
|
+
*/
|
|
19
|
+
import { canonicalBytes, jcsHash, signObject, signObjectAsync, verifySignedObject, type RemoteSigner, type SignedObject } from '@zanii/core';
|
|
20
|
+
import { verifyOwnerAttestation, type OwnerAttestation } from '@zanii/kyb';
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------------- //
|
|
23
|
+
// Inputs
|
|
24
|
+
// ---------------------------------------------------------------------------------- //
|
|
25
|
+
|
|
26
|
+
/** The shared `zanii.jurisdiction` binding format (`@zanii/jurisdiction`); accepted as a plain object. */
|
|
27
|
+
export interface JurisdictionBinding {
|
|
28
|
+
v: 1; type: 'zanii.jurisdiction'; subject: string; regulator: string; jurisdiction: string; sector: string;
|
|
29
|
+
licence: string; issued_at: string; expires_at: string; basis: 'self_declared' | 'regulator_attested'; signer: string; sig: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Certify `GET /v1/certificates/{hash}` (`CertificateView`), or any subset with `hash`. */
|
|
33
|
+
export interface CertificateInput {
|
|
34
|
+
hash: string;
|
|
35
|
+
certificate?: { holder?: string; issuer?: string; issuer_domain?: string; claim?: { scheme?: string; profile?: string; level?: number }; issued_at?: string; exp?: string; [k: string]: unknown };
|
|
36
|
+
status?: { status?: string; reason?: string | null; since?: string };
|
|
37
|
+
agent?: { did?: string };
|
|
38
|
+
profile?: string; level?: number; issued_at?: string; exp?: string;
|
|
39
|
+
evidence?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const DESCRIBE_KEYS = [
|
|
43
|
+
'name', 'summary', 'description', 'intended_purpose', 'status', 'data_and_logic',
|
|
44
|
+
'provider_name', 'provider_address', 'provider_contact', 'submitter', 'authorised_representative',
|
|
45
|
+
'deployer_name', 'deployer_address', 'deployer_contact', 'contact_email', 'team', 'senior_responsible_owner',
|
|
46
|
+
'region', 'member_states', 'eu_database_url', 'declaration_of_conformity_url', 'instructions_url', 'more_info_url',
|
|
47
|
+
'notified_body_certificate', 'not_high_risk_condition', 'not_high_risk_grounds',
|
|
48
|
+
'fria_summary', 'fria_url', 'dpia_summary', 'dpia_url', 'human_review', 'appeals', 'risks', 'model', 'lang',
|
|
49
|
+
] as const;
|
|
50
|
+
export type DescribeKey = (typeof DESCRIBE_KEYS)[number];
|
|
51
|
+
export type FieldValue = string | string[];
|
|
52
|
+
export type Describe = Partial<Record<DescribeKey, FieldValue>>;
|
|
53
|
+
|
|
54
|
+
export const FORMATS = ['eu-ai-act-annex-viii-a', 'eu-ai-act-annex-viii-b', 'eu-ai-act-annex-viii-c', 'uk-atrs', 'eurocities-ats'] as const;
|
|
55
|
+
export type Format = (typeof FORMATS)[number];
|
|
56
|
+
|
|
57
|
+
export interface SystemInput {
|
|
58
|
+
/** Register id of the system; defaults to the first agent DID (sorted). */
|
|
59
|
+
id?: string;
|
|
60
|
+
agents: string[];
|
|
61
|
+
owner: string;
|
|
62
|
+
jurisdiction?: JurisdictionBinding;
|
|
63
|
+
certificate?: CertificateInput;
|
|
64
|
+
/** A `@zanii/kyb` owner attestation by `owner`. */
|
|
65
|
+
kyb?: OwnerAttestation;
|
|
66
|
+
/** Which party the KYB entity is in the export: without it the KYB name fills no field. */
|
|
67
|
+
ownerRole?: 'provider' | 'deployer' | 'both';
|
|
68
|
+
describe?: Describe;
|
|
69
|
+
/** Per-format values by field id, for fields with no Zanii source: `{ 'uk-atrs': { '2.2.2': '...' } }`. */
|
|
70
|
+
overrides?: Partial<Record<Format, Record<string, FieldValue>>>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface RegisterEntry {
|
|
74
|
+
system: string;
|
|
75
|
+
agents: string[];
|
|
76
|
+
owner: string;
|
|
77
|
+
jurisdiction: null | { hash: string; subject: string; regulator: string; jurisdiction: string; sector: string; licence: string; basis: string; issued_at: string; expires_at: string; current: boolean };
|
|
78
|
+
certificate: null | { hash: string; holder: string | null; issuer: string | null; issuer_domain: string | null; scheme: string | null; profile: string | null; level: number | null; status: string | null; reason: string | null; since: string | null; evidence: string | null; exp: string | null };
|
|
79
|
+
kyb: null | { hash: string; did: string; domain: string; entity: { name: string; jurisdiction: string | null; registration: string | null }; role: string | null };
|
|
80
|
+
describe: Describe;
|
|
81
|
+
overrides: Partial<Record<Format, Record<string, FieldValue>>>;
|
|
82
|
+
evidence: string[];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const HASH = /^sha256:[0-9a-f]{64}$/;
|
|
86
|
+
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
87
|
+
const ms = (t: unknown) => (typeof t === 'string' && ISO.test(t) ? Date.parse(t) : NaN);
|
|
88
|
+
const isDid = (x: unknown): x is string => typeof x === 'string' && x.startsWith('did:');
|
|
89
|
+
const fail = (m: string): never => { throw new Error(`ai-register: ${m}`); };
|
|
90
|
+
const isValue = (v: unknown) => typeof v === 'string' || (Array.isArray(v) && v.every((s) => typeof s === 'string'));
|
|
91
|
+
|
|
92
|
+
function evidenceOf(e: Pick<RegisterEntry, 'jurisdiction' | 'certificate' | 'kyb'>): string[] {
|
|
93
|
+
return [e.jurisdiction?.hash, e.certificate?.hash, e.kyb?.hash].filter((h): h is string => !!h).sort();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* One register entry. Refuses (throws) a binding that does not verify or is about another
|
|
98
|
+
* subject, a certificate for another agent or whose credential does not match its hash, and a
|
|
99
|
+
* KYB attestation that does not verify or is not by the owner. `verifyBinding` replaces the
|
|
100
|
+
* default check (the binding's own `signer` signature) when the caller trusts specific regulators.
|
|
101
|
+
*/
|
|
102
|
+
export function buildEntry(input: SystemInput, opts: { asOf: string; verifyBinding?: (b: JurisdictionBinding) => boolean }): RegisterEntry {
|
|
103
|
+
if (Number.isNaN(ms(opts?.asOf))) fail('asOf must be an ISO timestamp');
|
|
104
|
+
if (!Array.isArray(input?.agents) || !input.agents.length || !input.agents.every(isDid)) fail('agents must be a non-empty list of DIDs');
|
|
105
|
+
if (!isDid(input.owner)) fail('owner must be a DID');
|
|
106
|
+
const agents = [...new Set(input.agents)].sort();
|
|
107
|
+
|
|
108
|
+
let jurisdiction: RegisterEntry['jurisdiction'] = null;
|
|
109
|
+
const b = input.jurisdiction;
|
|
110
|
+
if (b) {
|
|
111
|
+
if (b.v !== 1 || b.type !== 'zanii.jurisdiction') fail('jurisdiction is not a zanii.jurisdiction binding (v1)');
|
|
112
|
+
if (b.basis !== 'self_declared' && b.basis !== 'regulator_attested') fail('jurisdiction basis must be self_declared or regulator_attested');
|
|
113
|
+
if (!agents.includes(b.subject) && b.subject !== input.owner) fail('jurisdiction binding is about another subject');
|
|
114
|
+
if (Number.isNaN(ms(b.issued_at)) || Number.isNaN(ms(b.expires_at))) fail('jurisdiction binding times must be ISO timestamps');
|
|
115
|
+
if (!(opts.verifyBinding ? opts.verifyBinding(b) : verifySignedObject(b))) fail('jurisdiction binding does not verify');
|
|
116
|
+
const t = ms(opts.asOf);
|
|
117
|
+
jurisdiction = {
|
|
118
|
+
hash: jcsHash(b), subject: b.subject, regulator: b.regulator, jurisdiction: b.jurisdiction, sector: b.sector, licence: b.licence,
|
|
119
|
+
basis: b.basis, issued_at: b.issued_at, expires_at: b.expires_at, current: ms(b.issued_at) <= t && t < ms(b.expires_at),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let certificate: RegisterEntry['certificate'] = null;
|
|
124
|
+
const c = input.certificate;
|
|
125
|
+
if (c) {
|
|
126
|
+
if (typeof c.hash !== 'string' || !HASH.test(c.hash)) fail('certificate hash must be a sha256 hash');
|
|
127
|
+
if (c.certificate && jcsHash(c.certificate) !== c.hash) fail('certificate hash does not match the credential');
|
|
128
|
+
const cred = c.certificate ?? {};
|
|
129
|
+
const holder = cred.holder ?? c.agent?.did ?? null;
|
|
130
|
+
if (holder !== null && !agents.includes(holder)) fail('certificate is held by another agent');
|
|
131
|
+
certificate = {
|
|
132
|
+
hash: c.hash, holder, issuer: cred.issuer ?? null, issuer_domain: cred.issuer_domain ?? null,
|
|
133
|
+
scheme: cred.claim?.scheme ?? null, profile: c.profile ?? cred.claim?.profile ?? null, level: c.level ?? cred.claim?.level ?? null,
|
|
134
|
+
status: c.status?.status ?? null, reason: c.status?.reason ?? null, since: c.status?.since ?? null,
|
|
135
|
+
evidence: c.evidence ?? null, exp: c.exp ?? cred.exp ?? null,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
let kyb: RegisterEntry['kyb'] = null;
|
|
140
|
+
if (input.kyb) {
|
|
141
|
+
if (!verifyOwnerAttestation(input.kyb)) fail('kyb attestation does not verify');
|
|
142
|
+
if (input.kyb.did !== input.owner) fail('kyb attestation is not by the owner');
|
|
143
|
+
if (input.ownerRole !== undefined && !['provider', 'deployer', 'both'].includes(input.ownerRole)) fail('ownerRole must be provider, deployer or both');
|
|
144
|
+
const en = input.kyb.entity;
|
|
145
|
+
kyb = { hash: jcsHash(input.kyb), did: input.kyb.did, domain: input.kyb.domain, entity: { name: en.name, jurisdiction: en.jurisdiction ?? null, registration: en.registration ?? null }, role: input.ownerRole ?? null };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const describe: Describe = {};
|
|
149
|
+
for (const [k, v] of Object.entries(input.describe ?? {})) {
|
|
150
|
+
if (!(DESCRIBE_KEYS as readonly string[]).includes(k)) fail(`unknown describe field ${k}`);
|
|
151
|
+
if (!isValue(v)) fail(`describe.${k} must be a string or a list of strings`);
|
|
152
|
+
describe[k as DescribeKey] = v;
|
|
153
|
+
}
|
|
154
|
+
const overrides: RegisterEntry['overrides'] = {};
|
|
155
|
+
for (const [f, fields] of Object.entries(input.overrides ?? {})) {
|
|
156
|
+
if (!(FORMATS as readonly string[]).includes(f)) fail(`unknown override format ${f}`);
|
|
157
|
+
const ids = new Set(FIELD_MAPS[f as Format].fields.map((x) => x[0]));
|
|
158
|
+
for (const [id, v] of Object.entries(fields ?? {})) {
|
|
159
|
+
if (!ids.has(id)) fail(`unknown ${f} field ${id}`);
|
|
160
|
+
if (!isValue(v)) fail(`overrides.${f}.${id} must be a string or a list of strings`);
|
|
161
|
+
}
|
|
162
|
+
overrides[f as Format] = { ...fields };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const entry = { system: input.id ?? agents[0]!, agents, owner: input.owner, jurisdiction, certificate, kyb, describe, overrides, evidence: [] as string[] };
|
|
166
|
+
entry.evidence = evidenceOf(entry);
|
|
167
|
+
return entry;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** `jcsHash` of an entry. */
|
|
171
|
+
export function entryHash(entry: RegisterEntry): string {
|
|
172
|
+
return jcsHash(entry);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------------- //
|
|
176
|
+
// Field maps
|
|
177
|
+
// ---------------------------------------------------------------------------------- //
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* A source is `d:<describe key>`, `kyb:provider`, `kyb:deployer` (the KYB entity name when
|
|
181
|
+
* `ownerRole` says so), `name+agents`, `certify`, `assessments` or `const:<value>`.
|
|
182
|
+
* No sources = not supported by Zanii inputs (supply an override).
|
|
183
|
+
*/
|
|
184
|
+
type FieldDef = [id: string, name: string, sources: string[], note?: string];
|
|
185
|
+
interface FieldMap { format: Format; format_version: string; source_url: string; guidance: string; fields: FieldDef[] }
|
|
186
|
+
|
|
187
|
+
const EU_GUIDANCE = 'Guidance: field list of Regulation (EU) 2024/1689 Annex VIII as consolidated at artificialintelligenceact.eu. The EU database (Art. 71) format and some items remain subject to Commission implementing acts and to the Digital Omnibus amendments, whose timing is not final. A field map, not legal advice.';
|
|
188
|
+
const OMNIBUS = 'Marked "not present after the amendment" in the consolidated text (Digital Omnibus); kept for filings under the original text. Confirm before filing.';
|
|
189
|
+
const party = (p: 'A' | 'B' | 'C', who: 'provider' | 'deployer'): FieldDef[] => [
|
|
190
|
+
[`${p}.1.name`, `Name of the ${who}`, [`d:${who}_name`, `kyb:${who}`]],
|
|
191
|
+
[`${p}.1.address`, `Address of the ${who}`, [`d:${who}_address`]],
|
|
192
|
+
[`${p}.1.contact`, `Contact details of the ${who}`, [`d:${who}_contact`]],
|
|
193
|
+
[`${p}.2`, `Name, address and contact details of the person submitting information on behalf of the ${who}`, ['d:submitter']],
|
|
194
|
+
];
|
|
195
|
+
const common = (p: 'A' | 'B'): FieldDef[] => [
|
|
196
|
+
...party(p, 'provider'),
|
|
197
|
+
[`${p}.3`, 'Name, address and contact details of the authorised representative', ['d:authorised_representative']],
|
|
198
|
+
[`${p}.4`, 'AI system trade name and any additional unambiguous reference', ['name+agents']],
|
|
199
|
+
[`${p}.5`, 'Description of the intended purpose of the AI system', ['d:intended_purpose']],
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
export const FIELD_MAPS: Record<Format, FieldMap> = {
|
|
203
|
+
'eu-ai-act-annex-viii-a': {
|
|
204
|
+
format: 'eu-ai-act-annex-viii-a', format_version: 'Regulation (EU) 2024/1689, Annex VIII Section A (Art. 49(1))',
|
|
205
|
+
source_url: 'https://artificialintelligenceact.eu/annex/8/', guidance: EU_GUIDANCE,
|
|
206
|
+
fields: [
|
|
207
|
+
...common('A'),
|
|
208
|
+
['A.6', 'Basic and concise description of the information used by the system (data, inputs) and its operating logic', ['d:data_and_logic']],
|
|
209
|
+
['A.7', 'Status of the AI system (on the market or in service; no longer; recalled)', ['d:status']],
|
|
210
|
+
['A.8', 'Type, number and expiry date of the notified body certificate, and the notified body', ['d:notified_body_certificate'], 'A Zanii Certify certificate is not a notified-body certificate (Art. 44); Certify evidence is in the register entry.'],
|
|
211
|
+
['A.9', 'Scanned copy of the notified body certificate', []],
|
|
212
|
+
['A.10', 'Member States in which the AI system has been placed on the market, put into service or made available', ['d:member_states']],
|
|
213
|
+
['A.11', 'Copy of the EU declaration of conformity (Art. 47)', ['d:declaration_of_conformity_url']],
|
|
214
|
+
['A.12', 'Electronic instructions for use', ['d:instructions_url'], 'Not provided for high-risk systems in law enforcement or migration, asylum and border control (Annex III points 1, 6 and 7).'],
|
|
215
|
+
['A.13', 'URL for additional information (optional)', ['d:more_info_url']],
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
'eu-ai-act-annex-viii-b': {
|
|
219
|
+
format: 'eu-ai-act-annex-viii-b', format_version: 'Regulation (EU) 2024/1689, Annex VIII Section B (Art. 49(2))',
|
|
220
|
+
source_url: 'https://artificialintelligenceact.eu/annex/8/', guidance: EU_GUIDANCE,
|
|
221
|
+
fields: [
|
|
222
|
+
...common('B'),
|
|
223
|
+
['B.6', 'The condition or conditions under Art. 6(3) under which the system is considered not high-risk', ['d:not_high_risk_condition']],
|
|
224
|
+
['B.7', 'Short summary of the grounds for considering the system not high-risk', ['d:not_high_risk_grounds'], OMNIBUS],
|
|
225
|
+
['B.8', 'Status of the AI system', ['d:status']],
|
|
226
|
+
['B.9', 'Member States in which the AI system has been placed on the market, put into service or made available', ['d:member_states'], OMNIBUS],
|
|
227
|
+
],
|
|
228
|
+
},
|
|
229
|
+
'eu-ai-act-annex-viii-c': {
|
|
230
|
+
format: 'eu-ai-act-annex-viii-c', format_version: 'Regulation (EU) 2024/1689, Annex VIII Section C (Art. 49(3))',
|
|
231
|
+
source_url: 'https://artificialintelligenceact.eu/annex/8/', guidance: EU_GUIDANCE,
|
|
232
|
+
fields: [
|
|
233
|
+
...party('C', 'deployer'),
|
|
234
|
+
['C.3', 'URL of the entry of the AI system in the EU database by its provider', ['d:eu_database_url']],
|
|
235
|
+
['C.4', 'Summary of the findings of the fundamental rights impact assessment (Art. 27)', ['d:fria_summary']],
|
|
236
|
+
['C.5', 'Summary of the data protection impact assessment (GDPR Art. 35 or Directive (EU) 2016/680 Art. 27), where applicable', ['d:dpia_summary']],
|
|
237
|
+
],
|
|
238
|
+
},
|
|
239
|
+
'uk-atrs': {
|
|
240
|
+
format: 'uk-atrs', format_version: 'ATRS template 4.0',
|
|
241
|
+
source_url: 'https://www.gov.uk/government/publications/algorithmic-transparency-template',
|
|
242
|
+
guidance: 'Field ids and names of the ATRS 4.0 template (ATRS_V4.0__FINAL.xlsx). Tier 1 is 1.x, tier 2 is 2.x. Most tier 2 fields describe the tool itself and have no Zanii source.',
|
|
243
|
+
fields: [
|
|
244
|
+
['0.1', 'Title', ['d:name']],
|
|
245
|
+
['0.2', 'Organisation name', ['d:deployer_name', 'kyb:deployer']],
|
|
246
|
+
['0.3', 'Phase', ['d:status']],
|
|
247
|
+
['0.4', 'Region', ['d:region']],
|
|
248
|
+
['0.5', 'One sentence description', ['d:summary']],
|
|
249
|
+
['1.1', 'Name', ['d:name']],
|
|
250
|
+
['1.2', 'Description', ['d:summary']],
|
|
251
|
+
['1.3', 'Website URL', ['d:more_info_url']],
|
|
252
|
+
['1.4', 'Contact email', ['d:contact_email']],
|
|
253
|
+
['2.1.1', 'Organisation or department', ['d:deployer_name', 'kyb:deployer']],
|
|
254
|
+
['2.1.2', 'Team', ['d:team']],
|
|
255
|
+
['2.1.3', 'Senior responsible owner', ['d:senior_responsible_owner']],
|
|
256
|
+
['2.1.4', 'Third party involvement', []],
|
|
257
|
+
['2.1.4.1', 'Third party', ['d:provider_name', 'kyb:provider']],
|
|
258
|
+
['2.1.4.2', 'Companies House Number', []],
|
|
259
|
+
['2.1.4.3', 'Third party role', []],
|
|
260
|
+
['2.1.4.4', 'Procurement procedure type', []],
|
|
261
|
+
['2.1.4.5', 'Third party data access terms', []],
|
|
262
|
+
['2.2.1', 'Detailed description', ['d:description']],
|
|
263
|
+
['2.2.2', 'Benefits', []],
|
|
264
|
+
['2.2.3', 'Previous process', []],
|
|
265
|
+
['2.2.4', 'Alternatives considered', []],
|
|
266
|
+
['2.3.1', 'Integration into broader operational process', []],
|
|
267
|
+
['2.3.2', 'Human review', ['d:human_review']],
|
|
268
|
+
['2.3.3', 'Frequency and scale of usage', []],
|
|
269
|
+
['2.3.4', 'Required training', []],
|
|
270
|
+
['2.3.5', 'Appeals and review', ['d:appeals']],
|
|
271
|
+
['2.4.1.1', 'System architecture', []],
|
|
272
|
+
['2.4.1.2', 'System-level input', []],
|
|
273
|
+
['2.4.1.3', 'System-level output', []],
|
|
274
|
+
['2.4.1.4', 'Maintenance', []],
|
|
275
|
+
['2.4.1.5', 'Models', ['d:model']],
|
|
276
|
+
['2.4.2.1', 'Model name', ['d:model']],
|
|
277
|
+
['2.4.2.2', 'Model version', []],
|
|
278
|
+
['2.4.2.3', 'Model task', []],
|
|
279
|
+
['2.4.2.4', 'Model input', []],
|
|
280
|
+
['2.4.2.5', 'Model output', []],
|
|
281
|
+
['2.4.2.6', 'Model architecture', []],
|
|
282
|
+
['2.4.2.7', 'Model performance', ['certify'], 'Zanii Certify assesses the deployed agent configuration against a profile; it is not the model\'s own performance metrics.'],
|
|
283
|
+
['2.4.2.8', 'Datasets and their purposes', []],
|
|
284
|
+
['2.4.3.1', 'Development data description', []],
|
|
285
|
+
['2.4.3.2', 'Data modality', []],
|
|
286
|
+
['2.4.3.3', 'Data quantities', []],
|
|
287
|
+
['2.4.3.4', 'Sensitive attributes', []],
|
|
288
|
+
['2.4.3.5', 'Data completeness and representativeness', []],
|
|
289
|
+
['2.4.3.6', 'Data cleaning', []],
|
|
290
|
+
['2.4.3.7', 'Data collection', []],
|
|
291
|
+
['2.4.3.8', 'Data access and storage', []],
|
|
292
|
+
['2.4.3.9', 'Data sharing agreements', []],
|
|
293
|
+
['2.4.4.1', 'Data sources', []],
|
|
294
|
+
['2.4.4.2', 'Sensitive attributes', []],
|
|
295
|
+
['2.4.4.3', 'Data processing methods', []],
|
|
296
|
+
['2.4.4.4', 'Data access and storage', []],
|
|
297
|
+
['2.4.4.5', 'Data sharing agreements', []],
|
|
298
|
+
['2.5.1', 'Impact assessments', ['assessments']],
|
|
299
|
+
['2.5.2', 'Risks and mitigations', ['d:risks']],
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
'eurocities-ats': {
|
|
303
|
+
format: 'eurocities-ats', format_version: 'Algorithmic Transparency Standard 0.3.1',
|
|
304
|
+
source_url: 'https://www.algoritmeregister.org/standard/schemas/registration-v0.3.1.schema.json',
|
|
305
|
+
guidance: 'Field names of the Algorithmic Transparency Standard 0.3.1 JSON schema (Eurocities; Amsterdam and Helsinki registers). Enum fields (status, domain, risk_category, algorithm_type, type) are passed through unchecked.',
|
|
306
|
+
fields: [
|
|
307
|
+
['name', 'Name', ['d:name']],
|
|
308
|
+
['description_short', 'Short description', ['d:summary']],
|
|
309
|
+
['organization', 'Organization', ['d:deployer_name', 'kyb:deployer']],
|
|
310
|
+
['department', 'Department', ['d:team']],
|
|
311
|
+
['contact_email', 'Contact email', ['d:contact_email']],
|
|
312
|
+
['contact_phone', 'Contact phone', []],
|
|
313
|
+
['contact_name', 'Contact name', []],
|
|
314
|
+
['area', 'Area', []],
|
|
315
|
+
['domain', 'Domain', []],
|
|
316
|
+
['website', 'Website', ['d:more_info_url']],
|
|
317
|
+
['status', 'Status', ['d:status']],
|
|
318
|
+
['remark', 'Remark', []],
|
|
319
|
+
['decision_making_process', 'Decision making process', []],
|
|
320
|
+
['goal', 'Goal', ['d:intended_purpose']],
|
|
321
|
+
['impact', 'Impact', []],
|
|
322
|
+
['risks', 'Risks', ['d:risks']],
|
|
323
|
+
['risk_category', 'Risk category', []],
|
|
324
|
+
['proportionality', 'Proportionality', []],
|
|
325
|
+
['lawful_basis', 'Lawful basis', []],
|
|
326
|
+
['documentation', 'Documentation', ['d:instructions_url']],
|
|
327
|
+
['training_data', 'Training data', []],
|
|
328
|
+
['source_data', 'Source data', []],
|
|
329
|
+
['data_bias', 'Data bias', []],
|
|
330
|
+
['connected_data_sources', 'Connected data sources', []],
|
|
331
|
+
['DPIA_description', 'DPIA description', ['d:dpia_summary']],
|
|
332
|
+
['DPIA_url', 'DPIA URL', ['d:dpia_url']],
|
|
333
|
+
['algorithm_type', 'Type of algorithm', []],
|
|
334
|
+
['description', 'Description', ['d:description']],
|
|
335
|
+
['methods_and_models', 'Methods and models', ['d:model']],
|
|
336
|
+
['performance', 'Performance', ['certify'], 'Zanii Certify assesses the deployed agent configuration against a profile; it is not the model\'s own performance metrics.'],
|
|
337
|
+
['alternatives_considered', 'Alternatives considered', []],
|
|
338
|
+
['product_information', 'Product information', []],
|
|
339
|
+
['codebase_url', 'Codebase URL', []],
|
|
340
|
+
['product_page', 'Product page', []],
|
|
341
|
+
['AIA_description', 'Algorithmic impact assessment description', ['d:fria_summary']],
|
|
342
|
+
['AIA_url', 'Algorithmic impact assessment URL', ['d:fria_url']],
|
|
343
|
+
['monitoring', 'Monitoring', []],
|
|
344
|
+
['type', 'Type of use of the algorithm', []],
|
|
345
|
+
['human_intervention', 'Human intervention', ['d:human_review']],
|
|
346
|
+
['objection_procedure', 'Objection procedure', ['d:appeals']],
|
|
347
|
+
['rollback', 'Rollback', []],
|
|
348
|
+
['standard_version', 'Version of the standard used', ['const:0.3.1']],
|
|
349
|
+
['uuid', 'Unique identifier', []],
|
|
350
|
+
['lang', 'Language', ['d:lang']],
|
|
351
|
+
['publication_date', 'Publication date', []],
|
|
352
|
+
['last_change_date', 'Last change date', []],
|
|
353
|
+
['revision_date', 'Revision date', []],
|
|
354
|
+
['revision_note', 'Revision note', []],
|
|
355
|
+
['keywords', 'Keywords', []],
|
|
356
|
+
],
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ---------------------------------------------------------------------------------- //
|
|
361
|
+
// Exports
|
|
362
|
+
// ---------------------------------------------------------------------------------- //
|
|
363
|
+
|
|
364
|
+
export type ExportField =
|
|
365
|
+
| { id: string; name: string; value: FieldValue; source: string; note?: string }
|
|
366
|
+
| { id: string; name: string; value: null; reason: string; note?: string };
|
|
367
|
+
|
|
368
|
+
export interface RegisterExport {
|
|
369
|
+
v: 1; type: 'zanii.ai_register.export'; format: Format; format_version: string; source_url: string; guidance: string;
|
|
370
|
+
system: string; entry: string; fields: ExportField[];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function certifySummary(c: NonNullable<RegisterEntry['certificate']>): string {
|
|
374
|
+
let s = `Zanii Certify ${c.profile ?? 'profile not stated'}`;
|
|
375
|
+
if (c.level !== null) s += ` Level ${c.level}`;
|
|
376
|
+
if (c.scheme) s += ` (${c.scheme})`;
|
|
377
|
+
s += `, status ${c.status ?? 'not stated'}`;
|
|
378
|
+
if (c.reason) s += ` (${c.reason})`;
|
|
379
|
+
if (c.evidence) s += `, production evidence ${c.evidence}`;
|
|
380
|
+
return `${s}, certificate ${c.hash}`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const LABEL: Record<string, string> = {
|
|
384
|
+
'kyb:provider': 'a KYB record with ownerRole provider or both',
|
|
385
|
+
'kyb:deployer': 'a KYB record with ownerRole deployer or both',
|
|
386
|
+
certify: 'a Certify certificate',
|
|
387
|
+
assessments: 'describe.fria_summary or describe.dpia_summary',
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
function resolve(src: string, e: RegisterEntry): { value: FieldValue; source: string } | null {
|
|
391
|
+
if (src.startsWith('d:')) {
|
|
392
|
+
const v = e.describe[src.slice(2) as DescribeKey];
|
|
393
|
+
return v === undefined || v === '' || (Array.isArray(v) && !v.length) ? null : { value: v, source: `describe.${src.slice(2)}` };
|
|
394
|
+
}
|
|
395
|
+
if (src.startsWith('const:')) return { value: src.slice(6), source: 'const' };
|
|
396
|
+
if (src === 'kyb:provider' || src === 'kyb:deployer') {
|
|
397
|
+
const role = src.slice(4);
|
|
398
|
+
return e.kyb && (e.kyb.role === role || e.kyb.role === 'both') ? { value: e.kyb.entity.name, source: 'kyb' } : null;
|
|
399
|
+
}
|
|
400
|
+
if (src === 'name+agents') {
|
|
401
|
+
const name = e.describe.name;
|
|
402
|
+
return { value: typeof name === 'string' && name ? `${name} (${e.agents.join(', ')})` : e.agents.join(', '), source: typeof name === 'string' && name ? 'describe.name+agents' : 'agents' };
|
|
403
|
+
}
|
|
404
|
+
if (src === 'certify') return e.certificate ? { value: certifySummary(e.certificate), source: 'certificate' } : null;
|
|
405
|
+
if (src === 'assessments') {
|
|
406
|
+
const out: string[] = [];
|
|
407
|
+
for (const [label, s, u] of [['FRIA', 'fria_summary', 'fria_url'], ['DPIA', 'dpia_summary', 'dpia_url']] as const) {
|
|
408
|
+
const sum = e.describe[s];
|
|
409
|
+
if (typeof sum === 'string' && sum) out.push(`${label}: ${sum}${typeof e.describe[u] === 'string' && e.describe[u] ? ` (${e.describe[u] as string})` : ''}`);
|
|
410
|
+
}
|
|
411
|
+
return out.length ? { value: out, source: 'describe.fria_summary+dpia_summary' } : null;
|
|
412
|
+
}
|
|
413
|
+
return fail(`unknown source ${src}`);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** One export of an entry: a versioned field map, each field a value with its source or null with a reason. */
|
|
417
|
+
export function exportEntry(entry: RegisterEntry, format: Format): RegisterExport {
|
|
418
|
+
const map = FIELD_MAPS[format] ?? fail(`unknown format ${String(format)}`);
|
|
419
|
+
const fields = map.fields.map(([id, name, sources, note]): ExportField => {
|
|
420
|
+
const o = entry.overrides?.[format]?.[id];
|
|
421
|
+
let f: ExportField;
|
|
422
|
+
const hit = o !== undefined ? { value: o, source: 'override' } : sources.map((s) => resolve(s, entry)).find((x) => x !== null);
|
|
423
|
+
if (hit) f = { id, name, value: hit.value, source: hit.source };
|
|
424
|
+
else {
|
|
425
|
+
const reason = sources.length
|
|
426
|
+
? `not supplied: ${sources.map((s) => LABEL[s] ?? `describe.${s.slice(2)}`).join(' or ')}`
|
|
427
|
+
: `not supported by Zanii inputs: supply overrides["${format}"]["${id}"]`;
|
|
428
|
+
f = { id, name, value: null, reason };
|
|
429
|
+
}
|
|
430
|
+
if (note) f.note = note;
|
|
431
|
+
return f;
|
|
432
|
+
});
|
|
433
|
+
return { v: 1, type: 'zanii.ai_register.export', format, format_version: map.format_version, source_url: map.source_url, guidance: map.guidance, system: entry.system, entry: entryHash(entry), fields };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export const exportEuAiAct = (entry: RegisterEntry, section: 'A' | 'B' | 'C') =>
|
|
437
|
+
exportEntry(entry, `eu-ai-act-annex-viii-${section.toLowerCase()}` as Format);
|
|
438
|
+
export const exportAtrs = (entry: RegisterEntry) => exportEntry(entry, 'uk-atrs');
|
|
439
|
+
export const exportEurocities = (entry: RegisterEntry) => exportEntry(entry, 'eurocities-ats');
|
|
440
|
+
|
|
441
|
+
/** `{ field id: value or null }`: for the Eurocities format this is the registration JSON object. */
|
|
442
|
+
export function toRecord(x: RegisterExport): Record<string, FieldValue | null> {
|
|
443
|
+
return Object.fromEntries(x.fields.map((f) => [f.id, f.value]));
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const cell = (v: unknown): string => {
|
|
447
|
+
const s = v === null || v === undefined ? '' : Array.isArray(v) ? v.join('; ') : String(v);
|
|
448
|
+
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
/** RFC 4180 CSV, one row per field: system, format, id, name, value, source, reason. */
|
|
452
|
+
export function toCsv(exports: RegisterExport[]): string {
|
|
453
|
+
const rows = [['system', 'format', 'id', 'name', 'value', 'source', 'reason']];
|
|
454
|
+
for (const x of exports) for (const f of x.fields) rows.push([x.system, x.format, f.id, f.name, f.value as never, 'source' in f ? f.source : '', 'reason' in f ? f.reason : '']);
|
|
455
|
+
return rows.map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Canonical JSON (RFC 8785): the same bytes in TypeScript and Python. */
|
|
459
|
+
export function toJson(x: unknown): string {
|
|
460
|
+
return new TextDecoder().decode(canonicalBytes(x));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---------------------------------------------------------------------------------- //
|
|
464
|
+
// The signed register snapshot
|
|
465
|
+
// ---------------------------------------------------------------------------------- //
|
|
466
|
+
|
|
467
|
+
export type RegisterSnapshot = SignedObject<{ v: 1; type: 'zanii.ai_register'; regulator: string; jurisdiction: string; as_of: string; entries: RegisterEntry[] }>;
|
|
468
|
+
|
|
469
|
+
/** Sign a snapshot of the register as the regulator or operator. Entries are sorted by `system`. */
|
|
470
|
+
export function buildRegister(r: { regulator: string; jurisdiction: string; asOf: string; entries: RegisterEntry[] }, signerKey: Uint8Array): RegisterSnapshot {
|
|
471
|
+
if (!r?.regulator || !r.jurisdiction) fail('regulator and jurisdiction are required');
|
|
472
|
+
if (Number.isNaN(ms(r.asOf))) fail('asOf must be an ISO timestamp');
|
|
473
|
+
const entries = [...r.entries].sort((a, b) => (a.system < b.system ? -1 : a.system > b.system ? 1 : 0));
|
|
474
|
+
if (new Set(entries.map((e) => e.system)).size !== entries.length) fail('two entries have the same system id');
|
|
475
|
+
return signObject({ v: 1 as const, type: 'zanii.ai_register' as const, regulator: r.regulator, jurisdiction: r.jurisdiction, as_of: r.asOf, entries }, signerKey);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** Signature by an allowed signer, entries sorted and distinct, evidence lists consistent, bindings for this jurisdiction. */
|
|
479
|
+
export function verifyRegister(s: RegisterSnapshot, opts: { signers: string[] }): { ok: boolean; reasons: string[] } {
|
|
480
|
+
const reasons: string[] = [];
|
|
481
|
+
if (!s || typeof s !== 'object' || s.v !== 1 || s.type !== 'zanii.ai_register' || !Array.isArray(s.entries)) return { ok: false, reasons: ['not a zanii.ai_register snapshot (v1)'] };
|
|
482
|
+
if (!verifySignedObject(s)) reasons.push('bad signature');
|
|
483
|
+
else if (!opts?.signers?.length) reasons.push('no signers supplied: signer NOT checked');
|
|
484
|
+
else if (!opts.signers.includes(s.signer)) reasons.push('signed by a key that is not an allowed register signer');
|
|
485
|
+
if (Number.isNaN(ms(s.as_of))) reasons.push('as_of is not an ISO timestamp');
|
|
486
|
+
const ids = s.entries.map((e) => e?.system);
|
|
487
|
+
if (ids.some((id, i) => i > 0 && !(ids[i - 1]! < id!))) reasons.push('entries are not sorted and distinct by system');
|
|
488
|
+
for (const e of s.entries) {
|
|
489
|
+
if (!e || typeof e !== 'object') { reasons.push('an entry is not an object'); continue; }
|
|
490
|
+
if (evidenceOf(e).join() !== (Array.isArray(e.evidence) ? e.evidence.join() : '')) reasons.push(`entry ${e.system}: evidence does not match its records`);
|
|
491
|
+
if (e.jurisdiction && e.jurisdiction.jurisdiction !== s.jurisdiction) reasons.push(`entry ${e.system}: jurisdiction binding is for ${e.jurisdiction.jurisdiction}`);
|
|
492
|
+
}
|
|
493
|
+
return { ok: reasons.length === 0, reasons };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** `buildRegister` with a raw key or a remote signer (HSM, KMS); all the same checks run. */
|
|
497
|
+
export async function buildRegisterAsync(r: { regulator: string; jurisdiction: string; asOf: string; entries: RegisterEntry[] }, signer: Uint8Array | RemoteSigner): Promise<RegisterSnapshot> {
|
|
498
|
+
if (signer instanceof Uint8Array) return buildRegister(r, signer);
|
|
499
|
+
const { sig: _sig, signer: _signer, ...fields } = buildRegister(r, new Uint8Array(32).fill(1));
|
|
500
|
+
return (await signObjectAsync(fields, signer)) as unknown as RegisterSnapshot;
|
|
501
|
+
}
|