runward 0.14.1 → 0.15.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/README.md +9 -3
- package/dist/cli.js +27 -2
- package/dist/commands/check.js +72 -21
- package/dist/commands/compliance.js +20 -8
- package/dist/commands/doctor.js +6 -1
- package/dist/commands/init.js +9 -2
- package/dist/commands/manifest.js +72 -0
- package/dist/commands/rules.js +76 -0
- package/dist/commands/update.js +5 -1
- package/dist/lib/compliance.js +40 -50
- package/dist/lib/conformance.js +61 -15
- package/dist/lib/constants.js +1 -1
- package/dist/lib/evidence.js +222 -0
- package/dist/lib/manifest-sync.js +111 -0
- package/dist/lib/paths.js +2 -0
- package/dist/lib/regimes.js +32 -0
- package/dist/lib/rules.js +44 -0
- package/dist/lib/tools.js +1 -1
- package/dist/lib/verify-findings.js +24 -0
- package/examples/request-triage/code/README.md +74 -0
- package/examples/request-triage/code/package.json +19 -0
- package/examples/request-triage/code/src/adapters/hardcoded-account-registry.adapter.ts +23 -0
- package/examples/request-triage/code/src/adapters/in-memory-routing.adapter.ts +79 -0
- package/examples/request-triage/code/src/adapters/in-memory-triage-log.adapter.ts +42 -0
- package/examples/request-triage/code/src/adapters/keyword-model.adapter.ts +57 -0
- package/examples/request-triage/code/src/core/application/triage-request.usecase.ts +195 -0
- package/examples/request-triage/code/src/core/domain/guard.ts +121 -0
- package/examples/request-triage/code/src/core/domain/triage.ts +88 -0
- package/examples/request-triage/code/src/core/ports/account-registry.port.ts +15 -0
- package/examples/request-triage/code/src/core/ports/model-provider.port.ts +26 -0
- package/examples/request-triage/code/src/core/ports/routing.port.ts +32 -0
- package/examples/request-triage/code/src/core/ports/triage-log.port.ts +33 -0
- package/examples/request-triage/code/src/demo.ts +80 -0
- package/examples/request-triage/code/test/triage.test.ts +243 -0
- package/examples/request-triage/code/tsconfig.json +14 -0
- package/examples/request-triage/runward/adr/ADR-0003-port-placement-and-sovereignty.md +44 -0
- package/examples/request-triage/runward/architecture.md +4 -3
- package/examples/request-triage/runward/decision-matrix.md +1 -1
- package/examples/request-triage/runward/execution-topology.md +3 -3
- package/examples/request-triage/runward/floor.md +4 -4
- package/package.json +20 -8
- package/regimes/eu-ai-act@2024-1689.json +31 -0
- package/regimes/iso-42001@2023.json +22 -0
- package/regimes/nist-ai-rmf@1.0.json +19 -0
- package/templates/adapters/README.md +4 -0
- package/templates/adapters/gitlab-ci.yml +18 -0
- package/templates/mission/architecture.md +2 -0
- package/templates/mission/execution-topology.md +2 -0
- package/templates/mission/floor.md +2 -0
- package/templates/mission/threat-model.md +2 -0
- package/templates/rules/frontier-deterministic-boundary.md +1 -0
- package/templates/targets/AGENTS.md +1 -1
- package/templates/workflows/floor.md +1 -1
- package/templates/workflows/verify.md +2 -2
- package/examples/request-triage/runward/compliance/eu-ai-act-readiness.md +0 -89
- package/examples/request-triage/runward/compliance/oscal-component-definition.json +0 -209
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// Orchestrator use case: the floor of the request-triage mission
|
|
2
|
+
// (../../runward/floor.md). One short dependent sequence (ADR-0001):
|
|
3
|
+
// validate -> model proposes -> deterministic guard verifies/recomputes ->
|
|
4
|
+
// route. The orchestrator composes; the business rules live in the domain
|
|
5
|
+
// (guard) and at the boundaries (routing invariants).
|
|
6
|
+
//
|
|
7
|
+
// Compliance is fail-closed WITHOUT freezing the process: the validated
|
|
8
|
+
// record is serialized as a pending routing and the call returns
|
|
9
|
+
// "suspended"; resumeTriage() rehydrates it on the human decision. Approve
|
|
10
|
+
// routes with the approval recorded; reject means the routing never happens.
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
InboundRequestSchema,
|
|
14
|
+
QUEUE_BY_CATEGORY,
|
|
15
|
+
REVIEW_QUEUE,
|
|
16
|
+
type InboundRequest,
|
|
17
|
+
type TriageRecord,
|
|
18
|
+
type TriageResult,
|
|
19
|
+
} from "../domain/triage.js";
|
|
20
|
+
import {
|
|
21
|
+
guardCategory,
|
|
22
|
+
guardFields,
|
|
23
|
+
buildApprovalSummary,
|
|
24
|
+
} from "../domain/guard.js";
|
|
25
|
+
import type { TriageModelPort } from "../ports/model-provider.port.js";
|
|
26
|
+
import type { AccountRegistryPort } from "../ports/account-registry.port.js";
|
|
27
|
+
import type { RoutingPort } from "../ports/routing.port.js";
|
|
28
|
+
import type { TriageLogPort } from "../ports/triage-log.port.js";
|
|
29
|
+
|
|
30
|
+
export interface TriageDeps {
|
|
31
|
+
model: TriageModelPort;
|
|
32
|
+
registry: AccountRegistryPort;
|
|
33
|
+
routing: RoutingPort;
|
|
34
|
+
log: TriageLogPort;
|
|
35
|
+
clock: { nowIso(): string };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class TriageRequestUseCase {
|
|
39
|
+
constructor(private readonly deps: TriageDeps) {}
|
|
40
|
+
|
|
41
|
+
async triage(rawInput: InboundRequest): Promise<TriageResult> {
|
|
42
|
+
const { model, registry, routing, log, clock } = this.deps;
|
|
43
|
+
|
|
44
|
+
// 1. Validation at the boundary: never trust the input.
|
|
45
|
+
const parsed = InboundRequestSchema.safeParse(rawInput);
|
|
46
|
+
if (!parsed.success) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`Invalid request: ${parsed.error.issues.map((i) => i.message).join("; ")}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const input = parsed.data;
|
|
52
|
+
await log.append(input.requestId, {
|
|
53
|
+
at: clock.nowIso(),
|
|
54
|
+
event: "received",
|
|
55
|
+
detail: `from=${input.senderAddress}`,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// 2. The model proposes. Everything it returns is a hypothesis.
|
|
59
|
+
const proposal = await model.propose(input.body);
|
|
60
|
+
|
|
61
|
+
// 3. Deterministic guard (ADR-0002): closed vocabulary, registry
|
|
62
|
+
// resolution, deadline re-parsed from the source text.
|
|
63
|
+
const category = guardCategory(proposal.category);
|
|
64
|
+
const guarded = guardFields(proposal, input.body, registry);
|
|
65
|
+
|
|
66
|
+
const record: TriageRecord = {
|
|
67
|
+
requestId: input.requestId,
|
|
68
|
+
category,
|
|
69
|
+
confidence: proposal.confidence,
|
|
70
|
+
...(guarded.accountRef ? { accountRef: guarded.accountRef } : {}),
|
|
71
|
+
...(guarded.deadline ? { deadline: guarded.deadline } : {}),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// 4. Abstention is a first-class answer: an unknown category or a low
|
|
75
|
+
// confidence never routes — human review, not a plausible guess.
|
|
76
|
+
if (category === "unknown" || proposal.confidence === "low") {
|
|
77
|
+
const reason = "abstention: category unknown or confidence too low";
|
|
78
|
+
await log.append(input.requestId, {
|
|
79
|
+
at: clock.nowIso(),
|
|
80
|
+
event: "escalated",
|
|
81
|
+
detail: reason,
|
|
82
|
+
});
|
|
83
|
+
return { record, status: "needs_review", targetQueue: REVIEW_QUEUE, reason };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// 5. Guard escalation: a proposed value that failed verification never
|
|
87
|
+
// routes (fail-closed) — review is cheaper than misrouting.
|
|
88
|
+
if (guarded.escalations.length > 0) {
|
|
89
|
+
const reason = guarded.escalations.join("; ");
|
|
90
|
+
await log.append(input.requestId, {
|
|
91
|
+
at: clock.nowIso(),
|
|
92
|
+
event: "escalated",
|
|
93
|
+
detail: reason,
|
|
94
|
+
});
|
|
95
|
+
return { record, status: "needs_review", targetQueue: REVIEW_QUEUE, reason };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const targetQueue = QUEUE_BY_CATEGORY[category];
|
|
99
|
+
|
|
100
|
+
// 6. Compliance: approval required, always (the mission's attached
|
|
101
|
+
// condition: no silent miss). Suspend, do not block: serialize the
|
|
102
|
+
// validated record and free the process. The summary the approver
|
|
103
|
+
// sees is deterministic, built from the validated fields.
|
|
104
|
+
if (category === "compliance") {
|
|
105
|
+
const summary = buildApprovalSummary(
|
|
106
|
+
input.requestId,
|
|
107
|
+
category,
|
|
108
|
+
targetQueue,
|
|
109
|
+
record.accountRef,
|
|
110
|
+
record.deadline,
|
|
111
|
+
);
|
|
112
|
+
await log.setPending(input.requestId, {
|
|
113
|
+
record,
|
|
114
|
+
targetQueue,
|
|
115
|
+
summary,
|
|
116
|
+
suspendedAt: clock.nowIso(),
|
|
117
|
+
});
|
|
118
|
+
await log.append(input.requestId, {
|
|
119
|
+
at: clock.nowIso(),
|
|
120
|
+
event: "suspended",
|
|
121
|
+
detail: summary,
|
|
122
|
+
});
|
|
123
|
+
return { record, status: "suspended", targetQueue, reason: summary };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// 7. Support / sales: route directly through the guarded port.
|
|
127
|
+
const confirmation = await routing.assign(record, targetQueue);
|
|
128
|
+
await log.append(input.requestId, {
|
|
129
|
+
at: clock.nowIso(),
|
|
130
|
+
event: "routed",
|
|
131
|
+
detail: `queue=${confirmation.queue} ticket=${confirmation.ticketRef}`,
|
|
132
|
+
});
|
|
133
|
+
return {
|
|
134
|
+
record,
|
|
135
|
+
status: "routed",
|
|
136
|
+
targetQueue: confirmation.queue,
|
|
137
|
+
ticketRef: confirmation.ticketRef,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Rehydrates a suspended compliance record on the human decision.
|
|
142
|
+
// "approve" routes it with the approval recorded; "reject" ends it — the
|
|
143
|
+
// routing never happens.
|
|
144
|
+
async resumeTriage(
|
|
145
|
+
requestId: string,
|
|
146
|
+
decision: "approve" | "reject",
|
|
147
|
+
decidedBy: string,
|
|
148
|
+
): Promise<TriageResult> {
|
|
149
|
+
const { routing, log, clock } = this.deps;
|
|
150
|
+
|
|
151
|
+
const pending = await log.getPending(requestId);
|
|
152
|
+
if (!pending) {
|
|
153
|
+
throw new Error(`Request "${requestId}" is not suspended.`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await log.append(requestId, {
|
|
157
|
+
at: clock.nowIso(),
|
|
158
|
+
event: "approval_decision",
|
|
159
|
+
detail: `${decision} by=${decidedBy}`,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
if (decision === "reject") {
|
|
163
|
+
await log.clearPending(requestId);
|
|
164
|
+
await log.append(requestId, {
|
|
165
|
+
at: clock.nowIso(),
|
|
166
|
+
event: "rejected",
|
|
167
|
+
detail: "routing never executed",
|
|
168
|
+
});
|
|
169
|
+
return {
|
|
170
|
+
record: pending.record,
|
|
171
|
+
status: "rejected",
|
|
172
|
+
targetQueue: pending.targetQueue,
|
|
173
|
+
reason: `approval rejected by ${decidedBy}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Approve: route the EXACT serialized record, approval recorded.
|
|
178
|
+
const confirmation = await routing.assign(pending.record, pending.targetQueue, {
|
|
179
|
+
approvedBy: decidedBy,
|
|
180
|
+
at: clock.nowIso(),
|
|
181
|
+
});
|
|
182
|
+
await log.clearPending(requestId);
|
|
183
|
+
await log.append(requestId, {
|
|
184
|
+
at: clock.nowIso(),
|
|
185
|
+
event: "routed",
|
|
186
|
+
detail: `queue=${confirmation.queue} ticket=${confirmation.ticketRef} approved_by=${decidedBy}`,
|
|
187
|
+
});
|
|
188
|
+
return {
|
|
189
|
+
record: pending.record,
|
|
190
|
+
status: "routed",
|
|
191
|
+
targetQueue: confirmation.queue,
|
|
192
|
+
ticketRef: confirmation.ticketRef,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Deterministic guard on model-extracted fields (ADR-0002). Pure functions,
|
|
2
|
+
// unit-testable without a model in the loop.
|
|
3
|
+
//
|
|
4
|
+
// The rule: THE MODEL NEVER SUPPLIES A VALUE THE SYSTEM CAN COMPUTE OR
|
|
5
|
+
// VERIFY ITSELF. Concretely:
|
|
6
|
+
// - an account reference is resolved against the account registry; a
|
|
7
|
+
// reference that does not resolve is never routed on — the record
|
|
8
|
+
// escalates to human review with the reason attached;
|
|
9
|
+
// - a deadline is re-parsed from the source text by a deterministic
|
|
10
|
+
// parser; the model's proposal is DISCARDED in favor of the parsed
|
|
11
|
+
// value, whatever the model said;
|
|
12
|
+
// - the category must belong to the closed vocabulary; anything else is
|
|
13
|
+
// treated as "unknown" (abstention), never as a new category.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
CategorySchema,
|
|
17
|
+
type Category,
|
|
18
|
+
type TriageField,
|
|
19
|
+
} from "./triage.js";
|
|
20
|
+
import type { AccountRegistryPort } from "../ports/account-registry.port.js";
|
|
21
|
+
import type { TriageProposal } from "../ports/model-provider.port.js";
|
|
22
|
+
|
|
23
|
+
// ----------------------------------------------------------------------------
|
|
24
|
+
// Deterministic deadline parser. Reads ISO dates (YYYY-MM-DD) from the raw
|
|
25
|
+
// source text and validates them as real calendar dates. Returns the first
|
|
26
|
+
// valid one, normalized, or null. No model involved.
|
|
27
|
+
// ----------------------------------------------------------------------------
|
|
28
|
+
const ISO_DATE = /\b(\d{4})-(\d{2})-(\d{2})\b/g;
|
|
29
|
+
|
|
30
|
+
export function parseDeadline(sourceText: string): string | null {
|
|
31
|
+
for (const m of sourceText.matchAll(ISO_DATE)) {
|
|
32
|
+
const [raw, y, mo, d] = m;
|
|
33
|
+
const year = Number(y);
|
|
34
|
+
const month = Number(mo);
|
|
35
|
+
const day = Number(d);
|
|
36
|
+
// Real calendar date, not just a well-shaped string.
|
|
37
|
+
const date = new Date(Date.UTC(year, month - 1, day));
|
|
38
|
+
if (
|
|
39
|
+
date.getUTCFullYear() === year &&
|
|
40
|
+
date.getUTCMonth() === month - 1 &&
|
|
41
|
+
date.getUTCDate() === day
|
|
42
|
+
) {
|
|
43
|
+
return raw;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ----------------------------------------------------------------------------
|
|
50
|
+
// Category guard: closed vocabulary or abstention.
|
|
51
|
+
// ----------------------------------------------------------------------------
|
|
52
|
+
export function guardCategory(proposed: unknown): Category {
|
|
53
|
+
const parsed = CategorySchema.safeParse(proposed);
|
|
54
|
+
return parsed.success ? parsed.data : "unknown";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ----------------------------------------------------------------------------
|
|
58
|
+
// Field guard: promote, recompute, or escalate.
|
|
59
|
+
// ----------------------------------------------------------------------------
|
|
60
|
+
export interface GuardedFields {
|
|
61
|
+
accountRef?: TriageField & { accountName?: string };
|
|
62
|
+
deadline?: TriageField;
|
|
63
|
+
// Non-empty when a proposed value failed verification: the record must
|
|
64
|
+
// escalate to human review instead of routing (fail-closed).
|
|
65
|
+
escalations: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function guardFields(
|
|
69
|
+
proposal: TriageProposal,
|
|
70
|
+
sourceText: string,
|
|
71
|
+
registry: AccountRegistryPort,
|
|
72
|
+
): GuardedFields {
|
|
73
|
+
const out: GuardedFields = { escalations: [] };
|
|
74
|
+
|
|
75
|
+
// Account reference: verified against the registry or never routed on.
|
|
76
|
+
const proposedRef = proposal.fields.find((f) => f.name === "accountRef");
|
|
77
|
+
if (proposedRef) {
|
|
78
|
+
const account = registry.resolve(proposedRef.value);
|
|
79
|
+
if (account) {
|
|
80
|
+
out.accountRef = {
|
|
81
|
+
value: account.ref,
|
|
82
|
+
provenance: "verified",
|
|
83
|
+
accountName: account.name,
|
|
84
|
+
};
|
|
85
|
+
} else {
|
|
86
|
+
out.escalations.push(
|
|
87
|
+
`unverified account reference "${proposedRef.value}" (not in registry)`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Deadline: recomputed from the source text; the model's value is
|
|
93
|
+
// discarded even when present, even when it looks right.
|
|
94
|
+
const parsed = parseDeadline(sourceText);
|
|
95
|
+
if (parsed) {
|
|
96
|
+
out.deadline = { value: parsed, provenance: "computed" };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ----------------------------------------------------------------------------
|
|
103
|
+
// Deterministic approval summary for a suspended compliance record: a pure
|
|
104
|
+
// function of the validated fields, never a model reformulation.
|
|
105
|
+
// ----------------------------------------------------------------------------
|
|
106
|
+
export function buildApprovalSummary(
|
|
107
|
+
requestId: string,
|
|
108
|
+
category: Category,
|
|
109
|
+
targetQueue: string,
|
|
110
|
+
accountRef?: TriageField,
|
|
111
|
+
deadline?: TriageField,
|
|
112
|
+
): string {
|
|
113
|
+
const parts = [
|
|
114
|
+
`Approval required: route request "${requestId}"`,
|
|
115
|
+
`category=${category}`,
|
|
116
|
+
`queue=${targetQueue}`,
|
|
117
|
+
`accountRef=${accountRef ? `${accountRef.value} (${accountRef.provenance})` : "none"}`,
|
|
118
|
+
`deadline=${deadline ? `${deadline.value} (${deadline.provenance})` : "none"}`,
|
|
119
|
+
];
|
|
120
|
+
return parts.join(" | ");
|
|
121
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Pure triage domain. Zero framework, zero I/O; zod is used as a schema
|
|
2
|
+
// library only. Mirrors the TriageRecord v1.0 contract of the mission
|
|
3
|
+
// documents (../../runward/architecture.md §3): closed category vocabulary,
|
|
4
|
+
// provenance-marked fields, target queue, confidence level.
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
|
|
8
|
+
// ----------------------------------------------------------------------------
|
|
9
|
+
// Closed category vocabulary. Anything else is a validation failure, not a
|
|
10
|
+
// new category (contracts/model-port.md). "unknown" is a first-class answer:
|
|
11
|
+
// abstention beats a plausible guess.
|
|
12
|
+
// ----------------------------------------------------------------------------
|
|
13
|
+
export const CATEGORIES = ["support", "sales", "compliance", "unknown"] as const;
|
|
14
|
+
export const CategorySchema = z.enum(CATEGORIES);
|
|
15
|
+
export type Category = z.infer<typeof CategorySchema>;
|
|
16
|
+
|
|
17
|
+
export const ConfidenceSchema = z.enum(["high", "medium", "low"]);
|
|
18
|
+
export type Confidence = z.infer<typeof ConfidenceSchema>;
|
|
19
|
+
|
|
20
|
+
// ----------------------------------------------------------------------------
|
|
21
|
+
// Provenance markers (ADR-0002). A model output is a hypothesis, never a
|
|
22
|
+
// fact: it enters the system marked "model-proposed" and nothing acts on it
|
|
23
|
+
// until the deterministic guard has promoted it to "verified" (checked
|
|
24
|
+
// against a source of truth) or replaced it with a "computed" value.
|
|
25
|
+
// ----------------------------------------------------------------------------
|
|
26
|
+
export const ProvenanceSchema = z.enum(["computed", "verified", "model-proposed"]);
|
|
27
|
+
export type Provenance = z.infer<typeof ProvenanceSchema>;
|
|
28
|
+
|
|
29
|
+
export interface TriageField {
|
|
30
|
+
value: string;
|
|
31
|
+
provenance: Provenance;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ----------------------------------------------------------------------------
|
|
35
|
+
// Input entity: one raw inbound request (contracts/request-intake.md,
|
|
36
|
+
// reduced). The body is untrusted free text by definition. strict(): no
|
|
37
|
+
// smuggled keys cross the boundary.
|
|
38
|
+
// ----------------------------------------------------------------------------
|
|
39
|
+
export const InboundRequestSchema = z
|
|
40
|
+
.object({
|
|
41
|
+
requestId: z.string().min(1, "A request identifier cannot be empty."),
|
|
42
|
+
senderAddress: z.string().min(3),
|
|
43
|
+
body: z.string().min(1, "The body cannot be empty.").max(8000),
|
|
44
|
+
})
|
|
45
|
+
.strict();
|
|
46
|
+
export type InboundRequest = z.infer<typeof InboundRequestSchema>;
|
|
47
|
+
|
|
48
|
+
// ----------------------------------------------------------------------------
|
|
49
|
+
// Output entity: the validated triage record. Every field that can drive an
|
|
50
|
+
// action carries a provenance marker; the routing port refuses, fail-closed,
|
|
51
|
+
// any action-bearing field still "model-proposed".
|
|
52
|
+
// ----------------------------------------------------------------------------
|
|
53
|
+
export type TriageStatus =
|
|
54
|
+
| "routed" // assigned to a team queue through the routing port
|
|
55
|
+
| "suspended" // compliance: awaiting human approval (fail-closed, not frozen)
|
|
56
|
+
| "needs_review" // abstention or guard escalation: human review queue
|
|
57
|
+
| "rejected"; // approval denied on resume: never routed
|
|
58
|
+
|
|
59
|
+
export interface TriageRecord {
|
|
60
|
+
requestId: string;
|
|
61
|
+
category: Category;
|
|
62
|
+
confidence: Confidence;
|
|
63
|
+
// Resolved against the account registry, or absent. Never model-trusted.
|
|
64
|
+
accountRef?: TriageField & { accountName?: string };
|
|
65
|
+
// Re-parsed deterministically from the source text, or absent. The model's
|
|
66
|
+
// proposal for this field is always discarded (ADR-0002).
|
|
67
|
+
deadline?: TriageField;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface TriageResult {
|
|
71
|
+
record: TriageRecord;
|
|
72
|
+
status: TriageStatus;
|
|
73
|
+
targetQueue: string;
|
|
74
|
+
// Present when routed: the ticketing reference returned by the port.
|
|
75
|
+
ticketRef?: string;
|
|
76
|
+
// Present when suspended or escalated: why a human is in the loop.
|
|
77
|
+
reason?: string;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Queue resolution is deterministic policy, never a model output.
|
|
81
|
+
export const QUEUE_BY_CATEGORY: Record<Category, string> = {
|
|
82
|
+
support: "support-queue",
|
|
83
|
+
sales: "sales-queue",
|
|
84
|
+
compliance: "compliance-queue",
|
|
85
|
+
unknown: "review-queue",
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export const REVIEW_QUEUE = "review-queue";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Secondary port: the account registry, a deterministic source of truth.
|
|
2
|
+
// The guard resolves every model-proposed account reference against it: a
|
|
3
|
+
// reference that does not resolve is never routed on (ADR-0002). The model
|
|
4
|
+
// cannot mint an account into existence.
|
|
5
|
+
|
|
6
|
+
export interface Account {
|
|
7
|
+
ref: string;
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface AccountRegistryPort {
|
|
12
|
+
// Returns the account when the reference exists, null otherwise. Pure
|
|
13
|
+
// lookup: no fuzzy matching, no repair of a fabricated reference.
|
|
14
|
+
resolve(ref: string): Account | null;
|
|
15
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Secondary port: the model proposes, it never decides
|
|
2
|
+
// (../../runward/contracts/model-port.md). The output is a hypothesis: a
|
|
3
|
+
// closed-vocabulary category, field extractions each carrying the span of
|
|
4
|
+
// source text they were read from, and a confidence level. Everything is
|
|
5
|
+
// provenance-marked "model-proposed" by construction; nothing downstream may
|
|
6
|
+
// act on it before the deterministic guard (ADR-0002).
|
|
7
|
+
|
|
8
|
+
import type { Category, Confidence } from "../domain/triage.js";
|
|
9
|
+
|
|
10
|
+
export interface ProposedField {
|
|
11
|
+
name: "accountRef" | "deadline";
|
|
12
|
+
value: string;
|
|
13
|
+
// Span of source text the value was read from. A value with no span is
|
|
14
|
+
// rejected at the schema boundary.
|
|
15
|
+
sourceSpan: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TriageProposal {
|
|
19
|
+
category: Category;
|
|
20
|
+
fields: ProposedField[];
|
|
21
|
+
confidence: Confidence;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface TriageModelPort {
|
|
25
|
+
propose(requestText: string): Promise<TriageProposal>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Secondary port: the system's only action on the world
|
|
2
|
+
// (../../runward/contracts/routing-port.md). Assign one validated triage
|
|
3
|
+
// record to a queue. The most guarded boundary: it acts on verified facts
|
|
4
|
+
// only and fails closed.
|
|
5
|
+
//
|
|
6
|
+
// Invariants the adapter must hold:
|
|
7
|
+
// - refuse any record whose action-bearing fields are still
|
|
8
|
+
// "model-proposed" (fail-closed on provenance, ADR-0002);
|
|
9
|
+
// - never assign a compliance-flagged record without a recorded approval;
|
|
10
|
+
// - idempotent on requestId (no duplicate tickets).
|
|
11
|
+
|
|
12
|
+
import type { TriageRecord } from "../domain/triage.js";
|
|
13
|
+
|
|
14
|
+
export interface RoutingApproval {
|
|
15
|
+
approvedBy: string;
|
|
16
|
+
at: string; // ISO timestamp
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RoutingConfirmation {
|
|
20
|
+
ticketRef: string;
|
|
21
|
+
routedAt: string; // ISO timestamp, UTC
|
|
22
|
+
queue: string;
|
|
23
|
+
approval?: RoutingApproval;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RoutingPort {
|
|
27
|
+
assign(
|
|
28
|
+
record: TriageRecord,
|
|
29
|
+
targetQueue: string,
|
|
30
|
+
approval?: RoutingApproval,
|
|
31
|
+
): Promise<RoutingConfirmation>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Secondary port: persistence of triage decisions
|
|
2
|
+
// (../../runward/contracts/persistence-port.md, reduced). An append-only
|
|
3
|
+
// journal keyed by request id, plus the serialized suspension point of a
|
|
4
|
+
// compliance record awaiting human approval (suspend-and-rehydrate: the
|
|
5
|
+
// process is freed, the decision rehydrates the run).
|
|
6
|
+
|
|
7
|
+
import type { TriageRecord } from "../domain/triage.js";
|
|
8
|
+
|
|
9
|
+
export interface TriageLogEntry {
|
|
10
|
+
at: string; // ISO timestamp
|
|
11
|
+
event: string;
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Serialized suspension point: the exact validated record and target queue,
|
|
16
|
+
// plus a deterministic summary built by code from the real fields — never a
|
|
17
|
+
// model reformulation.
|
|
18
|
+
export interface PendingRouting {
|
|
19
|
+
record: TriageRecord;
|
|
20
|
+
targetQueue: string;
|
|
21
|
+
summary: string;
|
|
22
|
+
suspendedAt: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TriageLogPort {
|
|
26
|
+
append(requestId: string, entry: TriageLogEntry): Promise<void>;
|
|
27
|
+
read(requestId: string): Promise<TriageLogEntry[]>;
|
|
28
|
+
// Suspension point management. setPending throws if one already exists
|
|
29
|
+
// (a record is suspended at most once at a time).
|
|
30
|
+
setPending(requestId: string, pending: PendingRouting): Promise<void>;
|
|
31
|
+
getPending(requestId: string): Promise<PendingRouting | null>;
|
|
32
|
+
clearPending(requestId: string): Promise<void>;
|
|
33
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// End-to-end demo of the triage floor. Four inbound requests: a support
|
|
2
|
+
// issue, a sales inquiry, a compliance request (suspends, then approved),
|
|
3
|
+
// and one the system abstains on. No network, no key: the model adapter is
|
|
4
|
+
// the deterministic keyword classifier.
|
|
5
|
+
//
|
|
6
|
+
// Run: npm run demo
|
|
7
|
+
|
|
8
|
+
import { TriageRequestUseCase } from "./core/application/triage-request.usecase.js";
|
|
9
|
+
import { KeywordModelAdapter } from "./adapters/keyword-model.adapter.js";
|
|
10
|
+
import { HardcodedAccountRegistry } from "./adapters/hardcoded-account-registry.adapter.js";
|
|
11
|
+
import { InMemoryRoutingAdapter } from "./adapters/in-memory-routing.adapter.js";
|
|
12
|
+
import { InMemoryTriageLog } from "./adapters/in-memory-triage-log.adapter.js";
|
|
13
|
+
|
|
14
|
+
async function main(): Promise<void> {
|
|
15
|
+
const log = new InMemoryTriageLog();
|
|
16
|
+
const useCase = new TriageRequestUseCase({
|
|
17
|
+
model: new KeywordModelAdapter(),
|
|
18
|
+
registry: new HardcodedAccountRegistry(),
|
|
19
|
+
routing: new InMemoryRoutingAdapter(),
|
|
20
|
+
log,
|
|
21
|
+
clock: { nowIso: () => new Date().toISOString() },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const samples = [
|
|
25
|
+
{
|
|
26
|
+
requestId: "req-001",
|
|
27
|
+
senderAddress: "jane@acme.example",
|
|
28
|
+
body: "Our dashboard crashes with an error on login. Account ACC-1001. Please help.",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
requestId: "req-002",
|
|
32
|
+
senderAddress: "buyer@globex.example",
|
|
33
|
+
body: "Could we get a pricing quote for the enterprise plan? Account ACC-2002.",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
requestId: "req-003",
|
|
37
|
+
senderAddress: "dpo@initech.example",
|
|
38
|
+
body: "GDPR data deletion request for account ACC-3003, response required by 2026-08-01.",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
requestId: "req-004",
|
|
42
|
+
senderAddress: "someone@example.org",
|
|
43
|
+
body: "Following up on the thing we discussed last month.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
// A plausible-looking account reference the model extracted, that the
|
|
47
|
+
// registry does not know: exactly what an LLM fabricates most fluently.
|
|
48
|
+
// The deterministic guard refuses to route on it (ADR-0002), fail-closed.
|
|
49
|
+
requestId: "req-005",
|
|
50
|
+
senderAddress: "mallory@acme.example",
|
|
51
|
+
body: "Our dashboard crashes with an error on every login. Account ACC-7777, please help urgently.",
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
for (const sample of samples) {
|
|
56
|
+
const result = await useCase.triage(sample);
|
|
57
|
+
console.log(
|
|
58
|
+
`${sample.requestId}: category=${result.record.category} status=${result.status} queue=${result.targetQueue}` +
|
|
59
|
+
(result.ticketRef ? ` ticket=${result.ticketRef}` : "") +
|
|
60
|
+
(result.reason ? `\n reason: ${result.reason}` : ""),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The compliance request suspended: a human approves, the run rehydrates.
|
|
65
|
+
console.log("\nApproving the suspended compliance request (req-003)...");
|
|
66
|
+
const resumed = await useCase.resumeTriage("req-003", "approve", "ops-coordinator");
|
|
67
|
+
console.log(
|
|
68
|
+
`req-003: status=${resumed.status} queue=${resumed.targetQueue} ticket=${resumed.ticketRef}`,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
console.log("\nJournal of req-003:");
|
|
72
|
+
for (const entry of await log.read("req-003")) {
|
|
73
|
+
console.log(` [${entry.event}] ${entry.detail}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
main().catch((err) => {
|
|
78
|
+
console.error("Demo failed:", err);
|
|
79
|
+
process.exit(1);
|
|
80
|
+
});
|