@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/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
+ }
@@ -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
+ }