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.
Files changed (56) hide show
  1. package/README.md +9 -3
  2. package/dist/cli.js +27 -2
  3. package/dist/commands/check.js +72 -21
  4. package/dist/commands/compliance.js +20 -8
  5. package/dist/commands/doctor.js +6 -1
  6. package/dist/commands/init.js +9 -2
  7. package/dist/commands/manifest.js +72 -0
  8. package/dist/commands/rules.js +76 -0
  9. package/dist/commands/update.js +5 -1
  10. package/dist/lib/compliance.js +40 -50
  11. package/dist/lib/conformance.js +61 -15
  12. package/dist/lib/constants.js +1 -1
  13. package/dist/lib/evidence.js +222 -0
  14. package/dist/lib/manifest-sync.js +111 -0
  15. package/dist/lib/paths.js +2 -0
  16. package/dist/lib/regimes.js +32 -0
  17. package/dist/lib/rules.js +44 -0
  18. package/dist/lib/tools.js +1 -1
  19. package/dist/lib/verify-findings.js +24 -0
  20. package/examples/request-triage/code/README.md +74 -0
  21. package/examples/request-triage/code/package.json +19 -0
  22. package/examples/request-triage/code/src/adapters/hardcoded-account-registry.adapter.ts +23 -0
  23. package/examples/request-triage/code/src/adapters/in-memory-routing.adapter.ts +79 -0
  24. package/examples/request-triage/code/src/adapters/in-memory-triage-log.adapter.ts +42 -0
  25. package/examples/request-triage/code/src/adapters/keyword-model.adapter.ts +57 -0
  26. package/examples/request-triage/code/src/core/application/triage-request.usecase.ts +195 -0
  27. package/examples/request-triage/code/src/core/domain/guard.ts +121 -0
  28. package/examples/request-triage/code/src/core/domain/triage.ts +88 -0
  29. package/examples/request-triage/code/src/core/ports/account-registry.port.ts +15 -0
  30. package/examples/request-triage/code/src/core/ports/model-provider.port.ts +26 -0
  31. package/examples/request-triage/code/src/core/ports/routing.port.ts +32 -0
  32. package/examples/request-triage/code/src/core/ports/triage-log.port.ts +33 -0
  33. package/examples/request-triage/code/src/demo.ts +80 -0
  34. package/examples/request-triage/code/test/triage.test.ts +243 -0
  35. package/examples/request-triage/code/tsconfig.json +14 -0
  36. package/examples/request-triage/runward/adr/ADR-0003-port-placement-and-sovereignty.md +44 -0
  37. package/examples/request-triage/runward/architecture.md +4 -3
  38. package/examples/request-triage/runward/decision-matrix.md +1 -1
  39. package/examples/request-triage/runward/execution-topology.md +3 -3
  40. package/examples/request-triage/runward/floor.md +4 -4
  41. package/package.json +20 -8
  42. package/regimes/eu-ai-act@2024-1689.json +31 -0
  43. package/regimes/iso-42001@2023.json +22 -0
  44. package/regimes/nist-ai-rmf@1.0.json +19 -0
  45. package/templates/adapters/README.md +4 -0
  46. package/templates/adapters/gitlab-ci.yml +18 -0
  47. package/templates/mission/architecture.md +2 -0
  48. package/templates/mission/execution-topology.md +2 -0
  49. package/templates/mission/floor.md +2 -0
  50. package/templates/mission/threat-model.md +2 -0
  51. package/templates/rules/frontier-deterministic-boundary.md +1 -0
  52. package/templates/targets/AGENTS.md +1 -1
  53. package/templates/workflows/floor.md +1 -1
  54. package/templates/workflows/verify.md +2 -2
  55. package/examples/request-triage/runward/compliance/eu-ai-act-readiness.md +0 -89
  56. package/examples/request-triage/runward/compliance/oscal-component-definition.json +0 -209
@@ -0,0 +1,111 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { parseManifest, expectedRules, allRules } from "./conformance.js";
4
+ import { RULE_MIGRATIONS } from "./rule-migrations.js";
5
+ const SECTION_HEADER = /^##\s+Rule conformance/i;
6
+ const SECTION_TEMPLATE = (phase) => `
7
+ ## Rule conformance
8
+
9
+ > Account for every CRITICAL/HIGH craft rule mapped to the ${phase} phase (\`runward/rules/\`, frontmatter \`phases: [${phase}]\`). \`applied\` needs a pointer; \`deviated\` needs an ADR; \`n/a\` needs a reason. \`runward check --strict\` verifies this table.
10
+
11
+ | Rule | Status | Evidence |
12
+ |---|---|---|
13
+ `;
14
+ /** The slug a manifest table line carries, or null when the line is not a rule row. */
15
+ function rowSlug(line) {
16
+ if (!line.trim().startsWith("|"))
17
+ return null;
18
+ const cols = line.split("|").slice(1, -1).map((c) => c.replace(/`/g, "").trim());
19
+ if (cols.length < 1)
20
+ return null;
21
+ const rule = cols[0];
22
+ if (/^rule$/i.test(rule) || /^:?-+:?$/.test(rule) || /^\[.*\]$/.test(rule))
23
+ return null;
24
+ return rule;
25
+ }
26
+ export function syncManifest(missionDir, phase, deliverable, label) {
27
+ const report = {
28
+ deliverable, label, fileMissing: false, sectionCreated: false,
29
+ added: [], migrated: [], removed: [], unknown: [], duplicates: [], content: null,
30
+ };
31
+ const path = join(missionDir, deliverable);
32
+ if (!existsSync(path)) {
33
+ report.fileMissing = true;
34
+ return report;
35
+ }
36
+ let content = readFileSync(path, "utf8");
37
+ const expected = expectedRules(missionDir, phase);
38
+ const known = new Set(allRules(missionDir));
39
+ // 1. Migrations (ADR-0006): rewrite a renamed slug in place, status and evidence untouched.
40
+ // Skipped when the new slug already has its own row — that duplicate is the operator's edit.
41
+ const present = () => new Set(parseManifest(content).map((r) => r.rule));
42
+ for (const [from, m] of Object.entries(RULE_MIGRATIONS)) {
43
+ const rows = present();
44
+ if (!rows.has(from))
45
+ continue;
46
+ if (!m.to) {
47
+ report.removed.push({ slug: from, since: m.since, reason: m.reason });
48
+ continue;
49
+ }
50
+ if (rows.has(m.to)) {
51
+ report.duplicates.push(`${from} → ${m.to} (a ${m.to} row already exists — merge by hand)`);
52
+ continue;
53
+ }
54
+ content = content.split("\n").map((line) => {
55
+ const slug = rowSlug(line);
56
+ if (slug !== from)
57
+ return line;
58
+ const cols = line.split("|");
59
+ cols[1] = ` ${m.to} `;
60
+ return cols.join("|");
61
+ }).join("\n");
62
+ report.migrated.push({ from, to: m.to });
63
+ }
64
+ // 2. Section + missing rows, inside the manifest section only.
65
+ const lines = content.split("\n");
66
+ let start = lines.findIndex((l) => SECTION_HEADER.test(l));
67
+ if (start === -1 && expected.length > 0) {
68
+ if (!content.endsWith("\n"))
69
+ content += "\n";
70
+ content += SECTION_TEMPLATE(phase);
71
+ report.sectionCreated = true;
72
+ }
73
+ const rows = parseManifest(content).filter((r) => !/^\[.*\]$/.test(r.rule)); // template placeholders are form, not rows
74
+ const have = new Set(rows.map((r) => r.rule));
75
+ const counts = new Map();
76
+ for (const r of rows)
77
+ counts.set(r.rule, (counts.get(r.rule) ?? 0) + 1);
78
+ for (const [slug, n] of counts) {
79
+ if (n > 1)
80
+ report.duplicates.push(`${slug} (listed ${n} times — keep a single row)`);
81
+ if (!known.has(slug) && !RULE_MIGRATIONS[slug])
82
+ report.unknown.push(slug);
83
+ }
84
+ const missing = expected.filter((r) => !have.has(r));
85
+ if (missing.length > 0) {
86
+ const out = content.split("\n");
87
+ start = out.findIndex((l) => SECTION_HEADER.test(l));
88
+ // last table line of the section — append right below it, never reorder anything
89
+ let insertAt = -1;
90
+ for (let i = start + 1; i < out.length; i++) {
91
+ if (/^##\s/.test(out[i]))
92
+ break;
93
+ if (out[i].trim().startsWith("|"))
94
+ insertAt = i;
95
+ }
96
+ if (insertAt !== -1) {
97
+ out.splice(insertAt + 1, 0, ...missing.map((slug) => `| ${slug} | | |`));
98
+ report.added = missing;
99
+ // scaffolding real rows retires the template's placeholder row — form cleanup, no content touched
100
+ content = out.filter((l) => {
101
+ if (!l.trim().startsWith("|"))
102
+ return true;
103
+ const first = l.split("|").slice(1, -1)[0]?.replace(/`/g, "").trim() ?? "";
104
+ return !/^\[.*\]$/.test(first);
105
+ }).join("\n");
106
+ }
107
+ }
108
+ const original = readFileSync(path, "utf8");
109
+ report.content = content !== original ? content : null;
110
+ return report;
111
+ }
package/dist/lib/paths.js CHANGED
@@ -7,6 +7,8 @@ export const PKG_ROOT = join(HERE, "..", "..");
7
7
  export const TEMPLATES = join(PKG_ROOT, "templates");
8
8
  /** Filled reference mission (shipped) — used by `init --example`. */
9
9
  export const EXAMPLE_MISSION = join(PKG_ROOT, "examples", "request-triage", "runward");
10
+ /** The reference floor the mission's manifests point at — shipped with it, so the evidence resolves (ADR-0019). */
11
+ export const EXAMPLE_CODE = join(PKG_ROOT, "examples", "request-triage", "code");
10
12
  export const VERSION = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
11
13
  /** Mission layout: template file -> destination inside runward/ */
12
14
  export const MISSION_LAYOUT = {
@@ -0,0 +1,32 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { PKG_ROOT } from "./paths.js";
4
+ export const REGIMES_DIR = join(PKG_ROOT, "regimes");
5
+ /** Shipped versions of a regime, ascending lexicographic. That order IS the contract: version
6
+ * identifiers are chosen date-/zero-pad-shaped (2023, 2024-1689, 1.0) so lexicographic ==
7
+ * chronological within a regime, and the default pick is the last element. */
8
+ export function listRegimeVersions(regime, dir = REGIMES_DIR) {
9
+ if (!existsSync(dir))
10
+ return [];
11
+ const prefix = `${regime}@`;
12
+ return readdirSync(dir)
13
+ .filter((f) => f.startsWith(prefix) && f.endsWith(".json"))
14
+ .map((f) => f.slice(prefix.length, -".json".length))
15
+ .sort();
16
+ }
17
+ /** Load a regime mapping; default = highest shipped version. Throws with the shipped list on an
18
+ * unknown version — the caller maps that onto exit 2 (operator misuse). */
19
+ export function loadRegime(regime, version, dir = REGIMES_DIR) {
20
+ const versions = listRegimeVersions(regime, dir);
21
+ if (versions.length === 0)
22
+ throw new Error(`No shipped mapping data for regime "${regime}" (looked in ${dir}).`);
23
+ const pick = version ?? versions[versions.length - 1];
24
+ if (!versions.includes(pick)) {
25
+ throw new Error(`Unknown ${regime} mapping version "${version}". Shipped versions: ${versions.join(", ")}.`);
26
+ }
27
+ return JSON.parse(readFileSync(join(dir, `${regime}@${pick}.json`), "utf8"));
28
+ }
29
+ /** The lens identifier stamped into the draft header and the OSCAL props, e.g. "eu-ai-act@2024-1689". */
30
+ export function regimeLensId(m) {
31
+ return `${m.regime}@${m.version}`;
32
+ }
@@ -0,0 +1,44 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { rulesDir } from "./conformance.js";
4
+ import { TEMPLATES } from "./paths.js";
5
+ const FRONTMATTER = /^---\n([\s\S]*?)\n---/;
6
+ function listField(fm, key) {
7
+ const raw = fm.match(new RegExp(`^${key}:\\s*\\[(.*)\\]`, "m"))?.[1] ?? "";
8
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
9
+ }
10
+ export function parseRule(slug, content) {
11
+ const fm = content.match(FRONTMATTER)?.[1] ?? "";
12
+ const field = (key) => (fm.match(new RegExp(`^${key}:\\s*(.+)$`, "m"))?.[1] ?? "").trim();
13
+ return {
14
+ slug,
15
+ title: field("title") || slug,
16
+ impact: field("impact"),
17
+ phases: listField(fm, "phases"),
18
+ asi: listField(fm, "asi").map((s) => s.toUpperCase()).filter((s) => /^ASI\d{2}$/.test(s)),
19
+ tags: listField(fm, "tags"),
20
+ why: field("impactDescription"),
21
+ signature: field("signature") || null,
22
+ };
23
+ }
24
+ /** The rule body — everything after the frontmatter. */
25
+ export function ruleBody(content) {
26
+ return content.replace(FRONTMATTER, "").replace(/^\n+/, "");
27
+ }
28
+ /** The effective rule set: the mission's copy when a mission is given and has one, else the package's. */
29
+ export function ruleSetDir(missionDir) {
30
+ if (missionDir) {
31
+ const dir = rulesDir(missionDir);
32
+ return { dir, source: dir === join(TEMPLATES, "rules") ? "package" : "mission" };
33
+ }
34
+ return { dir: join(TEMPLATES, "rules"), source: "package" };
35
+ }
36
+ /** Deterministic inventory, sorted by slug. */
37
+ export function readRuleSet(dir) {
38
+ if (!existsSync(dir))
39
+ return [];
40
+ return readdirSync(dir)
41
+ .filter((f) => f.endsWith(".md"))
42
+ .sort()
43
+ .map((f) => parseRule(f.replace(/\.md$/, ""), readFileSync(join(dir, f), "utf8")));
44
+ }
package/dist/lib/tools.js CHANGED
@@ -41,7 +41,7 @@ const skillBody = (s) => [
41
41
  "",
42
42
  `Use this when ${s.when}.`,
43
43
  "",
44
- `Confront the CRITICAL/HIGH craft rules mapped to the \`${s.phase}\` phase in \`runward/rules/\` — each rule's \`phases:\` frontmatter declares where it applies — at the point of action, not from memory. Account for each in the deliverable's \`## Rule conformance\` manifest: \`applied\` with a \`file:line\` or test, \`deviated\` with an ADR, or \`n/a\` with a reason.`,
44
+ `Confront the CRITICAL/HIGH craft rules mapped to the \`${s.phase}\` phase in \`runward/rules/\` — each rule's \`phases:\` frontmatter declares where it applies — at the point of action, not from memory (\`runward explain <rule>\` prints a rule's why and full text). Account for each in the deliverable's \`## Rule conformance\` manifest — \`runward manifest --sync\` scaffolds the missing rows; you fill the decision: \`applied\` with a typed pointer the gate verifies (\`file:PATH[:LINE][#SYMBOL]\`, \`test:PATH[::NAME]\`) or prose, \`deviated\` with an ADR, or \`n/a\` with a real reason. Signed rules (frontmatter \`signature:\`) need evidence whose content matches their signature.`,
45
45
  "",
46
46
  "This skill helps you *apply* the rules; it does not enforce them. `runward check --strict` is the sole authority and verifies the manifest deterministically. A rule surfaced here but not accounted for still fails the gate.",
47
47
  "",
@@ -0,0 +1,24 @@
1
+ import { existsSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { GATED_DELIVERABLES } from "./conformance.js";
4
+ /**
5
+ * The verify workflow's findings artifact (ADR-0007, amended): presence and freshness
6
+ * only — the gate never reads a verdict out of it and never blocks on it. Fresh means
7
+ * newer than every gated manifest; a manifest edited after the pass makes it stale.
8
+ */
9
+ export const VERIFY_FINDINGS = "governance/verify-findings.md";
10
+ export function verifyFindings(missionDir) {
11
+ const path = join(missionDir, VERIFY_FINDINGS);
12
+ if (!existsSync(path))
13
+ return { present: false };
14
+ const mtime = statSync(path).mtimeMs;
15
+ let fresh = true;
16
+ for (const { deliverable } of GATED_DELIVERABLES) {
17
+ const d = join(missionDir, deliverable);
18
+ if (existsSync(d) && statSync(d).mtimeMs > mtime) {
19
+ fresh = false;
20
+ break;
21
+ }
22
+ }
23
+ return { present: true, date: new Date(statSync(path).mtime).toISOString().slice(0, 10), fresh };
24
+ }
@@ -0,0 +1,74 @@
1
+ # Request triage — runnable floor
2
+
3
+ The executable counterpart of the [request-triage example mission](../README.md).
4
+ The mission documents in [`../runward/`](../runward/) decide; this code does
5
+ exactly what they decided — nothing more. Standalone package, MIT, no key, no
6
+ network: the default model adapter is a deterministic keyword classifier.
7
+
8
+ ## What the code shows
9
+
10
+ - **Classification into a closed vocabulary** (`support`, `sales`,
11
+ `compliance`, `unknown`): the model proposes a category; anything outside
12
+ the vocabulary is treated as `unknown`. Abstention is a first-class answer:
13
+ an unknown category or a low confidence goes to the human review queue,
14
+ never to a plausible guess.
15
+ - **Deterministic guard on extracted fields**
16
+ ([ADR-0002](../runward/adr/ADR-0002-deterministic-guard-on-extracted-fields.md)):
17
+ the model never supplies a value the system can compute or verify itself.
18
+ An account reference is resolved against a hard-coded registry — resolved
19
+ means `verified`, unresolved means the request escalates to review, it is
20
+ never routed on. A deadline is re-parsed from the source text by a
21
+ deterministic parser and the model's proposal is discarded, even when it
22
+ looks right.
23
+ - **Guarded routing** (`RoutingPort`,
24
+ [contract](../runward/contracts/routing-port.md)): the system's only action
25
+ on the world. The adapter enforces the invariants itself: fail-closed
26
+ refusal of any record whose action-bearing fields are still
27
+ `model-proposed`, no compliance assignment without a recorded approval,
28
+ idempotency on the request id.
29
+ - **Fail-closed compliance without a frozen process**: a compliance request
30
+ suspends — the validated record is serialized, the call returns
31
+ `suspended`, and `resumeTriage(requestId, "approve" | "reject", decidedBy)`
32
+ rehydrates it on the human decision. Approve routes with the approval
33
+ recorded; reject means the routing never happens. The summary the approver
34
+ sees is built by code from the validated fields, never a model
35
+ reformulation.
36
+
37
+ ## Quickstart
38
+
39
+ Prerequisite: Node 20+.
40
+
41
+ ```bash
42
+ npm install
43
+ npm test # 14 deterministic tests via node:test + tsx
44
+ npm run demo # four requests end to end, including a compliance suspension
45
+ ```
46
+
47
+ ## Layout
48
+
49
+ ```
50
+ code/
51
+ src/
52
+ core/
53
+ domain/triage.ts # TriageRecord contract: categories, provenance markers, queues
54
+ domain/guard.ts # deterministic guard: registry resolution, deadline re-parse, summary
55
+ ports/ # model, account registry, routing, triage log
56
+ application/triage-request.usecase.ts # orchestrate: propose -> guard -> route / suspend
57
+ adapters/
58
+ keyword-model.adapter.ts # deterministic model (no key, no network)
59
+ hardcoded-account-registry.adapter.ts
60
+ in-memory-routing.adapter.ts # enforces the routing invariants, fail-closed
61
+ in-memory-triage-log.adapter.ts
62
+ demo.ts
63
+ test/triage.test.ts
64
+ ```
65
+
66
+ ## Relation to the mission documents
67
+
68
+ Same categories, same success criterion, same attached condition as the
69
+ documents in [`../runward/`](../runward/): no compliance-category request is
70
+ ever routed without human review — here that is a routing-port invariant the
71
+ tests exercise, not a promise. The architecture note's ports
72
+ ([architecture.md §3](../runward/architecture.md)) map one to one onto
73
+ `src/core/ports/`; the intake port is reduced to the validated input schema
74
+ of the use case.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "runward-example-request-triage",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "Runnable floor for the Runward request-triage example mission: classify inbound requests, extract key fields under a deterministic guard, route through a guarded port. Deterministic model adapter by default: no key, no network.",
7
+ "author": "Thibault Souris",
8
+ "license": "MIT",
9
+ "scripts": {
10
+ "test": "node --import tsx --test test/*.test.ts",
11
+ "demo": "node --import tsx src/demo.ts"
12
+ },
13
+ "dependencies": {
14
+ "zod": "^3.23.8"
15
+ },
16
+ "devDependencies": {
17
+ "tsx": "^4.19.2"
18
+ }
19
+ }
@@ -0,0 +1,23 @@
1
+ // Account registry adapter: a small hard-coded table standing in for the
2
+ // organization's system of record. The point is the contract, not the store:
3
+ // the guard resolves references here, and a reference the registry does not
4
+ // know is never routed on — however plausible the model made it look.
5
+
6
+ import type {
7
+ AccountRegistryPort,
8
+ Account,
9
+ } from "../core/ports/account-registry.port.js";
10
+
11
+ const ACCOUNTS: readonly Account[] = [
12
+ { ref: "ACC-1001", name: "Acme Corp" },
13
+ { ref: "ACC-2002", name: "Globex Industries" },
14
+ { ref: "ACC-3003", name: "Initech Ltd" },
15
+ ];
16
+
17
+ export class HardcodedAccountRegistry implements AccountRegistryPort {
18
+ private readonly byRef = new Map(ACCOUNTS.map((a) => [a.ref, a]));
19
+
20
+ resolve(ref: string): Account | null {
21
+ return this.byRef.get(ref) ?? null;
22
+ }
23
+ }
@@ -0,0 +1,79 @@
1
+ // Routing adapter: in-memory stand-in for the ticketing anticorruption
2
+ // adapter. It enforces the port's invariants itself — the guard is carried
3
+ // by the boundary, not by the caller's discipline:
4
+ // - fail-closed on provenance: any action-bearing field still
5
+ // "model-proposed" is refused (ADR-0002);
6
+ // - a compliance record is never assigned without a recorded approval;
7
+ // - idempotent on requestId: assigning twice returns the same ticketRef.
8
+
9
+ import type {
10
+ RoutingPort,
11
+ RoutingApproval,
12
+ RoutingConfirmation,
13
+ } from "../core/ports/routing.port.js";
14
+ import type { TriageRecord } from "../core/domain/triage.js";
15
+
16
+ export class ProvenanceRefusalError extends Error {
17
+ constructor(message: string) {
18
+ super(message);
19
+ this.name = "ProvenanceRefusalError";
20
+ }
21
+ }
22
+
23
+ export class ApprovalMissingError extends Error {
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "ApprovalMissingError";
27
+ }
28
+ }
29
+
30
+ export class InMemoryRoutingAdapter implements RoutingPort {
31
+ private readonly tickets = new Map<string, RoutingConfirmation>();
32
+ private seq = 0;
33
+
34
+ // Test observability: how many assignments actually happened.
35
+ get assignments(): number {
36
+ return this.tickets.size;
37
+ }
38
+
39
+ async assign(
40
+ record: TriageRecord,
41
+ targetQueue: string,
42
+ approval?: RoutingApproval,
43
+ ): Promise<RoutingConfirmation> {
44
+ // Fail-closed on provenance: refuse, never repair.
45
+ const actionBearing = [record.accountRef, record.deadline].filter(
46
+ (f): f is NonNullable<typeof f> => f !== undefined,
47
+ );
48
+ const unverified = actionBearing.filter(
49
+ (f) => f.provenance === "model-proposed",
50
+ );
51
+ if (unverified.length > 0) {
52
+ throw new ProvenanceRefusalError(
53
+ `Refused (fail-closed): action-bearing field(s) still model-proposed: ${unverified
54
+ .map((f) => f.value)
55
+ .join(", ")}.`,
56
+ );
57
+ }
58
+
59
+ // A compliance record never routes without a recorded human approval.
60
+ if (record.category === "compliance" && !approval) {
61
+ throw new ApprovalMissingError(
62
+ `Refused (fail-closed): compliance record "${record.requestId}" has no recorded approval.`,
63
+ );
64
+ }
65
+
66
+ // Idempotent on requestId: no duplicate tickets.
67
+ const existing = this.tickets.get(record.requestId);
68
+ if (existing) return existing;
69
+
70
+ const confirmation: RoutingConfirmation = {
71
+ ticketRef: `TCK-${++this.seq}`,
72
+ routedAt: new Date().toISOString(),
73
+ queue: targetQueue,
74
+ approval,
75
+ };
76
+ this.tickets.set(record.requestId, confirmation);
77
+ return confirmation;
78
+ }
79
+ }
@@ -0,0 +1,42 @@
1
+ // Persistence adapter: in-memory triage log. Append-only journal per request
2
+ // id, plus the serialized suspension point of a record awaiting approval.
3
+
4
+ import type {
5
+ TriageLogPort,
6
+ TriageLogEntry,
7
+ PendingRouting,
8
+ } from "../core/ports/triage-log.port.js";
9
+
10
+ export class InMemoryTriageLog implements TriageLogPort {
11
+ private readonly entries = new Map<string, TriageLogEntry[]>();
12
+ private readonly pending = new Map<string, PendingRouting>();
13
+
14
+ async append(requestId: string, entry: TriageLogEntry): Promise<void> {
15
+ const list = this.entries.get(requestId) ?? [];
16
+ list.push({ ...entry });
17
+ this.entries.set(requestId, list);
18
+ }
19
+
20
+ async read(requestId: string): Promise<TriageLogEntry[]> {
21
+ return (this.entries.get(requestId) ?? []).map((e) => ({ ...e }));
22
+ }
23
+
24
+ async setPending(requestId: string, pending: PendingRouting): Promise<void> {
25
+ if (this.pending.has(requestId)) {
26
+ throw new Error(`Request "${requestId}" is already suspended.`);
27
+ }
28
+ this.pending.set(requestId, {
29
+ ...pending,
30
+ record: structuredClone(pending.record),
31
+ });
32
+ }
33
+
34
+ async getPending(requestId: string): Promise<PendingRouting | null> {
35
+ const p = this.pending.get(requestId);
36
+ return p ? { ...p, record: structuredClone(p.record) } : null;
37
+ }
38
+
39
+ async clearPending(requestId: string): Promise<void> {
40
+ this.pending.delete(requestId);
41
+ }
42
+ }
@@ -0,0 +1,57 @@
1
+ // Default model adapter: deterministic keyword classifier and extractor.
2
+ // No key, no network, everything reproducible — the fallback classifier of
3
+ // the mission's model-port contract, honoring the same contract as a real
4
+ // model with lower recall. The adapter proposes; it never decides: every
5
+ // value it emits is a hypothesis the deterministic guard will verify or
6
+ // recompute (ADR-0002).
7
+
8
+ import type {
9
+ TriageModelPort,
10
+ TriageProposal,
11
+ ProposedField,
12
+ } from "../core/ports/model-provider.port.js";
13
+ import type { Category } from "../core/domain/triage.js";
14
+
15
+ // Order matters: compliance wins over support/sales when signals overlap —
16
+ // a data-privacy request that also says "help" must surface as compliance
17
+ // (a silent compliance miss is the one failure the mission forbids).
18
+ const RULES: Array<{ category: Category; pattern: RegExp }> = [
19
+ {
20
+ category: "compliance",
21
+ pattern: /\b(gdpr|data (deletion|erasure|access)|privacy|regulator|dpo|right to be forgotten)\b/i,
22
+ },
23
+ {
24
+ category: "support",
25
+ pattern: /\b(crash(es|ed)?|error|bug|broken|fail(s|ed|ing)?|cannot|can't|not working|help)\b/i,
26
+ },
27
+ {
28
+ category: "sales",
29
+ pattern: /\b(pricing|quote|purchase|demo|upgrade|licen[sc]e|subscription|buy)\b/i,
30
+ },
31
+ ];
32
+
33
+ const ACCOUNT_REF = /\bACC-\d{4}\b/;
34
+ const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/;
35
+
36
+ export class KeywordModelAdapter implements TriageModelPort {
37
+ async propose(requestText: string): Promise<TriageProposal> {
38
+ const rule = RULES.find((r) => r.pattern.test(requestText));
39
+ const category: Category = rule?.category ?? "unknown";
40
+
41
+ const fields: ProposedField[] = [];
42
+ const ref = requestText.match(ACCOUNT_REF);
43
+ if (ref) {
44
+ fields.push({ name: "accountRef", value: ref[0], sourceSpan: ref[0] });
45
+ }
46
+ const date = requestText.match(ISO_DATE);
47
+ if (date) {
48
+ fields.push({ name: "deadline", value: date[0], sourceSpan: date[0] });
49
+ }
50
+
51
+ return {
52
+ category,
53
+ fields,
54
+ confidence: rule ? "high" : "low",
55
+ };
56
+ }
57
+ }