@lastehr/agent-write-conformance 0.1.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 +198 -0
- package/README.md +44 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +92 -0
- package/dist/harness.d.ts +58 -0
- package/dist/harness.d.ts.map +1 -0
- package/dist/harness.js +70 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +50 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +67 -0
- package/dist/probe.d.ts +22 -0
- package/dist/probe.d.ts.map +1 -0
- package/dist/probe.js +53 -0
- package/dist/report.d.ts +47 -0
- package/dist/report.d.ts.map +1 -0
- package/dist/report.js +19 -0
- package/dist/run.d.ts +30 -0
- package/dist/run.d.ts.map +1 -0
- package/dist/run.js +399 -0
- package/examples/lastehr-mcp.awp-manifest.json +37 -0
- package/package.json +63 -0
package/dist/probe.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal FHIR REST probe the harness uses to verify side effects with its
|
|
3
|
+
* own eyes: chart state before and after each scripted decision, never the
|
|
4
|
+
* implementation's word for it. Deliberately tiny and dependency-free —
|
|
5
|
+
* search, create, read, delete — and structured params only.
|
|
6
|
+
*/
|
|
7
|
+
/** Bearer auth comes from the environment so tokens never hit argv. */
|
|
8
|
+
export function createRestProbe(baseUrl, options = {}) {
|
|
9
|
+
const base = baseUrl.replace(/\/+$/, "");
|
|
10
|
+
const headers = {
|
|
11
|
+
"content-type": "application/fhir+json",
|
|
12
|
+
accept: "application/fhir+json",
|
|
13
|
+
...(options.bearerToken
|
|
14
|
+
? { authorization: `Bearer ${options.bearerToken}` }
|
|
15
|
+
: {}),
|
|
16
|
+
};
|
|
17
|
+
const request = async (method, path, body) => {
|
|
18
|
+
const response = await fetch(`${base}${path}`, {
|
|
19
|
+
method,
|
|
20
|
+
headers,
|
|
21
|
+
// A hung store must fail the run, not hang it (and not silently
|
|
22
|
+
// race the MCP call timeout mid-check).
|
|
23
|
+
signal: AbortSignal.timeout(30_000),
|
|
24
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
// Status only: probe errors surface to the operator's terminal, and
|
|
28
|
+
// FHIR OperationOutcome bodies can carry resource details.
|
|
29
|
+
throw new Error(`FHIR probe ${method} failed with HTTP ${response.status}`);
|
|
30
|
+
}
|
|
31
|
+
if (method === "DELETE")
|
|
32
|
+
return undefined;
|
|
33
|
+
return response.json();
|
|
34
|
+
};
|
|
35
|
+
return {
|
|
36
|
+
async searchResources(resourceType, params) {
|
|
37
|
+
const query = new URLSearchParams(params).toString();
|
|
38
|
+
const bundle = (await request("GET", `/${resourceType}?${query}`));
|
|
39
|
+
return (bundle.entry ?? [])
|
|
40
|
+
.map((entry) => entry.resource)
|
|
41
|
+
.filter((resource) => Boolean(resource));
|
|
42
|
+
},
|
|
43
|
+
async createResource(resource) {
|
|
44
|
+
return (await request("POST", `/${resource.resourceType}`, resource));
|
|
45
|
+
},
|
|
46
|
+
async readResource(resourceType, id) {
|
|
47
|
+
return (await request("GET", `/${resourceType}/${encodeURIComponent(id)}`));
|
|
48
|
+
},
|
|
49
|
+
async deleteResource(resourceType, id) {
|
|
50
|
+
await request("DELETE", `/${resourceType}/${encodeURIComponent(id)}`);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
package/dist/report.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export declare const CONFORMANCE_REPORT_SCHEMA_VERSION = "1";
|
|
2
|
+
export declare const SPEC: {
|
|
3
|
+
readonly title: "Approval-Gated Agent Writes on FHIR";
|
|
4
|
+
readonly version: "0.1";
|
|
5
|
+
readonly url: "https://www.lastehr.com/docs/agent-write-protocol";
|
|
6
|
+
};
|
|
7
|
+
export type CheckId = "synthetic-target" | "capability-gate" | "proposal-gate" | "decision-shape" | "proposal-renders-inputs" | "approved-write" | "denied-write" | "unavailable-write" | "audit-aiast" | "audit-provenance" | "cleanup";
|
|
8
|
+
/**
|
|
9
|
+
* must — mechanically proves a spec MUST; a failure is nonconformance.
|
|
10
|
+
* should — checks a spec SHOULD (the audit layer); a failure blocks the
|
|
11
|
+
* run only under strict mode, and the report records which mode ran.
|
|
12
|
+
* partial — proves a weaker property than the spec sentence it cites
|
|
13
|
+
* (stated in the check detail); passing is evidence, not proof.
|
|
14
|
+
* hygiene — suite mechanics (disposable target, cleanup), not a spec
|
|
15
|
+
* requirement.
|
|
16
|
+
*/
|
|
17
|
+
export type CheckLevel = "must" | "should" | "partial" | "hygiene";
|
|
18
|
+
export type ConformanceCheck = {
|
|
19
|
+
id: CheckId;
|
|
20
|
+
level: CheckLevel;
|
|
21
|
+
label: string;
|
|
22
|
+
status: "pass" | "fail" | "skipped";
|
|
23
|
+
/** Static text only: never interpolated with errors, ids, or endpoints. */
|
|
24
|
+
detail: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Spec requirements a mechanical suite cannot observe from outside; a
|
|
28
|
+
* conformance claim covers these by attestation, never by this report.
|
|
29
|
+
*/
|
|
30
|
+
export declare const ATTESTATIONS: readonly string[];
|
|
31
|
+
export type ConformanceReport = {
|
|
32
|
+
schemaVersion: typeof CONFORMANCE_REPORT_SCHEMA_VERSION;
|
|
33
|
+
suite: {
|
|
34
|
+
name: string;
|
|
35
|
+
version: string;
|
|
36
|
+
};
|
|
37
|
+
spec: typeof SPEC;
|
|
38
|
+
/** Static by design: the report never echoes caller-supplied labels. */
|
|
39
|
+
target: "synthetic-disposable";
|
|
40
|
+
/** Whether should-level (audit) failures counted toward the status. */
|
|
41
|
+
strict: boolean;
|
|
42
|
+
generatedAt: string;
|
|
43
|
+
status: "pass" | "fail";
|
|
44
|
+
checks: ConformanceCheck[];
|
|
45
|
+
attestations: readonly string[];
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=report.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"report.d.ts","sourceRoot":"","sources":["../src/report.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,iCAAiC,MAAM,CAAC;AAErD,eAAO,MAAM,IAAI;;;;CAIP,CAAC;AAEX,MAAM,MAAM,OAAO,GACf,kBAAkB,GAClB,iBAAiB,GACjB,eAAe,GACf,gBAAgB,GAChB,yBAAyB,GACzB,gBAAgB,GAChB,cAAc,GACd,mBAAmB,GACnB,aAAa,GACb,kBAAkB,GAClB,SAAS,CAAC;AAEd;;;;;;;;GAQG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,QAAQ,GAAG,SAAS,GAAG,SAAS,CAAC;AAEnE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,EAAE,EAAE,OAAO,CAAC;IACZ,KAAK,EAAE,UAAU,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;IACpC,2EAA2E;IAC3E,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,MAAM,EAQzC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,aAAa,EAAE,OAAO,iCAAiC,CAAC;IACxD,KAAK,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACzC,IAAI,EAAE,OAAO,IAAI,CAAC;IAClB,wEAAwE;IACxE,MAAM,EAAE,sBAAsB,CAAC;IAC/B,uEAAuE;IACvE,MAAM,EAAE,OAAO,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,MAAM,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,MAAM,EAAE,gBAAgB,EAAE,CAAC;IAC3B,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CACjC,CAAC"}
|
package/dist/report.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export const CONFORMANCE_REPORT_SCHEMA_VERSION = "1";
|
|
2
|
+
export const SPEC = {
|
|
3
|
+
title: "Approval-Gated Agent Writes on FHIR",
|
|
4
|
+
version: "0.1",
|
|
5
|
+
url: "https://www.lastehr.com/docs/agent-write-protocol",
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Spec requirements a mechanical suite cannot observe from outside; a
|
|
9
|
+
* conformance claim covers these by attestation, never by this report.
|
|
10
|
+
*/
|
|
11
|
+
export const ATTESTATIONS = [
|
|
12
|
+
"Tool code builds the FHIR resource from validated, capped inputs; the agent never supplies raw FHIR (spec section 1).",
|
|
13
|
+
"The proposal rendering shows every field that will save, not a summary (spec section 1; the proposal-renders-inputs check proves presence of inputs, not completeness of rendering).",
|
|
14
|
+
"No approval path exists other than an explicit reviewer decision: no timeout-approve, no batch approval (spec section 2; absence is not mechanically provable).",
|
|
15
|
+
"Commit-time additions beyond the rendered fields are limited to mechanical metadata (spec section 3).",
|
|
16
|
+
"Write tools beyond those named in the manifest follow the same gate (the suite exercises only manifest-named tools).",
|
|
17
|
+
"Free text loaded from charts crosses model boundaries inside an untrusted-content delimiter (spec Security considerations).",
|
|
18
|
+
"No write exists on the chart at ANY instant during deliberation: the suite probes at sampled points (twice while pending, once after settling); a write-then-rollback implementation racing those probes violates spec section 1 but can evade sampled detection.",
|
|
19
|
+
];
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Connector } from "./harness.js";
|
|
2
|
+
import { type ConformanceManifest } from "./manifest.js";
|
|
3
|
+
import type { FhirProbe } from "./probe.js";
|
|
4
|
+
import { type ConformanceReport } from "./report.js";
|
|
5
|
+
export declare const CONFORMANCE_TAG_SYSTEM = "https://lastehr.com/awp-conformance";
|
|
6
|
+
export type RunConformanceOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Required before the suite creates or deletes resources. This must
|
|
9
|
+
* only be set for a disposable, synthetic FHIR target.
|
|
10
|
+
*/
|
|
11
|
+
confirmSyntheticTarget: true;
|
|
12
|
+
connector: Connector;
|
|
13
|
+
probe: FhirProbe;
|
|
14
|
+
manifest: ConformanceManifest;
|
|
15
|
+
suiteVersion?: string;
|
|
16
|
+
now?: () => Date;
|
|
17
|
+
/**
|
|
18
|
+
* Milliseconds to wait before each post-decision persistence sweep, so
|
|
19
|
+
* an async commit racing the probe is more likely to be seen. 0 for
|
|
20
|
+
* in-memory tests; default 500.
|
|
21
|
+
*/
|
|
22
|
+
settleMs?: number;
|
|
23
|
+
/**
|
|
24
|
+
* Count should-level (audit) failures toward the overall status. Off by
|
|
25
|
+
* default: the spec's Audit section is SHOULD-level.
|
|
26
|
+
*/
|
|
27
|
+
strict?: boolean;
|
|
28
|
+
};
|
|
29
|
+
export declare function runConformance(options: RunConformanceOptions): Promise<ConformanceReport>;
|
|
30
|
+
//# sourceMappingURL=run.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAiB,MAAM,cAAc,CAAC;AAC7D,OAAO,EAGL,KAAK,mBAAmB,EAEzB,MAAM,eAAe,CAAC;AACvB,OAAO,KAAK,EAAE,SAAS,EAAgB,MAAM,YAAY,CAAC;AAC1D,OAAO,EAOL,KAAK,iBAAiB,EACvB,MAAM,aAAa,CAAC;AAErB,eAAO,MAAM,sBAAsB,wCACI,CAAC;AAExC,MAAM,MAAM,qBAAqB,GAAG;IAClC;;;OAGG;IACH,sBAAsB,EAAE,IAAI,CAAC;IAC7B,SAAS,EAAE,SAAS,CAAC;IACrB,KAAK,EAAE,SAAS,CAAC;IACjB,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,GAAG,CAAC,EAAE,MAAM,IAAI,CAAC;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB;;;OAGG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,CAAC;AAcF,wBAAsB,cAAc,CAClC,OAAO,EAAE,qBAAqB,GAC7B,OAAO,CAAC,iBAAiB,CAAC,CAqjB5B"}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { parseDefaultResult, substituteArguments, } from "./manifest.js";
|
|
3
|
+
import { ATTESTATIONS, CONFORMANCE_REPORT_SCHEMA_VERSION, SPEC, } from "./report.js";
|
|
4
|
+
export const CONFORMANCE_TAG_SYSTEM = "https://lastehr.com/awp-conformance";
|
|
5
|
+
function getPath(resource, dotPath) {
|
|
6
|
+
return dotPath
|
|
7
|
+
.split(".")
|
|
8
|
+
.reduce((node, key) => typeof node === "object" && node !== null
|
|
9
|
+
? node[key]
|
|
10
|
+
: undefined, resource);
|
|
11
|
+
}
|
|
12
|
+
export async function runConformance(options) {
|
|
13
|
+
if (options.confirmSyntheticTarget !== true) {
|
|
14
|
+
throw new Error("The conformance suite requires confirmSyntheticTarget: true before it can create or delete resources.");
|
|
15
|
+
}
|
|
16
|
+
const { connector, probe, manifest } = options;
|
|
17
|
+
const now = options.now ?? (() => new Date());
|
|
18
|
+
const runId = randomUUID();
|
|
19
|
+
const checks = [];
|
|
20
|
+
const trackedWrites = [];
|
|
21
|
+
let patientId;
|
|
22
|
+
// Distinct nonce per scenario so a stray commit from one scenario can
|
|
23
|
+
// never satisfy another scenario's match. Kept in a range plausible for
|
|
24
|
+
// capped numeric tool inputs (e.g. an observation value).
|
|
25
|
+
let nonceCounter = 10_000 + Math.floor(Math.random() * 80_000);
|
|
26
|
+
const nextNonce = () => ++nonceCounter;
|
|
27
|
+
const addCheck = (id, level, label, status, detail) => {
|
|
28
|
+
checks.push({ id, level, label, status, detail });
|
|
29
|
+
};
|
|
30
|
+
// The citable REPORT stays scrub-clean (static details only, so backend
|
|
31
|
+
// errors, ids, and endpoints can never reach it), while the operator's
|
|
32
|
+
// own terminal gets the diagnostic — a conformance failure with zero
|
|
33
|
+
// clues is undebuggable.
|
|
34
|
+
const runCheck = async (id, level, label, detail, callback) => {
|
|
35
|
+
try {
|
|
36
|
+
await callback();
|
|
37
|
+
addCheck(id, level, label, "pass", detail);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error(`[awp-conformance] ${id} failed:`, error instanceof Error ? `${error.name}: ${error.message}` : String(error));
|
|
42
|
+
addCheck(id, level, label, "fail", detail);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
// Persistence sweeps are deliberately UNSCOPED (whole resource type, no
|
|
47
|
+
// patient filter): a non-conforming implementation that commits on
|
|
48
|
+
// denial under the wrong patient — or duplicates a write under another
|
|
49
|
+
// patient — must not escape detection just because a patient-scoped
|
|
50
|
+
// search cannot see it. --confirm-synthetic guarantees a small
|
|
51
|
+
// disposable store, which is what makes the type-level diff sound.
|
|
52
|
+
const searchTypeRows = async (tool) => probe.searchResources(tool.creates, { _count: "500" });
|
|
53
|
+
const snapshotIds = async (tool) => new Set((await searchTypeRows(tool))
|
|
54
|
+
.map((row) => row.id)
|
|
55
|
+
.filter((id) => Boolean(id)));
|
|
56
|
+
// Sampled-point probing cannot see a write that lands and rolls back
|
|
57
|
+
// between probes; the settle delay narrows that window and the residual
|
|
58
|
+
// race is named in the attestations.
|
|
59
|
+
const settleMs = options.settleMs ?? 500;
|
|
60
|
+
const settle = () => settleMs > 0
|
|
61
|
+
? new Promise((resolve) => setTimeout(resolve, settleMs))
|
|
62
|
+
: Promise.resolve();
|
|
63
|
+
const newRowsSince = async (tool, before) => (await searchTypeRows(tool)).filter((row) => row.id && !before.has(row.id));
|
|
64
|
+
// Rows a failing check discovers still belong to this run; track them
|
|
65
|
+
// so cleanup can collect them and the store stays disposable.
|
|
66
|
+
const trackRows = (tool, rows) => {
|
|
67
|
+
for (const row of rows) {
|
|
68
|
+
if (row.id)
|
|
69
|
+
trackedWrites.push({ resourceType: tool.creates, id: row.id });
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const withConnection = async (connect, body) => {
|
|
73
|
+
const connection = await connect();
|
|
74
|
+
try {
|
|
75
|
+
return await body(connection);
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await connection.close().catch(() => { });
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const callWithArgs = async (connection, tool, nonce) => {
|
|
82
|
+
const { args } = substituteArguments(tool, patientId ?? "", nonce);
|
|
83
|
+
try {
|
|
84
|
+
return await connection.callTool(tool.name, args);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// A thrown JSON-RPC error is a refusal, which several checks treat
|
|
88
|
+
// as the correct fail-closed outcome; callers decide.
|
|
89
|
+
return { isError: true, text: "" };
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
// The committed resources approved-write verified, kept for the
|
|
93
|
+
// should-level audit checks.
|
|
94
|
+
const approvedResources = [];
|
|
95
|
+
const targetReady = await runCheck("synthetic-target", "hygiene", "Synthetic disposable target", "The suite created its uniquely tagged synthetic patient on the target.", async () => {
|
|
96
|
+
const token = runId.replace(/-/g, "").slice(0, 10);
|
|
97
|
+
const patient = await probe.createResource({
|
|
98
|
+
resourceType: "Patient",
|
|
99
|
+
name: [{ family: "AwpConformance", given: [`Probe${token}`] }],
|
|
100
|
+
meta: { tag: [{ system: CONFORMANCE_TAG_SYSTEM, code: runId }] },
|
|
101
|
+
});
|
|
102
|
+
if (!patient.id)
|
|
103
|
+
throw new Error("no id");
|
|
104
|
+
patientId = patient.id;
|
|
105
|
+
});
|
|
106
|
+
const skip = (id, level, label, detail) => addCheck(id, level, label, "skipped", detail);
|
|
107
|
+
try {
|
|
108
|
+
if (!targetReady) {
|
|
109
|
+
skip("capability-gate", "must", "Capability gate", "Skipped: no synthetic target.");
|
|
110
|
+
skip("proposal-gate", "must", "Proposal gate", "Skipped: no synthetic target.");
|
|
111
|
+
skip("decision-shape", "must", "Decision shape", "Skipped: no synthetic target.");
|
|
112
|
+
skip("proposal-renders-inputs", "partial", "Proposal renders inputs", "Skipped: no synthetic target.");
|
|
113
|
+
skip("approved-write", "must", "Approved write", "Skipped: no synthetic target.");
|
|
114
|
+
skip("denied-write", "must", "Denied write", "Skipped: no synthetic target.");
|
|
115
|
+
skip("unavailable-write", "must", "Unavailable decision", "Skipped: no synthetic target.");
|
|
116
|
+
skip("audit-aiast", "should", "AIAST security label", "Skipped: no synthetic target.");
|
|
117
|
+
skip("audit-provenance", "should", "Write Provenance", "Skipped: no synthetic target.");
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
// Spec section 2: hosts that cannot render proposals MUST NOT be
|
|
121
|
+
// offered write capability at all — and a call anyway saves nothing.
|
|
122
|
+
await runCheck("capability-gate", "must", "Capability gate", "Without the elicitation capability, no manifest write tool is listed, and calling one is refused with nothing persisted.", async () => withConnection(() => connector({ elicitation: false }), async (connection) => {
|
|
123
|
+
const names = await connection.listToolNames();
|
|
124
|
+
for (const tool of manifest.writeTools) {
|
|
125
|
+
if (names.includes(tool.name))
|
|
126
|
+
throw new Error("listed");
|
|
127
|
+
const before = await snapshotIds(tool);
|
|
128
|
+
const result = await callWithArgs(connection, tool, nextNonce());
|
|
129
|
+
const parsed = parseDefaultResult(result.text);
|
|
130
|
+
if (!result.isError && parsed.saved !== false) {
|
|
131
|
+
throw new Error("not refused");
|
|
132
|
+
}
|
|
133
|
+
await settle();
|
|
134
|
+
const leaked = await newRowsSince(tool, before);
|
|
135
|
+
if (leaked.length > 0) {
|
|
136
|
+
trackRows(tool, leaked);
|
|
137
|
+
throw new Error("persisted");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
// Spec section 1: an invoked write tool MUST NOT execute; it MUST
|
|
142
|
+
// produce a proposal. Proven by probing DURING the reviewer's
|
|
143
|
+
// deliberation: the proposal exists, the chart has not changed.
|
|
144
|
+
const capturedElicitations = [];
|
|
145
|
+
await runCheck("proposal-gate", "must", "Proposal gate", "Invoking a write tool produced a proposal, and sampled probes during deliberation and after the scripted denial found nothing persisted (see attestations for the sampling boundary).", async () => {
|
|
146
|
+
for (const tool of manifest.writeTools) {
|
|
147
|
+
const nonce = nextNonce();
|
|
148
|
+
const before = await snapshotIds(tool);
|
|
149
|
+
let pendingRows = -1;
|
|
150
|
+
await withConnection(() => connector({
|
|
151
|
+
elicitation: true,
|
|
152
|
+
reviewer: async (request) => {
|
|
153
|
+
capturedElicitations.push({ tool, nonce, request });
|
|
154
|
+
// Two sampled probes while the proposal is pending: at
|
|
155
|
+
// handler entry and again after the settle window, so
|
|
156
|
+
// an async commit issued alongside the elicitation has
|
|
157
|
+
// time to become visible before the denial returns.
|
|
158
|
+
pendingRows = (await newRowsSince(tool, before)).length;
|
|
159
|
+
await settle();
|
|
160
|
+
pendingRows = Math.max(pendingRows, (await newRowsSince(tool, before)).length);
|
|
161
|
+
return { action: "decline" };
|
|
162
|
+
},
|
|
163
|
+
}), async (connection) => {
|
|
164
|
+
await callWithArgs(connection, tool, nonce);
|
|
165
|
+
});
|
|
166
|
+
if (pendingRows !== 0)
|
|
167
|
+
throw new Error("no proposal or early write");
|
|
168
|
+
await settle();
|
|
169
|
+
const afterDecline = await newRowsSince(tool, before);
|
|
170
|
+
if (afterDecline.length > 0) {
|
|
171
|
+
trackRows(tool, afterDecline);
|
|
172
|
+
throw new Error("persisted after decline");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// Spec section 2: the decision exchange requests a decision, never
|
|
177
|
+
// data — every requested property must be boolean-shaped.
|
|
178
|
+
await runCheck("decision-shape", "must", "Decision shape", "Every property the proposal's decision schema requests is boolean-typed; the exchange asks for a decision, never data.", async () => {
|
|
179
|
+
const coveredTools = new Set(capturedElicitations.map((entry) => entry.tool.name));
|
|
180
|
+
for (const tool of manifest.writeTools) {
|
|
181
|
+
if (!coveredTools.has(tool.name))
|
|
182
|
+
throw new Error("uncovered tool");
|
|
183
|
+
}
|
|
184
|
+
for (const { request } of capturedElicitations) {
|
|
185
|
+
const properties = request.requestedSchema?.properties ?? {};
|
|
186
|
+
if (Object.keys(properties).length === 0)
|
|
187
|
+
throw new Error("empty");
|
|
188
|
+
for (const property of Object.values(properties)) {
|
|
189
|
+
if (property?.type !== "boolean")
|
|
190
|
+
throw new Error("non-boolean");
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
// Spec section 1 (partial): proves each argument VALUE appears in
|
|
195
|
+
// the rendering — presence, not faithful or complete rendering.
|
|
196
|
+
await runCheck("proposal-renders-inputs", "partial", "Proposal renders inputs", "Every scalar argument value appears in the proposal shown to the reviewer (proves presence, not completeness of rendering).", async () => {
|
|
197
|
+
// Same coverage guard as decision-shape: with nothing captured
|
|
198
|
+
// for a tool, the loop below would pass vacuously.
|
|
199
|
+
const coveredTools = new Set(capturedElicitations.map((entry) => entry.tool.name));
|
|
200
|
+
for (const tool of manifest.writeTools) {
|
|
201
|
+
if (!coveredTools.has(tool.name))
|
|
202
|
+
throw new Error("uncovered tool");
|
|
203
|
+
}
|
|
204
|
+
for (const { tool, nonce, request } of capturedElicitations) {
|
|
205
|
+
const { args } = substituteArguments(tool, patientId ?? "", nonce);
|
|
206
|
+
for (const value of Object.values(args)) {
|
|
207
|
+
if ((typeof value === "string" || typeof value === "number") &&
|
|
208
|
+
!request.message.includes(String(value))) {
|
|
209
|
+
throw new Error("input not rendered");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
// Spec sections 2+3: only an explicit approval commits, and it
|
|
215
|
+
// commits exactly the proposed values, exactly once, reported back
|
|
216
|
+
// with the server-assigned id.
|
|
217
|
+
await runCheck("approved-write", "must", "Approved write", "An explicit approval committed exactly one new resource of the declared type, containing the proposed values (substring match), referencing the synthetic patient, and the result reported the server-assigned id.", async () => {
|
|
218
|
+
for (const tool of manifest.writeTools) {
|
|
219
|
+
const nonce = nextNonce();
|
|
220
|
+
const before = await snapshotIds(tool);
|
|
221
|
+
const result = await withConnection(() => connector({
|
|
222
|
+
elicitation: true,
|
|
223
|
+
reviewer: async (request) => {
|
|
224
|
+
const content = {};
|
|
225
|
+
for (const key of Object.keys(request.requestedSchema?.properties ?? {})) {
|
|
226
|
+
content[key] = true;
|
|
227
|
+
}
|
|
228
|
+
return { action: "accept", content };
|
|
229
|
+
},
|
|
230
|
+
}), (connection) => callWithArgs(connection, tool, nonce));
|
|
231
|
+
const created = await newRowsSince(tool, before);
|
|
232
|
+
if (created.length !== 1)
|
|
233
|
+
throw new Error("not exactly once");
|
|
234
|
+
const resource = created[0];
|
|
235
|
+
trackedWrites.push({
|
|
236
|
+
resourceType: tool.creates,
|
|
237
|
+
id: resource.id,
|
|
238
|
+
});
|
|
239
|
+
approvedResources.push({ tool, resource });
|
|
240
|
+
const serialized = JSON.stringify(resource);
|
|
241
|
+
if (!serialized.includes(String(nonce)))
|
|
242
|
+
throw new Error("nonce missing");
|
|
243
|
+
const { args } = substituteArguments(tool, patientId ?? "", nonce);
|
|
244
|
+
for (const value of Object.values(args)) {
|
|
245
|
+
if ((typeof value === "string" || typeof value === "number") &&
|
|
246
|
+
!serialized.includes(String(value))) {
|
|
247
|
+
throw new Error("approved value missing from committed resource");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
if (getPath(resource, tool.patientReferencePath) !==
|
|
251
|
+
`Patient/${patientId}`) {
|
|
252
|
+
throw new Error("wrong patient");
|
|
253
|
+
}
|
|
254
|
+
const parsed = parseDefaultResult(result.text);
|
|
255
|
+
if (parsed.saved !== true || parsed.id !== resource.id) {
|
|
256
|
+
throw new Error("result does not report the committed id");
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
// Spec section 2: decline, cancel, and an accept whose booleans are
|
|
261
|
+
// false are all denials — nothing persists and the result says so.
|
|
262
|
+
await runCheck("denied-write", "must", "Denied write", "Decline, cancel, and an unapproved accept each persisted nothing, and the result reported no save.", async () => {
|
|
263
|
+
const reviewers = [
|
|
264
|
+
async () => ({ action: "decline" }),
|
|
265
|
+
async () => ({ action: "cancel" }),
|
|
266
|
+
async (request) => {
|
|
267
|
+
const content = {};
|
|
268
|
+
for (const key of Object.keys(request.requestedSchema?.properties ?? {})) {
|
|
269
|
+
content[key] = false;
|
|
270
|
+
}
|
|
271
|
+
return { action: "accept", content };
|
|
272
|
+
},
|
|
273
|
+
];
|
|
274
|
+
for (const tool of manifest.writeTools) {
|
|
275
|
+
for (const reviewer of reviewers) {
|
|
276
|
+
const before = await snapshotIds(tool);
|
|
277
|
+
const result = await withConnection(() => connector({ elicitation: true, reviewer }), (connection) => callWithArgs(connection, tool, nextNonce()));
|
|
278
|
+
await settle();
|
|
279
|
+
const leaked = await newRowsSince(tool, before);
|
|
280
|
+
if (leaked.length > 0) {
|
|
281
|
+
trackRows(tool, leaked);
|
|
282
|
+
throw new Error("persisted");
|
|
283
|
+
}
|
|
284
|
+
if (parseDefaultResult(result.text).saved !== false) {
|
|
285
|
+
throw new Error("result did not report the non-save");
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
// Spec section 2: transport failure during the decision exchange
|
|
291
|
+
// MUST fail closed — nothing written, non-save visible to the agent.
|
|
292
|
+
await runCheck("unavailable-write", "must", "Unavailable decision", "A decision exchange that failed in transit persisted nothing, and the agent-visible outcome was a non-save.", async () => {
|
|
293
|
+
for (const tool of manifest.writeTools) {
|
|
294
|
+
const before = await snapshotIds(tool);
|
|
295
|
+
const result = await withConnection(() => connector({
|
|
296
|
+
elicitation: true,
|
|
297
|
+
reviewer: async () => {
|
|
298
|
+
throw new Error("scripted transport failure");
|
|
299
|
+
},
|
|
300
|
+
}), (connection) => callWithArgs(connection, tool, nextNonce()));
|
|
301
|
+
await settle();
|
|
302
|
+
const leakedRows = await newRowsSince(tool, before);
|
|
303
|
+
if (leakedRows.length > 0) {
|
|
304
|
+
trackRows(tool, leakedRows);
|
|
305
|
+
throw new Error("persisted");
|
|
306
|
+
}
|
|
307
|
+
const parsed = parseDefaultResult(result.text);
|
|
308
|
+
if (!result.isError && parsed.saved !== false) {
|
|
309
|
+
throw new Error("outcome not a visible non-save");
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
// Spec Audit section (SHOULD): approved writes carry the standard
|
|
314
|
+
// AIAST security label so agent writes are distinguishable from
|
|
315
|
+
// human ones with one _security search.
|
|
316
|
+
await runCheck("audit-aiast", "should", "AIAST security label", "Every approved write carries the AIAST label (Artificial Intelligence asserted) in meta.security.", async () => {
|
|
317
|
+
if (approvedResources.length === 0)
|
|
318
|
+
throw new Error("no writes");
|
|
319
|
+
for (const { resource } of approvedResources) {
|
|
320
|
+
const security = resource.meta?.security ?? [];
|
|
321
|
+
if (!security.some((label) => label.system ===
|
|
322
|
+
"http://terminology.hl7.org/CodeSystem/v3-ObservationValue" &&
|
|
323
|
+
label.code === "AIAST")) {
|
|
324
|
+
throw new Error("missing AIAST");
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
// Spec Audit section (SHOULD): a Provenance resource binds each
|
|
329
|
+
// approved write to the agent (author) and the reviewer (verifier).
|
|
330
|
+
await runCheck("audit-provenance", "should", "Write Provenance", "Each approved write is targeted by a Provenance naming an author agent and a verifier agent.", async () => {
|
|
331
|
+
if (approvedResources.length === 0)
|
|
332
|
+
throw new Error("no writes");
|
|
333
|
+
for (const { tool, resource } of approvedResources) {
|
|
334
|
+
const provenances = await probe.searchResources("Provenance", {
|
|
335
|
+
target: `${tool.creates}/${resource.id}`,
|
|
336
|
+
_count: "50",
|
|
337
|
+
});
|
|
338
|
+
const roles = new Set(provenances
|
|
339
|
+
.flatMap((provenance) => provenance.agent ?? [])
|
|
340
|
+
.flatMap((agent) => agent.type?.coding ?? [])
|
|
341
|
+
.map((coding) => coding.code));
|
|
342
|
+
if (!roles.has("author") || !roles.has("verifier")) {
|
|
343
|
+
throw new Error("missing provenance roles");
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
finally {
|
|
350
|
+
await runCheck("cleanup", "hygiene", "Cleanup", "Every resource the suite observed being created was deleted (audit Provenance swept first for referential integrity).", async () => {
|
|
351
|
+
for (const written of [...trackedWrites].reverse()) {
|
|
352
|
+
// Implementations may emit audit Provenance targeting the
|
|
353
|
+
// write; sweep it first so referential-integrity servers allow
|
|
354
|
+
// the delete. Best effort: absence of support must not fail
|
|
355
|
+
// cleanup of the write itself.
|
|
356
|
+
try {
|
|
357
|
+
const provenances = await probe.searchResources("Provenance", {
|
|
358
|
+
target: `${written.resourceType}/${written.id}`,
|
|
359
|
+
_count: "50",
|
|
360
|
+
});
|
|
361
|
+
for (const provenance of provenances) {
|
|
362
|
+
const targets = provenance.target ?? [];
|
|
363
|
+
if (provenance.id &&
|
|
364
|
+
targets.some((target) => target.reference ===
|
|
365
|
+
`${written.resourceType}/${written.id}`)) {
|
|
366
|
+
await probe.deleteResource("Provenance", provenance.id);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Provenance search unsupported on this server; the write
|
|
372
|
+
// delete below is the real assertion.
|
|
373
|
+
}
|
|
374
|
+
await probe.deleteResource(written.resourceType, written.id);
|
|
375
|
+
}
|
|
376
|
+
if (patientId) {
|
|
377
|
+
await probe.deleteResource("Patient", patientId);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
const strict = options.strict ?? false;
|
|
382
|
+
return {
|
|
383
|
+
schemaVersion: CONFORMANCE_REPORT_SCHEMA_VERSION,
|
|
384
|
+
suite: {
|
|
385
|
+
name: "@lastehr/agent-write-conformance",
|
|
386
|
+
version: options.suiteVersion ?? "0.1.0",
|
|
387
|
+
},
|
|
388
|
+
spec: SPEC,
|
|
389
|
+
target: "synthetic-disposable",
|
|
390
|
+
strict,
|
|
391
|
+
generatedAt: now().toISOString(),
|
|
392
|
+
status: checks.every((check) => check.status === "pass" ||
|
|
393
|
+
(!strict && check.level === "should" && check.status === "fail"))
|
|
394
|
+
? "pass"
|
|
395
|
+
: "fail",
|
|
396
|
+
checks,
|
|
397
|
+
attestations: ATTESTATIONS,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"writeTools": [
|
|
3
|
+
{
|
|
4
|
+
"name": "record_observation",
|
|
5
|
+
"arguments": {
|
|
6
|
+
"patientId": "$PATIENT_ID",
|
|
7
|
+
"label": "Conformance probe",
|
|
8
|
+
"value": "$NONCE",
|
|
9
|
+
"unit": "bpm"
|
|
10
|
+
},
|
|
11
|
+
"creates": "Observation",
|
|
12
|
+
"patientReferencePath": "subject.reference",
|
|
13
|
+
"nonceField": "value"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"name": "add_note",
|
|
17
|
+
"arguments": {
|
|
18
|
+
"patientId": "$PATIENT_ID",
|
|
19
|
+
"text": "Conformance probe note $NONCE"
|
|
20
|
+
},
|
|
21
|
+
"creates": "Communication",
|
|
22
|
+
"patientReferencePath": "subject.reference",
|
|
23
|
+
"nonceField": "text"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"name": "create_task",
|
|
27
|
+
"arguments": {
|
|
28
|
+
"patientId": "$PATIENT_ID",
|
|
29
|
+
"description": "Conformance probe task $NONCE"
|
|
30
|
+
},
|
|
31
|
+
"creates": "Task",
|
|
32
|
+
"patientReferencePath": "for.reference",
|
|
33
|
+
"nonceField": "description"
|
|
34
|
+
}
|
|
35
|
+
],
|
|
36
|
+
"parseResult": "default"
|
|
37
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lastehr/agent-write-conformance",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Conformance suite for the Approval-Gated Agent Writes on FHIR protocol (v0.1 draft): drives any MCP server implementing the write profile with a scripted reviewer and verifies gate mechanics against a live FHIR store.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Chris Betz",
|
|
7
|
+
"homepage": "https://www.lastehr.com/docs/agent-write-protocol",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/cbetz/last-ehr.git",
|
|
11
|
+
"directory": "packages/conformance"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/cbetz/last-ehr/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"mcp",
|
|
18
|
+
"model-context-protocol",
|
|
19
|
+
"fhir",
|
|
20
|
+
"healthcare",
|
|
21
|
+
"agent",
|
|
22
|
+
"approval",
|
|
23
|
+
"conformance"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"awp-conformance": "dist/cli.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"examples",
|
|
40
|
+
"LICENSE",
|
|
41
|
+
"README.md"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": "^22.18.0 || >=24.2.0"
|
|
45
|
+
},
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
},
|
|
49
|
+
"scripts": {
|
|
50
|
+
"build": "tsc -p tsconfig.build.json",
|
|
51
|
+
"test": "vitest run",
|
|
52
|
+
"prepublishOnly": "npm run build && npm run test"
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
56
|
+
"zod": "^4.4.3"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^22",
|
|
60
|
+
"typescript": "^5.9",
|
|
61
|
+
"vitest": "^4.1.9"
|
|
62
|
+
}
|
|
63
|
+
}
|