From 5c54e128b976efff2166aea1bfd67ca1ccd1e15c Mon Sep 17 00:00:00 2001 From: Jakub Stok Date: Mon, 31 Aug 2026 09:39:03 +0200 Subject: [PATCH] fix(validator): warn instead of failing when an RPC option list cannot see a reference value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A validator resolving an RPC-backed option store does not necessarily hold every placeholder the store URL interpolates. `CallSubscenario`'s scenario field fetches `GetScenarios?teamId={{teamId}}&scenarioId={{scenarioId}}`, and a caller without `scenarioId` asks a narrower question than the picker that offered the value — so a real, active scenario comes back absent and the write is rejected as `Value 'SCN_...' not found in options.` Measured in production over 14 days: 652 such rejections of well-formed `SCN_` values across 195 threads, re-sent identically 73% of the time (against 14% for every other value shape) because the value genuinely is correct. Treat a reference type (scenario, datastore, udt, hook, ...) whose options came from an RPC the same way `allowDynamicValues` already treats the mapper domain: warn, keep the value, let the editor surface it as a setup error. A false rejection is unrecoverable for the user; a false acceptance is not. Non-reference selects keep hard validation. Co-Authored-By: Claude Opus 5 --- src/validator.ts | 21 +++++++++++++++++---- test/scenario.spec.ts | 22 +++++++++++++++++++--- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/validator.ts b/src/validator.ts index c11245a..f5e7e95 100644 --- a/src/validator.ts +++ b/src/validator.ts @@ -39,6 +39,19 @@ function fieldAllowsCustomValue(field: FormanSchemaField): boolean { return field.mappable === true || field.editable === true; } +/** + * Whether a value missing from an RPC-resolved option list should warn instead of fail. + * + * Reference types are included because the caller cannot be relied on to hold every placeholder a + * store URL interpolates: `GetScenarios?teamId={{teamId}}&scenarioId={{scenarioId}}` resolves + * against whatever context the caller has, so a validator lacking `scenarioId` asks a narrower + * question than the picker that offered the value and legitimately fails to see it (MAIA-1311). + * Rejecting there is unrecoverable for the user; a warning still surfaces as a setup error. + */ +function unresolvedOptionIsTolerable(field: FormanSchemaField, root: DomainRoot): boolean { + return root.allowDynamicValues || fieldAllowsCustomValue(field) || isReferenceType(field.type); +} + /** * Context for schema validation operations */ @@ -949,7 +962,7 @@ async function handlePathType(value: unknown, field: FormanSchemaField, context: if (!selectedOption) { if ( optionsFromRPC && - (context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field)) + unresolvedOptionIsTolerable(field, context.roots[context.domain]!) ) { warnings.push({ domain: context.domain, @@ -1078,7 +1091,7 @@ async function handleSelectType( optionsOrGroups as FormanSchemaSelectOptionsStore, ); if (!found) { - (optionsFromRPC && (context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field)) + (optionsFromRPC && unresolvedOptionIsTolerable(field, context.roots[context.domain]!) ? warnings : errors ).push({ @@ -1092,7 +1105,7 @@ async function handleSelectType( if ( optionsFromRPC && - (context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field)) && + unresolvedOptionIsTolerable(field, context.roots[context.domain]!) && hasUnresolvedValue ) { context.roots[context.domain]!.fieldStates.push({ @@ -1143,7 +1156,7 @@ async function handleSelectType( if (!item) { if ( optionsFromRPC && - (context.roots[context.domain]!.allowDynamicValues || fieldAllowsCustomValue(field)) + unresolvedOptionIsTolerable(field, context.roots[context.domain]!) ) { warnings.push({ domain: context.domain, diff --git a/test/scenario.spec.ts b/test/scenario.spec.ts index dc67709..786b06d 100644 --- a/test/scenario.spec.ts +++ b/test/scenario.spec.ts @@ -62,9 +62,11 @@ describe('scenario type', () => { const valid = await validateForman({ scenario: 'SCN_34174' }, prodSchema, { resolveRemote }); expect(valid.valid).toBe(true); - const invalid = await validateForman({ scenario: 'SCN_00000' }, prodSchema, { resolveRemote }); - expect(invalid.valid).toBe(false); - expect(invalid.errors[0]!.message).toBe("Value 'SCN_00000' not found in options."); + // The validator resolves this store without the `scenarioId` the picker supplies, so a real + // scenario can be absent from the list it sees. Warn rather than block (MAIA-1311). + const unseen = await validateForman({ scenario: 'SCN_00000' }, prodSchema, { resolveRemote }); + expect(unseen.valid).toBe(true); + expect(unseen.warnings[0]!.message).toBe("Value 'SCN_00000' not found in options."); // Conversion carries rpc:// URL and custom keys const jsonSchema = toJSONSchema({ type: 'collection', spec: prodSchema }); @@ -78,4 +80,18 @@ describe('scenario type', () => { label: 'label', }); }); + it('should still hard-error for a non-reference select resolved from the same RPC', async () => { + const schema: FormanSchemaField[] = [ + { + name: 'mode', + type: 'select', + options: { store: 'rpc://scenario-service/2.15.6/GetModes?teamId={{teamId}}' }, + }, + ]; + const resolveRemote = async () => [{ value: 'fast', label: 'Fast' }]; + + const invalid = await validateForman({ mode: 'nope' }, schema, { resolveRemote }); + expect(invalid.valid).toBe(false); + expect(invalid.errors[0]!.message).toBe("Value 'nope' not found in options."); + }); });