From 3e2ca511048488fcffadb5e5233d2e761377c051 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:50:37 -0400 Subject: [PATCH 1/3] nlq: close the declared-unenforced gap inside the guard against declared-unenforced gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `assertSupportedKeywords` is the bar every vendored sourceos-spec contract clears at import, and its stated contract is that a schema using an unimplemented keyword fails LOUDLY rather than silently validating less than it says. It did not hold for `format`. `SUPPORTED_KEYWORDS` listed `format`, so a schema declaring ANY format cleared the bar — while `validateAgainst` enforced exactly one, `uri`. A contract declaring `date-time`, `email` or `uuid` therefore passed the loud-failure bar and was then validated for everything except the constraint it actually declared. Exactly one such declaration exists on main: `requestedAt` in `EffectRequest.json` — enforced only by a private regex at the `proposeRevendor` call site, and by nothing at all for any other consumer of the contract. `format` is now admitted by VALUE, not by name: * `FORMAT_CHECKS` is one map from format name to the check that implements it. It is the single source of truth for both halves of the guard — `validateAgainst` enforces through it, `assertSupportedKeywords` admits a declared format only if it has a key there. Adding a name to the accept-list without writing the check is not expressible, because the accept-list IS the set of checks. * `date-time` is implemented rather than allowlisted: the ISO-8601 rule moves out of `vendor-graph.ts` into `FORMAT_CHECKS`, so it now holds for EVERY contract. * `format` is dropped from `SUPPORTED_KEYWORDS`, which is fail-safe: delete the explicit branch and `format` becomes an unknown keyword, which throws. * A Map, not an object literal — `'constructor' in {}` is true, and a prototype key must never read as an implemented format. `vendor-graph.ts` loses its duplicate `ISO_DATE_TIME` regex and the comment documenting the limitation. The call site keeps a loud throw, now sourced from the one implementation via `matchesFormat`, because `analyzeVendorFreshness` seals whatever proposals it is handed without reading `contractViolations`: downgrading this to a violation entry would convert a loud failure into a silent one, which is the bug class this change exists to close. Teeth, both ways. `nlq.test.ts` states the expected formats and their counter-examples LITERALLY rather than deriving them from `IMPLEMENTED_FORMATS` — a test written from the implementation's own constant cannot catch a format that was accepted and never implemented. Verified by injection: adding a stub `email: () => true` fails the drift test, and adding it together with a matching test row still fails on the counter-example. --- ontology/effect-request/PROVENANCE.md | 18 ++++- ts/dist/index.d.mts | Bin 223714 -> 224693 bytes ts/dist/index.d.ts | Bin 223714 -> 224693 bytes ts/dist/index.js | Bin 424586 -> 425742 bytes ts/dist/index.mjs | Bin 415752 -> 416854 bytes ts/src/nlq.test.ts | 105 ++++++++++++++++++++++++++ ts/src/nlq.ts | 93 ++++++++++++++++++++--- ts/src/vendor-graph.test.ts | 38 +++++++++- ts/src/vendor-graph.ts | 17 ++--- 9 files changed, 249 insertions(+), 22 deletions(-) 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 cd44ef01c36cc523914212a35f6f5d7d34596719..db5b2456987fe22d97f8414cc10c0073d9ea1013 100644 GIT binary patch delta 1093 zcmZuwPiqrF6enAYQs_xA1r=XYK`9|UcneZ2ZAztoX``N`JG=WfJMHdFotaJCQ+n_l zRLM<{D0uN^$w%-kJ$mbh@a&t}q(vx%ke%#%zrS;NF?;@J_Vh#Lr}u2Q)=m)oSgMQ} zAG$2fQ3Hc6ibyJqAe6!=78=M{0TG5NQwW!*AAvWp0fvU9z`@(0_ozPui?D`@cWEqE zGIRfi35Lho{>(I{UDS2Z61aho2_X##mbn%51~U*zLbu2Go@>*fjY~5VP-3DDyxiW~ zYHhc6+O4(2_1*pLmG(gs_K`($@af#E_S-5)=p`t?cVc|d%y1oZT0EB7gy$A@*>6C5 zV}JL}0g!ysT$*6v6D~Tyvy&7v6h=F}Odr#!)yM*w8(;!*fr5rYtJfI_SwAO^0=uZ- zUCz;@1-+g5-Q9-~eeB zt)W5A)9B%Nad3o`8J+ex?Ib67#0;Lzf2l4|&@`;q9pk4Z@g``aNM5~cz}e>))zQ+> z!-@;lFJJO4?JL{@UaxGuYSmp_LE(aITw7ejjPbPQyK^hy7>k}%k<&fqL&6iL>sD>Z zlq#(2PAidNTv-17>q=bZ;o>-9TDz1C70@lEAru{xfQ;Sq1h6Ob)cRDKN)kFbBOWo) zbAT2`gX{@7l7VZdjmDJ+vIU!GP&SnI`Zv_iAIv|l%nkN#SEfflzIuPXzKy>CY7TvK zDq`8w!_|$}>gIu8r$&@E1WbX#IM?pVO)?(eGUDdSCJLZJQi156IpuVHC=Xgvls$Dt z2~(tt8QIdxMP&$Ma#^psNy@3S|2OvR_vPFm3o3U;o8P^QZ+CByH+rx$b?e^Db!A5P Jqp4rh{{Rtif?fas delta 201 zcmdmboA=RW-i8*&ElkFXx6fJ1l*l+;RfSPxdH_2k>vroEOy8L56H1FR6BP3Eaw@GA z5*3p3^Gb>mlS>qeQgbRZ^U{G53W<67B^jwj3JGcXMY)M32@1KT#U%>K8L7$H3YjHb z3R()O6$Lq&$(bcNl?sU^3P8mQ$%#2R3dNZvsR|m!si_JHWvO{7`9-?vMTrF&dL_jP zKtod$5{gm_xk^)uOHxxDOA<6ED=rkDo}0wTvHjO-rprsWKMG`K65IYco%s+e02QE0 AaR2}S diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index cd44ef01c36cc523914212a35f6f5d7d34596719..db5b2456987fe22d97f8414cc10c0073d9ea1013 100644 GIT binary patch delta 1093 zcmZuwPiqrF6enAYQs_xA1r=XYK`9|UcneZ2ZAztoX``N`JG=WfJMHdFotaJCQ+n_l zRLM<{D0uN^$w%-kJ$mbh@a&t}q(vx%ke%#%zrS;NF?;@J_Vh#Lr}u2Q)=m)oSgMQ} zAG$2fQ3Hc6ibyJqAe6!=78=M{0TG5NQwW!*AAvWp0fvU9z`@(0_ozPui?D`@cWEqE zGIRfi35Lho{>(I{UDS2Z61aho2_X##mbn%51~U*zLbu2Go@>*fjY~5VP-3DDyxiW~ zYHhc6+O4(2_1*pLmG(gs_K`($@af#E_S-5)=p`t?cVc|d%y1oZT0EB7gy$A@*>6C5 zV}JL}0g!ysT$*6v6D~Tyvy&7v6h=F}Odr#!)yM*w8(;!*fr5rYtJfI_SwAO^0=uZ- zUCz;@1-+g5-Q9-~eeB zt)W5A)9B%Nad3o`8J+ex?Ib67#0;Lzf2l4|&@`;q9pk4Z@g``aNM5~cz}e>))zQ+> z!-@;lFJJO4?JL{@UaxGuYSmp_LE(aITw7ejjPbPQyK^hy7>k}%k<&fqL&6iL>sD>Z zlq#(2PAidNTv-17>q=bZ;o>-9TDz1C70@lEAru{xfQ;Sq1h6Ob)cRDKN)kFbBOWo) zbAT2`gX{@7l7VZdjmDJ+vIU!GP&SnI`Zv_iAIv|l%nkN#SEfflzIuPXzKy>CY7TvK zDq`8w!_|$}>gIu8r$&@E1WbX#IM?pVO)?(eGUDdSCJLZJQi156IpuVHC=Xgvls$Dt z2~(tt8QIdxMP&$Ma#^psNy@3S|2OvR_vPFm3o3U;o8P^QZ+CByH+rx$b?e^Db!A5P Jqp4rh{{Rtif?fas delta 201 zcmdmboA=RW-i8*&ElkFXx6fJ1l*l+;RfSPxdH_2k>vroEOy8L56H1FR6BP3Eaw@GA z5*3p3^Gb>mlS>qeQgbRZ^U{G53W<67B^jwj3JGcXMY)M32@1KT#U%>K8L7$H3YjHb z3R()O6$Lq&$(bcNl?sU^3P8mQ$%#2R3dNZvsR|m!si_JHWvO{7`9-?vMTrF&dL_jP zKtod$5{gm_xk^)uOHxxDOA<6ED=rkDo}0wTvHjO-rprsWKMG`K65IYco%s+e02QE0 AaR2}S diff --git a/ts/dist/index.js b/ts/dist/index.js index 9880d5237ede5b59abb061bcb2ba8561c073adc5..ced20b9c993f8b1be753edfb22809008a0273c3b 100644 GIT binary patch delta 1321 zcmbtU&rcIU7)_B`O=J9ps6b=Bt_a(tY=9U*uo6-rR)rD@Cm^u2v+YW|i#yX|Q`Qpf zMPp11hlv;e0X=H=sGK!X6aNSgUW_p&9*ncQ#j*yy*h?p!`QG=w_ukhB-`XDiY}={@ zYE896wdNOShOjpkwAi7M1Tn@His!TfmPiCDK>!oeqN%f&gF-wcQ%oqo7ztXzl48PS za&~HLZaIE!B7TE?c!jzkDXbQ`d<3SGbN^DY`oA*5VzIxs?-D2tCUrY$j`QRu=C~Bz zCXn9-63AmhfaaVH@X;npnT#|TgGQOXfnu%oISK#4bwo-g7)VU z`qXT9Z=&f#{a!590`2F)^h8|{>)tIndGs0QykqZh;4G8h287YSgqc5b^Rid0x^|Zg zEd#yK)dk^tdf4;3sDr((qEqcW)AHWJTPyWyCvO&eWuaDfejA;(Pi~>dHv2Is40QO{ z&G&!8;zL4&75aqZYA>|qAa?e>q;MhB-q>a?jIW*jWKH;T>=#{x Bt0Vvb delta 356 zcmY+AKT88a5XH%#JwZXifFxisUJ-L*G+-f^oDeic3XPbg5Rqhax8W9&JK5d4LJ?D3 zWs!EjhX~t=onV)5U|}KH34)cl!88`bgL%K1Vcz&BclVVW#fdl>r{eSjI^l9BLQe~u z^8jW)U_&g_XM`q6^>U5g)AEd?rn(=Y<5Y?Ep}-xF4DtDlG%vumZ7@SdO~~hAjq+-b z2ZK34Fn@Bi1?meX$2F7NjWXkO1Wngo0C- zz?9`4X2gXE52$7W20ZL>A^`jD6eYgi;Ap@jOwamU5bRoA9T0$3)^eju-{S%z!W6ZQ R#)|rA8rfB~acR6Q{sMvBdGP=M diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index c876199ab4fad3559ce48fd49f56b41c7fbd84f2..f751ce9901bf68d770db64dff310c9ce1975dfca 100644 GIT binary patch delta 1256 zcmbVM&rj1(91pNgGh>V>CWyo@%>rFAn!yM$BhxG}bRx{KaWF8UY_F`cwb1vrxr{m! zXQF3fUW|!}7ta_?=0Oj7QEzhb;LU%)i~oS%wMCfjT>A4ht@zHakjuXpb4lzahMlM6m(L76k$@E5_ywUsT9CLwWMd6X6R5<7|NithKValwO>IfD}@qqY|0usWuS%- zvmI*#(Vg>%2cW4SsSu4up=@Y52=Set0@U@wb_ASLFygN{D{BNj1KnVt6}JkcNOZId z)~l`0YAv5)8;aKYs&r&O0{+^vEXy6_W@KuZObW<%3{98NQZZl!){qm3U~{u@?ZgeL zY^NqU3Ndf~P8U8j9~;8g?&PM;UvyZCj($Y3wXy0JF;pnS5K0s&Gc|ofgqcB|O zD^x1)-u^Pk_e|j!e{Bk<>#hy{+Vxa&L3R){wJMbZ7AOqmDa>jalOx%JVmgknJONCA znM#(ydI=q)u#imuk8$-5JUcoEE2Z(!*aRqtOkC{ z2y3cT=@b(7xlx10xStBqK36BjAMCJNPQ&j03-c@Dnv* z^gMP~KDgP|nmgF)mBqK`Jbk=j31^#?n(&~>7sKAkf%bdV_Q%1Wg}niOG3Y(TzlXh- fyVjZyC%pT;z5m|hX6mZ2L7TBf?62Xwb)W)=EEY7cek zRL!aAPbdhsOA#Krd+FY#bI?D~N?tlYzvq2 = [ + { 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( + [...IMPLEMENTED_FORMATS].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) + } +}) + +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(IMPLEMENTED_FORMATS.has(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..d00c046 100644 --- a/ts/src/nlq.ts +++ b/ts/src/nlq.ts @@ -73,14 +73,73 @@ 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) +} + +/** + * `format: "date-time"` — an RFC-3339 instant: 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. + */ +function isIsoDateTime(s: string): boolean { + return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(s) +} + +/** + * 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' }], +]) + +/** + * The `format` values a vendored contract may declare — i.e. the ones `validateAgainst` implements. + * Derived from the checks themselves, so it cannot name one that does not exist. + */ +export const IMPLEMENTED_FORMATS: ReadonlySet = new Set(FORMAT_CHECKS.keys()) + +/** + * 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: ${[...IMPLEMENTED_FORMATS].sort().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 +149,10 @@ 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 be one of `IMPLEMENTED_FORMATS`. */ export function assertSupportedKeywords(schema: unknown, at: string): void { if (Array.isArray(schema)) { schema.forEach((v, i) => assertSupportedKeywords(v, `${at}[${i}]`)); return } @@ -100,6 +163,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' || !IMPLEMENTED_FORMATS.has(v)) { + throw new Error( + `nlq: schema format ${JSON.stringify(v)} at ${at} is declared by the contract but is NOT ` + + `implemented by validateAgainst (implemented: ${[...IMPLEMENTED_FORMATS].sort().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 +217,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 +242,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) From d773c1d3168ae2c740c880dc6a2cd6238360fe79 Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:33:57 -0400 Subject: [PATCH 2/3] nlq: date-time must reject impossible instants, not just the wrong shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review finding on #37. `isIsoDateTime` was a purely structural regex, so `2026-99-99T99:99:99Z` passed: the right punctuation, and no time. A format check that admits values no clock can produce is shape-matching wearing enforcement's clothes — precisely what this PR's "declared ⇒ enforced" bar exists to prevent, so the gap sat inside the guard against exactly that gap. Component ranges are now checked, and the day against the real length of that month in that year (`2026-02-30` is rejected; `2024-02-29` is not). Month length is computed rather than asked of `Date`, because `Date.UTC(year, …)` maps a two-digit year onto 19xx and would answer for 1950 when asked about 0050. The new test is red against the old regex and green against this one — 12 impossible instants that used to pass, plus the leap-second and leap-day cases that must keep passing, and 1900-02-29 (not a leap year) at the other end. Also aligns the docstring's "RFC-3339 instant" with the violation message's "ISO-8601 date-time" (Copilot #37, two comments): one rule, named consistently, with RFC 3339 identified as the profile that makes the offset mandatory. --- ts/dist/index.js | Bin 425742 -> 426408 bytes ts/dist/index.mjs | Bin 416854 -> 417520 bytes ts/src/nlq.test.ts | 46 +++++++++++++++++++++++++++++++++++++++++++++ ts/src/nlq.ts | 37 +++++++++++++++++++++++++++++++++--- 4 files changed, 80 insertions(+), 3 deletions(-) diff --git a/ts/dist/index.js b/ts/dist/index.js index ced20b9c993f8b1be753edfb22809008a0273c3b..447e4835d63bcc6fe8533f511bd508c44b7c6952 100644 GIT binary patch delta 759 zcmZ`%%TB^j5RD=(_y7`J4Ms_Cc=ZMHun-d$vH%yd(NamJC6LfI>4QkXppnF#Tb9PS z@dHf!HCM)aFO(|iYUZAtnK^UjK7H-HJ?!31H>R7@&~)oP{OU{wstqT{7w4^Ny;VD_ zH%>tUS>Teu=aWF9z^=qKOesY=p4oPLmI<0VbgE{}GTkmS)KxnP2FVeOA^^P(F!TwM zBms}W*1N7}n?RUw03yX%h{t_|V_Aq{E(_zakBJx~^(<6?FF+A1E#c<2PFwxjMV?Pf z0jJEKo$%hIZJCY>2Hr7q#Wb~%c#Bdb@=)P2|>Gk_~-N{^IN9{X0A_84`;A5nMvGx80Ehu;qX delta 116 zcmZ3{EY5Ivk$}NO-l036 z!bCUv5+*){CO(4aoR*@ftC{oVKQsT#ygh|JpF_8E-+XQEpRYdzwv5?GG2!I+;=EC+ zG|Fd{>M4jI30xHTOcY2I*p>K;$@z`;P;U;ph7K)dY?bt~p$|IDSXIm@Xe384*#PLa zfuTCST z%py76V!+AvrV-eiH4WVwfQDyG)R|gqlDt7N68UAUy6@adWQw_3yw-}KBF-+Sj1-B< zXhnne6LIxgZ88=jmgv0Ht3K@a@whX`V_WH4DiJt#==35~hT4^CGMdE6$Xy%u=~13g zyx#9jke7YPn&AjIvMFcm0&sN4bT720-S7E=h!k;!BrW`VG`rsO?SwrCNf}`=FOUu% zOi`H1Qth^f(-fy!iu0OuDAPNRjBX1b4j-8Dq?y0UX5F&Lei3~k`{Ujpe7rLPukT+U C2+&Xf delta 112 zcmeycSMu5p$%Yoj7N!>F7M3ln0{PQ_=d;QM7NwSy7Ue1E$Hk;no7Cz8Nh2s30%ltw oks2|2F)7-b_8L(&(b~GPNHUr#`g$d)#U&cWn%gCdSg*eU01J*M`v3p{ diff --git a/ts/src/nlq.test.ts b/ts/src/nlq.test.ts index afbc7c0..69efd58 100644 --- a/ts/src/nlq.test.ts +++ b/ts/src/nlq.test.ts @@ -256,6 +256,52 @@ test('every accepted format has TEETH: it accepts the good value and rejects the } }) +/** + * 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) +}) + test('a contract declaring an UNIMPLEMENTED format fails LOUDLY at the import bar', () => { const withEmail: SchemaObj = { type: 'object', diff --git a/ts/src/nlq.ts b/ts/src/nlq.ts index d00c046..60c7e1b 100644 --- a/ts/src/nlq.ts +++ b/ts/src/nlq.ts @@ -81,11 +81,42 @@ function isAbsoluteUri(s: string): boolean { } /** - * `format: "date-time"` — an RFC-3339 instant: 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. + * 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 { - return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/.test(s) + 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 } /** From 87577f345b8a663f55334007cea2473643f60b7a Mon Sep 17 00:00:00 2001 From: Michael Heller <21163552+mdheller@users.noreply.github.com> Date: Wed, 29 Jul 2026 23:38:57 -0400 Subject: [PATCH 3/3] nlq: the accept-list must not be a mutable export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot review on #37, low-confidence channel — the sharpest finding on this PR, and one neither REST comment channel returned. `IMPLEMENTED_FORMATS` was `export const … : ReadonlySet`. `ReadonlySet` is erased at runtime: the value is a live `Set`, and `export * from './nlq'` puts it on the package's public surface. So (IMPLEMENTED_FORMATS as Set).add('email') widened the accept-list without adding a check. `validateAgainst` skips any format it has no `FORMAT_CHECKS` entry for, so `format: "email"` then cleared the import bar and was enforced by nobody — verified end to end before the fix. That is precisely the declared-unenforced gap this PR closes, reachable from outside the module: the guard against the gap had the gap. The accept-list is now asked of `FORMAT_CHECKS` on every call (`isImplementedFormat`), so there is no copy to widen, and `implementedFormats()` hands out a fresh FROZEN array — frozen because `readonly string[]` is erased exactly as `ReadonlySet` was, so only the freeze makes a mutation attempt fail rather than quietly succeed on a copy the caller then trusts. `IMPLEMENTED_FORMATS` is removed rather than deprecated: it was introduced by this same PR, so nothing outside it consumes the name. --- ts/dist/index.d.mts | Bin 224693 -> 225886 bytes ts/dist/index.d.ts | Bin 224693 -> 225886 bytes ts/dist/index.js | Bin 426408 -> 426543 bytes ts/dist/index.mjs | Bin 417520 -> 417626 bytes ts/src/nlq.test.ts | 37 +++++++++++++++++++++++++++++++++---- ts/src/nlq.ts | 37 ++++++++++++++++++++++++++++++------- 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/ts/dist/index.d.mts b/ts/dist/index.d.mts index db5b2456987fe22d97f8414cc10c0073d9ea1013..eb636a242be5caceedfd2aaca59538d24b1bc6d2 100644 GIT binary patch delta 1413 zcmZuxUu#@N6ek;`A`~e;SnA73D`ZzTE2vP6rLd$42Aged)+f>4vvY(MLRbrH}4J*l0Kq#$?3(2YE5 zX9qAYb$}TLnUu99i+_$g@6Wjrt%B?kp>ozV`xGAS@c#DR_Gr9)?{N!uA20w{+zHWZdvLcmn4?nNb*fOC z^%{7o*4^sObsjQMM?` z4SG@=Q~Rpt0BbJd%F3y!p*dGGJ8pE48yBb#o-i+QVh9TFDqzlmB}tRp9!Ugrw;a2} zmU}wVGy`Yh)`z$IVBBmYceW4}b7GOuS~7zk3|SA>?NV$|8j17NK#s;hMQ9TX*>PU} zoagYi31|D%pi71b^VX6mS|}>CB@Xb5-%qaJ&|&v1)1-FNMRZ%u7tep{yfYtj^Q+Xe zvZq&*fgDZXh$bUFg_5I{(j6?;p>*fY!?FtKJvLDDc_cQ&(LE9IDcMcPSl5(rXv6pA zS1IS@G0{(bE+EnJ}*Kkoh81(};3(JTg*MM<_Vl z?8{W~%&xX+f6nzzQC~L9%CB`^p1ROl3?+G)+yi+O7&y2B0$-zzJS0r{E-0OWtl>+o zo0JbB&D*wTc4G7)driAUHD9c+vcK%$ofhmNdf$V>r@ecjV``Ld~1PB0UjBNz~N^JZFBx)?=@uvmL{<@(~`m6g|)pZwhU{+H#+ g=d0hm^=A7Bo&_c>N3{CE%C-L_Y58rj`tg;20l$XwaR2}S delta 243 zcmccjgm>$0-i8*&Elehhg)&kV64LUEauZ7u6v`5FN>huc+bm{M6nNAyMIlo!RZpP= zs3Jcvbvn-~Ciy@v1uX@a)S}F?)D(rZqWoO2Y;s0wa&|F@nOmHiQIAOY?3ZumI0Cq;!?HATCO<6?awor Nbs4v7W;2_z0|4U6RLuYY diff --git a/ts/dist/index.d.ts b/ts/dist/index.d.ts index db5b2456987fe22d97f8414cc10c0073d9ea1013..eb636a242be5caceedfd2aaca59538d24b1bc6d2 100644 GIT binary patch delta 1413 zcmZuxUu#@N6ek;`A`~e;SnA73D`ZzTE2vP6rLd$42Aged)+f>4vvY(MLRbrH}4J*l0Kq#$?3(2YE5 zX9qAYb$}TLnUu99i+_$g@6Wjrt%B?kp>ozV`xGAS@c#DR_Gr9)?{N!uA20w{+zHWZdvLcmn4?nNb*fOC z^%{7o*4^sObsjQMM?` z4SG@=Q~Rpt0BbJd%F3y!p*dGGJ8pE48yBb#o-i+QVh9TFDqzlmB}tRp9!Ugrw;a2} zmU}wVGy`Yh)`z$IVBBmYceW4}b7GOuS~7zk3|SA>?NV$|8j17NK#s;hMQ9TX*>PU} zoagYi31|D%pi71b^VX6mS|}>CB@Xb5-%qaJ&|&v1)1-FNMRZ%u7tep{yfYtj^Q+Xe zvZq&*fgDZXh$bUFg_5I{(j6?;p>*fY!?FtKJvLDDc_cQ&(LE9IDcMcPSl5(rXv6pA zS1IS@G0{(bE+EnJ}*Kkoh81(};3(JTg*MM<_Vl z?8{W~%&xX+f6nzzQC~L9%CB`^p1ROl3?+G)+yi+O7&y2B0$-zzJS0r{E-0OWtl>+o zo0JbB&D*wTc4G7)driAUHD9c+vcK%$ofhmNdf$V>r@ecjV``Ld~1PB0UjBNz~N^JZFBx)?=@uvmL{<@(~`m6g|)pZwhU{+H#+ g=d0hm^=A7Bo&_c>N3{CE%C-L_Y58rj`tg;20l$XwaR2}S delta 243 zcmccjgm>$0-i8*&Elehhg)&kV64LUEauZ7u6v`5FN>huc+bm{M6nNAyMIlo!RZpP= zs3Jcvbvn-~Ciy@v1uX@a)S}F?)D(rZqWoO2Y;s0wa&|F@nOmHiQIAOY?3ZumI0Cq;!?HATCO<6?awor Nbs4v7W;2_z0|4U6RLuYY diff --git a/ts/dist/index.js b/ts/dist/index.js index 447e4835d63bcc6fe8533f511bd508c44b7c6952..7c21a6f365becf6d2fec99c43f56fbeb480873fe 100644 GIT binary patch delta 364 zcmZ3{EVaHxs-cCkg{g(Pg=GtCVR?O8X z8ko{lsOC~oC`v6UEy`1H^AGZM42gI4aCP<$*2_pNhAFbENm;4M zC38om3H(}FJS<8C!#`e}Qwmwl6R38^B*eYPLf-Q%Qky#xhd_amZLv8!-Jhrt$0JoKf Aga7~l delta 241 zcmZ4ABDJDfs-cCkg{g(Pg=GtCVYx+FVv&NUZ-9@hud82(t4q9_e~_n7Vy#7~4$I?ddseUzoP{<+Cjm0sx?dPR{@U diff --git a/ts/dist/index.mjs b/ts/dist/index.mjs index 4ee05f646469c0261101a810d6497e5710211f8a..23933a1091f537eaf9d34da9d53a433c6300281f 100644 GIT binary patch delta 317 zcmeycSMt_A$%Yoj7N!>F7M3ln_loM%O7oISGV}8kGK)QP3vyC(Q}aqvQ{3{4auZ85 z(!i9aLN%9yLQ!f-X;Genn}3k6V@SNShpV%9uwF)DF-(y)S1lL9Fm$7eHBe0PPs&P7 zF40RX;!I7gO4W$g)6<()%qqejt5=*~RH89G@e;Ghbk$F7M3ln_lhja5{nc(eFJ=4eO>)RTwUVb{DXWQLxL4-74lNc z6@pVsG$11J&K|DL-obj=sg=bVnwr+r+lpB)$VThw>0!|wt5=*~RI)vzg!M18Bo=wS zjKpG%vdM8LWv7*}vQM8^!P-N>D4R-FldD3Q?%ba1$Ckvg{c#wZF5`C92sTr8061kz Ai~s-t diff --git a/ts/src/nlq.test.ts b/ts/src/nlq.test.ts index 69efd58..1fdfecc 100644 --- a/ts/src/nlq.test.ts +++ b/ts/src/nlq.test.ts @@ -17,7 +17,8 @@ import { validateAgainst, validateSemanticAction, DEFAULT_SENSE_WEIGHTS, - IMPLEMENTED_FORMATS, + implementedFormats, + isImplementedFormat, NLQ_LITERAL_TYPE, SEMANTIC_ACTION_CONTRACT, SEMANTIC_ACTION_SCHEMA, @@ -208,7 +209,7 @@ test('stored action definitions are frozen — a search cannot be mutated undern * 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 `IMPLEMENTED_FORMATS` would make the + * 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 @@ -232,7 +233,7 @@ function declaredFormats(node: unknown, into = new Set()): Set { test('the format accept-list is exactly the set of formats that have a check', () => { assert.deepEqual( - [...IMPLEMENTED_FORMATS].sort(), + [...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') }) @@ -302,6 +303,34 @@ test('date-time rejects impossible instants, not just the wrong shape', () => { 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', @@ -332,7 +361,7 @@ test('SemanticAction declares only formats that are enforced, and its uri format 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(IMPLEMENTED_FORMATS.has(f), `${f} is declared but not implemented`) + 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. diff --git a/ts/src/nlq.ts b/ts/src/nlq.ts index 60c7e1b..74cfd7e 100644 --- a/ts/src/nlq.ts +++ b/ts/src/nlq.ts @@ -137,10 +137,32 @@ const FORMAT_CHECKS: ReadonlyMap boolean; expect ]) /** - * The `format` values a vendored contract may declare — i.e. the ones `validateAgainst` implements. - * Derived from the checks themselves, so it cannot name one that does not exist. + * 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 const IMPLEMENTED_FORMATS: ReadonlySet = new Set(FORMAT_CHECKS.keys()) +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 @@ -152,7 +174,7 @@ export function matchesFormat(format: string, value: string): boolean { if (f === undefined) { throw new Error( `nlq: format '${format}' is not implemented by validateAgainst ` + - `(implemented: ${[...IMPLEMENTED_FORMATS].sort().join(', ')})`) + `(implemented: ${implementedFormats().join(', ')})`) } return f.check(value) } @@ -183,7 +205,8 @@ const ANNOTATION_KEYWORDS = new Set(['$schema', '$id', 'title', 'description', ' * * `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 be one of `IMPLEMENTED_FORMATS`. + * 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 } @@ -195,10 +218,10 @@ export function assertSupportedKeywords(schema: unknown, at: string): void { } if (k === 'items' || k === 'additionalProperties') { assertSupportedKeywords(v, `${at}.${k}`); continue } if (k === 'format') { - if (typeof v !== 'string' || !IMPLEMENTED_FORMATS.has(v)) { + 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: ${[...IMPLEMENTED_FORMATS].sort().join(', ')}) — ` + + `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') }