@kashscript/hudhud 0.1.0 → 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.
Files changed (44) hide show
  1. package/LICENSE +135 -133
  2. package/README.md +11 -0
  3. package/dist/certificate/certificate.d.ts +11 -4
  4. package/dist/certificate/certificate.d.ts.map +1 -1
  5. package/dist/certificate/certificate.js +21 -18
  6. package/dist/certificate/certificate.js.map +1 -1
  7. package/dist/certificate/html.d.ts.map +1 -1
  8. package/dist/certificate/html.js +1 -2
  9. package/dist/certificate/html.js.map +1 -1
  10. package/dist/errors.d.ts +29 -2
  11. package/dist/errors.d.ts.map +1 -1
  12. package/dist/errors.js +6 -2
  13. package/dist/errors.js.map +1 -1
  14. package/dist/exception/index.d.ts +127 -24
  15. package/dist/exception/index.d.ts.map +1 -1
  16. package/dist/exception/index.js +209 -15
  17. package/dist/exception/index.js.map +1 -1
  18. package/dist/index.d.ts +1 -1
  19. package/dist/index.js +1 -1
  20. package/dist/ledger/writer.d.ts +10 -2
  21. package/dist/ledger/writer.d.ts.map +1 -1
  22. package/dist/ledger/writer.js +16 -4
  23. package/dist/ledger/writer.js.map +1 -1
  24. package/dist/runtime/escalation.d.ts +5 -0
  25. package/dist/runtime/escalation.d.ts.map +1 -1
  26. package/dist/runtime/escalation.js +15 -3
  27. package/dist/runtime/escalation.js.map +1 -1
  28. package/dist/sop/compiler.d.ts.map +1 -1
  29. package/dist/sop/compiler.js +2 -2
  30. package/dist/sop/compiler.js.map +1 -1
  31. package/dist/sop/service.d.ts +5 -0
  32. package/dist/sop/service.d.ts.map +1 -1
  33. package/dist/sop/service.js +25 -6
  34. package/dist/sop/service.js.map +1 -1
  35. package/package.json +3 -3
  36. package/src/certificate/certificate.ts +25 -17
  37. package/src/certificate/html.ts +1 -2
  38. package/src/errors.ts +27 -2
  39. package/src/exception/index.ts +315 -33
  40. package/src/index.ts +1 -1
  41. package/src/ledger/writer.ts +24 -4
  42. package/src/runtime/escalation.ts +21 -9
  43. package/src/sop/compiler.ts +2 -1
  44. package/src/sop/service.ts +30 -5
@@ -10,21 +10,72 @@
10
10
  // bytes (`id` + `timestampIso` minted INTO the signed payload).
11
11
  // Ported from the oreoasis-core-host engine (M5); the DST is neutralized, the
12
12
  // error reuses hudhud's `ExceptionClassificationError`, id generation inlined.
13
+ //
14
+ // ── Schema v2 (2026-09-23, ECOSYSTEM d43 §4.2) ──────────────────────────────
15
+ // v1 classifies WHERE A MODEL FAILED. d43 classifies HOW A DOER DEVIATED —
16
+ // error · at-risk · reckless · improvement — and an improvement is not a
17
+ // failure, so the second taxonomy is not an extension of the first. v2 carries
18
+ // both as ORTHOGONAL fields on one record: a mandatory `deviationClass` and an
19
+ // optional `failureClass`, present only when a model was involved (`model` is
20
+ // optional too; its absence means "no model", never "unknown"). One record, one
21
+ // operator signature, one fact; the model-failure dataset survives intact.
22
+ // v1 is frozen as a wire format: its DST, schema label and header are unchanged
23
+ // and `verifyClassificationSignature` accepts both versions forever.
24
+ //
25
+ // ── The doer's account (d43: no classification of a person is one-sided) ────
26
+ // The doer may attach their own account, countersigned over the exact body the
27
+ // operator signed. Two guards, both structural rather than policy:
28
+ // • UNLINKABLE. `attachDoerAccount` never accepts a DID — only the doer's
29
+ // seed, from which it derives a key for exactly ONE classification
30
+ // (identity-server's relationship derivation, salted by operator + record
31
+ // id). The same doer at two establishments — or twice at one — shows two
32
+ // unrelated DIDs, so the records cannot be joined into a per-person
33
+ // deviation history (§4.7). The doer, holding the seed, can re-derive each
34
+ // key and prove authorship of their own records (§4.8).
35
+ // • DECLINING IS FREE. Not signing is represented by ABSENCE of the
36
+ // `doerAccount` key — no null, no status, no "invited"/"declined" flag. A
37
+ // record without an account names no doer at all, so there is nothing to
38
+ // aggregate against a person; and absence reads identically whether the doer
39
+ // declined, was never asked, or is an AI helper.
40
+ // Limit, stated: an operator can still type a name into `resolutionSummary`.
41
+ // The schema cannot stop free text; it only refuses to make the join easy.
13
42
  // ============================================================================
14
43
 
15
- import { signEvent, verifyEvent } from "@kashscript/identity-core";
44
+ import { parseDid, signEvent, verifyEvent } from "@kashscript/identity-core";
45
+ import { deriveRelationshipSalt, deriveRelationshipSigningKey } from "@kashscript/identity-server";
46
+ import type { Did, RelationshipSigningKey } from "@kashscript/identity-server";
16
47
  import { ExceptionClassificationError } from "../errors";
48
+ // Re-export the error this module throws so a consumer can catch it from the
49
+ // same subpath it imports the mandatory gate from.
50
+ export { ExceptionClassificationError } from "../errors";
17
51
 
18
- /** The failure classes. No "unclassified": classification is mandatory. */
52
+ /** The model-failure classes v1's mandatory class, v2's OPTIONAL one
53
+ * (meaningful only when a model was involved). No "unclassified". */
19
54
  export const FAILURE_CLASSES = ["judgment", "gap-filling", "ambiguous"] as const;
20
55
  export type FailureClass = (typeof FAILURE_CLASSES)[number];
21
56
 
22
- /** DST so a classification signature can't replay as a ledger/attestation sig.
23
- * Neutral default; a venture passes its own DST at swap for wire-compat. */
57
+ /** d43 §4.2 — how a doer deviated. Mandatory on v2 records; no default, no
58
+ * "unclassified". error adjust the system, not the person · at-risk → coach
59
+ * · reckless → the only punitive branch · improvement → adopt. */
60
+ export const DEVIATION_CLASSES = ["error", "at-risk", "reckless", "improvement"] as const;
61
+ export type DeviationClass = (typeof DEVIATION_CLASSES)[number];
62
+
63
+ /** v1 signed label (DST) — v1 records verify under it forever. New records are
64
+ * v2 (`HUDHUD_EXCEPTION_CLASSIFICATION_V2_DST`). */
24
65
  export const HUDHUD_EXCEPTION_CLASSIFICATION_DST = "kash-exception-classification-v1\n" as const;
66
+ /** v1 schema label — frozen; see `EXCEPTION_CLASSIFICATION_SCHEMA_VERSION_V2`. */
25
67
  export const EXCEPTION_CLASSIFICATION_SCHEMA_VERSION = "exception-classification/1" as const;
26
68
  const SIGN_HEADER = { kind: "exception-classification/1" } as const;
27
69
 
70
+ /** v2 labels — registered in `@kashscript/attest/dst` (0.1.7) before first use. */
71
+ export const HUDHUD_EXCEPTION_CLASSIFICATION_V2_DST = "kash-exception-classification-v2\n" as const;
72
+ export const EXCEPTION_CLASSIFICATION_SCHEMA_VERSION_V2 = "exception-classification/2" as const;
73
+ const SIGN_HEADER_V2 = { kind: "exception-classification/2" } as const;
74
+ /** The doer's countersignature DST — so it can never replay as the operator's. */
75
+ export const HUDHUD_EXCEPTION_DOER_ACCOUNT_DST = "kash-exception-doer-account-v1\n" as const;
76
+ const DOER_SIGN_HEADER = { kind: "exception-doer-account/1" } as const;
77
+ const DOER_CONTEXT_PREFIX = "hudhud/exception-doer-account:";
78
+
28
79
  let lastIdMicros = 0;
29
80
  function newRecordId(): string {
30
81
  let micros = Date.now() * 1000;
@@ -36,9 +87,7 @@ function newRecordId(): string {
36
87
  return `exc_${micros.toString(36)}_${hex}`;
37
88
  }
38
89
 
39
- /** The mandatory gate throws `ExceptionClassificationError` if `value` is
40
- * absent/empty or not one of the classes. NOT merely the TS type. */
41
- export function assertFailureClass(value: unknown): asserts value is FailureClass {
90
+ function gateFailureClass(value: unknown): asserts value is FailureClass {
42
91
  if (value === undefined || value === null || value === "") {
43
92
  throw new ExceptionClassificationError(
44
93
  `FAILURE_CLASS_REQUIRED — an exception cannot be resolved without a failureClass (one of ${FAILURE_CLASSES.join(" | ")})`,
@@ -51,6 +100,29 @@ export function assertFailureClass(value: unknown): asserts value is FailureClas
51
100
  }
52
101
  }
53
102
 
103
+ /** The v1 mandatory gate — throws `ExceptionClassificationError` if `value` is
104
+ * absent/empty or not one of the classes. NOT merely the TS type.
105
+ * @deprecated New records are v2: gate with `assertDeviationClass`; the model
106
+ * class is optional there. Kept so existing callers compile unchanged. */
107
+ export function assertFailureClass(value: unknown): asserts value is FailureClass {
108
+ gateFailureClass(value);
109
+ }
110
+
111
+ /** The v2 mandatory gate — throws `ExceptionClassificationError` if `value` is
112
+ * absent/empty or not one of the four deviation classes. */
113
+ export function assertDeviationClass(value: unknown): asserts value is DeviationClass {
114
+ if (value === undefined || value === null || value === "") {
115
+ throw new ExceptionClassificationError(
116
+ `DEVIATION_CLASS_REQUIRED — a deviation cannot be resolved without a deviationClass (one of ${DEVIATION_CLASSES.join(" | ")})`,
117
+ );
118
+ }
119
+ if (!(DEVIATION_CLASSES as ReadonlyArray<string>).includes(value as string)) {
120
+ throw new ExceptionClassificationError(
121
+ `DEVIATION_CLASS_INVALID — deviationClass must be one of ${DEVIATION_CLASSES.join(" | ")} (got ${String(value)})`,
122
+ );
123
+ }
124
+ }
125
+
54
126
  export interface ExceptionClassificationInput {
55
127
  readonly failureClass: FailureClass;
56
128
  readonly failureTag: string;
@@ -97,14 +169,15 @@ function unsignedOf(record: ExceptionClassificationRecord): UnsignedClassificati
97
169
  return rest;
98
170
  }
99
171
 
100
- /** Mint + sign a classification record (id/timestamp minted into the signed
101
- * payload). Throws the mandatory gate if `failureClass` is missing/invalid. */
172
+ /** Mint + sign a v1 classification record (id/timestamp minted into the signed
173
+ * payload). Throws the mandatory gate if `failureClass` is missing/invalid.
174
+ * @deprecated Mint v2 with `buildSignedDeviationClassification` (d43). */
102
175
  export async function buildSignedClassification(
103
176
  input: ExceptionClassificationInput,
104
177
  operator: { readonly did: string; readonly privateKey: Uint8Array },
105
178
  link: { readonly escalationId?: string | null; readonly signoffLedgerStepIndex?: number | null } = {},
106
179
  ): Promise<ExceptionClassificationRecord> {
107
- assertFailureClass(input.failureClass);
180
+ gateFailureClass(input.failureClass);
108
181
  const unsigned: UnsignedClassification = {
109
182
  id: newRecordId(),
110
183
  schemaVersion: EXCEPTION_CLASSIFICATION_SCHEMA_VERSION,
@@ -134,15 +207,138 @@ export async function buildSignedClassification(
134
207
  };
135
208
  }
136
209
 
137
- /** Verify a record's operator signature. Returns false on a well-formed mismatch. */
210
+ // ── Schema v2 ──────────────────────────────────────────────────────────────
211
+
212
+ export interface DeviationClassificationInput {
213
+ readonly deviationClass: DeviationClass;
214
+ /** Where a model failed — ONLY when a model was involved; requires `model`. */
215
+ readonly failureClass?: FailureClass;
216
+ readonly deviationTag: string;
217
+ readonly workflowId: string;
218
+ /** The rule version the work was judged against (d43: due process in the schema). */
219
+ readonly sopVersion: string;
220
+ readonly inputRef: string;
221
+ readonly contentHash: string;
222
+ /** Omit for human work. Absence means "no model was involved". */
223
+ readonly model?: string;
224
+ /** Requires `model`. */
225
+ readonly modelVersion?: string;
226
+ readonly resolutionSummary: string;
227
+ readonly resolutionActionRefs: ReadonlyArray<string>;
228
+ }
229
+
230
+ /** The doer's own account, countersigned with a per-classification key. */
231
+ export interface DoerAccount {
232
+ /** A `did:kash` derived for THIS classification only — never the doer's
233
+ * stable identifier. Self-certifying: the verifier needs no lookup. */
234
+ readonly doerDid: string;
235
+ readonly account: string;
236
+ readonly signedEnvelope: ClassificationSignedEnvelope;
237
+ }
238
+
239
+ export interface ExceptionClassificationRecordV2 {
240
+ readonly id: string;
241
+ readonly schemaVersion: typeof EXCEPTION_CLASSIFICATION_SCHEMA_VERSION_V2;
242
+ readonly timestampIso: string;
243
+ readonly workflowId: string;
244
+ readonly sopVersion: string;
245
+ readonly inputRef: string;
246
+ readonly contentHash: string;
247
+ readonly model?: string;
248
+ readonly modelVersion?: string;
249
+ readonly deviationClass: DeviationClass;
250
+ readonly failureClass?: FailureClass;
251
+ readonly deviationTag: string;
252
+ readonly operatorDid: string;
253
+ readonly resolutionSummary: string;
254
+ readonly resolutionActionRefs: ReadonlyArray<string>;
255
+ readonly escalationId: string | null;
256
+ readonly signoffLedgerStepIndex: number | null;
257
+ /** The operator's signature over every field above. */
258
+ readonly signedEnvelope: ClassificationSignedEnvelope;
259
+ /** Present only when the doer signed. Absence is the ONLY representation of
260
+ * declining — see the module header. */
261
+ readonly doerAccount?: DoerAccount;
262
+ }
263
+
264
+ export type AnyExceptionClassificationRecord = ExceptionClassificationRecord | ExceptionClassificationRecordV2;
265
+
266
+ export function isV2Classification(record: AnyExceptionClassificationRecord): record is ExceptionClassificationRecordV2 {
267
+ return record.schemaVersion === EXCEPTION_CLASSIFICATION_SCHEMA_VERSION_V2;
268
+ }
269
+
270
+ type UnsignedClassificationV2 = Omit<ExceptionClassificationRecordV2, "signedEnvelope" | "doerAccount">;
271
+ function unsignedOfV2(record: ExceptionClassificationRecordV2): UnsignedClassificationV2 {
272
+ const { signedEnvelope: _sig, doerAccount: _doer, ...rest } = record;
273
+ return rest;
274
+ }
275
+
276
+ /** Mint + sign a v2 (d43) classification. Gates: `deviationClass` mandatory;
277
+ * `failureClass` only with `model`; `model`/`modelVersion` non-empty when
278
+ * present and OMITTED (never "") when there was no model. */
279
+ export async function buildSignedDeviationClassification(
280
+ input: DeviationClassificationInput,
281
+ operator: { readonly did: string; readonly privateKey: Uint8Array },
282
+ link: { readonly escalationId?: string | null; readonly signoffLedgerStepIndex?: number | null } = {},
283
+ ): Promise<ExceptionClassificationRecordV2> {
284
+ assertDeviationClass(input.deviationClass);
285
+ if (input.model !== undefined && (typeof input.model !== "string" || input.model === "")) {
286
+ throw new ExceptionClassificationError("MODEL_INVALID — `model`, when present, must be non-empty; omit it for work no model touched");
287
+ }
288
+ if (input.modelVersion !== undefined && (input.model === undefined || typeof input.modelVersion !== "string" || input.modelVersion === "")) {
289
+ throw new ExceptionClassificationError("MODEL_VERSION_INVALID — `modelVersion` must be non-empty and requires `model`");
290
+ }
291
+ if (input.failureClass !== undefined) {
292
+ gateFailureClass(input.failureClass);
293
+ if (input.model === undefined) {
294
+ throw new ExceptionClassificationError("FAILURE_CLASS_WITHOUT_MODEL — failureClass classifies where a model failed; it requires `model`");
295
+ }
296
+ }
297
+ const unsigned: UnsignedClassificationV2 = {
298
+ id: newRecordId(),
299
+ schemaVersion: EXCEPTION_CLASSIFICATION_SCHEMA_VERSION_V2,
300
+ timestampIso: new Date().toISOString(),
301
+ workflowId: input.workflowId,
302
+ sopVersion: input.sopVersion,
303
+ inputRef: input.inputRef,
304
+ contentHash: input.contentHash,
305
+ ...(input.model !== undefined ? { model: input.model } : {}),
306
+ ...(input.modelVersion !== undefined ? { modelVersion: input.modelVersion } : {}),
307
+ deviationClass: input.deviationClass,
308
+ ...(input.failureClass !== undefined ? { failureClass: input.failureClass } : {}),
309
+ deviationTag: input.deviationTag,
310
+ operatorDid: operator.did,
311
+ resolutionSummary: input.resolutionSummary,
312
+ resolutionActionRefs: [...input.resolutionActionRefs],
313
+ escalationId: link.escalationId ?? null,
314
+ signoffLedgerStepIndex: link.signoffLedgerStepIndex ?? null,
315
+ };
316
+ const signed = await signEvent(SIGN_HEADER_V2, unsigned, operator.privateKey, {
317
+ dst: HUDHUD_EXCEPTION_CLASSIFICATION_V2_DST,
318
+ encoding: "hex",
319
+ kid: operator.did,
320
+ });
321
+ return {
322
+ ...unsigned,
323
+ signedEnvelope: { alg: "ed25519", sig: signed.sig, dst: HUDHUD_EXCEPTION_CLASSIFICATION_V2_DST, kid: operator.did },
324
+ };
325
+ }
326
+
327
+ /** Verify a record's operator signature — v1 or v2, dispatched on
328
+ * `schemaVersion`. Returns false on a well-formed mismatch. The doer's
329
+ * account, if any, is outside the operator's bytes: check it with
330
+ * `verifyDoerAccount`. */
138
331
  export async function verifyClassificationSignature(
139
- record: ExceptionClassificationRecord,
332
+ record: AnyExceptionClassificationRecord,
140
333
  operatorPublicKey: Uint8Array,
141
334
  ): Promise<boolean> {
335
+ const v2 = isV2Classification(record);
336
+ // v2 pins its DST: a v2 body is never accepted under any other tag.
337
+ if (v2 && record.signedEnvelope.dst !== HUDHUD_EXCEPTION_CLASSIFICATION_V2_DST) return false;
142
338
  return verifyEvent(
143
339
  {
144
- header: SIGN_HEADER,
145
- body: unsignedOf(record),
340
+ header: v2 ? SIGN_HEADER_V2 : SIGN_HEADER,
341
+ body: v2 ? unsignedOfV2(record) : unsignedOf(record as ExceptionClassificationRecord),
146
342
  signature: {
147
343
  alg: "ed25519",
148
344
  sig: record.signedEnvelope.sig,
@@ -155,27 +351,111 @@ export async function verifyClassificationSignature(
155
351
  );
156
352
  }
157
353
 
354
+ // ── The doer's account ─────────────────────────────────────────────────────
355
+
356
+ function doerSignedBody(record: ExceptionClassificationRecordV2, doerDid: string, account: string) {
357
+ return { subject: unsignedOfV2(record), doerDid, account };
358
+ }
359
+
360
+ /** Derive the doer's signing key for exactly ONE classification. Deterministic,
361
+ * so the doer (holding the seed) can re-derive it and prove a record is theirs;
362
+ * unlinkable to every other classification without the seed. */
363
+ export async function deriveDoerAccountKey(
364
+ masterSeed: Uint8Array,
365
+ record: Pick<ExceptionClassificationRecordV2, "id" | "operatorDid">,
366
+ ): Promise<RelationshipSigningKey> {
367
+ if (!(masterSeed instanceof Uint8Array) || masterSeed.length < 32) {
368
+ throw new ExceptionClassificationError("DOER_SEED_INVALID — the doer's master seed must be at least 32 bytes");
369
+ }
370
+ const relationshipSalt = await deriveRelationshipSalt({
371
+ requesterDid: record.operatorDid as Did,
372
+ contextLabel: DOER_CONTEXT_PREFIX + record.id,
373
+ });
374
+ return deriveRelationshipSigningKey({ masterSeed, relationshipSalt });
375
+ }
376
+
377
+ /** Attach the doer's own account + countersignature over the exact bytes the
378
+ * operator signed. Takes the doer's SEED, never a DID — so no caller can write
379
+ * a stable identifier into the record. Declining = never calling this. */
380
+ export async function attachDoerAccount(
381
+ record: ExceptionClassificationRecordV2,
382
+ doer: { readonly masterSeed: Uint8Array },
383
+ account: string,
384
+ ): Promise<ExceptionClassificationRecordV2> {
385
+ if (!isV2Classification(record)) {
386
+ throw new ExceptionClassificationError("DOER_ACCOUNT_REQUIRES_V2 — a doer account attaches to an exception-classification/2 record");
387
+ }
388
+ if (record.doerAccount !== undefined) {
389
+ throw new ExceptionClassificationError("DOER_ACCOUNT_ALREADY_ATTACHED — a record carries at most one doer account");
390
+ }
391
+ if (typeof account !== "string" || account.trim() === "") {
392
+ throw new ExceptionClassificationError("DOER_ACCOUNT_EMPTY — an attached account must say something; to decline, attach nothing");
393
+ }
394
+ const key = await deriveDoerAccountKey(doer.masterSeed, record);
395
+ const signed = await signEvent(DOER_SIGN_HEADER, doerSignedBody(record, key.did, account), key.privateKey, {
396
+ dst: HUDHUD_EXCEPTION_DOER_ACCOUNT_DST,
397
+ encoding: "hex",
398
+ kid: key.did,
399
+ });
400
+ return {
401
+ ...record,
402
+ doerAccount: {
403
+ doerDid: key.did,
404
+ account,
405
+ signedEnvelope: { alg: "ed25519", sig: signed.sig, dst: HUDHUD_EXCEPTION_DOER_ACCOUNT_DST, kid: key.did },
406
+ },
407
+ };
408
+ }
409
+
410
+ /** Verify the doer's countersignature offline (the key comes from the
411
+ * self-certifying `doerDid`). False when absent, tampered, or re-bound to a
412
+ * different classification. Absence is not a failure of the doer — callers
413
+ * must not treat `false`-because-absent as a mark (check `doerAccount` first). */
414
+ export async function verifyDoerAccount(record: ExceptionClassificationRecordV2): Promise<boolean> {
415
+ const d = record.doerAccount;
416
+ if (d === undefined) return false;
417
+ if (d.signedEnvelope.dst !== HUDHUD_EXCEPTION_DOER_ACCOUNT_DST || d.signedEnvelope.kid !== d.doerDid) return false;
418
+ let publicKey: Uint8Array;
419
+ try {
420
+ publicKey = parseDid(d.doerDid);
421
+ } catch {
422
+ return false;
423
+ }
424
+ return verifyEvent(
425
+ {
426
+ header: DOER_SIGN_HEADER,
427
+ body: doerSignedBody(record, d.doerDid, d.account),
428
+ signature: { alg: "ed25519", sig: d.signedEnvelope.sig, dst: d.signedEnvelope.dst, encoding: "hex", kid: d.signedEnvelope.kid },
429
+ },
430
+ publicKey,
431
+ );
432
+ }
433
+
158
434
  // ── Store (append-only) ────────────────────────────────────────────────────
159
435
 
160
- export interface ExceptionClassificationStore {
161
- append(record: ExceptionClassificationRecord): Promise<void>;
162
- history(): Promise<ReadonlyArray<ExceptionClassificationRecord>>;
163
- latest(): Promise<ExceptionClassificationRecord | null>;
164
- byWorkflow(workflowId: string): Promise<ReadonlyArray<ExceptionClassificationRecord>>;
436
+ /** Generic over the record version; defaults to v1 so existing implementers
437
+ * compile unchanged. */
438
+ export interface ExceptionClassificationStore<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord> {
439
+ append(record: R): Promise<void>;
440
+ history(): Promise<ReadonlyArray<R>>;
441
+ latest(): Promise<R | null>;
442
+ byWorkflow(workflowId: string): Promise<ReadonlyArray<R>>;
165
443
  }
166
444
 
167
- export class InMemoryExceptionClassificationStore implements ExceptionClassificationStore {
168
- private readonly rows: ExceptionClassificationRecord[] = [];
169
- async append(record: ExceptionClassificationRecord): Promise<void> {
445
+ export class InMemoryExceptionClassificationStore<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord>
446
+ implements ExceptionClassificationStore<R>
447
+ {
448
+ private readonly rows: R[] = [];
449
+ async append(record: R): Promise<void> {
170
450
  this.rows.push(record);
171
451
  }
172
- async history(): Promise<ReadonlyArray<ExceptionClassificationRecord>> {
452
+ async history(): Promise<ReadonlyArray<R>> {
173
453
  return [...this.rows];
174
454
  }
175
- async latest(): Promise<ExceptionClassificationRecord | null> {
455
+ async latest(): Promise<R | null> {
176
456
  return this.rows.length === 0 ? null : this.rows[this.rows.length - 1]!;
177
457
  }
178
- async byWorkflow(workflowId: string): Promise<ReadonlyArray<ExceptionClassificationRecord>> {
458
+ async byWorkflow(workflowId: string): Promise<ReadonlyArray<R>> {
179
459
  return this.rows.filter((r) => r.workflowId === workflowId);
180
460
  }
181
461
  }
@@ -184,18 +464,18 @@ export class InMemoryExceptionClassificationStore implements ExceptionClassifica
184
464
 
185
465
  export const EXCEPTION_DATASET_FORMAT_VERSION = "kash-exception-dataset/1" as const;
186
466
 
187
- export interface ExceptionClassificationDatasetExport {
467
+ export interface ExceptionClassificationDatasetExport<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord> {
188
468
  readonly formatVersion: string;
189
469
  readonly exportedAtIso: string;
190
470
  readonly count: number;
191
- readonly records: ReadonlyArray<ExceptionClassificationRecord>;
471
+ readonly records: ReadonlyArray<R>;
192
472
  }
193
473
 
194
474
  /** Bundle the dataset for hand-off (records carry their own signatures). */
195
- export function exportExceptionClassificationDataset(
196
- records: ReadonlyArray<ExceptionClassificationRecord>,
475
+ export function exportExceptionClassificationDataset<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord>(
476
+ records: ReadonlyArray<R>,
197
477
  exportedAtIso: string = new Date().toISOString(),
198
- ): ExceptionClassificationDatasetExport {
478
+ ): ExceptionClassificationDatasetExport<R> {
199
479
  return { formatVersion: EXCEPTION_DATASET_FORMAT_VERSION, exportedAtIso, count: records.length, records: [...records] };
200
480
  }
201
481
 
@@ -218,15 +498,17 @@ function emptyClassMix(): Record<FailureClass, number> {
218
498
  return mix;
219
499
  }
220
500
 
221
- /** Class mix + per-workflow (+ over-time) exception counts (the rate numerator). */
501
+ /** Model-failure class mix + per-workflow (+ over-time) exception counts. A v2
502
+ * record counts toward `classMix` only when it carries a `failureClass`.
503
+ * Deliberately NO deviation-class mix and nothing per doer (d43 §4.7). */
222
504
  export function computeExceptionClassificationStats(
223
- records: ReadonlyArray<ExceptionClassificationRecord>,
505
+ records: ReadonlyArray<AnyExceptionClassificationRecord>,
224
506
  ): ExceptionClassificationStats {
225
507
  const classMix = emptyClassMix();
226
508
  const perWorkflow = new Map<string, number>();
227
509
  const perWfMonth = new Map<string, number>();
228
510
  for (const r of records) {
229
- classMix[r.failureClass] += 1;
511
+ if (r.failureClass !== undefined) classMix[r.failureClass] += 1;
230
512
  perWorkflow.set(r.workflowId, (perWorkflow.get(r.workflowId) ?? 0) + 1);
231
513
  const key = `${r.workflowId} ${r.timestampIso.slice(0, 7)}`;
232
514
  perWfMonth.set(key, (perWfMonth.get(key) ?? 0) + 1);
package/src/index.ts CHANGED
@@ -30,4 +30,4 @@ export * from "./identity";
30
30
  export * from "./runtime";
31
31
 
32
32
  /** The hudhud protocol/package version (M5 initial cut). */
33
- export const HUDHUD_VERSION = "0.1.0" as const;
33
+ export const HUDHUD_VERSION = "0.1.1" as const;
@@ -38,14 +38,26 @@ export interface HostActorSigner {
38
38
  sign(payload: LedgerSignaturePayload): Promise<string>;
39
39
  }
40
40
 
41
- /** Build a HostActorSigner from a raw Ed25519 private key. `actorDid` must be a
42
- * did:kash and be registered in the DidRegistry the service verifies against. */
41
+ /** Default DID acceptance policy: the canonical `did:kash:<base58btc>` shape
42
+ * (identity-core `DID_KASH_RE` / attest `KASH_DID_PATTERN`). A consumer whose
43
+ * DID method-specific-ids are STRUCTURED (e.g. hierarchical role DIDs such as
44
+ * `did:kash:oreoasis:kernel:host`) injects its own validator — DID-format is an
45
+ * identity-method policy the CONSUMER owns; the ledger's hard gates stay
46
+ * registry-resolution + signature-verification, not string shape. */
47
+ const isCanonicalKashDid = (did: string): boolean => KASH_DID_PATTERN.test(did);
48
+
49
+ /** Build a HostActorSigner from a raw Ed25519 private key. `actorDid` must be
50
+ * accepted by `isValidActorDid` (default: canonical did:kash) and be registered
51
+ * in the DidRegistry the service verifies against. */
43
52
  export function createEd25519HostSigner(opts: {
44
53
  readonly actorDid: string;
45
54
  readonly actorClass: ActorClass;
46
55
  readonly privateKey: Uint8Array;
56
+ /** Override the DID acceptance policy (default: canonical `did:kash`). */
57
+ readonly isValidActorDid?: (did: string) => boolean;
47
58
  }): HostActorSigner {
48
- if (!KASH_DID_PATTERN.test(opts.actorDid)) {
59
+ const isValidActorDid = opts.isValidActorDid ?? isCanonicalKashDid;
60
+ if (!isValidActorDid(opts.actorDid)) {
49
61
  throw new LedgerAppendError("host signer actorDid is not a valid did:kash:… identifier");
50
62
  }
51
63
  return {
@@ -83,18 +95,26 @@ export interface ForensicLedgerServiceDeps {
83
95
  readonly didRegistry: DidRegistry;
84
96
  /** Required only for `appendActorTransition` (the self-signing path). */
85
97
  readonly hostSigner?: HostActorSigner;
98
+ /** DID acceptance policy for `actorDid` (default: canonical `did:kash`).
99
+ * DID-format is an identity-method policy the CONSUMER owns; the ledger's
100
+ * hard gates remain registry-resolution + signature-verification. */
101
+ readonly isValidActorDid?: (did: string) => boolean;
86
102
  }
87
103
 
88
104
  export class ForensicLedgerService {
89
105
  constructor(private readonly deps: ForensicLedgerServiceDeps) {}
90
106
 
107
+ private isValidActorDid(did: string): boolean {
108
+ return (this.deps.isValidActorDid ?? isCanonicalKashDid)(did);
109
+ }
110
+
91
111
  /** Append a caller-pre-signed transition. @throws LedgerAppendError on any
92
112
  * validation / signature / store failure. */
93
113
  async appendTransitionLog(input: AppendTransitionLogInput): Promise<AppendedLedgerBlock> {
94
114
  if (typeof input.sessionId !== "string" || input.sessionId.length === 0) {
95
115
  throw new LedgerAppendError("sessionId is required");
96
116
  }
97
- if (typeof input.actorDid !== "string" || !KASH_DID_PATTERN.test(input.actorDid)) {
117
+ if (typeof input.actorDid !== "string" || !this.isValidActorDid(input.actorDid)) {
98
118
  throw new LedgerAppendError("actorDid must be a valid did:kash:… identifier");
99
119
  }
100
120
  if (typeof input.signature !== "string" || input.signature.length === 0) {
@@ -34,12 +34,17 @@ import { resolvePreviousBlockHash } from "../ledger/store";
34
34
  import type { LedgerStore } from "../ledger/store";
35
35
  import type { ForensicLedgerService } from "../ledger/writer";
36
36
 
37
- // Both identities are ordinary `did:kash` keys (base58btc, attest's
38
- // KASH_DID_PATTERN) so the operator sign-off is a first-class ledger actor.
39
- // The two-key guarantee comes from DISTINCTNESS, not a DID namespace: the agent
40
- // key is the one the host holds; the operator key is SEPARATE, so the host
41
- // cannot mint the operator (invoker) signature. Enforced below by requiring
42
- // agent.did operator.did (their derived DIDs differ because their keys do).
37
+ // Both identities are `did:kash` keys so the operator sign-off is a first-class
38
+ // ledger actor. The two-key guarantee comes from DISTINCTNESS, not a DID
39
+ // namespace: the agent key is the one the host holds; the operator key is
40
+ // SEPARATE, so the host cannot mint the operator (invoker) signature. Enforced
41
+ // below by requiring agent.did ≠ operator.did (their derived DIDs differ because
42
+ // their keys do). The DID-STRING shape is an identity-method policy the consumer
43
+ // owns (`isValidDid`, default canonical); it is NOT a security boundary here.
44
+
45
+ /** Default DID acceptance policy: the canonical `did:kash:<base58btc>` shape.
46
+ * Consumers with structured DIDs (e.g. `did:kash:operator:…`) inject their own. */
47
+ const isCanonicalKashDid = (did: string): boolean => KASH_DID_PATTERN.test(did);
43
48
 
44
49
  export type EscalationErrorCode =
45
50
  | "AGENT_DID_INVALID"
@@ -80,6 +85,9 @@ export interface BuildHumanApprovalEnvelopeInput {
80
85
  /** The OPERATOR identity — the SEPARATE human key. */
81
86
  readonly operator: { readonly did: string; readonly privateKey: Uint8Array; readonly publicKey: Uint8Array };
82
87
  readonly ttlMs?: number;
88
+ /** Override the DID acceptance policy for agent/operator `did` (default:
89
+ * canonical `did:kash`). */
90
+ readonly isValidDid?: (did: string) => boolean;
83
91
  }
84
92
 
85
93
  /**
@@ -88,10 +96,11 @@ export interface BuildHumanApprovalEnvelopeInput {
88
96
  * operator), so forging human judgment would require the operator private key.
89
97
  */
90
98
  export async function buildHumanApprovalEnvelope(input: BuildHumanApprovalEnvelopeInput): Promise<SignedActionEnvelope> {
91
- if (!KASH_DID_PATTERN.test(input.agent.did)) {
99
+ const isValidDid = input.isValidDid ?? isCanonicalKashDid;
100
+ if (!isValidDid(input.agent.did)) {
92
101
  throw new EscalationError("AGENT_DID_INVALID", "agent.did must be a valid did:kash identifier");
93
102
  }
94
- if (!KASH_DID_PATTERN.test(input.operator.did)) {
103
+ if (!isValidDid(input.operator.did)) {
95
104
  throw new EscalationError("OPERATOR_DID_INVALID", "operator.did must be a valid did:kash identifier");
96
105
  }
97
106
  if (input.agent.did === input.operator.did) {
@@ -145,6 +154,8 @@ export interface AppendOperatorSignoffInput {
145
154
  /** MANDATORY classification (required `failureClass`, no "unclassified"). */
146
155
  readonly classification: ExceptionClassificationInput;
147
156
  readonly exceptionClassificationStore: ExceptionClassificationStore;
157
+ /** Override the DID acceptance policy for operator `did` (default: canonical). */
158
+ readonly isValidDid?: (did: string) => boolean;
148
159
  }
149
160
 
150
161
  /**
@@ -155,7 +166,8 @@ export interface AppendOperatorSignoffInput {
155
166
  export async function appendOperatorSignoff(
156
167
  input: AppendOperatorSignoffInput,
157
168
  ): Promise<{ stepIndex: number; classification: ExceptionClassificationRecord }> {
158
- if (!KASH_DID_PATTERN.test(input.operator.did)) {
169
+ const isValidDid = input.isValidDid ?? isCanonicalKashDid;
170
+ if (!isValidDid(input.operator.did)) {
159
171
  throw new EscalationError("OPERATOR_DID_INVALID", "operator.did must be a valid did:kash identifier");
160
172
  }
161
173
  assertFailureClass(input.classification.failureClass);
@@ -62,7 +62,7 @@ export async function compileSopStructural(input: CompileSopInput): Promise<Comp
62
62
  const lines = input.sopText.split(/\r?\n/);
63
63
 
64
64
  if (input.sopText.trim().length === 0) {
65
- throw new SopCompilationError("SOP text is empty or whitespace-only — at least one step required");
65
+ throw new SopCompilationError("SOP text is empty or whitespace-only — at least one step required", { kind: "empty" });
66
66
  }
67
67
 
68
68
  const nodes: TaskDagNode[] = [];
@@ -76,6 +76,7 @@ export async function compileSopStructural(input: CompileSopInput): Promise<Comp
76
76
  if (nodes.length === 0) {
77
77
  throw new SopCompilationError(
78
78
  "No numbered/bulleted steps detected — structural compiler requires `1.` / `-` / `*` / `•` prefixes",
79
+ { kind: "no-steps" },
79
80
  );
80
81
  }
81
82