diff --git a/ontology/effect-request/PROVENANCE.md b/ontology/effect-request/PROVENANCE.md index d359a55..6c5f64a 100644 --- a/ontology/effect-request/PROVENANCE.md +++ b/ontology/effect-request/PROVENANCE.md @@ -44,9 +44,21 @@ speaks is subject to this plane. The proposal is validated against these bytes by the same JSON-Schema subset validator `ts/src/nlq.ts` uses (`validateAgainst`), whose implemented keyword set is asserted over this schema at import — an unimplemented keyword fails loudly rather than silently validating less -than the contract says. That subset implements `format: "uri"` only, so -`requestedAt`'s `format: "date-time"` is **not** covered by it; `vendor-graph.ts` checks that -field explicitly instead of leaving it unenforced. +than the contract says. + +`format` is part of that bar, and it is checked by **value**, not by name: the validator implements +`uri` and `date-time` (`FORMAT_CHECKS` in `nlq.ts`), and a schema declaring any other one — `email`, +`uuid` — fails loudly at import instead of clearing the bar and then being validated for everything +except the constraint it declares. So `requestedAt`'s `format: "date-time"` is enforced by the +shared validator, for this contract and every other one. + +It was not always. Until the fix, `format` was admitted as a bare keyword and enforced only for +`uri`, which meant this contract declared `date-time` and nothing checked it; `vendor-graph.ts` +carried a private regex at the `proposeRevendor` call site to cover the field. That regex is gone — +the call site now asks the one implementation (`matchesFormat`). It still fails loudly there rather +than recording a `contractViolations` entry, because `analyzeVendorFreshness` seals whatever +proposals it is handed without reading that array: a bad instant must stop the run, not ride inside +the seal. ## Regeneration The embedded copy the engine ships (`ts/src/effect-request-data.ts`) is generated from this file diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index cd44ef0..eb636a2 100644 Binary files a/ts/dist/index.d.mts and b/ts/dist/index.d.mts differ diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index cd44ef0..eb636a2 100644 Binary files a/ts/dist/index.d.ts and b/ts/dist/index.d.ts differ diff --git a/ts/dist/index.js b/ts/dist/index.js index 9880d52..7c21a6f 100644 Binary files a/ts/dist/index.js and b/ts/dist/index.js differ diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index c876199..23933a1 100644 Binary files a/ts/dist/index.mjs and b/ts/dist/index.mjs differ diff --git a/ts/src/nlq.test.ts b/ts/src/nlq.test.ts index ecd0619..1fdfecc 100644 --- a/ts/src/nlq.test.ts +++ b/ts/src/nlq.test.ts @@ -6,18 +6,25 @@ import { OPINION_WEIGHT_MULTIPLIER } from './claim-admissibility' import { SEMANTIC_ACTION_SCHEMA_SHA256 } from './semantic-action-data' import { ActionRegistry, + assertSupportedKeywords, compileQuestion, kkoConceptLexicon, kkoSemanticAnnotator, lexiconAnnotator, + matchesFormat, planNodes, tokenizeQuestion, + validateAgainst, validateSemanticAction, DEFAULT_SENSE_WEIGHTS, + implementedFormats, + isImplementedFormat, NLQ_LITERAL_TYPE, SEMANTIC_ACTION_CONTRACT, + SEMANTIC_ACTION_SCHEMA, type ConceptLexiconEntry, type PlanNode, + type SchemaObj, type SemanticActionDef, } from './nlq' @@ -196,6 +203,179 @@ test('stored action definitions are frozen — a search cannot be mutated undern assert.equal(r.get(GET_CONTACT_LISTS)!.inputs.length, 1) }) +// ─── format: declared ⇒ enforced ─────────────────────────────────────────────── + +/** + * The formats the validator is expected to implement, each with a value it must ACCEPT and one it + * must REJECT. + * + * Written out literally, on purpose. Deriving this list from `implementedFormats()` would make the + * tests below a tautology — they would go green for a format that reached the accept-list and was + * never implemented, which is exactly the bug this section exists to catch. So adding a format + * forces a row here, and the row forces a counter-example, which is what keeps a stub check that + * returns `true` for everything from being smuggled in behind an accepted name. + */ +const EXPECTED_FORMATS: ReadonlyArray<{ format: string; accepts: string; rejects: string }> = [ + { format: 'date-time', accepts: '2026-07-29T00:00:00Z', rejects: '2026-07-29' }, + { format: 'uri', accepts: 'https://schemas.srcos.ai/v2/SemanticAction.json', rejects: ':ContactList' }, +] + +/** Every `format` value a schema declares, at any depth. Independent of the validator's own walk. */ +function declaredFormats(node: unknown, into = new Set()): Set { + if (Array.isArray(node)) { for (const v of node) declaredFormats(v, into); return into } + if (node === null || typeof node !== 'object') return into + for (const [k, v] of Object.entries(node as Record)) { + if (k === 'format' && typeof v === 'string') into.add(v) + else declaredFormats(v, into) + } + return into +} + +test('the format accept-list is exactly the set of formats that have a check', () => { + assert.deepEqual( + [...implementedFormats()].sort(), + EXPECTED_FORMATS.map((f) => f.format).sort(), + 'a format reached the accept-list without a row — and a counter-example — in this test') +}) + +test('every accepted format has TEETH: it accepts the good value and rejects the bad one', () => { + for (const { format, accepts, rejects } of EXPECTED_FORMATS) { + const schema: SchemaObj = { type: 'string', format } + + const clean: string[] = [] + validateAgainst(schema, accepts, 'v', clean) + assert.deepEqual(clean, [], `${format}: '${accepts}' should validate clean`) + + const errs: string[] = [] + validateAgainst(schema, rejects, 'v', errs) + assert.equal(errs.length, 1, + `${format}: '${rejects}' must be rejected — a format that rejects nothing is declared, not enforced`) + + // The same rule asked directly, so a call site cannot get a second opinion. + assert.equal(matchesFormat(format, accepts), true, format) + assert.equal(matchesFormat(format, rejects), false, format) + } +}) + +/** + * Copilot review finding on #37. A structural regex accepts the right punctuation, not a time: + * `2026-99-99T99:99:99Z` cleared the old check. A format check that admits values no clock can + * produce is shape-matching wearing enforcement's clothes, which is the thing this whole section + * exists to prevent — so the ranges, and the calendar, are asserted here. + */ +test('date-time rejects impossible instants, not just the wrong shape', () => { + const IMPOSSIBLE = [ + '2026-99-99T99:99:99Z', // the value that cleared the purely structural regex + '2026-13-01T00:00:00Z', // month 13 + '2026-00-01T00:00:00Z', // month 0 + '2026-02-30T00:00:00Z', // February 30 — shape-valid, calendar-impossible + '2026-04-31T00:00:00Z', // April has 30 days + '2026-01-32T00:00:00Z', // day 32 + '2026-01-00T00:00:00Z', // day 0 + '2026-01-01T24:00:00Z', // hour 24 + '2026-01-01T00:60:00Z', // minute 60 + '2026-01-01T00:00:61Z', // second 61 (60 is a permitted leap second) + '2026-01-01T00:00:00+99:00', // offset hours out of range + '2026-01-01T00:00:00+00:99', // offset minutes out of range + ] + for (const bad of IMPOSSIBLE) { + assert.equal(matchesFormat('date-time', bad), false, `${bad} must not pass as an instant`) + const errs: string[] = [] + validateAgainst({ type: 'string', format: 'date-time' }, bad, 'v', errs) + assert.equal(errs.length, 1, `${bad} must be reported as a violation`) + } + + // …and the real ones still pass, including the leap second and a genuine leap day. + for (const good of [ + '2026-07-29T00:00:00Z', + '2024-02-29T12:00:00Z', // 2024 is a leap year + '2000-02-29T00:00:00Z', // divisible by 400 — a leap year + '2026-12-31T23:59:60Z', // leap second, permitted by RFC 3339 + '2026-01-01T00:00:00.123Z', + '2026-01-01T00:00:00-05:00', + ]) { + assert.equal(matchesFormat('date-time', good), true, `${good} is a real instant and must pass`) + } + + // 1900 is NOT a leap year (divisible by 100, not by 400) — the rule `Date.UTC` would have got + // wrong for a two-digit year is exercised at both ends. + assert.equal(matchesFormat('date-time', '1900-02-29T00:00:00Z'), false) + assert.equal(matchesFormat('date-time', '1900-02-28T00:00:00Z'), true) +}) + +/** + * Copilot review finding on #37, low-confidence channel — and the sharpest one on this PR. + * + * The accept-list used to be `export const IMPLEMENTED_FORMATS: ReadonlySet`. `ReadonlySet` + * is erased at runtime, so a caller could `.add()` to it and widen what the bar admits WITHOUT + * adding a check. `validateAgainst` skips any format it has no entry for, so the smuggled name then + * cleared import and was enforced by nobody — the module's own failure mode, reachable from outside + * it. The accept-list is now a function over `FORMAT_CHECKS`, so there is nothing to mutate. + */ +test('the accept-list cannot be widened from outside — there is no mutable copy of it', () => { + const before = implementedFormats() + assert.ok(!isImplementedFormat('email'), 'precondition: email is not implemented') + + // Whatever a caller is handed, mutating it must not widen the bar. (A fresh array per call, so + // this mutates a copy and nothing else.) + assert.throws(() => { (before as string[]).push('email') }, TypeError, + 'the snapshot handed to callers must be frozen') + + assert.equal(isImplementedFormat('email'), false, 'the bar must still refuse email') + assert.deepEqual(implementedFormats(), before, 'a later snapshot must be unchanged') + assert.throws(() => assertSupportedKeywords({ type: 'string', format: 'email' }, 'T'), + /is NOT implemented/, 'and a contract declaring it must still be refused at import') + + // Two snapshots must not be the same object, or handing one out leaks the source of truth. + assert.notEqual(implementedFormats(), implementedFormats(), 'each call returns a fresh array') + assert.deepEqual(implementedFormats(), implementedFormats(), '…with equal contents') +}) + +test('a contract declaring an UNIMPLEMENTED format fails LOUDLY at the import bar', () => { + const withEmail: SchemaObj = { + type: 'object', + properties: { contact: { type: 'string', format: 'email' } }, + } + assert.throws( + () => assertSupportedKeywords(withEmail, 'T'), + /format "email" at T\.properties\.contact is declared by the contract but is NOT implemented/) + + // Why the bar has to be the thing that rejects it: silence is the failure mode. Bypass the bar and + // the declared constraint simply is not applied — no error, no signal, less validation than the + // contract claims. That is the gap; the throw above is the whole fix. + const errs: string[] = [] + validateAgainst(withEmail, { contact: 'definitely not an email' }, 'T', errs) + assert.deepEqual(errs, [], 'unenforced when the bar is skipped — which is precisely why it must reject') + + for (const bogus of ['uuid', 'ipv4', 'hostname', 'URI', '', 'constructor', 'toString']) { + assert.throws(() => assertSupportedKeywords({ type: 'string', format: bogus }, 'T'), + /is NOT implemented/, `format: ${JSON.stringify(bogus)} must not clear the bar`) + } + assert.throws(() => assertSupportedKeywords({ type: 'string', format: 7 }, 'T'), /is NOT implemented/) + assert.throws(() => matchesFormat('email', 'a@b.example'), /not implemented by validateAgainst/) +}) + +test('SemanticAction declares only formats that are enforced, and its uri format bites', () => { + // Importing nlq.ts at all ran the bar over this schema; assert it explicitly so the claim is + // stated rather than implied, and pin what the contract actually declares. + assertSupportedKeywords(SEMANTIC_ACTION_SCHEMA as SchemaObj, 'SemanticAction') + const declared = declaredFormats(SEMANTIC_ACTION_SCHEMA) + assert.deepEqual([...declared].sort(), ['uri'], 'the contract declares format: uri and nothing else') + for (const f of declared) assert.ok(isImplementedFormat(f), `${f} is declared but not implemented`) + + // …and the real contract's own `format: uri` (on `typeRef`) rejects the bare `:Thing` shorthand, + // not just a synthetic schema built in this file. + const good = act({ name: 'crm.fmt' }) as unknown as Record + const clean: string[] = [] + validateAgainst(SEMANTIC_ACTION_SCHEMA as SchemaObj, good, 'SemanticAction', clean) + assert.deepEqual(clean, [], 'baseline: a well-formed action conforms') + + const errs: string[] = [] + validateAgainst(SEMANTIC_ACTION_SCHEMA as SchemaObj, + { ...good, output: { typeRef: ':ContactList', cardinality: 'one' } }, 'SemanticAction', errs) + assert.ok(errs.some((e) => /not an absolute URI/.test(e)), errs.join('; ')) +}) + // ─── Tokenization + annotation ───────────────────────────────────────────────── test('the tokenizer is deterministic and preserves exact character spans', () => { diff --git a/ts/src/nlq.ts b/ts/src/nlq.ts index b7d492b..74cfd7e 100644 --- a/ts/src/nlq.ts +++ b/ts/src/nlq.ts @@ -73,14 +73,126 @@ import { /** A parsed JSON Schema object. Exported with `validateAgainst`, which takes one. */ export type SchemaObj = Record +// ─── format: one list, and it IS the list of checks that exist ────────────────── + +/** `format: "uri"` — require an absolute URI (a scheme). Rejects bare `:Thing` shorthand. */ +function isAbsoluteUri(s: string): boolean { + return /^[A-Za-z][A-Za-z0-9+.-]*:[^\s]*$/.test(s) +} + +/** + * Length of `month` in `year`, by the proleptic Gregorian calendar. + * + * Computed rather than asked of `Date`: `Date.UTC(year, …)` maps a two-digit year onto 19xx, so + * `Date.UTC(50, 2, 0)` answers for 1950 — a wrong answer this function must not inherit for the + * years `\d{4}` admits. + */ +const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] +function daysInMonth(year: number, month: number): number { + if (month === 2) return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 ? 29 : 28 + return DAYS_IN_MONTH[month - 1] as number +} + +/** + * `format: "date-time"` — an ISO-8601 date-time in its RFC-3339 profile: date, `T`, time, and an + * EXPLICIT offset (`Z` or ±HH:MM). A bare date (`2026-07-29`) names a day, not an instant, and does + * not pass. (The violation message below says "ISO-8601 date-time"; the two names are used for the + * same rule throughout, and RFC 3339 is the profile that makes the offset mandatory.) + * + * Shape is necessary but NOT sufficient. A purely structural regex accepts `2026-99-99T99:99:99Z`, + * which has the right punctuation and names no time — so a value that no clock could ever produce + * would satisfy a check whose entire purpose is that a declared format is an enforced one. The + * component ranges are therefore checked too, and the day against the real length of that month in + * that year, so `2026-02-30` is rejected rather than waved through. + */ +function isIsoDateTime(s: string): boolean { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|([+-])(\d{2}):(\d{2}))$/.exec(s) + if (m === null) return false + const year = Number(m[1]), month = Number(m[2]), day = Number(m[3]) + if (month < 1 || month > 12) return false + if (day < 1 || day > daysInMonth(year, month)) return false + if (Number(m[4]) > 23 || Number(m[5]) > 59) return false + // 60 is a leap second, which RFC 3339 permits (`23:59:60Z`). + if (Number(m[6]) > 60) return false + // Present together or not at all — the regex alternation guarantees it. + if (m[7] !== undefined && (Number(m[8]) > 23 || Number(m[9]) > 59)) return false + return true +} + +/** + * Every `format` this validator genuinely enforces, with the phrase its violation reads as. + * + * This map is the SINGLE source of truth for both halves of the guard: `validateAgainst` enforces + * through it, and `assertSupportedKeywords` admits a declared `format` only if it has a key here. + * Adding a name to the accept-list without writing the check is therefore not expressible — the + * accept-list IS the set of checks. (A vacuous check that rejects nothing is still expressible; + * `nlq.test.ts` demands a rejected counter-example per format, which closes that door.) + * + * A Map, not an object: `'constructor' in {}` is true, and a prototype key must never read as an + * implemented format. + */ +const FORMAT_CHECKS: ReadonlyMap boolean; expected: string }> = new Map([ + ['date-time', { check: isIsoDateTime, expected: 'an ISO-8601 date-time' }], + ['uri', { check: isAbsoluteUri, expected: 'an absolute URI' }], +]) + +/** + * True when a vendored contract may declare `format: ` — i.e. when `validateAgainst` + * implements it. + * + * A FUNCTION over `FORMAT_CHECKS`, not an exported collection. This was + * `export const IMPLEMENTED_FORMATS: ReadonlySet`, and `ReadonlySet` is a compile-time + * fiction: the runtime value is a live `Set`, so `(IMPLEMENTED_FORMATS as Set).add('email')` + * widened the accept-list without adding a check — and `validateAgainst` skips a format it has no + * entry for, so `format: "email"` then passed the bar and was enforced by nobody. That is the exact + * declared-unenforced gap this module exists to close, reachable from outside the module. + * (Copilot review on #37, low-confidence channel.) Asking `FORMAT_CHECKS` on every call means the + * accept-list cannot be widened without adding the check that defines it. + */ +export function isImplementedFormat(format: string): boolean { + return FORMAT_CHECKS.has(format) +} + +/** + * The implemented `format` names, as a fresh sorted snapshot. + * + * A new array per call AND frozen: `readonly string[]` is erased at runtime exactly as + * `ReadonlySet` was, so the freeze is what makes a mutation attempt fail loudly (in strict mode, + * which every module here is) instead of silently succeeding on a copy the caller then believes in. + */ +export function implementedFormats(): readonly string[] { + return Object.freeze([...FORMAT_CHECKS.keys()].sort()) +} + +/** + * True when `value` satisfies `format: `. THROWS for a format nothing implements — asking + * about an unenforced format must never quietly answer "fine". This is the one door for a call site + * that needs a contract's format checked before it builds the object it will validate. + */ +export function matchesFormat(format: string, value: string): boolean { + const f = FORMAT_CHECKS.get(format) + if (f === undefined) { + throw new Error( + `nlq: format '${format}' is not implemented by validateAgainst ` + + `(implemented: ${implementedFormats().join(', ')})`) + } + return f.check(value) +} + /** * Keywords carrying validation semantics that `validateAgainst` implements. Anything else that could * change what validates ($ref, allOf, anyOf, …) must fail LOUDLY at import rather than silently * validate less than the contract says — the registry's whole claim is conformance. + * + * `format` is deliberately absent: it is admitted by VALUE, not by name (see `FORMAT_CHECKS` and the + * explicit branch in `assertSupportedKeywords`). Listing the bare keyword would clear this bar for + * `format: "email"` and then enforce nothing — the exact silent under-enforcement the bar exists to + * prevent. Its absence is also fail-safe: delete that branch and `format` becomes an unknown + * keyword, which throws. */ const SUPPORTED_KEYWORDS = new Set([ 'type', 'const', 'enum', 'pattern', 'minLength', 'required', - 'properties', 'additionalProperties', 'items', 'uniqueItems', 'format', + 'properties', 'additionalProperties', 'items', 'uniqueItems', ]) /** Pure annotations — no validation effect; safe to ignore. */ const ANNOTATION_KEYWORDS = new Set(['$schema', '$id', 'title', 'description', 'default', 'examples']) @@ -90,6 +202,11 @@ const ANNOTATION_KEYWORDS = new Set(['$schema', '$id', 'title', 'description', ' * vendored sourceos-spec contract in the engine (SemanticAction here, EffectRequest in * `vendor-graph.ts`) must clear the same bar at import: a contract using an unimplemented keyword * fails LOUDLY rather than silently validating less than it says. + * + * `format` is checked by VALUE, not by name. `format: "email"` names a keyword the validator has, + * carrying a constraint it does not implement — passing it on the strength of the keyword would be + * this guard failing at its own job, so the value must satisfy `isImplementedFormat`, which reads + * `FORMAT_CHECKS` itself rather than any exported copy of its keys. */ export function assertSupportedKeywords(schema: unknown, at: string): void { if (Array.isArray(schema)) { schema.forEach((v, i) => assertSupportedKeywords(v, `${at}[${i}]`)); return } @@ -100,6 +217,16 @@ export function assertSupportedKeywords(schema: unknown, at: string): void { continue } if (k === 'items' || k === 'additionalProperties') { assertSupportedKeywords(v, `${at}.${k}`); continue } + if (k === 'format') { + if (typeof v !== 'string' || !isImplementedFormat(v)) { + throw new Error( + `nlq: schema format ${JSON.stringify(v)} at ${at} is declared by the contract but is NOT ` + + `implemented by validateAgainst (implemented: ${implementedFormats().join(', ')}) — ` + + 'a declared format that nothing checks validates less than the contract says; implement it ' + + 'in nlq.ts (and its tests) before re-vendoring a schema that declares it') + } + continue + } if (SUPPORTED_KEYWORDS.has(k) || ANNOTATION_KEYWORDS.has(k)) continue throw new Error( `nlq: schema keyword '${k}' at ${at} is outside the implemented validation subset — extend the ` + @@ -144,17 +271,12 @@ function jsonTypeOf(v: unknown): string { return typeof v } -/** `format: "uri"` — require an absolute URI (a scheme). Rejects bare `:Thing` shorthand. */ -function isAbsoluteUri(s: string): boolean { - return /^[A-Za-z][A-Za-z0-9+.-]*:[^\s]*$/.test(s) -} - /** * Validate `value` against the implemented 2020-12 subset of `schema`, appending violations to * `errs`. Shared by every vendored-contract validator in the engine so there is ONE validation * subset, guarded by ONE `assertSupportedKeywords` bar — never two that can drift apart. - * `format` is enforced for `uri` only; a contract relying on another `format` must check it - * explicitly at the call site (see `vendor-graph.ts` and `requestedAt`). + * `format` is enforced for every entry in `FORMAT_CHECKS`, and the bar refuses a contract declaring + * any other one — so for anything that cleared import, a declared format is an enforced format. */ export function validateAgainst(schema: SchemaObj, value: unknown, at: string, errs: string[]): void { const t = schema['type'] @@ -174,7 +296,14 @@ export function validateAgainst(schema: SchemaObj, value: unknown, at: string, e if (typeof p === 'string' && !new RegExp(p).test(value)) errs.push(`${at}: does not match pattern ${p}`) const ml = schema['minLength'] if (typeof ml === 'number' && value.length < ml) errs.push(`${at}: shorter than minLength ${ml}`) - if (schema['format'] === 'uri' && !isAbsoluteUri(value)) errs.push(`${at}: '${value}' is not an absolute URI`) + const fmt = schema['format'] + if (typeof fmt === 'string') { + // `assertSupportedKeywords` refuses a contract declaring a format with no check, so `f` is + // present for every schema that cleared import. A hand-built schema that skipped the bar + // degrades to unchecked rather than throwing mid-validation: the bar polices formats, not this. + const f = FORMAT_CHECKS.get(fmt) + if (f !== undefined && !f.check(value)) errs.push(`${at}: '${value}' is not ${f.expected}`) + } } if (Array.isArray(value)) { const items = schema['items'] diff --git a/ts/src/vendor-graph.test.ts b/ts/src/vendor-graph.test.ts index 6da94e8..0ecf502 100644 --- a/ts/src/vendor-graph.test.ts +++ b/ts/src/vendor-graph.test.ts @@ -364,11 +364,47 @@ test('the proposal is validated against the sha-asserted vendored contract', () assert.equal((EFFECT_REQUEST_SCHEMA as Record)['title'], 'EffectRequest') }) -test('requestedAt must be a real date-time — the shared validator subset does not cover format: date-time', () => { +/** Every `format` the schema declares, at any depth — walked here, not asked of the validator. */ +function declaredFormats(node: unknown, into = new Set()): Set { + if (Array.isArray(node)) { for (const v of node) declaredFormats(v, into); return into } + if (node === null || typeof node !== 'object') return into + for (const [k, v] of Object.entries(node as Record)) { + if (k === 'format' && typeof v === 'string') into.add(v) + else declaredFormats(v, into) + } + return into +} + +/** + * `requestedAt` carries the contract's `format: "date-time"`. That format used to be declared here + * and enforced nowhere but a private regex in `vendor-graph.ts`; the shared validator now implements + * it, so this asserts BOTH ends: the contract-shaped check has teeth on its own, and the call site + * still refuses to build a proposal — loudly — from a value that fails it. + */ +test('requestedAt must be a real date-time — enforced by the SHARED validator, from the contract', () => { const store = fixture() + + // 1. Every format this contract declares is one the validator implements (the whole bar). + const declared = declaredFormats(EFFECT_REQUEST_SCHEMA) + assert.deepEqual([...declared].sort(), ['date-time'], 'the contract declares format: date-time') + + // 2. The shared validator itself rejects it — no call-site workaround involved. Before the fix + // this produced [] : the format was declared by the contract and checked by nobody. + const good = proposeRevendor(store, SERVICE_PIN, { requestedAt: '2026-07-29T00:00:00Z' }) + assert.deepEqual(good.contractViolations, [], 'baseline: a real instant conforms') + + const errs: string[] = [] + validateAgainst(EFFECT_REQUEST_SCHEMA as Record, + { ...good.effectRequest, requestedAt: '2026-07-29' }, 'EffectRequest', errs) + assert.ok(errs.some((e) => /requestedAt.*not an ISO-8601 date-time/.test(e)), errs.join('; ')) + + // 3. And the call site still fails loudly rather than sealing a proposal that carries a violation. assert.throws( () => proposeRevendor(store, SERVICE_PIN, { requestedAt: '2026-07-29' }), /not an ISO-8601 date-time/) + assert.throws( + () => analyzeVendorFreshness(store, { requestedAt: '2026-07-29' }), + /not an ISO-8601 date-time/, 'a bad instant must stop the run, not ride inside the seal') }) // ─── Sealing ─────────────────────────────────────────────────────────────────── diff --git a/ts/src/vendor-graph.ts b/ts/src/vendor-graph.ts index a278092..8e51f7e 100644 --- a/ts/src/vendor-graph.ts +++ b/ts/src/vendor-graph.ts @@ -66,7 +66,7 @@ import { createHash } from 'node:crypto' import type { HellGraphStore } from './store' import type { PropertyValue } from './types' -import { validateAgainst, assertSupportedKeywords, type SchemaObj } from './nlq' +import { validateAgainst, assertSupportedKeywords, matchesFormat, type SchemaObj } from './nlq' import { EFFECT_REQUEST_SCHEMA_TEXT, EFFECT_REQUEST_SCHEMA_SHA256, @@ -887,8 +887,6 @@ export interface ProposeOptions { blastRadius?: number } -const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/ - /** * Emit a re-vendor **proposal** for one pin as an `EffectRequest`-shaped object, validated against * the sha-asserted vendored contract. @@ -902,12 +900,13 @@ const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}: * PURE with respect to the graph. */ export function proposeRevendor(store: HellGraphStore, pinId: string, opts: ProposeOptions): RevendorProposal { - if (!ISO_DATE_TIME.test(opts.requestedAt)) { - // The shared subset validator implements `format: "uri"` only, so `requestedAt`'s - // `format: "date-time"` would otherwise go unchecked. Checked here rather than left unenforced. - throw new Error( - `vendor-graph: requestedAt '${opts.requestedAt}' is not an ISO-8601 date-time (the contract's ` + - 'format: date-time is outside the shared validator subset and is enforced here instead)') + // `requestedAt` carries the contract's `format: "date-time"`, which the shared validator now + // implements — so this asks that ONE implementation (`matchesFormat`) rather than keeping a second + // copy of the rule here. It stays a THROW rather than a `contractViolations` entry because + // `analyzeVendorFreshness` seals whatever proposals this returns without reading that array: a bad + // `requestedAt` reaching the seal must stop the run, not ride along inside it. + if (!matchesFormat('date-time', opts.requestedAt)) { + throw new Error(`vendor-graph: requestedAt '${opts.requestedAt}' is not an ISO-8601 date-time`) } const verdict = stalenessOf(store, pinId) const risk = contractCrossingRiskOf(store, pinId)