Skip to content

fix(schema): terminate recursive schema generation, stabilize JSON keys across targets - #41

Merged
dbrattli merged 2 commits into
mainfrom
fix/schema-recursion-and-key-stability
Aug 1, 2026
Merged

dbrattli merged 2 commits into
mainfrom
fix/schema-recursion-and-key-stability

Conversation

@dbrattli

@dbrattli dbrattli commented Aug 1, 2026

Copy link
Copy Markdown
Collaborator

Two reflection-driven defects, both invisible to the existing suite because every
record it exercised was either non-recursive or single-word.

1. Recursive types blew the stack

schemaForRecord is defined in terms of its fields' schemas, so a record that
reaches itself had no base case:

type Tree = { Label: string; Children: Tree list }
jsonSchema (auto<Tree> ())   // stack overflow

schemaForType/schemaForRecord now thread a visited chain of record
FullNames from the root, and a record already on that chain truncates to
{"type": "object", "title": "Tree"} instead of descending again.

The chain is root-to-node, not a global accumulator — sibling fields of the
same type each still expand fully. The diamond test is what pins that; a global
visited set would truncate Right.

Truncation rather than $ref/$defs: these schemas feed LLM tool-call
validators, which handle flat schemas far better than refs. The cost is that a
recursive type's schema is lossy past the first cycle.

Public signatures are unchanged — the recursive pair went private behind
wrappers that seed an empty chain.

On BEAM this was masked until Fable 5.8.1, where typeof<Tree> killed the
compiler (Fable#4766)
before the emitter was reached. It has always been reachable on .NET, Python
and JS.

2. JSON keys tracked the backend, not the F# field name

PropertyInfo.Name does not report the same string on every Fable target, and
what it reports has changed over time:

Target Reports Since
.NET, JS AirTemperature always
BEAM snake_case → AirTemperature 5.8.1 (#4766)
Python snake_case → AirTemperature 5.14.0 (#4852)

lowerFirstTransform — backing dump / validateJson / validateMap, which
key off the field name directly instead of going through CaseRules —
lowercased the first character and returned an all-lowercase name unchanged. So
it tracked whichever spelling the compiler produced: Python emitted
air_temperature where BEAM, JS and .NET emitted airTemperature
, despite the
function's own doc comment promising stability across targets.

applyCaseRule already solved this by pivoting through canonical PascalCase, but
the two key paths didn't share the pivot. It's hoisted into Casing.fs
(compiled before Schema.fs, which compiles before Json) and both now route
through it. A JSON key is a function of the F# field name alone.

Breaking on Python

dump / validateJson / validateMap with multi-word fields now emit
airTemperature, not air_temperature. Fable 5.14.0 changes those keys anyway
via #4852 — this lands the
same wire format one Fable version earlier, so the format doesn't shift under
consumers mid-upgrade. The CaseRules codec API is unaffected; applyCaseRule
already normalized.

The snake_case reverse is lossy for adjacent capitals (HTTPStatus →
httpstatus → Httpstatus); only #4852 recovers those.

Tests

  • Recursive types terminate (4): self-reference via list, via option (and
    still optional in required), mutual recursion truncating at the second hop,
    and the diamond that pins visited to the path.
  • Multi-word field keys (4): dump, validateJson, validateMap, and a
    dump → validateJson round-trip. Each guards the two spellings the backends
    drifted to. These three fail on main under Fable 5.13.0.

Also refreshes the stale comments that asserted the old BEAM snake_case
behaviour (Schema.fs, TypedJson.fs, CLAUDE.md) — they were what made me
misread the Python side in the first place.

just test: BEAM 174/174 · Python 174/174 · JS 173 + 1 skipped · .NET 170 + 4
skipped
. Fantomas clean.

🤖 Generated with Claude Code

dbrattli and others added 2 commits August 1, 2026 23:29
`schemaForRecord` is defined in terms of its fields' schemas, so a record
that reaches itself had no base case and recursed until the stack blew:

    type Tree = { Label: string; Children: Tree list }
    jsonSchema (auto<Tree> ())   // stack overflow

Thread a `visited` chain of record FullNames from the root and truncate a
record already on that chain to `{"type": "object", "title": "Tree"}`.

The chain is root-to-node, not a global accumulator, so sibling fields of
the same type each still expand fully — the diamond test pins that.

Truncation rather than `$ref`/`$defs`: this library's schemas feed LLM
tool-call validators, which handle flat schemas far better than refs. The
cost is that a recursive type's schema is lossy past the first cycle.

Public signatures are unchanged — the recursive pair is now private behind
`schemaForType`/`schemaForRecord` wrappers that seed an empty chain.

On BEAM this was masked until Fable 5.8.1: `typeof<Tree>` killed the
compiler (fable-compiler/Fable#4766) before the emitter was reached. It has
always been reachable on .NET, Python and JS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`PropertyInfo.Name` does not report the same string on every Fable target,
and what it reports has changed over time:

  .NET, JS      AirTemperature    always
  BEAM          air_temperature   until Fable 5.8.1  (Fable#4766)
  Python        air_temperature   until Fable 5.14.0 (Fable#4852)

`lowerFirstTransform` — backing `dump`, `validateJson` and `validateMap`,
which key off the field name directly instead of going through CaseRules —
lowercased the first character and returned an all-lowercase name unchanged.
So it tracked whichever spelling the compiler produced: Python emitted
`air_temperature` where BEAM, JS and .NET emitted `airTemperature`.

`applyCaseRule` already solved this by pivoting through canonical
PascalCase, but the two key paths did not share the pivot. Hoist it into
`Casing.fs` (compiled before `Schema.fs`, which compiles before `Json`) and
route both through it. A JSON key is now a function of the F# field name
alone — identical on all four targets and stable across the Fable upgrade.

BREAKING on Python for `dump`/`validateJson`/`validateMap` with multi-word
fields: keys become `airTemperature`, not `air_temperature`. Fable 5.14.0
changes those keys anyway via Fable#4852; this lands the same wire format
one Fable version earlier so it does not shift under consumers mid-upgrade.

The snake_case reverse is lossy for adjacent capitals (`HTTPStatus` ->
`httpstatus` -> `Httpstatus`); only Fable#4852 recovers those.

Tests cover multi-word keys over dump/validateJson/validateMap plus a
round-trip — every existing record in TestSchema.fs was single-word, where
the transform is a no-op and could not catch the drift.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dbrattli
dbrattli merged commit 9a1c1d7 into main Aug 1, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant