@emilia-protocol/gate 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,538 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * EMILIA Gate — auditor control-testing workpaper (ITGC / SOX-shaped).
4
+ *
5
+ * The artifact an external auditor's control test consumes: a pinned population
6
+ * of guarded gate decisions over a period, a deterministic (seed-reproducible,
7
+ * RNG-free) attribute sample, per-item attribute observations tied to named
8
+ * evidence-log fields, and an exception list — computed from the gate's
9
+ * tamper-evident evidence log. Pure function: same entries + same options in,
10
+ * identical JSON out (pin `now` for a byte-stable artifact).
11
+ *
12
+ * HONESTY BOUNDARY (carried inside the artifact): this workpaper SUPPORTS the
13
+ * auditor's control test. It never performs, reviews, or concludes the test —
14
+ * the sign-off fields are ALWAYS emitted null and rendered as blanks for the
15
+ * auditor to complete. A refusal (deny decision) is the deny-by-default control
16
+ * operating as designed and is NOT a control exception.
17
+ *
18
+ * Fail closed: missing client/engagement/control reference, an invalid or
19
+ * inverted period, a non-integer sample size, or a missing sample seed is an
20
+ * error, not a guess. Entries that cannot be verified as log records are
21
+ * EXCLUDED from the population and surfaced as integrity_warnings — the
22
+ * workpaper never samples what it cannot account for. Window is half-open
23
+ * [periodStart, periodEnd): an entry stamped exactly at periodEnd belongs to
24
+ * the NEXT period, so adjacent workpapers never double-count.
25
+ */
26
+
27
+ import crypto from 'node:crypto';
28
+
29
+ export const AUDIT_WORKPAPER_VERSION = 'EP-GATE-AUDIT-WORKPAPER-v1';
30
+
31
+ /**
32
+ * Mandatory honesty header. Present verbatim in every workpaper and every
33
+ * rendered view; a document without it is not an EP-GATE-AUDIT-WORKPAPER-v1.
34
+ */
35
+ export const AUDIT_WORKPAPER_HONESTY_NOTICE =
36
+ 'This workpaper was prepared from the deploying organization\'s tamper-evident '
37
+ + 'evidence log to SUPPORT control testing. It supplies the population, a '
38
+ + 'reproducible sample, and per-item attribute observations; it does not perform '
39
+ + 'the test. The auditor performs and concludes the test and remains responsible '
40
+ + 'for sample evaluation and any conclusion drawn. This is not an audit opinion, '
41
+ + 'and nothing in this document constitutes a conclusion, certification, or '
42
+ + 'attestation by the preparer or by EMILIA Gate.';
43
+
44
+ /**
45
+ * Refusal treatment is a structural statement of the format, not commentary:
46
+ * a denial is the control WORKING, so it can never be a control exception.
47
+ */
48
+ export const REFUSAL_TREATMENT =
49
+ 'A refusal (deny decision) is the deny-by-default control operating as designed: '
50
+ + 'the gate withheld execution. Refusals are therefore NOT control exceptions. '
51
+ + 'Attributes A1-A5 describe properties of a granted authorization and are recorded '
52
+ + 'as not_applicable on refusals; only A6 (the refusal was durably logged in the '
53
+ + 'tamper-evident chain) is tested on a refusal.';
54
+
55
+ /**
56
+ * The attribute test plan. Each attribute names the evidence-log field(s) the
57
+ * observation is read from, so the auditor can retrace every pass/fail to the
58
+ * underlying record. Missing or malformed evidence FAILS the attribute — an
59
+ * observation the log cannot support is never presumed to pass.
60
+ */
61
+ export const AUDIT_ATTRIBUTES = [
62
+ {
63
+ id: 'A1',
64
+ name: 'receipt_verified_against_pinned_issuer',
65
+ evidence_field: 'signer',
66
+ test: 'The decision records the pinned issuer key that verified the receipt signature. The gate sets `signer` only after Ed25519 verification against pinned/registry issuer keys; an allow without a recorded signer fails.',
67
+ },
68
+ {
69
+ id: 'A2',
70
+ name: 'credited_assurance_tier_meets_required',
71
+ evidence_field: 'have_tier, required_tier',
72
+ test: 'The cryptographically credited assurance tier meets or exceeds the tier the manifest requires for this action. An unknown or missing tier on either side fails (fail closed).',
73
+ },
74
+ {
75
+ id: 'A3',
76
+ name: 'not_a_replay',
77
+ evidence_field: 'reason, receipt_id',
78
+ test: 'The authorization was granted on a fresh presentation: the decision reason is `allow` and a stable issuer-generated receipt_id (the replay-defense key) is present. Without a receipt_id, replay detection is impossible for this decision.',
79
+ },
80
+ {
81
+ id: 'A4',
82
+ name: 'one_time_consumption_recorded',
83
+ evidence_field: 'consumption_mode',
84
+ test: 'One-time consumption of the receipt was actually recorded (`consume` or `reserve`). Mode `none` means replay defense was explicitly bypassed for this decision and fails.',
85
+ },
86
+ {
87
+ id: 'A5',
88
+ name: 'named_principal_present',
89
+ evidence_field: 'subject',
90
+ test: 'A named principal (the receipt subject) is recorded on the decision, attributing the authorization to a human identity as pinned by the deployer.',
91
+ },
92
+ {
93
+ id: 'A6',
94
+ name: 'decision_logged_tamper_evident',
95
+ evidence_field: 'hash, prev_hash, seq',
96
+ test: 'The decision is a well-formed record of the hash-chained evidence log: 64-hex record hash, non-empty prev_hash, non-negative integer seq.',
97
+ },
98
+ ];
99
+
100
+ const TIER_RANK = { software: 0, class_a: 1, quorum: 2 };
101
+ const HEX64 = /^[0-9a-f]{64}$/;
102
+
103
+ function sha256hex(s) {
104
+ return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
105
+ }
106
+
107
+ function toMs(t) {
108
+ if (t == null) return null;
109
+ const ms = typeof t === 'number' ? t : Date.parse(t);
110
+ return Number.isFinite(ms) ? ms : null;
111
+ }
112
+
113
+ function nonEmptyString(v) {
114
+ return typeof v === 'string' && v.length > 0;
115
+ }
116
+
117
+ /* ------------------------- attribute observations ------------------------ */
118
+
119
+ function observeA1(e) {
120
+ return { pass: nonEmptyString(e.signer), observed: nonEmptyString(e.signer) ? e.signer : 'signer absent' };
121
+ }
122
+ function observeA2(e) {
123
+ const have = e.have_tier;
124
+ const req = e.required_tier;
125
+ const pass = nonEmptyString(have) && nonEmptyString(req)
126
+ && TIER_RANK[have] !== undefined && TIER_RANK[req] !== undefined
127
+ && TIER_RANK[have] >= TIER_RANK[req];
128
+ return { pass, observed: `credited=${have ?? 'absent'} required=${req ?? 'absent'}` };
129
+ }
130
+ function observeA3(e) {
131
+ const pass = e.reason === 'allow' && nonEmptyString(e.receipt_id);
132
+ return { pass, observed: `reason=${e.reason ?? 'absent'} receipt_id=${nonEmptyString(e.receipt_id) ? e.receipt_id : 'absent'}` };
133
+ }
134
+ function observeA4(e) {
135
+ const pass = e.consumption_mode === 'consume' || e.consumption_mode === 'reserve';
136
+ return { pass, observed: `consumption_mode=${e.consumption_mode ?? 'absent'}` };
137
+ }
138
+ function observeA5(e) {
139
+ return { pass: nonEmptyString(e.subject), observed: nonEmptyString(e.subject) ? e.subject : 'subject absent' };
140
+ }
141
+ function observeA6(e) {
142
+ const pass = nonEmptyString(e.hash) && HEX64.test(e.hash)
143
+ && nonEmptyString(e.prev_hash)
144
+ && Number.isInteger(e.seq) && e.seq >= 0;
145
+ return {
146
+ pass,
147
+ observed: `hash=${nonEmptyString(e.hash) && HEX64.test(e.hash) ? 'well-formed' : 'malformed'} prev_hash=${nonEmptyString(e.prev_hash) ? 'present' : 'absent'} seq=${Number.isInteger(e.seq) ? e.seq : 'absent'}`,
148
+ };
149
+ }
150
+
151
+ const OBSERVERS = { A1: observeA1, A2: observeA2, A3: observeA3, A4: observeA4, A5: observeA5, A6: observeA6 };
152
+
153
+ /** Attributes tested even on a refusal (the refusal itself must be accounted for). */
154
+ const REFUSAL_TESTED = new Set(['A6']);
155
+
156
+ function testItem(e) {
157
+ const isRefusal = e.allow === false;
158
+ const attributes = AUDIT_ATTRIBUTES.map((a) => {
159
+ if (isRefusal && !REFUSAL_TESTED.has(a.id)) {
160
+ return {
161
+ id: a.id,
162
+ name: a.name,
163
+ evidence_field: a.evidence_field,
164
+ result: 'not_applicable',
165
+ observed: `refusal (reason=${e.reason ?? 'unspecified'}) — control operated as designed; attribute describes a granted authorization`,
166
+ };
167
+ }
168
+ const { pass, observed } = OBSERVERS[a.id](e);
169
+ return { id: a.id, name: a.name, evidence_field: a.evidence_field, result: pass ? 'pass' : 'fail', observed };
170
+ });
171
+ return {
172
+ hash: e.hash,
173
+ seq: Number.isInteger(e.seq) ? e.seq : null,
174
+ at: e.at,
175
+ action: nonEmptyString(e.action) ? e.action : null,
176
+ verdict: isRefusal ? 'refusal' : 'allow',
177
+ reason: nonEmptyString(e.reason) ? e.reason : null,
178
+ attributes,
179
+ };
180
+ }
181
+
182
+ /* --------------------------------- build --------------------------------- */
183
+
184
+ /**
185
+ * Build the control-testing workpaper over a slice of the evidence log.
186
+ *
187
+ * Population = every well-formed guarded decision entry in the half-open
188
+ * window [periodStart, periodEnd). `population_hash` pins the population
189
+ * itself: sha256 over the lexicographically sorted entry hashes, newline-
190
+ * joined — the auditor can recompute it from the listed items.
191
+ *
192
+ * Sampling is deterministic and RNG-free: for each population entry compute
193
+ * sha256(sampleSeed + entry_hash) as lowercase hex; order ascending (ties
194
+ * broken by entry hash); select the first sampleSize entries. Reproducible by
195
+ * the auditor from the same seed and the same population. sampleSize >= the
196
+ * population size selects the full population ("100% examination").
197
+ *
198
+ * @param {Array<object>} entries evidence.all() (or a durable export of it)
199
+ * @param {object} o
200
+ * @param {string} o.client audit client / deploying organization (required)
201
+ * @param {string} o.engagement engagement reference (required)
202
+ * @param {string} o.controlRef control identifier under test (required)
203
+ * @param {string|number} o.periodStart inclusive window start (ISO or epoch ms)
204
+ * @param {string|number} o.periodEnd EXCLUSIVE window end (ISO or epoch ms)
205
+ * @param {number} o.sampleSize positive integer sample size (required)
206
+ * @param {string} o.sampleSeed seed pinning the sample selection (required)
207
+ * @param {number|function} [o.now=Date.now] clock for generated_at (pin for determinism)
208
+ * @returns {object} EP-GATE-AUDIT-WORKPAPER-v1 document
209
+ */
210
+ export function buildAuditWorkpaper(entries = [], {
211
+ client, engagement, controlRef, periodStart, periodEnd, sampleSize, sampleSeed, now = Date.now,
212
+ } = {}) {
213
+ if (!Array.isArray(entries)) throw new Error('audit workpaper: entries must be an array (evidence.all())');
214
+ if (!nonEmptyString(client)) throw new Error('audit workpaper: client is required');
215
+ if (!nonEmptyString(engagement)) throw new Error('audit workpaper: engagement is required');
216
+ if (!nonEmptyString(controlRef)) throw new Error('audit workpaper: controlRef is required');
217
+ const startMs = toMs(periodStart);
218
+ const endMs = toMs(periodEnd);
219
+ if (startMs == null || endMs == null) {
220
+ throw new Error('audit workpaper: periodStart and periodEnd must be ISO timestamps or epoch ms');
221
+ }
222
+ if (endMs <= startMs) throw new Error('audit workpaper: empty or inverted period (periodEnd must be after periodStart)');
223
+ if (!Number.isInteger(sampleSize) || sampleSize < 1) {
224
+ throw new Error('audit workpaper: sampleSize must be a positive integer');
225
+ }
226
+ if (!nonEmptyString(sampleSeed)) {
227
+ throw new Error('audit workpaper: sampleSeed is required — the sample must be reproducible by the auditor');
228
+ }
229
+
230
+ // Scope + integrity pass. Out-of-window records are out of scope; records we
231
+ // cannot verify as log entries are warned AND excluded — never sampled over.
232
+ const warnings = [];
233
+ const population = []; // guarded decision entries, in supplied (log) order
234
+ let inWindow = 0;
235
+ let outsideWindow = 0;
236
+ let notGuarded = 0;
237
+ let executions = 0;
238
+ entries.forEach((e, index) => {
239
+ const ref = { index, seq: e && typeof e === 'object' && Number.isInteger(e.seq) ? e.seq : null };
240
+ if (!e || typeof e !== 'object' || Array.isArray(e)) { warnings.push({ ...ref, problem: 'not_an_object' }); return; }
241
+ // An entry whose timestamp cannot be parsed cannot be placed in ANY period;
242
+ // it is warned, never silently assigned in or out of the window.
243
+ const t = toMs(e.at);
244
+ if (t == null) { warnings.push({ ...ref, problem: 'missing_or_unparseable_at' }); return; }
245
+ if (t < startMs || t >= endMs) { outsideWindow += 1; return; }
246
+ if (!nonEmptyString(e.hash)) { warnings.push({ ...ref, problem: 'missing_hash' }); return; }
247
+ if (e.kind !== 'decision' && e.kind !== 'execution') { warnings.push({ ...ref, problem: 'unknown_kind', kind: e.kind ?? null }); return; }
248
+ inWindow += 1;
249
+ if (e.kind === 'execution') { executions += 1; return; }
250
+ if (typeof e.allow !== 'boolean') { warnings.push({ ...ref, problem: 'decision_missing_allow' }); inWindow -= 1; return; }
251
+ if (e.reason === 'not_guarded') { notGuarded += 1; return; } // ran OUTSIDE the control — not in the tested population
252
+ population.push(e);
253
+ });
254
+
255
+ // Pin the population itself: order-independent hash over the entry hashes.
256
+ const popHashes = population.map((e) => e.hash);
257
+ const populationHash = sha256hex([...popHashes].sort().join('\n'));
258
+
259
+ // Deterministic, RNG-free sampling — reproducible from (seed, population).
260
+ const keyed = population
261
+ .map((e) => ({ e, key: sha256hex(sampleSeed + e.hash) }))
262
+ .sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : (a.e.hash < b.e.hash ? -1 : 1)));
263
+ const fullPopulation = sampleSize >= population.length;
264
+ const selected = fullPopulation ? keyed : keyed.slice(0, sampleSize);
265
+
266
+ const testedItems = selected.map(({ e }) => testItem(e));
267
+
268
+ // Exceptions = failed attributes on sampled items. Refusals cannot produce
269
+ // A1-A5 exceptions by construction (not_applicable) — see REFUSAL_TREATMENT.
270
+ const exceptions = [];
271
+ for (const item of testedItems) {
272
+ for (const a of item.attributes) {
273
+ if (a.result === 'fail') {
274
+ exceptions.push({
275
+ entry_hash: item.hash,
276
+ seq: item.seq,
277
+ at: item.at,
278
+ action: item.action,
279
+ attribute: a.id,
280
+ name: a.name,
281
+ evidence_field: a.evidence_field,
282
+ observed: a.observed,
283
+ });
284
+ }
285
+ }
286
+ }
287
+
288
+ // Chain head at export time: the last supplied entry carrying a hash.
289
+ let chainHead = null;
290
+ for (let i = entries.length - 1; i >= 0; i -= 1) {
291
+ const e = entries[i];
292
+ if (e && typeof e === 'object' && nonEmptyString(e.hash)) { chainHead = e.hash; break; }
293
+ }
294
+
295
+ const nowMs = typeof now === 'function' ? now() : now;
296
+
297
+ return {
298
+ '@version': AUDIT_WORKPAPER_VERSION,
299
+ notice: AUDIT_WORKPAPER_HONESTY_NOTICE,
300
+ client,
301
+ engagement,
302
+ control: {
303
+ ref: controlRef,
304
+ name: 'EMILIA Gate — deny-by-default authorization of consequential machine actions',
305
+ statement: 'Guarded actions execute only with a valid, in-scope, sufficiently-assured, fresh, one-time authorization receipt from a named principal; every decision (allow or refusal) is appended to a hash-chained, tamper-evident evidence log.',
306
+ },
307
+ period: { start: new Date(startMs).toISOString(), end: new Date(endMs).toISOString(), end_exclusive: true },
308
+ generated_at: new Date(nowMs).toISOString(),
309
+ population: {
310
+ size: population.length,
311
+ population_hash: populationHash,
312
+ hash_method: 'sha256 over the lexicographically sorted entry hashes, newline-joined',
313
+ excluded: {
314
+ outside_window: outsideWindow,
315
+ not_guarded_passthroughs: notGuarded,
316
+ executions,
317
+ integrity_warnings: warnings.length,
318
+ },
319
+ items: population.map((e) => ({
320
+ hash: e.hash,
321
+ seq: Number.isInteger(e.seq) ? e.seq : null,
322
+ at: e.at,
323
+ action: nonEmptyString(e.action) ? e.action : null,
324
+ allow: e.allow,
325
+ reason: nonEmptyString(e.reason) ? e.reason : null,
326
+ })),
327
+ },
328
+ sampling: {
329
+ method: 'Deterministic attribute sampling, no RNG: for each population entry compute sha256(seed + entry_hash) as lowercase hex; order ascending (ties broken by entry hash); select the first sample_size entries. Reproducible from the same seed and the same population.',
330
+ seed: sampleSeed,
331
+ requested_size: sampleSize,
332
+ selected_size: selected.length,
333
+ full_population: fullPopulation,
334
+ basis: fullPopulation ? '100% examination' : 'attribute sample',
335
+ selected: selected.map(({ e }) => e.hash),
336
+ },
337
+ attribute_testing: {
338
+ plan: AUDIT_ATTRIBUTES,
339
+ refusal_treatment: REFUSAL_TREATMENT,
340
+ items: testedItems,
341
+ },
342
+ exceptions: {
343
+ total: exceptions.length,
344
+ refusals_are_not_exceptions: REFUSAL_TREATMENT,
345
+ items: exceptions,
346
+ },
347
+ completeness: {
348
+ entries_supplied: entries.length,
349
+ entries_in_window: inWindow,
350
+ population_size: population.length,
351
+ first_population_hash: popHashes[0] ?? null,
352
+ last_population_hash: popHashes[popHashes.length - 1] ?? null,
353
+ chain_head: chainHead,
354
+ },
355
+ integrity_warnings: warnings,
356
+ // The module NEVER concludes. These are the auditor's fields, emitted null
357
+ // by construction and rendered as blanks to fill.
358
+ conclusion: { tested_by: null, reviewed_by: null, conclusion: null },
359
+ };
360
+ }
361
+
362
+ /* -------------------------------- markdown ------------------------------- */
363
+
364
+ /** Table cells must not break the table; the source strings are log-derived. */
365
+ function cell(v) {
366
+ return String(v ?? '—').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
367
+ }
368
+
369
+ const BLANK = '`____________________`';
370
+
371
+ function resultMark(r) {
372
+ return r === 'pass' ? 'pass' : r === 'fail' ? 'FAIL' : 'n/a';
373
+ }
374
+
375
+ /**
376
+ * Render the workpaper for the audit file. Refuses any document that is not a
377
+ * verbatim EP-GATE-AUDIT-WORKPAPER-v1: wrong @version, an altered or removed
378
+ * honesty notice, or machine-filled sign-off fields (the format defines them
379
+ * as always-null; a filled conclusion did not come from this module and must
380
+ * never render as an apparently machine-supported conclusion).
381
+ */
382
+ export function renderMarkdown(pack) {
383
+ if (!pack || typeof pack !== 'object' || pack['@version'] !== AUDIT_WORKPAPER_VERSION) {
384
+ throw new Error(`audit workpaper: renderMarkdown requires an ${AUDIT_WORKPAPER_VERSION} document`);
385
+ }
386
+ if (pack.notice !== AUDIT_WORKPAPER_HONESTY_NOTICE) {
387
+ throw new Error('audit workpaper: refusing to render a document whose honesty notice was altered or removed');
388
+ }
389
+ const c = pack.conclusion;
390
+ if (!c || typeof c !== 'object' || c.tested_by !== null || c.reviewed_by !== null || c.conclusion !== null) {
391
+ throw new Error('audit workpaper: refusing to render — sign-off fields must be null (the auditor completes them on the rendered workpaper, never in the machine artifact)');
392
+ }
393
+
394
+ const L = [];
395
+ L.push('# Control-Testing Workpaper — EMILIA Gate');
396
+ L.push('');
397
+ L.push(`> ${pack.notice}`);
398
+ L.push('');
399
+ L.push(`- **Format:** ${AUDIT_WORKPAPER_VERSION}`);
400
+ L.push(`- **Client:** ${cell(pack.client)}`);
401
+ L.push(`- **Engagement:** ${cell(pack.engagement)}`);
402
+ L.push(`- **Control:** ${cell(pack.control.ref)} — ${cell(pack.control.name)}`);
403
+ L.push(`- **Period:** ${pack.period.start} — ${pack.period.end} (end exclusive)`);
404
+ L.push(`- **Generated:** ${pack.generated_at}`);
405
+ L.push('');
406
+ L.push('## Control under test');
407
+ L.push('');
408
+ L.push(pack.control.statement);
409
+ L.push('');
410
+
411
+ L.push('## Population');
412
+ L.push('');
413
+ L.push(`- Guarded decisions in window: **${pack.population.size}**`);
414
+ L.push(`- Population hash: \`${cell(pack.population.population_hash)}\` (${pack.population.hash_method})`);
415
+ L.push(`- Excluded: ${pack.population.excluded.outside_window} outside window, ${pack.population.excluded.not_guarded_passthroughs} not-guarded pass-through(s), ${pack.population.excluded.executions} execution record(s), ${pack.population.excluded.integrity_warnings} integrity warning(s)`);
416
+ if (pack.population.items.length === 0) {
417
+ L.push('');
418
+ L.push('_No guarded decisions in the period. A zero-activity population is a valid (boring) result, not an error._');
419
+ } else {
420
+ L.push('');
421
+ L.push('| Seq | At | Action | Verdict | Reason | Entry hash |');
422
+ L.push('| ---: | --- | --- | --- | --- | --- |');
423
+ for (const it of pack.population.items) {
424
+ L.push(`| ${cell(it.seq)} | ${cell(it.at)} | ${cell(it.action)} | ${it.allow ? 'allow' : 'refusal'} | ${cell(it.reason)} | \`${cell(it.hash)}\` |`);
425
+ }
426
+ }
427
+ L.push('');
428
+
429
+ L.push('## Sampling');
430
+ L.push('');
431
+ L.push(`- Method: ${pack.sampling.method}`);
432
+ L.push(`- Seed: \`${cell(pack.sampling.seed)}\``);
433
+ L.push(`- Requested: ${pack.sampling.requested_size} · Selected: ${pack.sampling.selected_size} · Basis: **${pack.sampling.basis}**`);
434
+ if (pack.sampling.selected.length) {
435
+ L.push('- Selected entry hashes (selection order):');
436
+ for (const h of pack.sampling.selected) L.push(` - \`${cell(h)}\``);
437
+ } else {
438
+ L.push('- Selected entry hashes: _none (empty population)_');
439
+ }
440
+ L.push('');
441
+
442
+ L.push('## Attribute test plan');
443
+ L.push('');
444
+ L.push('| # | Attribute | Evidence field(s) | Test |');
445
+ L.push('| --- | --- | --- | --- |');
446
+ for (const a of pack.attribute_testing.plan) {
447
+ L.push(`| ${a.id} | ${cell(a.name)} | ${cell(a.evidence_field)} | ${cell(a.test)} |`);
448
+ }
449
+ L.push('');
450
+ L.push(`> ${pack.attribute_testing.refusal_treatment}`);
451
+ L.push('');
452
+
453
+ L.push('## Attribute results');
454
+ if (pack.attribute_testing.items.length === 0) {
455
+ L.push('');
456
+ L.push('_No items sampled (empty population)._');
457
+ } else {
458
+ L.push('');
459
+ L.push('| Item | At | Action | Verdict | A1 | A2 | A3 | A4 | A5 | A6 |');
460
+ L.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |');
461
+ for (const it of pack.attribute_testing.items) {
462
+ const marks = it.attributes.map((a) => resultMark(a.result));
463
+ L.push(`| \`${cell(it.hash)}\` | ${cell(it.at)} | ${cell(it.action)} | ${it.verdict} | ${marks.join(' | ')} |`);
464
+ }
465
+ L.push('');
466
+ L.push('Observations (per attribute, per item):');
467
+ L.push('');
468
+ for (const it of pack.attribute_testing.items) {
469
+ L.push(`- \`${cell(it.hash)}\` (${it.verdict}${it.reason ? `, reason: ${cell(it.reason)}` : ''})`);
470
+ for (const a of it.attributes) {
471
+ L.push(` - ${a.id} ${cell(a.name)} [${resultMark(a.result)}] — ${cell(a.evidence_field)}: ${cell(a.observed)}`);
472
+ }
473
+ }
474
+ }
475
+ L.push('');
476
+
477
+ L.push('## Exceptions');
478
+ L.push('');
479
+ L.push(`> ${pack.exceptions.refusals_are_not_exceptions}`);
480
+ L.push('');
481
+ if (pack.exceptions.total === 0) {
482
+ L.push('No exceptions: no sampled item failed an applicable attribute.');
483
+ } else {
484
+ L.push(`${pack.exceptions.total} failed attribute observation(s):`);
485
+ L.push('');
486
+ L.push('| Entry hash | Seq | Attribute | Evidence field(s) | Observed |');
487
+ L.push('| --- | ---: | --- | --- | --- |');
488
+ for (const x of pack.exceptions.items) {
489
+ L.push(`| \`${cell(x.entry_hash)}\` | ${cell(x.seq)} | ${x.attribute} ${cell(x.name)} | ${cell(x.evidence_field)} | ${cell(x.observed)} |`);
490
+ }
491
+ }
492
+ L.push('');
493
+
494
+ L.push('## Completeness');
495
+ L.push('');
496
+ L.push(`- Log entries supplied: ${pack.completeness.entries_supplied} · in window: ${pack.completeness.entries_in_window} · population: ${pack.completeness.population_size}`);
497
+ L.push(`- First population hash: ${pack.completeness.first_population_hash ? `\`${cell(pack.completeness.first_population_hash)}\`` : '—'}`);
498
+ L.push(`- Last population hash: ${pack.completeness.last_population_hash ? `\`${cell(pack.completeness.last_population_hash)}\`` : '—'}`);
499
+ L.push(`- Evidence chain head at export: ${pack.completeness.chain_head ? `\`${cell(pack.completeness.chain_head)}\`` : '—'}`);
500
+ L.push('');
501
+
502
+ L.push('## Integrity warnings');
503
+ if (pack.integrity_warnings.length === 0) {
504
+ L.push('');
505
+ L.push('None.');
506
+ } else {
507
+ L.push('');
508
+ L.push(`${pack.integrity_warnings.length} supplied entr(ies) could not be verified as log records and are EXCLUDED from the population above:`);
509
+ L.push('');
510
+ L.push('| Index | Seq | Problem |');
511
+ L.push('| ---: | ---: | --- |');
512
+ for (const w of pack.integrity_warnings) {
513
+ L.push(`| ${w.index} | ${cell(w.seq)} | ${cell(w.problem)} |`);
514
+ }
515
+ }
516
+ L.push('');
517
+
518
+ L.push('## Sign-off (auditor completes)');
519
+ L.push('');
520
+ L.push('_These fields are intentionally blank. The preparer of this workpaper does not perform, review, or conclude the test._');
521
+ L.push('');
522
+ L.push('| Field | Entry |');
523
+ L.push('| --- | --- |');
524
+ L.push(`| Tested by / date | ${BLANK} |`);
525
+ L.push(`| Reviewed by / date | ${BLANK} |`);
526
+ L.push(`| Conclusion | ${BLANK} |`);
527
+ L.push('');
528
+ return L.join('\n');
529
+ }
530
+
531
+ export default {
532
+ AUDIT_WORKPAPER_VERSION,
533
+ AUDIT_WORKPAPER_HONESTY_NOTICE,
534
+ AUDIT_ATTRIBUTES,
535
+ REFUSAL_TREATMENT,
536
+ buildAuditWorkpaper,
537
+ renderMarkdown,
538
+ };