From f28c061d0d4c6c9512534058d72675c33c074b35 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:19:07 +0000 Subject: [PATCH 1/3] fix(core): close isCompatible() correctness holes for cross-app duck typing Require field.required === true on the candidate instead of mere presence (a candidate declaring a required field optional no longer passes), add a text/string read-compatibility table so the spec's own motivating example works, and recurse into object properties and array items so nested required-field mismatches are caught. Scope isCompatible() explicitly as read-compatibility in both the code and the spec, matching #54. Refs #54 --- docs/spec.md | 26 ++++++- packages/core/src/schema.ts | 50 +++++++++++-- packages/core/tests/schema.test.ts | 110 +++++++++++++++++++++++++++++ 3 files changed, 177 insertions(+), 9 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 8baf764..6441031 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -213,9 +213,29 @@ type StackType = { **Schema drift detection:** If two Records share a `typeId` but their Type definitions have different `schemaHash` values, that is unambiguously a bug — intentional changes always produce a new version number. -**Type compatibility:** Structural/duck-typed — a Type is compatible with a required schema if it contains all required fields with matching kinds. Used by apps that want to work with Records regardless of exact Type, e.g. any Type with `{ text: string }`: +**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, it declares that field as required with a read-compatible kind. This licenses *consuming* Records, not writing them: a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which `isCompatible()` never inspects). Used by apps that want to work with Records regardless of exact Type, e.g. any Type with a required `text` or `string` field: ```ts +const READ_COMPATIBLE: Record = { + string: ['string', 'text'], + text: ['text', 'string'], + number: ['number'], + boolean: ['boolean'], + date: ['date'], + 'record-ref': ['record-ref'], +}; + +function isFieldCompatible(candidate: FieldDef, required: FieldDef): boolean { + if (required.kind === 'array') { + return candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items); + } + if (required.kind === 'object') { + return candidate.kind === 'object' && isCompatible(candidate.properties, required.properties); + } + if (candidate.kind === 'array' || candidate.kind === 'object') return false; + return READ_COMPATIBLE[required.kind].includes(candidate.kind); +} + function isCompatible( candidateSchema: TypeSchema, // the Record's actual Type requiredSchema: TypeSchema, // minimum fields the app needs @@ -223,11 +243,13 @@ function isCompatible( return Object.entries(requiredSchema).every(([key, def]) => { if (!def.required) return true; const field = candidateSchema[key]; - return field !== undefined && field.kind === def.kind; + return field !== undefined && field.required === true && isFieldCompatible(field, def); }); } ``` +`string` and `text` are mutually read-compatible — both are strings at the value level, and the distinction is presentation/indexing intent. Every other kind pair requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. + Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()`. **System types** (reserved, library-defined): `_config@1`, `_entity@1`, `_app@1`, `_group@1`, `_grant@1`, `_attachment@1`. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is created via `Stack.create()` — they are always available without any setup by the caller. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index a9a278b..426b0fc 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -9,7 +9,7 @@ * the namespaced ID controlled by the app author. */ -import type { TypeSchema, FieldDef } from './types.js'; +import type { TypeSchema, FieldDef, ScalarFieldKind } from './types.js'; // ------------------------------------------------------- // Canonical schema serialization @@ -73,12 +73,48 @@ export const hashSchema = async (schema: TypeSchema): Promise => { // ------------------------------------------------------- /** - * Check whether a candidate schema satisfies a required schema. + * Kinds acceptable in a candidate field, per required kind. `string` and + * `text` share an identical value set (the distinction is presentation/ + * indexing intent, not data shape), so they're mutually acceptable for + * reading. Everything else requires an exact kind match — notably `date` + * is NOT compatible with `string`: it carries a parse/validity guarantee + * a plain string doesn't. + */ +const READ_COMPATIBLE: Record = { + string: ['string', 'text'], + text: ['text', 'string'], + number: ['number'], + boolean: ['boolean'], + date: ['date'], + 'record-ref': ['record-ref'], +}; + +/** + * Check whether a candidate field satisfies a required field, recursing + * into array items and object properties. + */ +const isFieldCompatible = (candidate: FieldDef, required: FieldDef): boolean => { + if (required.kind === 'array') { + return candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items); + } + if (required.kind === 'object') { + return candidate.kind === 'object' && isCompatible(candidate.properties, required.properties); + } + if (candidate.kind === 'array' || candidate.kind === 'object') return false; + return READ_COMPATIBLE[required.kind].includes(candidate.kind); +}; + +/** + * Check whether a candidate schema is read-compatible with a required schema: + * whether records of the candidate Type carry every field an app needs to + * *read*, at a kind the app can safely consume. This licenses consuming + * records, not writing them — a consumer writing through a "compatible" view + * still has to validate against the candidate's full schema. * - * A candidate is compatible if it contains all *required* fields from - * the required schema with matching kinds. Optional fields in the - * required schema are ignored. Array and object fields are matched - * shallowly — only the top-level kind is checked. + * A candidate is compatible if, for every *required* field in the required + * schema, the candidate declares that field as required with a read-compatible + * kind (see READ_COMPATIBLE). Optional fields in the required schema are + * ignored. Array and object fields recurse into their items/properties. * * Apps that need precise type matching should compare typeIds directly. * isCompatible() is for duck-typed consumption across types. @@ -87,7 +123,7 @@ export const isCompatible = (candidateSchema: TypeSchema, requiredSchema: TypeSc return Object.entries(requiredSchema).every(([key, def]) => { if (!def.required) return true; const field = candidateSchema[key]; - return field !== undefined && field.kind === def.kind; + return field !== undefined && field.required === true && isFieldCompatible(field, def); }); }; diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index 3435516..b49859b 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -149,6 +149,116 @@ describe('isCompatible', () => { const required: TypeSchema = { text: { kind: 'text', required: true } }; expect(isCompatible({}, required)).toBe(false); }); + + test('candidate declaring a required field as optional is not compatible', () => { + const candidate: TypeSchema = { title: { kind: 'string', required: false } }; + const required: TypeSchema = { title: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('candidate declaring a required field with no required flag at all is not compatible', () => { + const candidate: TypeSchema = { title: { kind: 'string' } }; + const required: TypeSchema = { title: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('text candidate satisfies a required string field', () => { + const candidate: TypeSchema = { body: { kind: 'text', required: true } }; + const required: TypeSchema = { body: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('string candidate satisfies a required text field', () => { + const candidate: TypeSchema = { body: { kind: 'string', required: true } }; + const required: TypeSchema = { body: { kind: 'text', required: true } }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('date candidate does not satisfy a required string field', () => { + const candidate: TypeSchema = { body: { kind: 'date', required: true } }; + const required: TypeSchema = { body: { kind: 'string', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('string candidate does not satisfy a required date field', () => { + const candidate: TypeSchema = { body: { kind: 'string', required: true } }; + const required: TypeSchema = { body: { kind: 'date', required: true } }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested required object property mismatch is not compatible', () => { + const candidate: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: false }, + }, + }, + }; + const required: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested object property satisfying required sub-field is compatible', () => { + const candidate: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + const required: TypeSchema = { + author: { + kind: 'object', + required: true, + properties: { + name: { kind: 'string', required: true }, + }, + }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test('nested array item kind mismatch is not compatible', () => { + const candidate: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'number' } }, + }; + const required: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'string' } }, + }; + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('nested array item kind text/string equivalence is compatible', () => { + const candidate: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'text' } }, + }; + const required: TypeSchema = { + tags: { kind: 'array', required: true, items: { kind: 'string' } }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); + + test("spec's motivating example: a text body satisfies a required string requirement", () => { + const candidate: TypeSchema = { + text: { kind: 'text', required: true }, + }; + const required: TypeSchema = { + text: { kind: 'string', required: true }, + }; + expect(isCompatible(candidate, required)).toBe(true); + }); }); // ------------------------------------------------------- From 428ccf8bd92cb817c5535f2edcd5f9b2d7b2aad9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:25:33 +0000 Subject: [PATCH 2/3] docs(core): replace inlined isCompatible() reproduction with prose + table The spec previously duplicated the runtime implementation in a code block, which is exactly what let it drift from the real function (the bug this PR fixes). Describe the rule in prose, present the kind compatibility relation as a table, and point to the source/tests as the single authoritative reference instead. --- docs/spec.md | 46 ++++++++++++---------------------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 6441031..2f45644 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -213,44 +213,22 @@ type StackType = { **Schema drift detection:** If two Records share a `typeId` but their Type definitions have different `schemaHash` values, that is unambiguously a bug — intentional changes always produce a new version number. -**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, it declares that field as required with a read-compatible kind. This licenses *consuming* Records, not writing them: a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which `isCompatible()` never inspects). Used by apps that want to work with Records regardless of exact Type, e.g. any Type with a required `text` or `string` field: +**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses *consuming* Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects). -```ts -const READ_COMPATIBLE: Record = { - string: ['string', 'text'], - text: ['text', 'string'], - number: ['number'], - boolean: ['boolean'], - date: ['date'], - 'record-ref': ['record-ref'], -}; - -function isFieldCompatible(candidate: FieldDef, required: FieldDef): boolean { - if (required.kind === 'array') { - return candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items); - } - if (required.kind === 'object') { - return candidate.kind === 'object' && isCompatible(candidate.properties, required.properties); - } - if (candidate.kind === 'array' || candidate.kind === 'object') return false; - return READ_COMPATIBLE[required.kind].includes(candidate.kind); -} +A field's kind is read-compatible with a required kind per this table (row = required kind, columns = candidate kinds accepted): -function isCompatible( - candidateSchema: TypeSchema, // the Record's actual Type - requiredSchema: TypeSchema, // minimum fields the app needs -): boolean { - return Object.entries(requiredSchema).every(([key, def]) => { - if (!def.required) return true; - const field = candidateSchema[key]; - return field !== undefined && field.required === true && isFieldCompatible(field, def); - }); -} -``` +| required → | `string` | `text` | `number` | `boolean` | `date` | `record-ref` | +| --- | --- | --- | --- | --- | --- | --- | +| `string` | ✓ | ✓ | | | | | +| `text` | ✓ | ✓ | | | | | +| `number` | | | ✓ | | | | +| `boolean` | | | | ✓ | | | +| `date` | | | | | ✓ | | +| `record-ref` | | | | | | ✓ | -`string` and `text` are mutually read-compatible — both are strings at the value level, and the distinction is presentation/indexing intent. Every other kind pair requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. +`string` and `text` are mutually read-compatible — both are strings at the value level, and the distinction is presentation/indexing intent. Every other kind requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. -Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()`. +Used by apps that want to work with Records regardless of exact Type, e.g. any Type with a required `text` or `string` field. Apps that care about semantics filter by exact `typeId`. Apps that want flexibility use `isCompatible()` — see `packages/core/src/schema.ts` for the authoritative implementation and `packages/core/tests/schema.test.ts` for its behavior under nesting and the string/text equivalence. **System types** (reserved, library-defined): `_config@1`, `_entity@1`, `_app@1`, `_group@1`, `_grant@1`, `_attachment@1`. System types follow the same versioned ID format as user-defined types and can evolve using the same migration mechanism. All six are pre-seeded when a Stack is created via `Stack.create()` — they are always available without any setup by the caller. From c8894e021bd97055fdc8eeb85a3fca262529b0a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:31:20 +0000 Subject: [PATCH 3/3] fix(core): bound isCompatible() recursion depth against malicious schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A candidate schema can originate from another app's Type definition — the untrusted side of duck-typed consumption — so deeply nested array/object fields could recurse unboundedly and overflow the call stack. Cap recursion at 32 levels (matching MAX_VALIDATION_DEPTH in validate.ts) and fail closed: past the limit, treat as incompatible rather than risk a crash. --- docs/spec.md | 18 ++++++------ packages/core/src/schema.ts | 44 ++++++++++++++++++++++-------- packages/core/tests/schema.test.ts | 27 ++++++++++++++++++ 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/docs/spec.md b/docs/spec.md index 2f45644..5fd0bcc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -213,18 +213,18 @@ type StackType = { **Schema drift detection:** If two Records share a `typeId` but their Type definitions have different `schemaHash` values, that is unambiguously a bug — intentional changes always produce a new version number. -**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses *consuming* Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects). +**Type compatibility:** Structural/duck-typed — a Type is **read-compatible** with a required schema if, for every required field, the candidate declares that same field as required, at a read-compatible kind. Array and object fields recurse: their `items`/`properties` must themselves be read-compatible. This licenses _consuming_ Records, not writing them — a consumer writing through a "compatible" view still has to validate against the candidate's full schema (its other required fields, which compatibility checking never inspects). A field's kind is read-compatible with a required kind per this table (row = required kind, columns = candidate kinds accepted): -| required → | `string` | `text` | `number` | `boolean` | `date` | `record-ref` | -| --- | --- | --- | --- | --- | --- | --- | -| `string` | ✓ | ✓ | | | | | -| `text` | ✓ | ✓ | | | | | -| `number` | | | ✓ | | | | -| `boolean` | | | | ✓ | | | -| `date` | | | | | ✓ | | -| `record-ref` | | | | | | ✓ | +| required → | `string` | `text` | `number` | `boolean` | `date` | `record-ref` | +| ------------ | -------- | ------ | -------- | --------- | ------ | ------------ | +| `string` | ✓ | ✓ | | | | | +| `text` | ✓ | ✓ | | | | | +| `number` | | | ✓ | | | | +| `boolean` | | | | ✓ | | | +| `date` | | | | | ✓ | | +| `record-ref` | | | | | | ✓ | `string` and `text` are mutually read-compatible — both are strings at the value level, and the distinction is presentation/indexing intent. Every other kind requires an exact match; notably `date` is not compatible with `string`, since `date` carries a parse/validity guarantee a plain string doesn't. diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 426b0fc..3d07af7 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -89,21 +89,47 @@ const READ_COMPATIBLE: Record = { 'record-ref': ['record-ref'], }; +// Candidate schemas can come from another app's Type definition (the +// untrusted side of duck-typed consumption), so recursion into array +// items / object properties is depth-bounded — matches MAX_VALIDATION_DEPTH +// in validate.ts. Past the limit we can't verify compatibility, so we +// fail closed (treat as incompatible) rather than risk a stack overflow. +const MAX_COMPATIBILITY_DEPTH = 32; + /** * Check whether a candidate field satisfies a required field, recursing * into array items and object properties. */ -const isFieldCompatible = (candidate: FieldDef, required: FieldDef): boolean => { +const isFieldCompatible = (candidate: FieldDef, required: FieldDef, depth: number): boolean => { + if (depth > MAX_COMPATIBILITY_DEPTH) return false; if (required.kind === 'array') { - return candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items); + return ( + candidate.kind === 'array' && isFieldCompatible(candidate.items, required.items, depth + 1) + ); } if (required.kind === 'object') { - return candidate.kind === 'object' && isCompatible(candidate.properties, required.properties); + return ( + candidate.kind === 'object' && + isCompatibleAtDepth(candidate.properties, required.properties, depth + 1) + ); } if (candidate.kind === 'array' || candidate.kind === 'object') return false; return READ_COMPATIBLE[required.kind].includes(candidate.kind); }; +const isCompatibleAtDepth = ( + candidateSchema: TypeSchema, + requiredSchema: TypeSchema, + depth: number, +): boolean => { + if (depth > MAX_COMPATIBILITY_DEPTH) return false; + return Object.entries(requiredSchema).every(([key, def]) => { + if (!def.required) return true; + const field = candidateSchema[key]; + return field !== undefined && field.required === true && isFieldCompatible(field, def, depth); + }); +}; + /** * Check whether a candidate schema is read-compatible with a required schema: * whether records of the candidate Type carry every field an app needs to @@ -114,18 +140,14 @@ const isFieldCompatible = (candidate: FieldDef, required: FieldDef): boolean => * A candidate is compatible if, for every *required* field in the required * schema, the candidate declares that field as required with a read-compatible * kind (see READ_COMPATIBLE). Optional fields in the required schema are - * ignored. Array and object fields recurse into their items/properties. + * ignored. Array and object fields recurse into their items/properties, up + * to MAX_COMPATIBILITY_DEPTH. * * Apps that need precise type matching should compare typeIds directly. * isCompatible() is for duck-typed consumption across types. */ -export const isCompatible = (candidateSchema: TypeSchema, requiredSchema: TypeSchema): boolean => { - return Object.entries(requiredSchema).every(([key, def]) => { - if (!def.required) return true; - const field = candidateSchema[key]; - return field !== undefined && field.required === true && isFieldCompatible(field, def); - }); -}; +export const isCompatible = (candidateSchema: TypeSchema, requiredSchema: TypeSchema): boolean => + isCompatibleAtDepth(candidateSchema, requiredSchema, 0); // ------------------------------------------------------- // Type ID parsing diff --git a/packages/core/tests/schema.test.ts b/packages/core/tests/schema.test.ts index b49859b..b0b3e80 100644 --- a/packages/core/tests/schema.test.ts +++ b/packages/core/tests/schema.test.ts @@ -259,6 +259,33 @@ describe('isCompatible', () => { }; expect(isCompatible(candidate, required)).toBe(true); }); + + test('deeply nested matching schemas beyond the depth limit are not compatible (fails closed)', () => { + const buildNested = (depth: number): TypeSchema => { + let schema: TypeSchema = { leaf: { kind: 'string', required: true } }; + for (let i = 0; i < depth; i++) { + schema = { nested: { kind: 'object', required: true, properties: schema } }; + } + return schema; + }; + const candidate = buildNested(1000); + const required = buildNested(1000); + expect(() => isCompatible(candidate, required)).not.toThrow(); + expect(isCompatible(candidate, required)).toBe(false); + }); + + test('matching schemas within the depth limit remain compatible', () => { + const buildNested = (depth: number): TypeSchema => { + let schema: TypeSchema = { leaf: { kind: 'string', required: true } }; + for (let i = 0; i < depth; i++) { + schema = { nested: { kind: 'object', required: true, properties: schema } }; + } + return schema; + }; + const candidate = buildNested(10); + const required = buildNested(10); + expect(isCompatible(candidate, required)).toBe(true); + }); }); // -------------------------------------------------------