Skip to content

Class constraints: nested JSON Schema for json and array values - #2220

Merged
joepio merged 4 commits into
developfrom
claude/project-thread-3oq7ug
Oct 12, 2026
Merged

joepio merged 4 commits into
developfrom
claude/project-thread-3oq7ug

Conversation

@joepio

@joepio joepio commented Oct 11, 2026

Copy link
Copy Markdown
Member

Before: a Class's constraints entry could only check one level of a value (minimum, pattern, enum, minItems and so on). A JSON Schema with properties, items of objects, nullable type: [X, "null"] or anyOf below a property was refused on import, so apps with structured json values (Atomic Audio's tracks, for example) had to keep their own validator next to the ontology.

After: a constraint can describe nested values with type (including nullable), const, properties, required, additionalProperties (bool or schema), items and anyOf, up to 16 levels deep. Rust and TypeScript check them identically, and an error names the failing spot as a JSON pointer (... breaks required at /tracks/0: missing property "name"). JSON Schema import keeps these schemas instead of refusing them, and export writes them back.

This replaces the parallel frozen-schema API from the closed #2045 (frozen app schemas) with the ordinary ontology model from #2151 (content-addressed properties, class constraints, JSON Schema interop), as agreed: schemas stay ordinary ontologies.

How: lib/src/class_constraints.rs and browser/lib/src/class-constraints.ts get a recursive parse_at/check_at with a depth limit. Top-level behaviour is unchanged (an enum on an array still checks each item; nested enum compares the whole value). class is refused inside a nested schema. Where several classes constrain one property, nested keywords are not merged: the first class wins (documented). lib/src/schema/json_schema.rs and schema-json-schema.ts read nested schemas through read_nested. Still refused, by pointer: oneOf, nested $ref, nested format, tuple items, patternProperties, not, if, allOf, and anyOf on a class. The form builder's moveClassConstraint now moves the raw stored JSON instead of a parsed Constraint, which would have written RegExp objects.

Version skew: an older peer that reads a class with the new keywords cannot parse that class's constraints and skips them (existing behaviour for unparseable maps), so it does not enforce them but does not reject the data either.

Related Issues

Follows #2151 (ontology schemas: content-addressed properties, class constraints, JSON Schema). Needed to move Atomic Audio off #2045 (frozen app schemas, closed).

Checklist

  • Add changelog entry linking to issue, describe API changes
  • Add or update tests if needed (about 75 shared fixture cases in lib/tests/fixtures/class-constraints.json, nested round trips in json-schema-interop.json, run by both Rust and TS)
  • Update docs if needed (docs/src/schema/classes.md, docs/src/schema/json-schema.md)

Local: cargo test -p atomic_lib --features db-redb --lib 794 passed (two redb "Database already open" lock flakes pass on rerun), cargo test -p atomic-server --lib 387 passed, clippy and fmt clean, browser/lib 1637 tests, lint and tsc clean, data-browser typecheck and lint clean.


Generated by Claude Code

A Class's constraints entry can now describe nested values with type
(including nullable), const, properties, required, additionalProperties,
items and anyOf, up to 16 levels deep, checked identically in Rust and
TypeScript with JSON pointer error paths. JSON Schema import keeps nested
schemas, nullable types and anyOf below a property instead of refusing
them, and export writes them back.
@joepio joepio self-assigned this Oct 11, 2026

joepio commented Oct 11, 2026

Copy link
Copy Markdown
Member Author

Run 5220 (Main (Mancave) / CI) failed on one test: browser/lib/tests/agent-profile-sync.integration.test.ts > "shows the owner name and renames on a server that only got the drive" got undefined instead of 'Old name' at line 84.

I don't think this PR caused it. The test writes an agent profile through ensurePrivateDrive and then reads it back after a fixed delay(1500). Agent profiles have no class constraints, and this PR only changes how a constraints map is parsed and checked. The fixed sleep would miss the write if Mancave was under load. Every other unit, lint and build stage passed, and so did the atomic-saas downstream build. I'm asking for one re-run to confirm. If it fails again, I'll treat it as this PR's and dig in.


Generated by Claude Code

joepio commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Complexity review at head 4ecd556 (read the patches of all 12 files, not the fixture contents, the docs patches or the unchanged surrounding code). The design is sound: it extends the existing schema/json_schema.rs and schema-json-schema.ts instead of adding a second JSON Schema layer, and nested schemas stay plain JSON in the class constraints, so they remain an ordinary ontology. The shared fixtures run on both sides, which holds the Rust/TS drift risk.

One thing to settle before merge: old readers. KEYWORDS is a closed list that rejects unknown keywords. The PR body says an old peer skips "that class's constraints". If that means the class's whole constraint map, then plain keywords on the same class (minimum and so on) also stop being enforced on old peers. Please confirm the granularity and say so in the docs and changelog.

Also worth a line in the PR: TS plain() (class-constraints.ts ~345) now unwraps {'@id': x} only when it is the sole key. That changes top-level behaviour for link objects with extra keys; add a fixture or a note.

Simplification (optional):

  1. type is validated three times on each side: class_constraints.rs ~150 (parse_at), json_schema.rs read_nested (~400) and read_property (~720), and the same in TS. read_nested also ends by parsing its own output, so its hand-written type/enum pre-checks mostly duplicate that; dropping them saves about 40 lines per side.
  2. read_nested_keywords_except_items (json_schema.rs ~500) is a one-line wrapper over read_nested_keywords(..., 0, false, ...); inline it.
  3. SCHEMA_TYPES in the importer repeats TYPES from class_constraints; export one.
  4. The MAX_DEPTH = 16 guard in check_at (class_constraints.rs ~640, TS checkAt) cannot be reached by any constraint that came through the parser; remove it or reduce it to a comment.

Generated by Claude Code

Older nodes reject the nested keywords on write and skip the whole class
when they read it. The JSON Schema importers reuse the constraint type
list, the one-line keyword wrapper is inlined, and plain() says why only
a bare @id link is unwrapped.

joepio commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Thanks. These are addressed in 1774f4a.

Old readers. Yes, it's the whole map. An old node can't parse a class's constraints, so check_resource skips that class entirely, and its flat keywords (minimum, pattern) aren't enforced there either. Other classes are unaffected. An old server also refuses a commit that writes the new keywords into a class (validate_constraints_prop, "Unknown constraint keyword"). Both points are now in docs/src/schema/classes.md under Enforcement and in the CHANGELOG line.

plain(). This is on purpose. Only a bare {'@id': x} link is unwrapped. An object with more keys is data (a nested resource, or JSON that has an @id key) and is checked as an object, which matches Rust: value_to_json turns a nested resource into an object, not its subject. There's a comment on plain() now. I didn't add a shared fixture because the fixtures feed plain JSON to both checkers, and Rust has no link unwrapping to compare against.

Simplifications. I did 2 and 3: the wrapper is inlined, and the importers reuse the constraint type list (TYPES / CONSTRAINT_TYPES). I kept 1 because the importer's own type/enum checks report the exact pointer (.../type), while the final parse only names the node. I kept 4 because Constraint is a public struct that Rust callers can build without the parser, so the check-time guard is the only bound on recursion there.


Generated by Claude Code

joepio commented Oct 12, 2026

Copy link
Copy Markdown
Member Author

Run 5231 on 1774f4a: the units, lint, build and the atomic-saas downstream all passed. E2E went red on all 4 shards with 7 failed and 7 flaky tests out of about 340.

I don't think this PR caused it. The failures have no common feature. Most die in devDrive() waiting for /app/dev-drive to reach a drive (page.waitForURL 30 s timeout at test-utils.ts:641), in aggregates, calendar, delete resource, ai-chat-log-migration and others. The perf budgets add a 408 (Request Timeout) from the server. That is the dev-drive hang develop is already chasing (df26ebf, "e2e: say why devDrive never reached the drive", lists tables, canvas-undo, ai-chat-log-migration and others with the same timeout). No failure names a constraint error. This PR only changes how a class's constraints map is parsed and checked, and a fresh dev drive carries no class with nested constraints.

I've asked for one re-run when Mancave is quiet. If the same tests fail again, I'll treat them as this PR's and dig in.


Generated by Claude Code

@joepio
joepio marked this pull request as ready for review October 12, 2026 15:57
@joepio
joepio merged commit 1aa9ee9 into develop Oct 12, 2026
8 checks passed
@joepio
joepio deleted the claude/project-thread-3oq7ug branch October 12, 2026 15:57
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