@archstone/runtime
Advanced tools
| // src/verify.ts | ||
| import { readFileSync } from "fs"; | ||
| import { resolve } from "path"; | ||
| import { | ||
| describeShape, | ||
| diffShape, | ||
| fingerprintShape, | ||
| fingerprintShapeMap, | ||
| hasShapeDrift, | ||
| shapeDriftSummary | ||
| } from "@archstone/compiler"; | ||
| import { invokeRest } from "@archstone/provider-rest"; | ||
| import { evaluatePolicy, lifecycleExposure } from "@archstone/emitter-support"; | ||
| // src/mapping.ts | ||
| import { applyResponseMapping } from "@archstone/emitter-support"; | ||
| // src/verify.ts | ||
| function readFixture(dir, path) { | ||
| try { | ||
| return JSON.parse(readFileSync(resolve(dir, path), "utf8")); | ||
| } catch { | ||
| return void 0; | ||
| } | ||
| } | ||
| function narrateShapeChange(contract, liveShape, liveFingerprint) { | ||
| const fingerprints = `fingerprint ${contract.fingerprint} \u2192 ${liveFingerprint}`; | ||
| if (!contract.shape) return { detail: `response shape changed (${fingerprints})` }; | ||
| if (fingerprintShapeMap(contract.shape) !== contract.fingerprint) { | ||
| return { detail: `response shape changed (${fingerprints}); recorded shape is stale and was not used \u2014 re-record this contract` }; | ||
| } | ||
| const drift = diffShape(contract.shape, liveShape); | ||
| if (!hasShapeDrift(drift)) return { detail: `response shape changed (${fingerprints})` }; | ||
| return { detail: `response shape ${shapeDriftSummary(drift)}`, drift }; | ||
| } | ||
| async function verifyTool(tool, dir, resources, opts) { | ||
| const base = { capabilityId: tool.id }; | ||
| const contract = tool.contract; | ||
| if (!contract) return { ...base, status: "red", detail: "no contract: declared \u2014 nothing to verify" }; | ||
| const fixture = readFixture(dir, contract.probeFixture); | ||
| if (!fixture) return { ...base, status: "red", detail: `fixture not found or unreadable: ${contract.probeFixture}` }; | ||
| const decision = evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| return { | ||
| ...base, | ||
| status: "red", | ||
| detail: `policy denied before any request was made: ${decision.denial.message}`, | ||
| policyDenied: true | ||
| }; | ||
| } | ||
| const result = await invokeRest(tool, fixture.request, opts); | ||
| if (!result.ok) return { ...base, status: "red", detail: `live request failed: ${result.error ?? `status ${result.status}`}` }; | ||
| const liveFingerprint = fingerprintShape(result.data); | ||
| const fingerprintChanged = liveFingerprint !== contract.fingerprint; | ||
| const liveShape = describeShape(result.data); | ||
| if (!tool.response) { | ||
| if (!fingerprintChanged) return { ...base, status: "green", detail: "fingerprint unchanged" }; | ||
| const { detail, drift } = narrateShapeChange(contract, liveShape, liveFingerprint); | ||
| return { ...base, status: "yellow", detail, ...drift ? { drift } : {} }; | ||
| } | ||
| const mapped = applyResponseMapping(tool, result.data, resources); | ||
| if (mapped.status === "violation") { | ||
| return { ...base, status: "red", detail: `contract violation: missing required field(s) ${(mapped.missing ?? []).join(", ")}` }; | ||
| } | ||
| if (fixture.expects?.collectionNonEmpty) { | ||
| const field = tool.response.field; | ||
| const value = mapped.data?.[field]; | ||
| const empty = Array.isArray(value) ? value.length === 0 : value === void 0 || value === null; | ||
| if (empty) return { ...base, status: "red", detail: `expected a non-empty '${field}' collection; got none` }; | ||
| } | ||
| if (mapped.status === "degraded") { | ||
| return { ...base, status: "yellow", detail: `degraded: optional field(s) absent \u2014 ${(mapped.degraded ?? []).join(", ")}` }; | ||
| } | ||
| if (fingerprintChanged) { | ||
| const { detail, drift } = narrateShapeChange(contract, liveShape, liveFingerprint); | ||
| return { ...base, status: "yellow", detail: `mapping still resolves; ${detail}`, ...drift ? { drift } : {} }; | ||
| } | ||
| return { ...base, status: "green", detail: "fingerprint unchanged, mapping OK" }; | ||
| } | ||
| async function runVerify(tools, dir, resources, opts) { | ||
| const contractBearing = tools.filter((t) => t.contract && lifecycleExposure(t.lifecycle).blockedReason !== "retired"); | ||
| return Promise.all(contractBearing.map((t) => verifyTool(t, dir, resources, opts))); | ||
| } | ||
| var NOT_ATTEMPTED_RE = /^missing (?:env var|caller credential)\(s\):/; | ||
| async function recordContract(tool, input, resources, opts) { | ||
| const base = { capabilityId: tool.id }; | ||
| const decision = evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| return { ...base, outcome: "not-attempted", detail: `policy denied before any request was made: ${decision.denial.message}` }; | ||
| } | ||
| const result = await invokeRest(tool, input, opts); | ||
| if (!result.ok) { | ||
| const error = result.error ?? `status ${result.status}`; | ||
| if (result.status === 0 && NOT_ATTEMPTED_RE.test(error)) { | ||
| return { ...base, outcome: "not-attempted", detail: `no request was sent \u2014 ${error}` }; | ||
| } | ||
| return { ...base, outcome: "red", detail: `live request failed: ${error}` }; | ||
| } | ||
| const fingerprint = fingerprintShape(result.data); | ||
| const shape = describeShape(result.data); | ||
| const fixture = { | ||
| capabilityId: tool.id, | ||
| recordedAt: (opts?.now ?? /* @__PURE__ */ new Date()).toISOString(), | ||
| request: input | ||
| }; | ||
| if (!tool.response) { | ||
| return { ...base, outcome: "green", detail: "recorded \u2014 no response mapping to validate", fingerprint, shape, fixture }; | ||
| } | ||
| const mapped = applyResponseMapping(tool, result.data, resources); | ||
| if (mapped.status === "violation") { | ||
| return { | ||
| ...base, | ||
| outcome: "red", | ||
| detail: `contract violation on the recorded response: missing required field(s) ${(mapped.missing ?? []).join(", ")}`, | ||
| ...mapped.missing ? { missing: mapped.missing } : {} | ||
| }; | ||
| } | ||
| if (mapped.status === "degraded") { | ||
| return { | ||
| ...base, | ||
| outcome: "yellow", | ||
| detail: `recorded, degraded: optional field(s) absent \u2014 ${(mapped.degraded ?? []).join(", ")}`, | ||
| fingerprint, | ||
| shape, | ||
| fixture, | ||
| ...mapped.degraded ? { degraded: mapped.degraded } : {} | ||
| }; | ||
| } | ||
| return { ...base, outcome: "green", detail: "recorded \u2014 mapping OK", fingerprint, shape, fixture }; | ||
| } | ||
| export { | ||
| applyResponseMapping, | ||
| verifyTool, | ||
| runVerify, | ||
| recordContract | ||
| }; | ||
| //# sourceMappingURL=chunk-WPGTXEHU.js.map |
| {"version":3,"sources":["../src/verify.ts","../src/mapping.ts"],"sourcesContent":["// @archstone/runtime — Contract probe runner (ADD-18 / RFC-0006 Phase 2).\n//\n// `runVerify` replays a bound tool's golden fixture against the LIVE backend and\n// derives a health status. This is the only place outside a real MCP invocation that\n// makes a network call — always explicit, on demand (`archstone verify`), never\n// triggered by `apply`/`serve`. Reuses #12's `applyResponseMapping` verbatim (ADD-18\n// D-3/R-4): one mapper, so a probe VIOLATION is exactly what a real call would see.\n\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport {\n describeShape,\n diffShape,\n fingerprintShape,\n fingerprintShapeMap,\n hasShapeDrift,\n shapeDriftSummary,\n type IRContract,\n type IRTool,\n type IRResourceRegistry,\n type ShapeDiff,\n type ShapeMap,\n} from \"@archstone/compiler\";\nimport { invokeRest, type InvokeOptions } from \"@archstone/provider-rest\";\nimport { evaluatePolicy, lifecycleExposure } from \"@archstone/emitter-support\";\nimport { applyResponseMapping } from \"./mapping\";\n// ADD-24: HealthStatus's canonical home moved to @archstone/emitter-support (registry.ts's\n// exposure composition needs it, and runtime depends on emitter-support, never the reverse) —\n// re-exported here, unchanged, so nothing downstream (e.g. the CLI's `HealthStatus` import\n// from \"@archstone/runtime\") breaks.\nimport type { HealthStatus } from \"@archstone/emitter-support\";\nexport type { HealthStatus } from \"@archstone/emitter-support\";\n\nexport interface ToolVerification {\n capabilityId: string;\n status: HealthStatus;\n detail: string;\n /**\n * #43 (ADD-43 D-14): set iff this verification was refused by the policy evaluation point\n * before any request was issued — i.e. the probe observed **nothing at all** about the\n * backend's contract.\n *\n * Why an additive optional field rather than a fourth `HealthStatus` value: `HealthStatus` is\n * a CLOSED set already consumed by ADD-24's `combineExposure` and by ADD-20's published\n * `archstone verify --json` shape, so a `\"denied\"` member would take a published CLI contract\n * and the exposure severity ordering with it. `red` stays correct for the OPERATOR-facing\n * report — they asked \"is this binding healthy?\" and the honest answer is \"I could not\n * establish that\" (D-7).\n *\n * What this flag exists to stop is that `red` travelling ONWARD into an AGENT-facing surface.\n * `readHealthSnapshot` (registry.ts) skips any entry carrying it, so the tool ends up with no\n * health entry at all, `combineExposure` leaves its exposure untouched, and no hint is\n * appended to its advertised description. Without it, the documented ADD-24 D-8 workflow\n * (`archstone verify --json` > `.archstone-health.json`, then serve) would append\n * `\"binding health: red — the last contract verification failed\"` at the highest severity to\n * a policy-gated tool's description, for EVERY caller including permitted ones — a statement\n * that is factually false (no verification occurred) and that makes policy affect listing,\n * which BR-36 forbids. Because the CLI supplies no caller, that is the DEFAULT outcome for\n * any `allow`-bearing capability, not a corner case.\n *\n * The failure is silent: nothing throws, no exit code changes, an agent just reads a false\n * warning. `runtime/test/lifecycle.integration.test.ts` asserts it.\n */\n policyDenied?: true;\n /**\n * #114 (ADD-114 D-4): which paths the provider gained, lost or retyped since the contract was\n * recorded. Present only when the binding recorded a `contract.shape`, that shape is\n * consistent with its own fingerprint (D-3), and something actually moved.\n *\n * NARRATIVE ONLY. `status` above is derived exactly as ADD-18 D-4 defines it, from the\n * fingerprint alone — this field explains a status it never determines (D-2). It rides into\n * `archstone verify --json` for free, since the CLI serialises this object directly.\n */\n drift?: ShapeDiff;\n}\n\nexport interface GoldenFixture {\n capabilityId: string;\n recordedAt?: string;\n request: Record<string, unknown>;\n expects?: { collectionNonEmpty?: boolean };\n}\n\nfunction readFixture(dir: string, path: string): GoldenFixture | undefined {\n try {\n return JSON.parse(readFileSync(resolve(dir, path), \"utf8\")) as GoldenFixture;\n } catch {\n return undefined;\n }\n}\n\n/**\n * Turn a fingerprint mismatch into a sentence that names what moved — or explains why it\n * cannot (ADD-114 D-3).\n *\n * Three outcomes, in order of how much we are entitled to claim:\n * 1. no `contract.shape` recorded → ADD-18's original wording, unchanged;\n * 2. a shape recorded that disagrees with its own fingerprint → say so, and name nothing. The\n * two are records of one observation and can only diverge by hand-editing; naming fields\n * from a shape that does not describe this contract is worse than naming none;\n * 3. a consistent shape → the named diff.\n */\nfunction narrateShapeChange(\n contract: IRContract,\n liveShape: ShapeMap,\n liveFingerprint: string,\n): { detail: string; drift?: ShapeDiff } {\n const fingerprints = `fingerprint ${contract.fingerprint} → ${liveFingerprint}`;\n if (!contract.shape) return { detail: `response shape changed (${fingerprints})` };\n if (fingerprintShapeMap(contract.shape) !== contract.fingerprint) {\n return { detail: `response shape changed (${fingerprints}); recorded shape is stale and was not used — re-record this contract` };\n }\n const drift = diffShape(contract.shape, liveShape);\n if (!hasShapeDrift(drift)) return { detail: `response shape changed (${fingerprints})` };\n return { detail: `response shape ${shapeDriftSummary(drift)}`, drift };\n}\n\n/** Verify one tool's contract against the live backend. Returns green/yellow/red — never\n * throws (a network/fs failure is itself a red result, not an exception the CLI must catch). */\nexport async function verifyTool(tool: IRTool, dir: string, resources: IRResourceRegistry, opts?: InvokeOptions): Promise<ToolVerification> {\n const base = { capabilityId: tool.id };\n const contract = tool.contract;\n if (!contract) return { ...base, status: \"red\", detail: \"no contract: declared — nothing to verify\" };\n\n const fixture = readFixture(dir, contract.probeFixture);\n if (!fixture) return { ...base, status: \"red\", detail: `fixture not found or unreadable: ${contract.probeFixture}` };\n\n // #43 (ADD-43 D-6): the contract prober is the THIRD invocation consumer, and it must route\n // through the same evaluation point as `callTool`/`executeCapability`. A probe makes a real\n // call with real credentials and is `authenticated`-gated today only because that gate lives\n // inside `invokeRest`; moving the gate (D-4) would silently un-gate `archstone verify` and the\n // published `runVerify()` unless this call exists. Placed immediately before `invokeRest`, so\n // \"no contract\" / \"fixture not found\" keep reporting themselves first.\n //\n // ADD-51 (#51) D-6, deliberately, do NOT \"fix\" this into a third exposure gate: unlike\n // `callTool`/`executeCapability`, `verifyTool` itself does not read\n // `registry.getExposure(tool.id)` and still probes a `lifecycle: retired` capability exactly\n // like a `stable` one IF it is called directly on one. Two reasons, both load-bearing. (1)\n // `verifyTool` never emits an `Execution` audit record under any outcome, so the\n // manufactured-evidence harm ADD-51 exists to close is structurally impossible on this path\n // regardless of lifecycle wiring. (2) Gating `verifyTool` itself would make it impossible to\n // ever probe a retired capability on purpose (e.g. investigating one before un-retiring it).\n //\n // #54 (R-2's fix, once filed): the CI-release-gate regression this residual risk named — a\n // retired-but-still-`contract:`-bearing capability turning `archstone verify`'s gate red\n // forever — is fixed one level up, in `runVerify`'s contract-bearing filter (below), which\n // now excludes a non-invocable (retired) tool before it ever reaches this function. See\n // `runVerify`'s doc comment. This function is unchanged by that fix and remains reachable\n // directly on a retired tool by a caller who wants to probe one deliberately.\n const decision = evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // `red`, with a detail textually distinguishable from the `live request failed:` prefix\n // below — because no live request was made (BR-37). `policyDenied` keeps this out of the\n // health snapshot entirely (D-14, see the field's doc comment).\n return {\n ...base,\n status: \"red\",\n detail: `policy denied before any request was made: ${decision.denial.message}`,\n policyDenied: true,\n };\n }\n\n const result = await invokeRest(tool, fixture.request, opts);\n if (!result.ok) return { ...base, status: \"red\", detail: `live request failed: ${result.error ?? `status ${result.status}`}` };\n\n const liveFingerprint = fingerprintShape(result.data);\n const fingerprintChanged = liveFingerprint !== contract.fingerprint;\n const liveShape = describeShape(result.data);\n\n if (!tool.response) {\n // No response mapping to validate against — fingerprint drift is all we can see.\n if (!fingerprintChanged) return { ...base, status: \"green\", detail: \"fingerprint unchanged\" };\n const { detail, drift } = narrateShapeChange(contract, liveShape, liveFingerprint);\n return { ...base, status: \"yellow\", detail, ...(drift ? { drift } : {}) };\n }\n\n const mapped = applyResponseMapping(tool, result.data, resources);\n if (mapped.status === \"violation\") {\n return { ...base, status: \"red\", detail: `contract violation: missing required field(s) ${(mapped.missing ?? []).join(\", \")}` };\n }\n\n if (fixture.expects?.collectionNonEmpty) {\n const field = tool.response.field;\n const value = mapped.data?.[field];\n const empty = Array.isArray(value) ? value.length === 0 : value === undefined || value === null;\n if (empty) return { ...base, status: \"red\", detail: `expected a non-empty '${field}' collection; got none` };\n }\n\n if (mapped.status === \"degraded\") {\n return { ...base, status: \"yellow\", detail: `degraded: optional field(s) absent — ${(mapped.degraded ?? []).join(\", \")}` };\n }\n if (fingerprintChanged) {\n const { detail, drift } = narrateShapeChange(contract, liveShape, liveFingerprint);\n return { ...base, status: \"yellow\", detail: `mapping still resolves; ${detail}`, ...(drift ? { drift } : {}) };\n }\n return { ...base, status: \"green\", detail: \"fingerprint unchanged, mapping OK\" };\n}\n\n/**\n * Verify every contract-bearing tool in a registry.\n *\n * #54 (fixing ADD-51 D-6's named residual risk, R-2): a `lifecycle: retired` capability is\n * excluded from the contract-bearing filter here — never handed to `verifyTool` at all, so it\n * never enters the returned report. This is deliberately NOT the same fix as `policyDenied`\n * (ADD-43 D-14): a policy denial still enters the report (marked, then skipped only by the\n * health-snapshot reader, `registry.ts`'s `readHealthSnapshot`) because a policy evaluation is\n * itself a fact worth reporting. A retirement is not — a business withdrawing a capability is a\n * normal operational event, not a thing `archstone verify` has anything to say about, so the\n * capability is simply never probed and never appears, exactly as if its `contract:` block did\n * not exist. That is what keeps `reports.some(r => r.status === \"red\")` (`cli/src/index.ts`,\n * the CI release gate) from going permanently red the day a `contract:`-bearing capability is\n * retired without also deleting its contract block.\n *\n * Invocability is read via `lifecycleExposure` — the exact pure lowering\n * `Registry.getExposure` (`@archstone/emitter-support/registry.ts`) composes into its\n * `exposureById` map, reused verbatim rather than re-deriving `lifecycle === \"retired\"` here\n * (ADD-24 D-6/R-5: any future reader shares this one computation). `runVerify` receives raw\n * `IRTool[]`, not a `Registry`, and health never affects `invocable` (ADD-24 D-9), so calling\n * `lifecycleExposure` directly — the same function `getExposure` calls, with no health\n * component to compose — yields an identical answer to `registry.getExposure(t.id).invocable`\n * for every tool.\n *\n * This does NOT change `verifyTool` itself (still deliberately ungated per D-6, directly\n * reachable and still probing a retired capability if called on one on purpose) — only this\n * orchestrator, which is what `archstone verify`/the CLI gate actually walks.\n *\n * `policyDenied` entries' gate handling is unchanged and explicitly out of scope for this fix\n * (see #54's PR description) — a separate decision, deferred.\n *\n * Bug fix (found reviewing #54): the original filter excluded every `invocable:false` tool —\n * `lifecycleExposure(...).invocable` is `false` for BOTH `lifecycle: \"retired\"` (this fix's\n * actual target) AND the `unevaluatable`/default branch (an unrecognized `lifecycle` value on a\n * hand-written or forward-versioned IR, ADD-56). That conflated a governance refusal with a\n * compatibility refusal: a capability with a corrupted/unrecognized lifecycle AND a genuinely\n * broken `contract:` block was silently excluded from the report instead of being probed and\n * flagged red — undermining ADD-56's \"make incompatibility loud\" goal on this one path. The\n * filter now checks `blockedReason !== \"retired\"` specifically, so an unrecognized-lifecycle\n * tool (`blockedReason: \"unevaluatable\"`) stays in `contractBearing` and is probed by\n * `verifyTool` exactly as it was before this whole feature shipped. `Exposure.blockedReason` is\n * always present when `invocable:false` and always absent when `invocable:true` (`exposure.ts`),\n * so this substitution needs no separate `invocable` check.\n */\nexport async function runVerify(\n tools: IRTool[],\n dir: string,\n resources: IRResourceRegistry,\n opts?: InvokeOptions,\n): Promise<ToolVerification[]> {\n const contractBearing = tools.filter((t) => t.contract && lifecycleExposure(t.lifecycle).blockedReason !== \"retired\");\n return Promise.all(contractBearing.map((t) => verifyTool(t, dir, resources, opts)));\n}\n\n// ---------------------------------------------------------------------------------------\n// Recording a contract (ADD-37 D-6 / R-1)\n// ---------------------------------------------------------------------------------------\n\n/**\n * How a probe ended.\n *\n * `green` / `yellow` / `red` mirror `HealthStatus` deliberately — this is the same question\n * `verifyTool` answers, asked one moment earlier. `not-attempted` is the fourth outcome\n * ADD-37 Amendment 1 §A-5 adds, and it is not a nicety:\n *\n * `invokeRest` returns `{ok: false, status: 0, error: \"missing env var(s): …\"}` BEFORE it\n * sends anything. Reporting that as `red` asserts that the backend disagreed with the\n * manifest, which is false — nothing was asked of the backend at all. False reds are how\n * people learn to ignore reds, and this one would fire on the very first run of every\n * generated manifest whose credential variable is not set yet.\n *\n * Same disposition as `red` for the CONTRACT (write nothing); the opposite disposition in the\n * report.\n */\nexport type ProbeOutcome = \"green\" | \"yellow\" | \"red\" | \"not-attempted\";\n\n/**\n * The result of one recording attempt.\n *\n * `fingerprint` and `fixture` are present together or not at all — the schema requires\n * `source` + `fingerprint` + `probe.fixture`, so a half-recording is not a thing a caller\n * could write down even if it wanted to.\n */\nexport interface ContractRecording {\n capabilityId: string;\n outcome: ProbeOutcome;\n detail: string;\n fingerprint?: string;\n /** The recorded response shape (ADD-114 D-6), derived from the SAME body as `fingerprint`\n * in the same call — which is what makes the two consistent by construction at the only\n * point that writes them, and is what D-3's check later relies on. */\n shape?: ShapeMap;\n fixture?: GoldenFixture;\n /** Optional fields that came back absent or null. Real required/optional evidence — the\n * caller may offer a loosening at the gate, and must never apply one silently: n=1 is not\n * a classification. */\n degraded?: string[];\n /** Required fields that came back absent or null. A VIOLATION, and the reason nothing is\n * written: a manifest that violates on its own recording is not a manifest. */\n missing?: string[];\n}\n\nexport interface RecordContractOptions extends InvokeOptions {\n /** Injected so a test can pin the recorded timestamp. Defaults to the wall clock — this\n * module is the runtime, not the pure core, and recording is inherently a moment in time. */\n now?: Date;\n}\n\n/** Errors `invokeRest` returns WITHOUT sending a request. Matched on the message because that\n * is the only signal in the shipped return shape — `status: 0` alone also covers a network\n * failure, which is a genuine red. */\nconst NOT_ATTEMPTED_RE = /^missing (?:env var|caller credential)\\(s\\):/;\n\n/**\n * Record a contract for a tool that does not have one yet (ADD-37 D-6).\n *\n * A SIBLING of `verifyTool`, not a flag on it, and the reason is structural rather than\n * stylistic: `verifyTool` returns `red` on `!tool.contract` before doing anything, and the\n * contract is precisely what this function exists to create. The chicken-and-egg is real.\n *\n * What makes this the right place for it (R-1): it is the SAME module, over the SAME\n * `invokeRest` call, with the same policy evaluation and the same `fingerprintShape` and\n * `applyResponseMapping`, as the replay that will later be asked to trust the artifact. A\n * second orchestration of \"call the backend, hash the shape, run the mapper\" living in\n * `init` would look green at record time and be unreplayable afterwards — silently, for the\n * manifest's lifetime.\n *\n * It reads no filesystem: there is no fixture to find yet. That is the one deliberate\n * departure from ADD-37 §6 step 6's sketched `(tool, input, dir, resources, opts)` signature —\n * carrying a `dir` this function cannot use would suggest it does something with it.\n *\n * NOTE it never decides WHETHER to probe. Consent, the confirmed `effect: read` and the method\n * rule (R-8) are the caller's gate, upstream, where the human is.\n */\nexport async function recordContract(\n tool: IRTool,\n input: Record<string, unknown>,\n resources: IRResourceRegistry,\n opts?: RecordContractOptions,\n): Promise<ContractRecording> {\n const base = { capabilityId: tool.id };\n\n // Same evaluation point as `verifyTool` (#43 / ADD-43 D-6), for the same reason: a probe\n // makes a real call with real credentials. `init` never emits `policies:`, so this cannot\n // fire on a freshly generated manifest — it is here so that re-recording an EXISTING\n // hand-written manifest cannot route around the gate.\n const decision = evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // `not-attempted`, not `red`.\n //\n // DELIBERATELY DIVERGENT FROM `verifyTool`, which answers `red` + `policyDenied` for this\n // identical condition — recorded here so nobody \"fixes\" the two into agreement. They are\n // answering different questions. `verifyTool` answers an OPERATOR's \"is this binding\n // healthy?\", and \"I could not establish that\" is honestly red (ADD-43 D-7); its\n // `policyDenied` flag then exists to stop that red travelling onward into an agent-facing\n // surface. `recordContract` answers \"did I learn anything worth writing down?\", and the\n // answer is simply no — nothing was asked of the backend. Both refuse to write a contract;\n // only the report wording differs, which is the whole point of the fourth outcome.\n return { ...base, outcome: \"not-attempted\", detail: `policy denied before any request was made: ${decision.denial.message}` };\n }\n\n const result = await invokeRest(tool, input, opts);\n if (!result.ok) {\n const error = result.error ?? `status ${result.status}`;\n if (result.status === 0 && NOT_ATTEMPTED_RE.test(error)) {\n return { ...base, outcome: \"not-attempted\", detail: `no request was sent — ${error}` };\n }\n return { ...base, outcome: \"red\", detail: `live request failed: ${error}` };\n }\n\n const fingerprint = fingerprintShape(result.data);\n const shape = describeShape(result.data);\n const fixture: GoldenFixture = {\n capabilityId: tool.id,\n recordedAt: (opts?.now ?? new Date()).toISOString(),\n request: input,\n };\n\n if (!tool.response) {\n // Nothing to validate against; the fingerprint is still a real, replayable fact.\n return { ...base, outcome: \"green\", detail: \"recorded — no response mapping to validate\", fingerprint, shape, fixture };\n }\n\n const mapped = applyResponseMapping(tool, result.data, resources);\n if (mapped.status === \"violation\") {\n // KEEP NOTHING. A field the manifest marks required came back null or absent on the very\n // response we are recording, so the contract would be green against a fiction and red\n // against reality. The loosening belongs at the gate, offered to a human, never applied\n // here: n=1 is not a classification.\n return {\n ...base,\n outcome: \"red\",\n detail: `contract violation on the recorded response: missing required field(s) ${(mapped.missing ?? []).join(\", \")}`,\n ...(mapped.missing ? { missing: mapped.missing } : {}),\n };\n }\n if (mapped.status === \"degraded\") {\n return {\n ...base,\n outcome: \"yellow\",\n detail: `recorded, degraded: optional field(s) absent — ${(mapped.degraded ?? []).join(\", \")}`,\n fingerprint,\n shape,\n fixture,\n ...(mapped.degraded ? { degraded: mapped.degraded } : {}),\n };\n }\n return { ...base, outcome: \"green\", detail: \"recorded — mapping OK\", fingerprint, shape, fixture };\n}\n","// @archstone/runtime — Response mapper (ADD-12 / RFC-0006)\n//\n// Moved to @archstone/emitter-support (ADD-0008 #27), unchanged logic — re-exported here for\n// back-compat so nothing downstream breaks (verify.ts and existing consumers still import\n// from \"./mapping\").\nexport { applyResponseMapping, type MappingStatus, type MappingResult } from \"@archstone/emitter-support\";\n"],"mappings":"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","names":[]} |
+44
-2
| import { LoadIssue } from '@archstone/schema'; | ||
| import { Diagnostic } from '@archstone/compiler'; | ||
| import { Diagnostic, JsonType, SemanticType, IRTool, ShapeDiff, IRResourceRegistry } from '@archstone/compiler'; | ||
| import { Registry, AuditSink } from '@archstone/emitter-support'; | ||
@@ -44,2 +44,44 @@ export { AuditSink, AuditWritable, ExecutionConsumer, ExecutionDenialReason, ExecutionPhase, ExecutionRecord, ExecutionStatus, HealthStatus, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, MappingResult, MappingStatus, REDACTED, Registry, applyResponseMapping, inputJsonSchema, jsonLinesAuditSink, objectJsonSchema, toolName } from '@archstone/emitter-support'; | ||
| /** Why a named path cannot be adopted. Stated, never silently skipped — a candidate that | ||
| * disappears from the report reads as "there was nothing there". */ | ||
| type AdoptionRefusal = "outside-collection" | "nested" | "no-boolean-type" | "not-a-leaf" | "already-declared"; | ||
| interface AdoptableField { | ||
| adoptable: true; | ||
| /** The JSONPath the drift reported, e.g. `$.stays[].boardType`. */ | ||
| path: string; | ||
| /** The resource field name it would become, e.g. `boardType`. */ | ||
| field: string; | ||
| /** The path written into the binding's `response.map`, relative to the collection item. */ | ||
| itemPath: string; | ||
| observed: JsonType; | ||
| /** What it is declared as. See ADD-117 §3 — the table is deliberately dull. */ | ||
| semantic: SemanticType; | ||
| } | ||
| interface UnadoptableField { | ||
| adoptable: false; | ||
| path: string; | ||
| observed: JsonType; | ||
| reason: AdoptionRefusal; | ||
| /** One sentence an operator can act on, or at least understand. */ | ||
| detail: string; | ||
| } | ||
| type AdoptionCandidate = AdoptableField | UnadoptableField; | ||
| interface AdoptionPlan { | ||
| capabilityId: string; | ||
| /** The resource the binding's `response:` maps onto — the file a field would be added to. */ | ||
| resource?: string; | ||
| candidates: AdoptionCandidate[]; | ||
| } | ||
| /** | ||
| * What could be declared, and why the rest could not. | ||
| * | ||
| * Only `drift.added` is considered: `removed` is a loss with nothing to declare, and `retyped` | ||
| * needs a judgment no shape comparison can make — is `price_per_night` the old `pricePerNight`, | ||
| * or a new field that happens to look like it? ADR-0008 puts both out of scope, and the diff | ||
| * still names them so a human can act. | ||
| */ | ||
| declare function planAdoption(tool: IRTool, drift: ShapeDiff, resources: IRResourceRegistry): AdoptionPlan; | ||
| /** The adoptable candidates, in the order they would be offered. */ | ||
| declare function adoptable(plan: AdoptionPlan): AdoptableField[]; | ||
| interface RotatingFileAuditSinkOptions { | ||
@@ -80,2 +122,2 @@ /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */ | ||
| export { type BuildResult, HEALTH_SNAPSHOT_FILE, type RotatingFileAuditSinkOptions, buildRegistry, rotatingFileAuditSink, serveStdio }; | ||
| export { type AdoptableField, type AdoptionCandidate, type AdoptionPlan, type AdoptionRefusal, type BuildResult, HEALTH_SNAPSHOT_FILE, type RotatingFileAuditSinkOptions, type UnadoptableField, adoptable, buildRegistry, planAdoption, rotatingFileAuditSink, serveStdio }; |
+59
-1
@@ -15,3 +15,3 @@ import { | ||
| verifyTool | ||
| } from "./chunk-UOG4VWZ3.js"; | ||
| } from "./chunk-WPGTXEHU.js"; | ||
@@ -95,2 +95,58 @@ // src/registry.ts | ||
| // src/adopt.ts | ||
| function semanticFor(observed) { | ||
| if (observed === "string") return "text"; | ||
| if (observed === "number") return "quantity"; | ||
| return void 0; | ||
| } | ||
| function refusalDetail(reason, observed) { | ||
| switch (reason) { | ||
| case "outside-collection": | ||
| return "outside the collection this capability maps; it is not a field of the resource"; | ||
| case "nested": | ||
| return "nested, or a provider key containing a dot \u2014 indistinguishable here; either way the resource field would have to be another resource, which adoption does not create"; | ||
| case "no-boolean-type": | ||
| return "CDL has no boolean semantic type, and declaring it as text would state a lie about the shape"; | ||
| case "not-a-leaf": | ||
| return `observed as ${observed}, which is a structure rather than a value`; | ||
| case "already-declared": | ||
| return "already declared by this capability"; | ||
| } | ||
| } | ||
| function itemPrefix(collection) { | ||
| if (!collection) return "$"; | ||
| return collection.replace(/\[\*\]/g, "[]"); | ||
| } | ||
| function planAdoption(tool, drift, resources) { | ||
| const mapping = tool.response; | ||
| if (!mapping) return { capabilityId: tool.id, candidates: [] }; | ||
| const prefix = itemPrefix(mapping.collection); | ||
| const declared = /* @__PURE__ */ new Set([ | ||
| ...mapping.fields.map((f) => f.name), | ||
| ...(resources[mapping.resource] ?? []).map((f) => f.name) | ||
| ]); | ||
| const refuse = (path, observed, reason) => ({ | ||
| adoptable: false, | ||
| path, | ||
| observed, | ||
| reason, | ||
| detail: refusalDetail(reason, observed) | ||
| }); | ||
| const candidates = drift.added.map(({ path, type: observed }) => { | ||
| if (!path.startsWith(`${prefix}.`)) return refuse(path, observed, "outside-collection"); | ||
| const rest = path.slice(prefix.length + 1); | ||
| if (rest.includes("[")) return refuse(path, observed, "nested"); | ||
| if (rest.includes(".")) return refuse(path, observed, "nested"); | ||
| if (observed === "boolean") return refuse(path, observed, "no-boolean-type"); | ||
| const semantic = semanticFor(observed); | ||
| if (!semantic) return refuse(path, observed, "not-a-leaf"); | ||
| if (declared.has(rest)) return refuse(path, observed, "already-declared"); | ||
| return { adoptable: true, path, field: rest, itemPath: `$.${rest}`, observed, semantic }; | ||
| }); | ||
| return { capabilityId: tool.id, resource: mapping.resource, candidates }; | ||
| } | ||
| function adoptable(plan) { | ||
| return plan.candidates.filter((c) => c.adoptable); | ||
| } | ||
| // src/audit-file.ts | ||
@@ -143,2 +199,3 @@ import { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from "fs"; | ||
| Registry2 as Registry, | ||
| adoptable, | ||
| applyResponseMapping, | ||
@@ -151,2 +208,3 @@ buildRegistry, | ||
| objectJsonSchema, | ||
| planAdoption, | ||
| recordContract, | ||
@@ -153,0 +211,0 @@ rotatingFileAuditSink, |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts","../src/audit-file.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n","// @archstone/runtime — file-backed audit retention.\n//\n// `@archstone/emitter-support` ships `jsonLinesAuditSink`: one line, one write, no retention,\n// because that package imports no `node:` module and must stay usable on an edge runtime. A\n// self-hosted deployment that has to *keep* its audit trail — the whole point of an evidentiary\n// log — then has to solve rotation itself, which is where this lives: `runtime` already reads\n// the filesystem (`registry`), so fs belongs here and only here.\n//\n// Deliberately NOT a shipping/collector sink. Sending records to Splunk, an OTLP endpoint or an\n// S3 bucket is HTTP, and HTTP appears in exactly one package in this repository (`providers/rest`)\n// — a rule worth more than the convenience. Wrap this sink, or write your own; a sink is a\n// function.\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport type { AuditSink, ExecutionRecord } from \"@archstone/emitter-support\";\n\nexport interface RotatingFileAuditSinkOptions {\n /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */\n path: string;\n /** Rotate once the live file would exceed this. Default 64 MiB. */\n maxBytes?: number;\n /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */\n maxFiles?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_FILES = 10;\n\n/**\n * A JSON Lines audit sink that rotates by size and bounds its own disk use.\n *\n * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly\n * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces\n * a single file that outgrows the disk between rotations. Size-based rotation gives the one\n * guarantee an operator actually needs — total footprint is at most\n * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic.\n *\n * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer\n * would be faster and would lose the last N records exactly when they matter most — a crash,\n * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the\n * process dies is a record that never existed. Evidentiary logs trade throughput for durability;\n * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss.\n *\n * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at\n * construction) so the common path is one `append` and no `stat`. Two processes appending to the\n * same path therefore rotate on each other's estimates — give each instance its own path, which\n * a shared volume makes trivial and which also keeps records attributable to an instance.\n *\n * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place.\n * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure:\n * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is\n * unaffected — an audit backend must never be able to take the capability down.\n */\nexport function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink {\n const { path } = opts;\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\n const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;\n\n if (!path) throw new Error(\"rotatingFileAuditSink: `path` is required.\");\n if (!Number.isInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`);\n }\n if (!Number.isInteger(maxFiles) || maxFiles < 1) {\n throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`);\n }\n\n // Fail at wiring time, not at the first denied invocation: a deployer who mistyped the path\n // should learn now, while they are looking at the config, and not from a stream of caught\n // sink failures under load.\n mkdirSync(dirname(path), { recursive: true });\n\n let liveBytes = existsSync(path) ? statSync(path).size : 0;\n\n function rotate(): void {\n // Oldest first, so nothing is overwritten before it has been moved along.\n const oldest = `${path}.${maxFiles}`;\n if (existsSync(oldest)) unlinkSync(oldest);\n for (let i = maxFiles - 1; i >= 1; i--) {\n const from = `${path}.${i}`;\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\n }\n if (existsSync(path)) renameSync(path, `${path}.1`);\n liveBytes = 0;\n }\n\n return (record: ExecutionRecord) => {\n const line = `${JSON.stringify(record)}\\n`;\n const size = Buffer.byteLength(line);\n // A single record larger than the whole budget still gets written, to its own generation,\n // rather than being silently dropped: losing an oversized record is losing evidence, and a\n // deployer who sees one file over budget can raise maxBytes. Dropping it teaches nothing.\n if (liveBytes > 0 && liveBytes + size > maxBytes) rotate();\n appendFileSync(path, line);\n liveBytes += size;\n };\n}\n"],"mappings":"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","names":["Registry"]} | ||
| {"version":3,"sources":["../src/registry.ts","../src/mcp.ts","../src/adopt.ts","../src/audit-file.ts"],"sourcesContent":["// @archstone/runtime — Capability Registry (#5)\n//\n// The product kernel: capabilities queryable at runtime, indexed over the IR.\n// File-backed (no DB) — the IR is derived from manifests on disk. The MCP emitter\n// (#7) consumes this to list and resolve tools.\n//\n// `Registry` (index-only) moved to @archstone/emitter-support (ADD-0008 #27) — re-exported\n// here for back-compat so nothing downstream breaks. This file keeps the fs-touching\n// pipeline (`buildRegistry`), which is why the /http subpath (http.ts) never imports it.\n//\n// ADD-24 (#24): `buildRegistry` also optionally reads a conventional health-snapshot file\n// (`readHealthSnapshot`, below) — the ONE other fs-touching, network-free addition this ADD\n// makes. Binding health itself is never computed here (that's `archstone verify`'s own live\n// probe, ADD-18 D-5) — only its already-serialized `--json` output is read back.\n\nimport { readFileSync } from \"node:fs\";\nimport { join } from \"node:path\";\nimport { load, type LoadResult, type LoadIssue } from \"@archstone/schema\";\nimport { validateSemantics, compile, type Diagnostic } from \"@archstone/compiler\";\nimport { Registry, type HealthStatus } from \"@archstone/emitter-support\";\n\nexport { Registry } from \"@archstone/emitter-support\";\n\n/** Conventional health-snapshot file, read once next to the manifest dir (ADD-24 D-8): the\n * operator/CI populates it by redirecting the ALREADY-shipped `archstone verify --json`\n * output here — no new serialization. `buildRegistry` reads it (fs, but no network — the\n * live probe stays exclusively `verify`'s, ADD-18 D-5) and hands the parsed map to\n * `Registry`, which composes it with each tool's lifecycle exposure (ADD-24 §7 step 5). */\nexport const HEALTH_SNAPSHOT_FILE = \".archstone-health.json\";\n\nconst HEALTH_STATUSES: ReadonlySet<string> = new Set([\"green\", \"yellow\", \"red\"]);\n\n/**\n * Parse the `{results: ToolVerification[]}` shape `archstone verify --json` already produces\n * (ADD-20) into a capabilityId -> HealthStatus map. Fail-open (ADD-24 D-9): a missing file, a\n * parse error, or a malformed/unexpected shape all return `undefined` — the caller then\n * proceeds with lifecycle-only exposure, never mistaking \"no snapshot\" for \"known bad\".\n *\n * #43 (ADD-43 D-14): an entry marked `policyDenied` is SKIPPED. A refusal that happened before\n * the call is not a health fact — no request was issued, so nothing about the backend's contract\n * was observed. Left in, it would reach `combineExposure` as a `red` and append\n * `\"binding health: red — the last contract verification failed\"` to the tool's agent-facing\n * description at the highest severity: a statement that never happened, shown to every caller\n * including permitted ones, making policy affect listing (which BR-36 forbids). Skipping leaves\n * the tool with NO health entry, which is exactly ADD-24 D-9's ratified posture — absent health\n * must never be manufactured into known-bad.\n */\nfunction readHealthSnapshot(dir: string): Map<string, HealthStatus> | undefined {\n let parsed: unknown;\n try {\n parsed = JSON.parse(readFileSync(join(dir, HEALTH_SNAPSHOT_FILE), \"utf8\"));\n } catch {\n return undefined; // absent, unreadable, or invalid JSON — fail-open\n }\n\n const results = (parsed as { results?: unknown } | null)?.results;\n if (!Array.isArray(results)) return undefined;\n\n const map = new Map<string, HealthStatus>();\n for (const r of results) {\n if (!r || typeof r !== \"object\") continue;\n // ADD-43 D-14: a policy denial is not a health reading — drop it rather than let it become\n // an agent-facing \"the last contract verification failed\" hint for a verification that\n // never ran. See this function's doc comment.\n if ((r as { policyDenied?: unknown }).policyDenied === true) continue;\n const capabilityId = (r as { capabilityId?: unknown }).capabilityId;\n const status = (r as { status?: unknown }).status;\n if (typeof capabilityId === \"string\" && typeof status === \"string\" && HEALTH_STATUSES.has(status)) {\n map.set(capabilityId, status as HealthStatus);\n }\n }\n return map;\n}\n\nexport interface BuildResult {\n ok: boolean;\n registry?: Registry;\n issues: LoadIssue[];\n diagnostics: Diagnostic[];\n}\n\n/**\n * File-backed pipeline: load (#2) → semantic-validate (#3) → compile (#4) → Registry (#5).\n * `registry` is present only when shapes are valid, there are no semantic errors, AND no\n * tool-name collision (ADD-30 D-2) — folded into this function's existing `diagnostics`/\n * `ok` contract (new `tool-name-collision` diagnostic code) rather than a new mechanism, so\n * `serveStdio`/`runServeHttp` (which already refuse to proceed on `!built.ok`) inherit the\n * gate for free.\n */\nexport function buildRegistry(dir: string): BuildResult {\n const model: LoadResult = load(dir);\n const diagnostics = validateSemantics(model);\n const hasErrors = diagnostics.some((d) => d.severity === \"error\");\n let ok = model.ok && !hasErrors;\n\n const registry = ok ? new Registry(compile(model), readHealthSnapshot(dir)) : undefined;\n if (registry) {\n for (const c of registry.toolNameCollisions) {\n ok = false;\n diagnostics.push({\n severity: \"error\",\n code: \"tool-name-collision\",\n message: `tool name '${c.name}' is ambiguous — capabilities ${c.ids.join(\", \")} all sanitize to it`,\n });\n }\n }\n\n return {\n ok,\n registry: ok ? registry : undefined,\n issues: model.issues,\n diagnostics,\n };\n}\n","// @archstone/runtime — MCP emitter (#7) — stdio entrypoint\n//\n// serveStdio builds the registry from disk (buildRegistry, registry.ts) and serves it over\n// stdio, the channel Claude Desktop uses. The fs-free MCP server construction\n// (toolDefinitions/callTool/createMcpServer) lives in ./server (ADD-0008 #27) — re-exported\n// here, alongside the semantic-lowering functions from @archstone/emitter-support, for\n// back-compat so nothing downstream breaks.\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\";\nimport type { InvokeOptions } from \"@archstone/provider-rest\";\nimport { buildRegistry } from \"./registry\";\nimport { toolDefinitions, createMcpServer } from \"./server\";\n\nexport { toolName, inputJsonSchema, objectJsonSchema } from \"@archstone/emitter-support\";\n/** #44: the audit sink surface, re-exported so a deployer wiring `serveStdio`/`createMcpServer`\n * imports it from the package they already depend on. See `AuditSink`'s own doc comment for\n * the fire-and-forget contract and for the statement that the trail is best-effort and lossy. */\nexport {\n jsonLinesAuditSink,\n REDACTED,\n LIFECYCLE_BLOCKED_REASON,\n LIFECYCLE_UNEVALUATABLE_REASON,\n} from \"@archstone/emitter-support\";\nexport type {\n AuditSink,\n AuditWritable,\n ExecutionRecord,\n ExecutionStatus,\n ExecutionPhase,\n ExecutionConsumer,\n ExecutionDenialReason,\n} from \"@archstone/emitter-support\";\nexport * from \"./server\";\n\n/**\n * Build the registry from a manifest dir and serve it over stdio (blocks).\n *\n * `invoke` (ADD-32) is forwarded verbatim to `createMcpServer` — this closes a real gap: prior\n * to #32, `serveStdio` passed NO `InvokeOptions` at all, so nothing (not `env`, not `caller`)\n * could ever be injected here. A stdio server is one child process per conversation (Claude\n * Desktop's model) — single-process, single-user by construction — so a static per-process\n * `invoke.caller` is architecturally sound here, unlike the HTTP case (`createHttpHandler`'s\n * `resolveCaller`, which must vary per inbound request).\n */\nexport async function serveStdio(dir: string, invoke?: InvokeOptions): Promise<void> {\n const built = buildRegistry(dir);\n if (!built.ok || !built.registry) {\n // stdout is the MCP channel — all human output goes to stderr.\n console.error(`archstone: cannot serve '${dir}' — manifest invalid:`);\n for (const i of built.issues) console.error(` - ${i.file}: ${i.message}`);\n for (const d of built.diagnostics.filter((x) => x.severity === \"error\")) console.error(` - ${d.message}`);\n process.exit(1);\n }\n const tools = toolDefinitions(built.registry);\n console.error(`archstone: serving ${tools.length} tool(s) over stdio: ${tools.map((t) => t.name).join(\", \") || \"(none)\"}`);\n const server = createMcpServer(built.registry, invoke);\n await server.connect(new StdioServerTransport());\n}\n","// @archstone/runtime — Adoption planner (ADD-117 / ADR-0008).\n//\n// Turns a field ADD-114's drift NAMED into a field the manifest DECLARES. This module is the\n// pure half: it decides what could be adopted and why each rejected candidate was rejected.\n// It touches no disk, asks no human, and mutates nothing — the CLI owns all three (D-6), which\n// is what lets every rule below be tested without a temp directory or a terminal.\n//\n// The rules exist because ADR-0008 draws a hard line: an undeclared field never reaches a\n// model. Adoption is the ONLY way across that line, and it is deliberately a human act.\n\nimport type { IRTool, IRResourceRegistry, JsonType, SemanticType, ShapeDiff } from \"@archstone/compiler\";\n\n/** Why a named path cannot be adopted. Stated, never silently skipped — a candidate that\n * disappears from the report reads as \"there was nothing there\". */\nexport type AdoptionRefusal =\n | \"outside-collection\"\n | \"nested\"\n | \"no-boolean-type\"\n | \"not-a-leaf\"\n | \"already-declared\";\n\nexport interface AdoptableField {\n adoptable: true;\n /** The JSONPath the drift reported, e.g. `$.stays[].boardType`. */\n path: string;\n /** The resource field name it would become, e.g. `boardType`. */\n field: string;\n /** The path written into the binding's `response.map`, relative to the collection item. */\n itemPath: string;\n observed: JsonType;\n /** What it is declared as. See ADD-117 §3 — the table is deliberately dull. */\n semantic: SemanticType;\n}\n\nexport interface UnadoptableField {\n adoptable: false;\n path: string;\n observed: JsonType;\n reason: AdoptionRefusal;\n /** One sentence an operator can act on, or at least understand. */\n detail: string;\n}\n\nexport type AdoptionCandidate = AdoptableField | UnadoptableField;\n\nexport interface AdoptionPlan {\n capabilityId: string;\n /** The resource the binding's `response:` maps onto — the file a field would be added to. */\n resource?: string;\n candidates: AdoptionCandidate[];\n}\n\n/**\n * ADD-117 §3. An observed JSON type becomes exactly one CDL semantic type, or nothing.\n *\n * Deliberately NOT clever. `string` does not become `date` however much a value looked like\n * one, because the shape records types and never values — there is nothing here to\n * pattern-match, and inferring a date from a field NAME is exactly the guess this project\n * refuses to make. `number` does not become `money`, because whether a number is a price is a\n * business fact and `money` carries a currency this field does not have. The human can widen\n * either afterwards; the manifest is theirs.\n */\nfunction semanticFor(observed: JsonType): SemanticType | undefined {\n if (observed === \"string\") return \"text\";\n if (observed === \"number\") return \"quantity\";\n return undefined;\n}\n\nfunction refusalDetail(reason: AdoptionRefusal, observed: JsonType): string {\n switch (reason) {\n case \"outside-collection\":\n return \"outside the collection this capability maps; it is not a field of the resource\";\n case \"nested\":\n return \"nested, or a provider key containing a dot — indistinguishable here; either way the resource field would have to be another resource, which adoption does not create\";\n case \"no-boolean-type\":\n return \"CDL has no boolean semantic type, and declaring it as text would state a lie about the shape\";\n case \"not-a-leaf\":\n return `observed as ${observed}, which is a structure rather than a value`;\n case \"already-declared\":\n return \"already declared by this capability\";\n }\n}\n\n/**\n * The path prefix every field of one collection item shares.\n *\n * A binding's `collection` is a JSONPath over the payload (`$.stays[*]`); a recorded shape\n * flattens an array to its first element (`$.stays[]`). One translation, here, rather than two\n * conventions leaking into every comparison below. A capability with no `collection` maps a\n * single object, whose fields hang off the root.\n */\nfunction itemPrefix(collection: string | undefined): string {\n if (!collection) return \"$\";\n return collection.replace(/\\[\\*\\]/g, \"[]\");\n}\n\n/**\n * What could be declared, and why the rest could not.\n *\n * Only `drift.added` is considered: `removed` is a loss with nothing to declare, and `retyped`\n * needs a judgment no shape comparison can make — is `price_per_night` the old `pricePerNight`,\n * or a new field that happens to look like it? ADR-0008 puts both out of scope, and the diff\n * still names them so a human can act.\n */\nexport function planAdoption(tool: IRTool, drift: ShapeDiff, resources: IRResourceRegistry): AdoptionPlan {\n const mapping = tool.response;\n if (!mapping) return { capabilityId: tool.id, candidates: [] };\n\n const prefix = itemPrefix(mapping.collection);\n const declared = new Set<string>([\n ...mapping.fields.map((f) => f.name),\n ...(resources[mapping.resource] ?? []).map((f) => f.name),\n ]);\n\n const refuse = (path: string, observed: JsonType, reason: AdoptionRefusal): UnadoptableField => ({\n adoptable: false,\n path,\n observed,\n reason,\n detail: refusalDetail(reason, observed),\n });\n\n const candidates = drift.added.map<AdoptionCandidate>(({ path, type: observed }) => {\n if (!path.startsWith(`${prefix}.`)) return refuse(path, observed, \"outside-collection\");\n const rest = path.slice(prefix.length + 1);\n if (rest.includes(\"[\")) return refuse(path, observed, \"nested\");\n // A dot here is either a nested object (`address.city`) or a single provider key that\n // contains a dot. Those two are INDISTINGUISHABLE in this flattened space — the same\n // collision `describeShape` documents — so there is one refusal, not a coin flip between\n // two, and its detail says so. Either way the answer is the same: not adopted.\n if (rest.includes(\".\")) return refuse(path, observed, \"nested\");\n if (observed === \"boolean\") return refuse(path, observed, \"no-boolean-type\");\n const semantic = semanticFor(observed);\n if (!semantic) return refuse(path, observed, \"not-a-leaf\");\n if (declared.has(rest)) return refuse(path, observed, \"already-declared\");\n return { adoptable: true, path, field: rest, itemPath: `$.${rest}`, observed, semantic };\n });\n\n return { capabilityId: tool.id, resource: mapping.resource, candidates };\n}\n\n/** The adoptable candidates, in the order they would be offered. */\nexport function adoptable(plan: AdoptionPlan): AdoptableField[] {\n return plan.candidates.filter((c): c is AdoptableField => c.adoptable);\n}\n","// @archstone/runtime — file-backed audit retention.\n//\n// `@archstone/emitter-support` ships `jsonLinesAuditSink`: one line, one write, no retention,\n// because that package imports no `node:` module and must stay usable on an edge runtime. A\n// self-hosted deployment that has to *keep* its audit trail — the whole point of an evidentiary\n// log — then has to solve rotation itself, which is where this lives: `runtime` already reads\n// the filesystem (`registry`), so fs belongs here and only here.\n//\n// Deliberately NOT a shipping/collector sink. Sending records to Splunk, an OTLP endpoint or an\n// S3 bucket is HTTP, and HTTP appears in exactly one package in this repository (`providers/rest`)\n// — a rule worth more than the convenience. Wrap this sink, or write your own; a sink is a\n// function.\n\nimport { appendFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from \"node:fs\";\nimport { dirname } from \"node:path\";\nimport type { AuditSink, ExecutionRecord } from \"@archstone/emitter-support\";\n\nexport interface RotatingFileAuditSinkOptions {\n /** Where the live file goes. Rotated generations are `<path>.1` … `<path>.<maxFiles>`. */\n path: string;\n /** Rotate once the live file would exceed this. Default 64 MiB. */\n maxBytes?: number;\n /** How many rotated generations to keep. The oldest is deleted on rotation. Default 10. */\n maxFiles?: number;\n}\n\nconst DEFAULT_MAX_BYTES = 64 * 1024 * 1024;\nconst DEFAULT_MAX_FILES = 10;\n\n/**\n * A JSON Lines audit sink that rotates by size and bounds its own disk use.\n *\n * **Size, not time.** An audit stream grows with invocations, not with the clock: hourly\n * rotation on a quiet deployment produces a directory of empty files, and on a busy one produces\n * a single file that outgrows the disk between rotations. Size-based rotation gives the one\n * guarantee an operator actually needs — total footprint is at most\n * `maxBytes × (maxFiles + 1)`, computable before deployment and independent of traffic.\n *\n * **Synchronous, on purpose.** `appendFileSync` per record costs a syscall; a buffered writer\n * would be faster and would lose the last N records exactly when they matter most — a crash,\n * an OOM kill, a `SIGKILL` during an incident. An audit record still in a buffer when the\n * process dies is a record that never existed. Evidentiary logs trade throughput for durability;\n * if that trade is wrong for a deployment, wrap a buffered writer yourself and own the loss.\n *\n * **Single writer.** The live file's size is tracked in memory (seeded from `statSync` at\n * construction) so the common path is one `append` and no `stat`. Two processes appending to the\n * same path therefore rotate on each other's estimates — give each instance its own path, which\n * a shared volume makes trivial and which also keeps records attributable to an instance.\n *\n * Rotation is `rename`, so the live inode is replaced and no record is ever rewritten in place.\n * A failure to rotate (permissions, a full disk) surfaces as an ordinary sink failure:\n * `emitExecutionRecord` catches it, announces the loss on stderr, and the invocation itself is\n * unaffected — an audit backend must never be able to take the capability down.\n */\nexport function rotatingFileAuditSink(opts: RotatingFileAuditSinkOptions): AuditSink {\n const { path } = opts;\n const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;\n const maxFiles = opts.maxFiles ?? DEFAULT_MAX_FILES;\n\n if (!path) throw new Error(\"rotatingFileAuditSink: `path` is required.\");\n if (!Number.isInteger(maxBytes) || maxBytes <= 0) {\n throw new Error(`rotatingFileAuditSink: maxBytes must be a positive integer, got ${String(maxBytes)}.`);\n }\n if (!Number.isInteger(maxFiles) || maxFiles < 1) {\n throw new Error(`rotatingFileAuditSink: maxFiles must be a positive integer, got ${String(maxFiles)}.`);\n }\n\n // Fail at wiring time, not at the first denied invocation: a deployer who mistyped the path\n // should learn now, while they are looking at the config, and not from a stream of caught\n // sink failures under load.\n mkdirSync(dirname(path), { recursive: true });\n\n let liveBytes = existsSync(path) ? statSync(path).size : 0;\n\n function rotate(): void {\n // Oldest first, so nothing is overwritten before it has been moved along.\n const oldest = `${path}.${maxFiles}`;\n if (existsSync(oldest)) unlinkSync(oldest);\n for (let i = maxFiles - 1; i >= 1; i--) {\n const from = `${path}.${i}`;\n if (existsSync(from)) renameSync(from, `${path}.${i + 1}`);\n }\n if (existsSync(path)) renameSync(path, `${path}.1`);\n liveBytes = 0;\n }\n\n return (record: ExecutionRecord) => {\n const line = `${JSON.stringify(record)}\\n`;\n const size = Buffer.byteLength(line);\n // A single record larger than the whole budget still gets written, to its own generation,\n // rather than being silently dropped: losing an oversized record is losing evidence, and a\n // deployer who sees one file over budget can raise maxBytes. Dropping it teaches nothing.\n if (liveBytes > 0 && liveBytes + size > maxBytes) rotate();\n appendFileSync(path, line);\n liveBytes += size;\n };\n}\n"],"mappings":"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","names":["Registry"]} |
+15
-1
@@ -1,2 +0,2 @@ | ||
| import { IRTool, IRResourceRegistry } from '@archstone/compiler'; | ||
| import { ShapeMap, ShapeDiff, IRTool, IRResourceRegistry } from '@archstone/compiler'; | ||
| import { InvokeOptions } from '@archstone/provider-rest'; | ||
@@ -37,2 +37,12 @@ import { HealthStatus } from '@archstone/emitter-support'; | ||
| policyDenied?: true; | ||
| /** | ||
| * #114 (ADD-114 D-4): which paths the provider gained, lost or retyped since the contract was | ||
| * recorded. Present only when the binding recorded a `contract.shape`, that shape is | ||
| * consistent with its own fingerprint (D-3), and something actually moved. | ||
| * | ||
| * NARRATIVE ONLY. `status` above is derived exactly as ADD-18 D-4 defines it, from the | ||
| * fingerprint alone — this field explains a status it never determines (D-2). It rides into | ||
| * `archstone verify --json` for free, since the CLI serialises this object directly. | ||
| */ | ||
| drift?: ShapeDiff; | ||
| } | ||
@@ -124,2 +134,6 @@ interface GoldenFixture { | ||
| fingerprint?: string; | ||
| /** The recorded response shape (ADD-114 D-6), derived from the SAME body as `fingerprint` | ||
| * in the same call — which is what makes the two consistent by construction at the only | ||
| * point that writes them, and is what D-3's check later relies on. */ | ||
| shape?: ShapeMap; | ||
| fixture?: GoldenFixture; | ||
@@ -126,0 +140,0 @@ /** Optional fields that came back absent or null. Real required/optional evidence — the |
+1
-1
@@ -5,3 +5,3 @@ import { | ||
| verifyTool | ||
| } from "./chunk-UOG4VWZ3.js"; | ||
| } from "./chunk-WPGTXEHU.js"; | ||
| export { | ||
@@ -8,0 +8,0 @@ recordContract, |
+5
-5
| { | ||
| "name": "@archstone/runtime", | ||
| "version": "0.13.0", | ||
| "version": "0.14.0", | ||
| "private": false, | ||
@@ -50,6 +50,6 @@ "type": "module", | ||
| "@modelcontextprotocol/sdk": "^1.12.0", | ||
| "@archstone/compiler": "0.13.0", | ||
| "@archstone/emitter-support": "0.13.0", | ||
| "@archstone/provider-rest": "0.13.0", | ||
| "@archstone/schema": "0.13.0" | ||
| "@archstone/compiler": "0.14.0", | ||
| "@archstone/emitter-support": "0.14.0", | ||
| "@archstone/provider-rest": "0.14.0", | ||
| "@archstone/schema": "0.14.0" | ||
| }, | ||
@@ -56,0 +56,0 @@ "devDependencies": { |
| // src/verify.ts | ||
| import { readFileSync } from "fs"; | ||
| import { resolve } from "path"; | ||
| import { fingerprintShape } from "@archstone/compiler"; | ||
| import { invokeRest } from "@archstone/provider-rest"; | ||
| import { evaluatePolicy, lifecycleExposure } from "@archstone/emitter-support"; | ||
| // src/mapping.ts | ||
| import { applyResponseMapping } from "@archstone/emitter-support"; | ||
| // src/verify.ts | ||
| function readFixture(dir, path) { | ||
| try { | ||
| return JSON.parse(readFileSync(resolve(dir, path), "utf8")); | ||
| } catch { | ||
| return void 0; | ||
| } | ||
| } | ||
| async function verifyTool(tool, dir, resources, opts) { | ||
| const base = { capabilityId: tool.id }; | ||
| const contract = tool.contract; | ||
| if (!contract) return { ...base, status: "red", detail: "no contract: declared \u2014 nothing to verify" }; | ||
| const fixture = readFixture(dir, contract.probeFixture); | ||
| if (!fixture) return { ...base, status: "red", detail: `fixture not found or unreadable: ${contract.probeFixture}` }; | ||
| const decision = evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| return { | ||
| ...base, | ||
| status: "red", | ||
| detail: `policy denied before any request was made: ${decision.denial.message}`, | ||
| policyDenied: true | ||
| }; | ||
| } | ||
| const result = await invokeRest(tool, fixture.request, opts); | ||
| if (!result.ok) return { ...base, status: "red", detail: `live request failed: ${result.error ?? `status ${result.status}`}` }; | ||
| const liveFingerprint = fingerprintShape(result.data); | ||
| const fingerprintChanged = liveFingerprint !== contract.fingerprint; | ||
| if (!tool.response) { | ||
| return fingerprintChanged ? { ...base, status: "yellow", detail: `response shape changed (fingerprint ${contract.fingerprint} \u2192 ${liveFingerprint})` } : { ...base, status: "green", detail: "fingerprint unchanged" }; | ||
| } | ||
| const mapped = applyResponseMapping(tool, result.data, resources); | ||
| if (mapped.status === "violation") { | ||
| return { ...base, status: "red", detail: `contract violation: missing required field(s) ${(mapped.missing ?? []).join(", ")}` }; | ||
| } | ||
| if (fixture.expects?.collectionNonEmpty) { | ||
| const field = tool.response.field; | ||
| const value = mapped.data?.[field]; | ||
| const empty = Array.isArray(value) ? value.length === 0 : value === void 0 || value === null; | ||
| if (empty) return { ...base, status: "red", detail: `expected a non-empty '${field}' collection; got none` }; | ||
| } | ||
| if (mapped.status === "degraded") { | ||
| return { ...base, status: "yellow", detail: `degraded: optional field(s) absent \u2014 ${(mapped.degraded ?? []).join(", ")}` }; | ||
| } | ||
| if (fingerprintChanged) { | ||
| return { ...base, status: "yellow", detail: `response shape changed (fingerprint ${contract.fingerprint} \u2192 ${liveFingerprint}) but mapping still resolves` }; | ||
| } | ||
| return { ...base, status: "green", detail: "fingerprint unchanged, mapping OK" }; | ||
| } | ||
| async function runVerify(tools, dir, resources, opts) { | ||
| const contractBearing = tools.filter((t) => t.contract && lifecycleExposure(t.lifecycle).blockedReason !== "retired"); | ||
| return Promise.all(contractBearing.map((t) => verifyTool(t, dir, resources, opts))); | ||
| } | ||
| var NOT_ATTEMPTED_RE = /^missing (?:env var|caller credential)\(s\):/; | ||
| async function recordContract(tool, input, resources, opts) { | ||
| const base = { capabilityId: tool.id }; | ||
| const decision = evaluatePolicy(tool, { | ||
| principal: opts?.caller?.principal, | ||
| credentialPresent: opts?.caller?.accessToken !== void 0 | ||
| }); | ||
| if (!decision.allowed) { | ||
| return { ...base, outcome: "not-attempted", detail: `policy denied before any request was made: ${decision.denial.message}` }; | ||
| } | ||
| const result = await invokeRest(tool, input, opts); | ||
| if (!result.ok) { | ||
| const error = result.error ?? `status ${result.status}`; | ||
| if (result.status === 0 && NOT_ATTEMPTED_RE.test(error)) { | ||
| return { ...base, outcome: "not-attempted", detail: `no request was sent \u2014 ${error}` }; | ||
| } | ||
| return { ...base, outcome: "red", detail: `live request failed: ${error}` }; | ||
| } | ||
| const fingerprint = fingerprintShape(result.data); | ||
| const fixture = { | ||
| capabilityId: tool.id, | ||
| recordedAt: (opts?.now ?? /* @__PURE__ */ new Date()).toISOString(), | ||
| request: input | ||
| }; | ||
| if (!tool.response) { | ||
| return { ...base, outcome: "green", detail: "recorded \u2014 no response mapping to validate", fingerprint, fixture }; | ||
| } | ||
| const mapped = applyResponseMapping(tool, result.data, resources); | ||
| if (mapped.status === "violation") { | ||
| return { | ||
| ...base, | ||
| outcome: "red", | ||
| detail: `contract violation on the recorded response: missing required field(s) ${(mapped.missing ?? []).join(", ")}`, | ||
| ...mapped.missing ? { missing: mapped.missing } : {} | ||
| }; | ||
| } | ||
| if (mapped.status === "degraded") { | ||
| return { | ||
| ...base, | ||
| outcome: "yellow", | ||
| detail: `recorded, degraded: optional field(s) absent \u2014 ${(mapped.degraded ?? []).join(", ")}`, | ||
| fingerprint, | ||
| fixture, | ||
| ...mapped.degraded ? { degraded: mapped.degraded } : {} | ||
| }; | ||
| } | ||
| return { ...base, outcome: "green", detail: "recorded \u2014 mapping OK", fingerprint, fixture }; | ||
| } | ||
| export { | ||
| applyResponseMapping, | ||
| verifyTool, | ||
| runVerify, | ||
| recordContract | ||
| }; | ||
| //# sourceMappingURL=chunk-UOG4VWZ3.js.map |
| {"version":3,"sources":["../src/verify.ts","../src/mapping.ts"],"sourcesContent":["// @archstone/runtime — Contract probe runner (ADD-18 / RFC-0006 Phase 2).\n//\n// `runVerify` replays a bound tool's golden fixture against the LIVE backend and\n// derives a health status. This is the only place outside a real MCP invocation that\n// makes a network call — always explicit, on demand (`archstone verify`), never\n// triggered by `apply`/`serve`. Reuses #12's `applyResponseMapping` verbatim (ADD-18\n// D-3/R-4): one mapper, so a probe VIOLATION is exactly what a real call would see.\n\nimport { readFileSync } from \"node:fs\";\nimport { resolve } from \"node:path\";\nimport { fingerprintShape, type IRTool, type IRResourceRegistry } from \"@archstone/compiler\";\nimport { invokeRest, type InvokeOptions } from \"@archstone/provider-rest\";\nimport { evaluatePolicy, lifecycleExposure } from \"@archstone/emitter-support\";\nimport { applyResponseMapping } from \"./mapping\";\n// ADD-24: HealthStatus's canonical home moved to @archstone/emitter-support (registry.ts's\n// exposure composition needs it, and runtime depends on emitter-support, never the reverse) —\n// re-exported here, unchanged, so nothing downstream (e.g. the CLI's `HealthStatus` import\n// from \"@archstone/runtime\") breaks.\nimport type { HealthStatus } from \"@archstone/emitter-support\";\nexport type { HealthStatus } from \"@archstone/emitter-support\";\n\nexport interface ToolVerification {\n capabilityId: string;\n status: HealthStatus;\n detail: string;\n /**\n * #43 (ADD-43 D-14): set iff this verification was refused by the policy evaluation point\n * before any request was issued — i.e. the probe observed **nothing at all** about the\n * backend's contract.\n *\n * Why an additive optional field rather than a fourth `HealthStatus` value: `HealthStatus` is\n * a CLOSED set already consumed by ADD-24's `combineExposure` and by ADD-20's published\n * `archstone verify --json` shape, so a `\"denied\"` member would take a published CLI contract\n * and the exposure severity ordering with it. `red` stays correct for the OPERATOR-facing\n * report — they asked \"is this binding healthy?\" and the honest answer is \"I could not\n * establish that\" (D-7).\n *\n * What this flag exists to stop is that `red` travelling ONWARD into an AGENT-facing surface.\n * `readHealthSnapshot` (registry.ts) skips any entry carrying it, so the tool ends up with no\n * health entry at all, `combineExposure` leaves its exposure untouched, and no hint is\n * appended to its advertised description. Without it, the documented ADD-24 D-8 workflow\n * (`archstone verify --json` > `.archstone-health.json`, then serve) would append\n * `\"binding health: red — the last contract verification failed\"` at the highest severity to\n * a policy-gated tool's description, for EVERY caller including permitted ones — a statement\n * that is factually false (no verification occurred) and that makes policy affect listing,\n * which BR-36 forbids. Because the CLI supplies no caller, that is the DEFAULT outcome for\n * any `allow`-bearing capability, not a corner case.\n *\n * The failure is silent: nothing throws, no exit code changes, an agent just reads a false\n * warning. `runtime/test/lifecycle.integration.test.ts` asserts it.\n */\n policyDenied?: true;\n}\n\nexport interface GoldenFixture {\n capabilityId: string;\n recordedAt?: string;\n request: Record<string, unknown>;\n expects?: { collectionNonEmpty?: boolean };\n}\n\nfunction readFixture(dir: string, path: string): GoldenFixture | undefined {\n try {\n return JSON.parse(readFileSync(resolve(dir, path), \"utf8\")) as GoldenFixture;\n } catch {\n return undefined;\n }\n}\n\n/** Verify one tool's contract against the live backend. Returns green/yellow/red — never\n * throws (a network/fs failure is itself a red result, not an exception the CLI must catch). */\nexport async function verifyTool(tool: IRTool, dir: string, resources: IRResourceRegistry, opts?: InvokeOptions): Promise<ToolVerification> {\n const base = { capabilityId: tool.id };\n const contract = tool.contract;\n if (!contract) return { ...base, status: \"red\", detail: \"no contract: declared — nothing to verify\" };\n\n const fixture = readFixture(dir, contract.probeFixture);\n if (!fixture) return { ...base, status: \"red\", detail: `fixture not found or unreadable: ${contract.probeFixture}` };\n\n // #43 (ADD-43 D-6): the contract prober is the THIRD invocation consumer, and it must route\n // through the same evaluation point as `callTool`/`executeCapability`. A probe makes a real\n // call with real credentials and is `authenticated`-gated today only because that gate lives\n // inside `invokeRest`; moving the gate (D-4) would silently un-gate `archstone verify` and the\n // published `runVerify()` unless this call exists. Placed immediately before `invokeRest`, so\n // \"no contract\" / \"fixture not found\" keep reporting themselves first.\n //\n // ADD-51 (#51) D-6, deliberately, do NOT \"fix\" this into a third exposure gate: unlike\n // `callTool`/`executeCapability`, `verifyTool` itself does not read\n // `registry.getExposure(tool.id)` and still probes a `lifecycle: retired` capability exactly\n // like a `stable` one IF it is called directly on one. Two reasons, both load-bearing. (1)\n // `verifyTool` never emits an `Execution` audit record under any outcome, so the\n // manufactured-evidence harm ADD-51 exists to close is structurally impossible on this path\n // regardless of lifecycle wiring. (2) Gating `verifyTool` itself would make it impossible to\n // ever probe a retired capability on purpose (e.g. investigating one before un-retiring it).\n //\n // #54 (R-2's fix, once filed): the CI-release-gate regression this residual risk named — a\n // retired-but-still-`contract:`-bearing capability turning `archstone verify`'s gate red\n // forever — is fixed one level up, in `runVerify`'s contract-bearing filter (below), which\n // now excludes a non-invocable (retired) tool before it ever reaches this function. See\n // `runVerify`'s doc comment. This function is unchanged by that fix and remains reachable\n // directly on a retired tool by a caller who wants to probe one deliberately.\n const decision = evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // `red`, with a detail textually distinguishable from the `live request failed:` prefix\n // below — because no live request was made (BR-37). `policyDenied` keeps this out of the\n // health snapshot entirely (D-14, see the field's doc comment).\n return {\n ...base,\n status: \"red\",\n detail: `policy denied before any request was made: ${decision.denial.message}`,\n policyDenied: true,\n };\n }\n\n const result = await invokeRest(tool, fixture.request, opts);\n if (!result.ok) return { ...base, status: \"red\", detail: `live request failed: ${result.error ?? `status ${result.status}`}` };\n\n const liveFingerprint = fingerprintShape(result.data);\n const fingerprintChanged = liveFingerprint !== contract.fingerprint;\n\n if (!tool.response) {\n // No response mapping to validate against — fingerprint drift is all we can see.\n return fingerprintChanged\n ? { ...base, status: \"yellow\", detail: `response shape changed (fingerprint ${contract.fingerprint} → ${liveFingerprint})` }\n : { ...base, status: \"green\", detail: \"fingerprint unchanged\" };\n }\n\n const mapped = applyResponseMapping(tool, result.data, resources);\n if (mapped.status === \"violation\") {\n return { ...base, status: \"red\", detail: `contract violation: missing required field(s) ${(mapped.missing ?? []).join(\", \")}` };\n }\n\n if (fixture.expects?.collectionNonEmpty) {\n const field = tool.response.field;\n const value = mapped.data?.[field];\n const empty = Array.isArray(value) ? value.length === 0 : value === undefined || value === null;\n if (empty) return { ...base, status: \"red\", detail: `expected a non-empty '${field}' collection; got none` };\n }\n\n if (mapped.status === \"degraded\") {\n return { ...base, status: \"yellow\", detail: `degraded: optional field(s) absent — ${(mapped.degraded ?? []).join(\", \")}` };\n }\n if (fingerprintChanged) {\n return { ...base, status: \"yellow\", detail: `response shape changed (fingerprint ${contract.fingerprint} → ${liveFingerprint}) but mapping still resolves` };\n }\n return { ...base, status: \"green\", detail: \"fingerprint unchanged, mapping OK\" };\n}\n\n/**\n * Verify every contract-bearing tool in a registry.\n *\n * #54 (fixing ADD-51 D-6's named residual risk, R-2): a `lifecycle: retired` capability is\n * excluded from the contract-bearing filter here — never handed to `verifyTool` at all, so it\n * never enters the returned report. This is deliberately NOT the same fix as `policyDenied`\n * (ADD-43 D-14): a policy denial still enters the report (marked, then skipped only by the\n * health-snapshot reader, `registry.ts`'s `readHealthSnapshot`) because a policy evaluation is\n * itself a fact worth reporting. A retirement is not — a business withdrawing a capability is a\n * normal operational event, not a thing `archstone verify` has anything to say about, so the\n * capability is simply never probed and never appears, exactly as if its `contract:` block did\n * not exist. That is what keeps `reports.some(r => r.status === \"red\")` (`cli/src/index.ts`,\n * the CI release gate) from going permanently red the day a `contract:`-bearing capability is\n * retired without also deleting its contract block.\n *\n * Invocability is read via `lifecycleExposure` — the exact pure lowering\n * `Registry.getExposure` (`@archstone/emitter-support/registry.ts`) composes into its\n * `exposureById` map, reused verbatim rather than re-deriving `lifecycle === \"retired\"` here\n * (ADD-24 D-6/R-5: any future reader shares this one computation). `runVerify` receives raw\n * `IRTool[]`, not a `Registry`, and health never affects `invocable` (ADD-24 D-9), so calling\n * `lifecycleExposure` directly — the same function `getExposure` calls, with no health\n * component to compose — yields an identical answer to `registry.getExposure(t.id).invocable`\n * for every tool.\n *\n * This does NOT change `verifyTool` itself (still deliberately ungated per D-6, directly\n * reachable and still probing a retired capability if called on one on purpose) — only this\n * orchestrator, which is what `archstone verify`/the CLI gate actually walks.\n *\n * `policyDenied` entries' gate handling is unchanged and explicitly out of scope for this fix\n * (see #54's PR description) — a separate decision, deferred.\n *\n * Bug fix (found reviewing #54): the original filter excluded every `invocable:false` tool —\n * `lifecycleExposure(...).invocable` is `false` for BOTH `lifecycle: \"retired\"` (this fix's\n * actual target) AND the `unevaluatable`/default branch (an unrecognized `lifecycle` value on a\n * hand-written or forward-versioned IR, ADD-56). That conflated a governance refusal with a\n * compatibility refusal: a capability with a corrupted/unrecognized lifecycle AND a genuinely\n * broken `contract:` block was silently excluded from the report instead of being probed and\n * flagged red — undermining ADD-56's \"make incompatibility loud\" goal on this one path. The\n * filter now checks `blockedReason !== \"retired\"` specifically, so an unrecognized-lifecycle\n * tool (`blockedReason: \"unevaluatable\"`) stays in `contractBearing` and is probed by\n * `verifyTool` exactly as it was before this whole feature shipped. `Exposure.blockedReason` is\n * always present when `invocable:false` and always absent when `invocable:true` (`exposure.ts`),\n * so this substitution needs no separate `invocable` check.\n */\nexport async function runVerify(\n tools: IRTool[],\n dir: string,\n resources: IRResourceRegistry,\n opts?: InvokeOptions,\n): Promise<ToolVerification[]> {\n const contractBearing = tools.filter((t) => t.contract && lifecycleExposure(t.lifecycle).blockedReason !== \"retired\");\n return Promise.all(contractBearing.map((t) => verifyTool(t, dir, resources, opts)));\n}\n\n// ---------------------------------------------------------------------------------------\n// Recording a contract (ADD-37 D-6 / R-1)\n// ---------------------------------------------------------------------------------------\n\n/**\n * How a probe ended.\n *\n * `green` / `yellow` / `red` mirror `HealthStatus` deliberately — this is the same question\n * `verifyTool` answers, asked one moment earlier. `not-attempted` is the fourth outcome\n * ADD-37 Amendment 1 §A-5 adds, and it is not a nicety:\n *\n * `invokeRest` returns `{ok: false, status: 0, error: \"missing env var(s): …\"}` BEFORE it\n * sends anything. Reporting that as `red` asserts that the backend disagreed with the\n * manifest, which is false — nothing was asked of the backend at all. False reds are how\n * people learn to ignore reds, and this one would fire on the very first run of every\n * generated manifest whose credential variable is not set yet.\n *\n * Same disposition as `red` for the CONTRACT (write nothing); the opposite disposition in the\n * report.\n */\nexport type ProbeOutcome = \"green\" | \"yellow\" | \"red\" | \"not-attempted\";\n\n/**\n * The result of one recording attempt.\n *\n * `fingerprint` and `fixture` are present together or not at all — the schema requires\n * `source` + `fingerprint` + `probe.fixture`, so a half-recording is not a thing a caller\n * could write down even if it wanted to.\n */\nexport interface ContractRecording {\n capabilityId: string;\n outcome: ProbeOutcome;\n detail: string;\n fingerprint?: string;\n fixture?: GoldenFixture;\n /** Optional fields that came back absent or null. Real required/optional evidence — the\n * caller may offer a loosening at the gate, and must never apply one silently: n=1 is not\n * a classification. */\n degraded?: string[];\n /** Required fields that came back absent or null. A VIOLATION, and the reason nothing is\n * written: a manifest that violates on its own recording is not a manifest. */\n missing?: string[];\n}\n\nexport interface RecordContractOptions extends InvokeOptions {\n /** Injected so a test can pin the recorded timestamp. Defaults to the wall clock — this\n * module is the runtime, not the pure core, and recording is inherently a moment in time. */\n now?: Date;\n}\n\n/** Errors `invokeRest` returns WITHOUT sending a request. Matched on the message because that\n * is the only signal in the shipped return shape — `status: 0` alone also covers a network\n * failure, which is a genuine red. */\nconst NOT_ATTEMPTED_RE = /^missing (?:env var|caller credential)\\(s\\):/;\n\n/**\n * Record a contract for a tool that does not have one yet (ADD-37 D-6).\n *\n * A SIBLING of `verifyTool`, not a flag on it, and the reason is structural rather than\n * stylistic: `verifyTool` returns `red` on `!tool.contract` before doing anything, and the\n * contract is precisely what this function exists to create. The chicken-and-egg is real.\n *\n * What makes this the right place for it (R-1): it is the SAME module, over the SAME\n * `invokeRest` call, with the same policy evaluation and the same `fingerprintShape` and\n * `applyResponseMapping`, as the replay that will later be asked to trust the artifact. A\n * second orchestration of \"call the backend, hash the shape, run the mapper\" living in\n * `init` would look green at record time and be unreplayable afterwards — silently, for the\n * manifest's lifetime.\n *\n * It reads no filesystem: there is no fixture to find yet. That is the one deliberate\n * departure from ADD-37 §6 step 6's sketched `(tool, input, dir, resources, opts)` signature —\n * carrying a `dir` this function cannot use would suggest it does something with it.\n *\n * NOTE it never decides WHETHER to probe. Consent, the confirmed `effect: read` and the method\n * rule (R-8) are the caller's gate, upstream, where the human is.\n */\nexport async function recordContract(\n tool: IRTool,\n input: Record<string, unknown>,\n resources: IRResourceRegistry,\n opts?: RecordContractOptions,\n): Promise<ContractRecording> {\n const base = { capabilityId: tool.id };\n\n // Same evaluation point as `verifyTool` (#43 / ADD-43 D-6), for the same reason: a probe\n // makes a real call with real credentials. `init` never emits `policies:`, so this cannot\n // fire on a freshly generated manifest — it is here so that re-recording an EXISTING\n // hand-written manifest cannot route around the gate.\n const decision = evaluatePolicy(tool, {\n principal: opts?.caller?.principal,\n credentialPresent: opts?.caller?.accessToken !== undefined,\n });\n if (!decision.allowed) {\n // `not-attempted`, not `red`.\n //\n // DELIBERATELY DIVERGENT FROM `verifyTool`, which answers `red` + `policyDenied` for this\n // identical condition — recorded here so nobody \"fixes\" the two into agreement. They are\n // answering different questions. `verifyTool` answers an OPERATOR's \"is this binding\n // healthy?\", and \"I could not establish that\" is honestly red (ADD-43 D-7); its\n // `policyDenied` flag then exists to stop that red travelling onward into an agent-facing\n // surface. `recordContract` answers \"did I learn anything worth writing down?\", and the\n // answer is simply no — nothing was asked of the backend. Both refuse to write a contract;\n // only the report wording differs, which is the whole point of the fourth outcome.\n return { ...base, outcome: \"not-attempted\", detail: `policy denied before any request was made: ${decision.denial.message}` };\n }\n\n const result = await invokeRest(tool, input, opts);\n if (!result.ok) {\n const error = result.error ?? `status ${result.status}`;\n if (result.status === 0 && NOT_ATTEMPTED_RE.test(error)) {\n return { ...base, outcome: \"not-attempted\", detail: `no request was sent — ${error}` };\n }\n return { ...base, outcome: \"red\", detail: `live request failed: ${error}` };\n }\n\n const fingerprint = fingerprintShape(result.data);\n const fixture: GoldenFixture = {\n capabilityId: tool.id,\n recordedAt: (opts?.now ?? new Date()).toISOString(),\n request: input,\n };\n\n if (!tool.response) {\n // Nothing to validate against; the fingerprint is still a real, replayable fact.\n return { ...base, outcome: \"green\", detail: \"recorded — no response mapping to validate\", fingerprint, fixture };\n }\n\n const mapped = applyResponseMapping(tool, result.data, resources);\n if (mapped.status === \"violation\") {\n // KEEP NOTHING. A field the manifest marks required came back null or absent on the very\n // response we are recording, so the contract would be green against a fiction and red\n // against reality. The loosening belongs at the gate, offered to a human, never applied\n // here: n=1 is not a classification.\n return {\n ...base,\n outcome: \"red\",\n detail: `contract violation on the recorded response: missing required field(s) ${(mapped.missing ?? []).join(\", \")}`,\n ...(mapped.missing ? { missing: mapped.missing } : {}),\n };\n }\n if (mapped.status === \"degraded\") {\n return {\n ...base,\n outcome: \"yellow\",\n detail: `recorded, degraded: optional field(s) absent — ${(mapped.degraded ?? []).join(\", \")}`,\n fingerprint,\n fixture,\n ...(mapped.degraded ? { degraded: mapped.degraded } : {}),\n };\n }\n return { ...base, outcome: \"green\", detail: \"recorded — mapping OK\", fingerprint, fixture };\n}\n","// @archstone/runtime — Response mapper (ADD-12 / RFC-0006)\n//\n// Moved to @archstone/emitter-support (ADD-0008 #27), unchanged logic — re-exported here for\n// back-compat so nothing downstream breaks (verify.ts and existing consumers still import\n// from \"./mapping\").\nexport { applyResponseMapping, type MappingStatus, type MappingResult } from \"@archstone/emitter-support\";\n"],"mappings":"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","names":[]} |
147578
13.75%990
15.79%+ Added
+ Added
+ Added
+ Added
- Removed
- Removed
- Removed
- Removed
Updated
Updated