@kashscript/hudhud 0.1.1 → 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 +135 -133
- package/README.md +11 -0
- package/dist/certificate/certificate.d.ts +11 -4
- package/dist/certificate/certificate.d.ts.map +1 -1
- package/dist/certificate/certificate.js +21 -18
- package/dist/certificate/certificate.js.map +1 -1
- package/dist/certificate/html.d.ts.map +1 -1
- package/dist/certificate/html.js +1 -2
- package/dist/certificate/html.js.map +1 -1
- package/dist/exception/index.d.ts +126 -24
- package/dist/exception/index.d.ts.map +1 -1
- package/dist/exception/index.js +206 -15
- package/dist/exception/index.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/certificate/certificate.ts +25 -17
- package/src/certificate/html.ts +1 -2
- package/src/exception/index.ts +312 -33
- package/src/index.ts +1 -1
|
@@ -16,9 +16,12 @@ import type { DsseEnvelope, DsseSigner } from "@kashscript/attest";
|
|
|
16
16
|
|
|
17
17
|
import type { AnchorRecord } from "../anchor";
|
|
18
18
|
import { FAILURE_CLASSES } from "../exception";
|
|
19
|
-
import type {
|
|
19
|
+
import type { AnyExceptionClassificationRecord, FailureClass } from "../exception";
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
/** /2 (2026-09-23, d43 §4.7): the summary no longer carries `automationRatePct`.
|
|
22
|
+
* /1 certificates still verify — including their rate, when present. */
|
|
23
|
+
export const WORK_CERTIFICATE_FORMAT_VERSION = "kash-work-certificate/2" as const;
|
|
24
|
+
export const WORK_CERTIFICATE_FORMAT_VERSION_V1 = "kash-work-certificate/1" as const;
|
|
22
25
|
export const WORK_CERTIFICATE_PAYLOAD_TYPE = "application/vnd.kash.work-certificate+json" as const;
|
|
23
26
|
|
|
24
27
|
/** The AIUC-1 standard's canonical reference (web-verified). */
|
|
@@ -53,7 +56,7 @@ export interface CertifiedWorkRecord {
|
|
|
53
56
|
readonly ledgerRowCount: number;
|
|
54
57
|
readonly ledgerTipHash: string;
|
|
55
58
|
readonly signoff: { readonly operatorDid: string; readonly ledgerStepIndex: number } | null;
|
|
56
|
-
readonly classification:
|
|
59
|
+
readonly classification: AnyExceptionClassificationRecord | null;
|
|
57
60
|
readonly anchor: AnchorRecord | null;
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -75,7 +78,11 @@ export interface BuildWorkCertificateInput {
|
|
|
75
78
|
export interface WorkCertificateSummary {
|
|
76
79
|
readonly workVolumeDelivered: number;
|
|
77
80
|
readonly workVolumeDeclined: number;
|
|
78
|
-
|
|
81
|
+
/** @deprecated Present only on `kash-work-certificate/1` certificates; never
|
|
82
|
+
* minted since /2 — founder decision 2026-09-23 under d43 §4.7 (no
|
|
83
|
+
* productivity metric). Still recomputed on verify when present, so an old
|
|
84
|
+
* certificate cannot claim a rate its own outcomes do not support. */
|
|
85
|
+
readonly automationRatePct?: number;
|
|
79
86
|
readonly exceptionCount: number;
|
|
80
87
|
readonly exceptionsByClass: Record<FailureClass, number>;
|
|
81
88
|
readonly humanSignoffCount: number;
|
|
@@ -138,26 +145,23 @@ function emptyClassMix(): Record<FailureClass, number> {
|
|
|
138
145
|
}
|
|
139
146
|
|
|
140
147
|
function summarize(records: ReadonlyArray<CertifiedWorkRecord>, slaTargetPct: number | undefined): WorkCertificateSummary {
|
|
141
|
-
let
|
|
142
|
-
let withSignoff = 0;
|
|
148
|
+
let delivered = 0;
|
|
143
149
|
let declined = 0;
|
|
144
150
|
let slaMet = 0;
|
|
145
151
|
let anchored = 0;
|
|
146
152
|
const exceptionsByClass = emptyClassMix();
|
|
147
153
|
for (const r of records) {
|
|
148
|
-
if (r.status === "
|
|
149
|
-
else
|
|
150
|
-
else declined += 1;
|
|
154
|
+
if (r.status === "declined") declined += 1;
|
|
155
|
+
else delivered += 1;
|
|
151
156
|
if (r.slaMet) slaMet += 1;
|
|
152
157
|
if (r.anchor !== null) anchored += 1;
|
|
153
|
-
|
|
158
|
+
const fc = r.classification?.failureClass;
|
|
159
|
+
if (fc !== undefined) exceptionsByClass[fc] += 1;
|
|
154
160
|
}
|
|
155
|
-
const delivered = autonomous + withSignoff;
|
|
156
161
|
const total = records.length;
|
|
157
162
|
return {
|
|
158
163
|
workVolumeDelivered: delivered,
|
|
159
164
|
workVolumeDeclined: declined,
|
|
160
|
-
automationRatePct: delivered === 0 ? 0 : round1((autonomous / delivered) * 100),
|
|
161
165
|
exceptionCount: records.filter((r) => r.classification !== null).length,
|
|
162
166
|
exceptionsByClass,
|
|
163
167
|
humanSignoffCount: records.filter((r) => r.signoff !== null).length,
|
|
@@ -179,9 +183,9 @@ function mapAiuc1Coverage(records: ReadonlyArray<CertifiedWorkRecord>, summary:
|
|
|
179
183
|
case "B":
|
|
180
184
|
return { ...d, status: "evidenced", evidence: [`Every action Ed25519-signed into an append-only, hash-chained ledger (${records.reduce((n, r) => n + r.ledgerRowCount, 0)} signed rows this period).`, anchoredEvidence] };
|
|
181
185
|
case "C":
|
|
182
|
-
return { ...d, status: "evidenced", evidence: [`${summary.exceptionCount} exception(s) flagged for human review and resolved this period; each classified
|
|
186
|
+
return { ...d, status: "evidenced", evidence: [`${summary.exceptionCount} exception(s) flagged for human review and resolved this period; each classified and signed by a named human (and, where a model was involved, by where the model failed).`, "Restricted actions suspend for human judgment rather than proceed autonomously."] };
|
|
183
187
|
case "D":
|
|
184
|
-
return { ...d, status: "evidenced", evidence: ["Tools restricted to a per-provider allowlist (scope-gating): an out-of-allowlist or restricted tool call cannot be dispatched by construction."
|
|
188
|
+
return { ...d, status: "evidenced", evidence: ["Tools restricted to a per-provider allowlist (scope-gating): an out-of-allowlist or restricted tool call cannot be dispatched by construction."] };
|
|
185
189
|
case "E":
|
|
186
190
|
return { ...d, status: "evidenced", evidence: ["Complete signed activity log (the ledger) for every work item; a self-contained audit bundle is independently verifiable offline.", `${summary.humanSignoffCount} human sign-off(s), each a SECOND-key signed ledger row (the host cannot forge human judgment).`] };
|
|
187
191
|
case "F":
|
|
@@ -273,7 +277,6 @@ function recomputeCountsMatch(body: WorkCertificateBody): ReadonlyArray<string>
|
|
|
273
277
|
const o = body.outcomes;
|
|
274
278
|
const delivered = o.filter((x) => x.status !== "declined").length;
|
|
275
279
|
const declined = o.filter((x) => x.status === "declined").length;
|
|
276
|
-
const autonomous = o.filter((x) => x.status === "delivered_autonomous").length;
|
|
277
280
|
const slaMet = o.filter((x) => x.slaMet).length;
|
|
278
281
|
const exceptionCount = o.filter((x) => x.classificationId !== null).length;
|
|
279
282
|
const signoffCount = o.filter((x) => x.signoffLedgerStepIndex !== null).length;
|
|
@@ -288,8 +291,13 @@ function recomputeCountsMatch(body: WorkCertificateBody): ReadonlyArray<string>
|
|
|
288
291
|
check("exceptionCount", exceptionCount, s.exceptionCount);
|
|
289
292
|
check("humanSignoffCount", signoffCount, s.humanSignoffCount);
|
|
290
293
|
check("anchoredCount", anchored, s.anchoredCount);
|
|
291
|
-
|
|
292
|
-
|
|
294
|
+
// /1 certificates carried a rate; it is still held to its own outcomes. /2
|
|
295
|
+
// never mints one, and a /2 body that claims one is checked all the same.
|
|
296
|
+
if (s.automationRatePct !== undefined) {
|
|
297
|
+
const autonomous = o.filter((x) => x.status === "delivered_autonomous").length;
|
|
298
|
+
const wantAutomation = delivered === 0 ? 0 : round1((autonomous / delivered) * 100);
|
|
299
|
+
if (wantAutomation !== s.automationRatePct) mismatches.push(`automationRatePct: recomputed=${wantAutomation} vs summary=${s.automationRatePct}`);
|
|
300
|
+
}
|
|
293
301
|
const wantSla = total === 0 ? 0 : round1((slaMet / total) * 100);
|
|
294
302
|
if (wantSla !== s.slaAdherencePct) mismatches.push(`slaAdherencePct: recomputed=${wantSla} vs summary=${s.slaAdherencePct}`);
|
|
295
303
|
const classMix = emptyClassMix();
|
package/src/certificate/html.ts
CHANGED
|
@@ -89,7 +89,7 @@ export function renderWorkCertificateHtml(body: WorkCertificateBody, brand: Work
|
|
|
89
89
|
.band .verifiable { margin-top: 12px; display: inline-block; font-size: 12px; background: rgba(255,255,255,.16); padding: 4px 10px; border-radius: 3px; }
|
|
90
90
|
.body { padding: 28px 40px 40px; }
|
|
91
91
|
h2 { font-size: 15px; text-transform: uppercase; letter-spacing: .06em; color: var(--accent); border-bottom: 2px solid #eceff2; padding-bottom: 6px; margin: 30px 0 14px; }
|
|
92
|
-
.cards { display: grid; grid-template-columns: repeat(
|
|
92
|
+
.cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
|
93
93
|
.card { border: 1px solid #e6eaee; border-radius: 5px; padding: 14px; }
|
|
94
94
|
.card-v { font-size: 24px; font-weight: 680; color: var(--accent); }
|
|
95
95
|
.card-l { font-size: 12px; font-weight: 600; margin-top: 2px; }
|
|
@@ -124,7 +124,6 @@ export function renderWorkCertificateHtml(body: WorkCertificateBody, brand: Work
|
|
|
124
124
|
<h2>Delivered work this period</h2>
|
|
125
125
|
<div class="cards">
|
|
126
126
|
${metricCard("Outcomes delivered", String(s.workVolumeDelivered), `${s.workVolumeDeclined} declined at sign-off`)}
|
|
127
|
-
${metricCard("Automation rate", `${s.automationRatePct}%`, "delivered without human touch")}
|
|
128
127
|
${metricCard("Exceptions", String(s.exceptionCount), `${s.humanSignoffCount} human sign-off(s)`)}
|
|
129
128
|
${metricCard("SLA adherence", `${s.slaAdherencePct}%`, s.slaTargetPct === null ? "no target set" : `target ${s.slaTargetPct}%`)}
|
|
130
129
|
</div>
|
package/src/exception/index.ts
CHANGED
|
@@ -10,24 +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";
|
|
17
48
|
// Re-export the error this module throws so a consumer can catch it from the
|
|
18
49
|
// same subpath it imports the mandatory gate from.
|
|
19
50
|
export { ExceptionClassificationError } from "../errors";
|
|
20
51
|
|
|
21
|
-
/** The failure classes
|
|
52
|
+
/** The model-failure classes — v1's mandatory class, v2's OPTIONAL one
|
|
53
|
+
* (meaningful only when a model was involved). No "unclassified". */
|
|
22
54
|
export const FAILURE_CLASSES = ["judgment", "gap-filling", "ambiguous"] as const;
|
|
23
55
|
export type FailureClass = (typeof FAILURE_CLASSES)[number];
|
|
24
56
|
|
|
25
|
-
/**
|
|
26
|
-
*
|
|
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`). */
|
|
27
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`. */
|
|
28
67
|
export const EXCEPTION_CLASSIFICATION_SCHEMA_VERSION = "exception-classification/1" as const;
|
|
29
68
|
const SIGN_HEADER = { kind: "exception-classification/1" } as const;
|
|
30
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
|
+
|
|
31
79
|
let lastIdMicros = 0;
|
|
32
80
|
function newRecordId(): string {
|
|
33
81
|
let micros = Date.now() * 1000;
|
|
@@ -39,9 +87,7 @@ function newRecordId(): string {
|
|
|
39
87
|
return `exc_${micros.toString(36)}_${hex}`;
|
|
40
88
|
}
|
|
41
89
|
|
|
42
|
-
|
|
43
|
-
* absent/empty or not one of the classes. NOT merely the TS type. */
|
|
44
|
-
export function assertFailureClass(value: unknown): asserts value is FailureClass {
|
|
90
|
+
function gateFailureClass(value: unknown): asserts value is FailureClass {
|
|
45
91
|
if (value === undefined || value === null || value === "") {
|
|
46
92
|
throw new ExceptionClassificationError(
|
|
47
93
|
`FAILURE_CLASS_REQUIRED — an exception cannot be resolved without a failureClass (one of ${FAILURE_CLASSES.join(" | ")})`,
|
|
@@ -54,6 +100,29 @@ export function assertFailureClass(value: unknown): asserts value is FailureClas
|
|
|
54
100
|
}
|
|
55
101
|
}
|
|
56
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
|
+
|
|
57
126
|
export interface ExceptionClassificationInput {
|
|
58
127
|
readonly failureClass: FailureClass;
|
|
59
128
|
readonly failureTag: string;
|
|
@@ -100,14 +169,15 @@ function unsignedOf(record: ExceptionClassificationRecord): UnsignedClassificati
|
|
|
100
169
|
return rest;
|
|
101
170
|
}
|
|
102
171
|
|
|
103
|
-
/** Mint + sign a classification record (id/timestamp minted into the signed
|
|
104
|
-
* 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). */
|
|
105
175
|
export async function buildSignedClassification(
|
|
106
176
|
input: ExceptionClassificationInput,
|
|
107
177
|
operator: { readonly did: string; readonly privateKey: Uint8Array },
|
|
108
178
|
link: { readonly escalationId?: string | null; readonly signoffLedgerStepIndex?: number | null } = {},
|
|
109
179
|
): Promise<ExceptionClassificationRecord> {
|
|
110
|
-
|
|
180
|
+
gateFailureClass(input.failureClass);
|
|
111
181
|
const unsigned: UnsignedClassification = {
|
|
112
182
|
id: newRecordId(),
|
|
113
183
|
schemaVersion: EXCEPTION_CLASSIFICATION_SCHEMA_VERSION,
|
|
@@ -137,15 +207,138 @@ export async function buildSignedClassification(
|
|
|
137
207
|
};
|
|
138
208
|
}
|
|
139
209
|
|
|
140
|
-
|
|
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`. */
|
|
141
331
|
export async function verifyClassificationSignature(
|
|
142
|
-
record:
|
|
332
|
+
record: AnyExceptionClassificationRecord,
|
|
143
333
|
operatorPublicKey: Uint8Array,
|
|
144
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;
|
|
145
338
|
return verifyEvent(
|
|
146
339
|
{
|
|
147
|
-
header: SIGN_HEADER,
|
|
148
|
-
body: unsignedOf(record),
|
|
340
|
+
header: v2 ? SIGN_HEADER_V2 : SIGN_HEADER,
|
|
341
|
+
body: v2 ? unsignedOfV2(record) : unsignedOf(record as ExceptionClassificationRecord),
|
|
149
342
|
signature: {
|
|
150
343
|
alg: "ed25519",
|
|
151
344
|
sig: record.signedEnvelope.sig,
|
|
@@ -158,27 +351,111 @@ export async function verifyClassificationSignature(
|
|
|
158
351
|
);
|
|
159
352
|
}
|
|
160
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
|
+
|
|
161
434
|
// ── Store (append-only) ────────────────────────────────────────────────────
|
|
162
435
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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>>;
|
|
168
443
|
}
|
|
169
444
|
|
|
170
|
-
export class InMemoryExceptionClassificationStore
|
|
171
|
-
|
|
172
|
-
|
|
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> {
|
|
173
450
|
this.rows.push(record);
|
|
174
451
|
}
|
|
175
|
-
async history(): Promise<ReadonlyArray<
|
|
452
|
+
async history(): Promise<ReadonlyArray<R>> {
|
|
176
453
|
return [...this.rows];
|
|
177
454
|
}
|
|
178
|
-
async latest(): Promise<
|
|
455
|
+
async latest(): Promise<R | null> {
|
|
179
456
|
return this.rows.length === 0 ? null : this.rows[this.rows.length - 1]!;
|
|
180
457
|
}
|
|
181
|
-
async byWorkflow(workflowId: string): Promise<ReadonlyArray<
|
|
458
|
+
async byWorkflow(workflowId: string): Promise<ReadonlyArray<R>> {
|
|
182
459
|
return this.rows.filter((r) => r.workflowId === workflowId);
|
|
183
460
|
}
|
|
184
461
|
}
|
|
@@ -187,18 +464,18 @@ export class InMemoryExceptionClassificationStore implements ExceptionClassifica
|
|
|
187
464
|
|
|
188
465
|
export const EXCEPTION_DATASET_FORMAT_VERSION = "kash-exception-dataset/1" as const;
|
|
189
466
|
|
|
190
|
-
export interface ExceptionClassificationDatasetExport {
|
|
467
|
+
export interface ExceptionClassificationDatasetExport<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord> {
|
|
191
468
|
readonly formatVersion: string;
|
|
192
469
|
readonly exportedAtIso: string;
|
|
193
470
|
readonly count: number;
|
|
194
|
-
readonly records: ReadonlyArray<
|
|
471
|
+
readonly records: ReadonlyArray<R>;
|
|
195
472
|
}
|
|
196
473
|
|
|
197
474
|
/** Bundle the dataset for hand-off (records carry their own signatures). */
|
|
198
|
-
export function exportExceptionClassificationDataset(
|
|
199
|
-
records: ReadonlyArray<
|
|
475
|
+
export function exportExceptionClassificationDataset<R extends AnyExceptionClassificationRecord = ExceptionClassificationRecord>(
|
|
476
|
+
records: ReadonlyArray<R>,
|
|
200
477
|
exportedAtIso: string = new Date().toISOString(),
|
|
201
|
-
): ExceptionClassificationDatasetExport {
|
|
478
|
+
): ExceptionClassificationDatasetExport<R> {
|
|
202
479
|
return { formatVersion: EXCEPTION_DATASET_FORMAT_VERSION, exportedAtIso, count: records.length, records: [...records] };
|
|
203
480
|
}
|
|
204
481
|
|
|
@@ -221,15 +498,17 @@ function emptyClassMix(): Record<FailureClass, number> {
|
|
|
221
498
|
return mix;
|
|
222
499
|
}
|
|
223
500
|
|
|
224
|
-
/**
|
|
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). */
|
|
225
504
|
export function computeExceptionClassificationStats(
|
|
226
|
-
records: ReadonlyArray<
|
|
505
|
+
records: ReadonlyArray<AnyExceptionClassificationRecord>,
|
|
227
506
|
): ExceptionClassificationStats {
|
|
228
507
|
const classMix = emptyClassMix();
|
|
229
508
|
const perWorkflow = new Map<string, number>();
|
|
230
509
|
const perWfMonth = new Map<string, number>();
|
|
231
510
|
for (const r of records) {
|
|
232
|
-
classMix[r.failureClass] += 1;
|
|
511
|
+
if (r.failureClass !== undefined) classMix[r.failureClass] += 1;
|
|
233
512
|
perWorkflow.set(r.workflowId, (perWorkflow.get(r.workflowId) ?? 0) + 1);
|
|
234
513
|
const key = `${r.workflowId} ${r.timestampIso.slice(0, 7)}`;
|
|
235
514
|
perWfMonth.set(key, (perWfMonth.get(key) ?? 0) + 1);
|
package/src/index.ts
CHANGED