@archstone/emitter-support
Advanced tools
+106
-1
@@ -142,2 +142,13 @@ import { Lifecycle, IRTool, IR, IRField, IRResourceRegistry } from '@archstone/compiler'; | ||
| getCapability(idOrName: string): IRTool | undefined; | ||
| /** | ||
| * A resource's declared fields, by canonical name — `undefined` when the IR has no such | ||
| * resource. Added for the extraction surface (ADR-0011), which needs to fail closed on an | ||
| * unknown resource name rather than lower an empty field list into a schema that accepts | ||
| * `{}`. `ir.resources` was always reachable through the public `ir` property; this exists so | ||
| * a consumer asks the Registry a question instead of indexing its internals, exactly as | ||
| * `getCapability` does for tools. | ||
| */ | ||
| getResource(name: string): IRField[] | undefined; | ||
| /** Every declared resource name, in IR order. The listing counterpart to `getResource`. */ | ||
| listResources(): string[]; | ||
| get size(): number; | ||
@@ -154,2 +165,35 @@ } | ||
| declare function inputJsonSchema(fields: IRField[], resources?: IRResourceRegistry): JsonSchema; | ||
| /** | ||
| * Thrown by `extractionJsonSchema` when a resource cannot be lowered into a CLOSED schema — | ||
| * an unknown resource name, or a `type:`-recursive one whose expansion the cycle guard stops. | ||
| * | ||
| * Both degrade to an open `{type: "object"}` in the ordinary lowering, which is the right | ||
| * answer for an MCP `outputSchema` (a document describing a response) and the wrong one here: | ||
| * an extraction schema with an open object inside it is open, and a guarantee with a hole in it | ||
| * that nobody is told about is worse than a refusal. `validateExtraction` refuses the same | ||
| * resources through the same check, so the schema and the validator are never in a state where | ||
| * one accepts what the other cannot describe. | ||
| */ | ||
| declare class ExtractionSchemaError extends Error { | ||
| constructor(reason: string); | ||
| } | ||
| /** | ||
| * Lower an IR field list to a **closed** JSON Schema — the shape a model is required to | ||
| * produce (ADR-0011). | ||
| * | ||
| * A sibling of `objectJsonSchema`, never a replacement for it. The open lowering is correct for | ||
| * what it was built for: an MCP `outputSchema` describes what a tool returns, and a client | ||
| * validates a response against it. An extraction schema states what the model may emit, and one | ||
| * that permits anything extra is not fail-closed. Both walk the same fields through the same | ||
| * recursion — `lowerObject` — so the two can never disagree about a field's type, its | ||
| * required-ness, or its description. | ||
| * | ||
| * `additionalProperties: false` is emitted at every object level: the root, an expanded | ||
| * `type:`-resource field, the `items` of a `collection:`, and the three composite semantic | ||
| * shapes (`money`, `party`, `date-range`). A `ref:` field stays a bare string, unexpanded, as | ||
| * everywhere else. | ||
| * | ||
| * Throws `ExtractionSchemaError` rather than degrading to an open object — see that class. | ||
| */ | ||
| declare function extractionJsonSchema(fields: IRField[], resources?: IRResourceRegistry): JsonSchema; | ||
@@ -670,2 +714,63 @@ type MappingStatus = "ok" | "degraded" | "violation"; | ||
| export { type AuditCaller, type AuditSink, type AuditWritable, type BuildExecutionRecordInput, type ExecutionConsumer, type ExecutionDenialReason, type ExecutionPhase, type ExecutionRecord, type ExecutionStatus, type Exposure, type ExposureHint, type HealthStatus, type HintLevel, InMemoryRateLimitCounter, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, type MappingResult, type MappingStatus, type NamedTool, type PolicyCaller, type PolicyDecision, type PolicyDenial, type PolicyDenialReason, RATE_LIMIT_EXCEEDED_REASON, REDACTED, type RateLimitCounter, type RateLimitDecision, type RateLimitDenial, type RateLimitDenialReason, Registry, type SharedCounterStore, SharedWindowRateLimitCounter, type ToolNameCollision, applyResponseMapping, auditNow, buildExecutionRecord, combineExposure, contractViolationMessage, emitExecutionRecord, evaluatePolicy, evaluateRateLimit, inputJsonSchema, jsonLinesAuditSink, lifecycleExposure, objectJsonSchema, redisSharedCounterStore, toolName }; | ||
| type ExtractionStatus = "ok" | "degraded" | "violation"; | ||
| /** | ||
| * The outcome of validating one model-produced document against a declared field list. | ||
| * | ||
| * **`status` does not mean the extraction is correct.** It means the document has the shape the | ||
| * manifest declares. A model that invents a plausible, correctly-typed value returns `ok`, and | ||
| * nothing at this boundary can tell that apart from a true one — the same way a green `verify` | ||
| * means a provider still answers in the recorded shape, not that its answers are right. Stated | ||
| * here, on the type, because a deployer wires the type and may never read the guide. | ||
| */ | ||
| interface ExtractionResult { | ||
| status: ExtractionStatus; | ||
| /** Declared fields only, and only when `status` is not `violation` — a violated document is | ||
| * withheld whole, exactly as a contract violation withholds a provider's raw body. An | ||
| * undeclared key cannot appear here under any input, at any depth. */ | ||
| data?: Record<string, unknown>; | ||
| /** Declared **required** fields the document does not carry. Any entry ⇒ `violation`. */ | ||
| missing?: string[]; | ||
| /** Declared fields present with the wrong shape, as `path: expected <type>`. Any entry ⇒ | ||
| * `violation`. **Never contains a value from the document** — the extraction input is by | ||
| * construction the most sensitive text in the deployment (the clinical note, the invoice, | ||
| * the claim), and an error that echoes it writes that text into whatever catches it. */ | ||
| invalid?: string[]; | ||
| /** Declared **optional** fields the document does not carry. Any entry (absent a violation) | ||
| * ⇒ `degraded`. */ | ||
| degraded?: string[]; | ||
| /** Keys the document carries that the manifest does not declare. **Dropped** — they never | ||
| * reach `data` — and named, which is the whole difference from silently discarding them. | ||
| * | ||
| * This does not change `status`, deliberately. An undeclared key is a fact about *this | ||
| * inference* — drift, a prompt regression, a resource someone forgot to update — and the | ||
| * deployer's own threshold for it belongs in the deployer's own code. What is not | ||
| * negotiable is that it does not propagate; there is no passthrough option here for the | ||
| * same reason there is none in ADR-0008. | ||
| * | ||
| * Note, as ADR-0008 R-2 notes for recorded shapes: a key NAME is itself informative even | ||
| * though no value is carried. That is strictly less exposure than the value would be, and it | ||
| * is the minimum that makes the signal usable at all. */ | ||
| undeclared?: string[]; | ||
| } | ||
| /** | ||
| * Validate a model-produced document against a declared field list (ADR-0011). | ||
| * | ||
| * Three outcomes, the same three `applyResponseMapping` returns on the other side of the same | ||
| * boundary: a missing **required** field is a `violation`, a missing **optional** field | ||
| * `degrade`s, and everything else is `ok`. There is no fourth state for an undeclared key — | ||
| * it is dropped and named, and `status` is unmoved. | ||
| * | ||
| * **No coercion, ever.** `"42"` for a `quantity` is a violation, not a number; there are no | ||
| * defaults, no repair, and no re-prompt. Every one of those turns a governance boundary into a | ||
| * heuristic, and all of them share one consequence: a repaired extraction is indistinguishable | ||
| * downstream from a correct one. A deployer who wants a retry loop writes it around a | ||
| * `violation`, where their policy is visible in their own code. | ||
| * | ||
| * Refuses exactly what `extractionJsonSchema` refuses, by asking it — an unknown resource name | ||
| * or a `type:`-recursive resource throws `ExtractionSchemaError` from here too. The alternative | ||
| * is a second, independently-maintained definition of "which resources can be extracted", which | ||
| * is precisely the drift this file exists to avoid. | ||
| */ | ||
| declare function validateExtraction(fields: IRField[], document: unknown, resources?: IRResourceRegistry): ExtractionResult; | ||
| export { type AuditCaller, type AuditSink, type AuditWritable, type BuildExecutionRecordInput, type ExecutionConsumer, type ExecutionDenialReason, type ExecutionPhase, type ExecutionRecord, type ExecutionStatus, type Exposure, type ExposureHint, type ExtractionResult, ExtractionSchemaError, type ExtractionStatus, type HealthStatus, type HintLevel, InMemoryRateLimitCounter, LIFECYCLE_BLOCKED_REASON, LIFECYCLE_UNEVALUATABLE_REASON, type MappingResult, type MappingStatus, type NamedTool, type PolicyCaller, type PolicyDecision, type PolicyDenial, type PolicyDenialReason, RATE_LIMIT_EXCEEDED_REASON, REDACTED, type RateLimitCounter, type RateLimitDecision, type RateLimitDenial, type RateLimitDenialReason, Registry, type SharedCounterStore, SharedWindowRateLimitCounter, type ToolNameCollision, applyResponseMapping, auditNow, buildExecutionRecord, combineExposure, contractViolationMessage, emitExecutionRecord, evaluatePolicy, evaluateRateLimit, extractionJsonSchema, inputJsonSchema, jsonLinesAuditSink, lifecycleExposure, objectJsonSchema, redisSharedCounterStore, toolName, validateExtraction }; |
+216
-14
@@ -5,3 +5,4 @@ // src/lowering.ts | ||
| } | ||
| function semanticJsonSchema(semantic, values) { | ||
| function semanticJsonSchema(semantic, values, strict) { | ||
| const closed = strict ? { additionalProperties: false } : {}; | ||
| switch (semantic) { | ||
@@ -14,3 +15,4 @@ case "location": | ||
| properties: { from: { type: "string", format: "date" }, to: { type: "string", format: "date" } }, | ||
| required: ["from", "to"] | ||
| required: ["from", "to"], | ||
| ...closed | ||
| }; | ||
@@ -21,3 +23,4 @@ case "party": | ||
| properties: { adults: { type: "integer" }, children: { type: "integer" } }, | ||
| required: ["adults"] | ||
| required: ["adults"], | ||
| ...closed | ||
| }; | ||
@@ -30,3 +33,4 @@ case "preference-set": | ||
| properties: { amount: { type: "number" }, currency: { type: "string" } }, | ||
| required: ["amount", "currency"] | ||
| required: ["amount", "currency"], | ||
| ...closed | ||
| }; | ||
@@ -49,22 +53,26 @@ case "time-slot": | ||
| } | ||
| function resourceJsonSchema(name, resources, visited) { | ||
| function resourceJsonSchema(name, resources, visited, strict) { | ||
| const fields = resources[name]; | ||
| if (!fields || visited.has(name)) return { type: "object" }; | ||
| if (!fields || visited.has(name)) { | ||
| if (strict) throw new ExtractionSchemaError(!fields ? `resource '${name}' is not in the registry` : `resource '${name}' is self-referential through a \`type:\` field`); | ||
| return { type: "object" }; | ||
| } | ||
| const next = new Set(visited).add(name); | ||
| return objectJsonSchema(fields, resources, next); | ||
| return lowerObject(fields, resources, next, strict); | ||
| } | ||
| function fieldJsonSchema(f, resources, visited) { | ||
| function fieldJsonSchema(f, resources, visited, strict) { | ||
| const base = f.description ? { description: f.description } : {}; | ||
| if (f.type.kind === "collection") return { ...base, type: "array", items: resourceJsonSchema(f.type.of, resources, visited) }; | ||
| if (f.type.kind === "collection") return { ...base, type: "array", items: resourceJsonSchema(f.type.of, resources, visited, strict) }; | ||
| if (f.type.kind === "resource") { | ||
| if (f.type.identity) return { ...base, type: "string" }; | ||
| return { ...base, ...resourceJsonSchema(f.type.name, resources, visited) }; | ||
| return { ...base, ...resourceJsonSchema(f.type.name, resources, visited, strict) }; | ||
| } | ||
| return { ...base, ...semanticJsonSchema(f.type.semantic, f.type.values) }; | ||
| const semantic = semanticJsonSchema(f.type.semantic, f.type.values, strict); | ||
| return f.description ? { ...base, ...semantic, description: f.description } : { ...base, ...semantic }; | ||
| } | ||
| function objectJsonSchema(fields, resources = {}, visited = /* @__PURE__ */ new Set()) { | ||
| function lowerObject(fields, resources, visited, strict) { | ||
| const properties = {}; | ||
| const required = []; | ||
| for (const f of fields) { | ||
| properties[f.name] = fieldJsonSchema(f, resources, visited); | ||
| properties[f.name] = fieldJsonSchema(f, resources, visited, strict); | ||
| if (f.required) required.push(f.name); | ||
@@ -74,7 +82,20 @@ } | ||
| if (required.length > 0) schema.required = required; | ||
| if (strict) schema.additionalProperties = false; | ||
| return schema; | ||
| } | ||
| function objectJsonSchema(fields, resources = {}, visited = /* @__PURE__ */ new Set()) { | ||
| return lowerObject(fields, resources, visited, false); | ||
| } | ||
| function inputJsonSchema(fields, resources = {}) { | ||
| return objectJsonSchema(fields, resources); | ||
| } | ||
| var ExtractionSchemaError = class extends Error { | ||
| constructor(reason) { | ||
| super(`cannot build a closed extraction schema: ${reason}`); | ||
| this.name = "ExtractionSchemaError"; | ||
| } | ||
| }; | ||
| function extractionJsonSchema(fields, resources = {}) { | ||
| return lowerObject(fields, resources, /* @__PURE__ */ new Set(), true); | ||
| } | ||
@@ -224,2 +245,17 @@ // src/exposure.ts | ||
| } | ||
| /** | ||
| * A resource's declared fields, by canonical name — `undefined` when the IR has no such | ||
| * resource. Added for the extraction surface (ADR-0011), which needs to fail closed on an | ||
| * unknown resource name rather than lower an empty field list into a schema that accepts | ||
| * `{}`. `ir.resources` was always reachable through the public `ir` property; this exists so | ||
| * a consumer asks the Registry a question instead of indexing its internals, exactly as | ||
| * `getCapability` does for tools. | ||
| */ | ||
| getResource(name) { | ||
| return this.ir.resources[name]; | ||
| } | ||
| /** Every declared resource name, in IR order. The listing counterpart to `getResource`. */ | ||
| listResources() { | ||
| return Object.keys(this.ir.resources); | ||
| } | ||
| get size() { | ||
@@ -546,3 +582,167 @@ return this.byId.size; | ||
| } | ||
| // src/extraction.ts | ||
| var COMPOSITE = { | ||
| money: { required: { amount: "number", currency: "string" } }, | ||
| party: { required: { adults: "integer" }, optional: { children: "integer" } }, | ||
| "date-range": { required: { from: "string", to: "string" } } | ||
| }; | ||
| var STRING_SEMANTICS = /* @__PURE__ */ new Set([ | ||
| "location", | ||
| "identifier", | ||
| "string", | ||
| "text", | ||
| "date", | ||
| "datetime", | ||
| "time-slot" | ||
| ]); | ||
| function isPlainObject(v) { | ||
| return typeof v === "object" && v !== null && !Array.isArray(v); | ||
| } | ||
| function leafOk(v, leaf) { | ||
| if (leaf === "string") return typeof v === "string"; | ||
| if (leaf === "number") return typeof v === "number" && Number.isFinite(v); | ||
| return typeof v === "number" && Number.isInteger(v); | ||
| } | ||
| function join(path, key) { | ||
| return path ? `${path}.${key}` : key; | ||
| } | ||
| function checkComposite(spec, value, path, acc) { | ||
| if (!isPlainObject(value)) { | ||
| acc.invalid.push(`${path}: expected object`); | ||
| return void 0; | ||
| } | ||
| const out = {}; | ||
| let failed = false; | ||
| for (const [key, leaf] of Object.entries(spec.required)) { | ||
| const v = value[key]; | ||
| if (v === void 0 || v === null) { | ||
| acc.missing.push(join(path, key)); | ||
| failed = true; | ||
| continue; | ||
| } | ||
| if (!leafOk(v, leaf)) { | ||
| acc.invalid.push(`${join(path, key)}: expected ${leaf}`); | ||
| failed = true; | ||
| continue; | ||
| } | ||
| out[key] = v; | ||
| } | ||
| for (const [key, leaf] of Object.entries(spec.optional ?? {})) { | ||
| const v = value[key]; | ||
| if (v === void 0 || v === null) continue; | ||
| if (!leafOk(v, leaf)) { | ||
| acc.invalid.push(`${join(path, key)}: expected ${leaf}`); | ||
| failed = true; | ||
| continue; | ||
| } | ||
| out[key] = v; | ||
| } | ||
| const declared = /* @__PURE__ */ new Set([...Object.keys(spec.required), ...Object.keys(spec.optional ?? {})]); | ||
| for (const key of Object.keys(value)) if (!declared.has(key)) acc.undeclared.push(join(path, key)); | ||
| return failed ? void 0 : out; | ||
| } | ||
| function checkField(f, value, path, resources, acc) { | ||
| if (f.type.kind === "collection") { | ||
| if (!Array.isArray(value)) { | ||
| acc.invalid.push(`${path}: expected array`); | ||
| return void 0; | ||
| } | ||
| const fields = resources[f.type.of] ?? []; | ||
| const items = []; | ||
| let failed = false; | ||
| value.forEach((item, i) => { | ||
| const mapped = checkObject(fields, item, `${path}[${i}]`, resources, acc); | ||
| if (mapped === void 0) failed = true; | ||
| else items.push(mapped); | ||
| }); | ||
| return failed ? void 0 : items; | ||
| } | ||
| if (f.type.kind === "resource") { | ||
| if (f.type.identity) { | ||
| if (typeof value !== "string") { | ||
| acc.invalid.push(`${path}: expected string`); | ||
| return void 0; | ||
| } | ||
| return value; | ||
| } | ||
| return checkObject(resources[f.type.name] ?? [], value, path, resources, acc); | ||
| } | ||
| const { semantic, values } = f.type; | ||
| const composite = COMPOSITE[semantic]; | ||
| if (composite) return checkComposite(composite, value, path, acc); | ||
| if (semantic === "preference-set") { | ||
| if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) { | ||
| acc.invalid.push(`${path}: expected array of string`); | ||
| return void 0; | ||
| } | ||
| return value; | ||
| } | ||
| if (semantic === "enum") { | ||
| if (typeof value !== "string" || !(values ?? []).includes(value)) { | ||
| acc.invalid.push(`${path}: expected one of ${(values ?? []).length} declared enum values`); | ||
| return void 0; | ||
| } | ||
| return value; | ||
| } | ||
| if (semantic === "quantity") { | ||
| if (!leafOk(value, "number")) { | ||
| acc.invalid.push(`${path}: expected number`); | ||
| return void 0; | ||
| } | ||
| return value; | ||
| } | ||
| if (STRING_SEMANTICS.has(semantic)) { | ||
| if (typeof value !== "string") { | ||
| acc.invalid.push(`${path}: expected string`); | ||
| return void 0; | ||
| } | ||
| return value; | ||
| } | ||
| acc.invalid.push(`${path}: unrecognized declared type`); | ||
| return void 0; | ||
| } | ||
| function checkObject(fields, value, path, resources, acc) { | ||
| if (!isPlainObject(value)) { | ||
| acc.invalid.push(`${path || "(root)"}: expected object`); | ||
| return void 0; | ||
| } | ||
| const out = {}; | ||
| let failed = false; | ||
| for (const f of fields) { | ||
| const v = value[f.name]; | ||
| if (v === void 0 || v === null) { | ||
| if (f.required) { | ||
| acc.missing.push(join(path, f.name)); | ||
| failed = true; | ||
| } else { | ||
| acc.degraded.push(join(path, f.name)); | ||
| } | ||
| continue; | ||
| } | ||
| const kept = checkField(f, v, join(path, f.name), resources, acc); | ||
| if (kept === void 0) failed = true; | ||
| else out[f.name] = kept; | ||
| } | ||
| const declared = new Set(fields.map((f) => f.name)); | ||
| for (const key of Object.keys(value)) if (!declared.has(key)) acc.undeclared.push(join(path, key)); | ||
| return failed ? void 0 : out; | ||
| } | ||
| function validateExtraction(fields, document, resources = {}) { | ||
| extractionJsonSchema(fields, resources); | ||
| const acc = { missing: [], invalid: [], degraded: [], undeclared: [] }; | ||
| const data = checkObject(fields, document, "", resources, acc); | ||
| const violated = acc.missing.length > 0 || acc.invalid.length > 0; | ||
| const result = { | ||
| status: violated ? "violation" : acc.degraded.length > 0 ? "degraded" : "ok" | ||
| }; | ||
| if (!violated && data !== void 0) result.data = data; | ||
| if (acc.missing.length > 0) result.missing = acc.missing; | ||
| if (acc.invalid.length > 0) result.invalid = acc.invalid; | ||
| if (acc.degraded.length > 0) result.degraded = acc.degraded; | ||
| if (acc.undeclared.length > 0) result.undeclared = acc.undeclared; | ||
| return result; | ||
| } | ||
| export { | ||
| ExtractionSchemaError, | ||
| InMemoryRateLimitCounter, | ||
@@ -563,2 +763,3 @@ LIFECYCLE_BLOCKED_REASON, | ||
| evaluateRateLimit, | ||
| extractionJsonSchema, | ||
| inputJsonSchema, | ||
@@ -569,4 +770,5 @@ jsonLinesAuditSink, | ||
| redisSharedCounterStore, | ||
| toolName | ||
| toolName, | ||
| validateExtraction | ||
| }; | ||
| //# sourceMappingURL=index.js.map |
+4
-3
| { | ||
| "name": "@archstone/emitter-support", | ||
| "version": "0.18.0", | ||
| "version": "0.19.0", | ||
| "private": false, | ||
@@ -40,7 +40,8 @@ "type": "module", | ||
| "dependencies": { | ||
| "@archstone/compiler": "0.18.0" | ||
| "@archstone/compiler": "0.19.0" | ||
| }, | ||
| "devDependencies": { | ||
| "ajv": "^8.17.1", | ||
| "tsup": "^8.5.1", | ||
| "@archstone/schema": "0.18.0" | ||
| "@archstone/schema": "0.19.0" | ||
| }, | ||
@@ -47,0 +48,0 @@ "scripts": { |
Sorry, the diff of this file is too big to display
212714
21.37%1523
25.04%3
50%+ Added
+ Added
- Removed
- Removed
Updated