From 1be83e9b64c2faf9e9c29a3a6eb9cf0d1e33bbd7 Mon Sep 17 00:00:00 2001 From: "Henry H. Andrews" Date: Wed, 23 Sep 2026 11:18:13 -0700 Subject: [PATCH 1/3] Treat retrieval aliases and bundled metaschema URIs as claims (P15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P12 refused two schemas claiming one resource URI but left three ways a URI could still be shadowed silently: - An `$id` equal to another document's retrieval URI registered, and was unreachable: lookups prefer the alias. - A retrieval URI reused for a different document silently repointed its alias. - Claiming a bundled metaschema's URI with different content succeeded on a fresh engine and failed once the metaschema had been used. All three are `DuplicateResourceError` now. The first two follow the TypeScript engine's ADR 0005. For the third, the owner ruled that bundled URIs are reserved for content equal to the bundled document, checked against `_bundled` before lazy registration so the answer no longer depends on order. A custom metaschema goes under its own URI. Resolves DESIGN.md §7 "P12 has three gaps where an identifier still shadows silently". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 11 ++-- DESIGN.md | 35 +++------- docs/reference.md | 7 +- src/json_schema_engine/core/registry.py | 45 ++++++++++++- tests/core/test_registry.py | 87 ++++++++++++++++++++++++- 5 files changed, 150 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9779dba..9075bed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,11 +44,12 @@ minor versions may change public API. a *modified* document can no longer be re-registered under its URI — there is not yet a way to replace or unregister one, so an edit-and-re-register loop needs a fresh engine (DESIGN.md §7, "No way to replace a registered - document"). **Known gaps:** three shapes still shadow silently — an embedded - `$id` equal to another document's retrieval URI, a retrieval URI reused for - a different document, and a bundled metaschema's URI, which can be claimed - only before that metaschema is first used (DESIGN.md §7, "P12 has three gaps - where an identifier still shadows silently"). + document"). Retrieval URIs and bundled metaschema URIs are claims too + (DESIGN.md P15): an `$id` equal to another document's retrieval URI, a + retrieval URI reused for a different document, and a bundled metaschema's + URI claimed with different content are all `DuplicateResourceError`. The + last used to succeed on a fresh engine and fail once the metaschema had been + used. - **Registration is now all-or-nothing (DESIGN.md P13).** A document whose registration raises leaves the registry exactly as it found it, so an engine stays usable after a caught registration error. Previously the half-indexed diff --git a/DESIGN.md b/DESIGN.md index 3a5511a..f5454b6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -91,6 +91,7 @@ not the intent), **N/A** (JavaScript-only). | P12 | Duplicate identifiers are errors | Two different schemas may not claim one resource URI, and two different schema objects may not claim one anchor name within a resource. `DuplicateResourceError` covers a single document minting an `$id` twice and a later registration rebinding a URI an earlier one bound to a *different* schema (`json_equal`, so re-registering an equal document stays a no-op). `DuplicateAnchorError` covers `$anchor`, `$dynamicAnchor`, and the draft-07/06 `$id: "#name"` form alike, keyed on the `_anchors` entry that all three write; one object carrying both anchor kinds under one name claims the same key with the same target and is allowed. Claims are scoped to one registration walk, so a re-registered resource rewrites its own anchors without a false positive, and a `$ref` sibling under draft-07/06 claims nothing because it suppresses identifiers (D18). | Both were silent last-write-wins, so one schema shadowed the other and a reference resolved to whichever the walk reached last. The anchor case was worse than shadowing: because a dynamic anchor is also a plain anchor (D8), `$anchor: "n"` on one object and `$dynamicAnchor: "n"` on another left `$ref` and `$dynamicRef` resolving the same fragment to *different* schemas. The spec is silent on duplicate `$id` and deems a duplicate anchor undefined behavior an implementation MAY reject; the owner deems rejection correct for both. Duplicate `$id` is also what made the P11 parent relation cyclic. | A schema in the wild proves a duplicate is load-bearing somewhere (none known; the vendored suite, the bundled metaschemas, and every repo fixture are clean). | | P13 | Registration is all-or-nothing | `SchemaRegistry.register` journals every index write and undoes them if anything escapes, so a document that fails leaves the registry exactly as it found it. A `_Registration` holds `(index, key, prior)` for the seven dict indexes and `(index, member)` for the four set ones, recording only genuinely new members because `_produced_ids`/`_consumed_ids` are unions. The undo restores prior values rather than deleting keys: a walk legitimately rebinds entries an earlier registration owns, and `_documents` order is what `resources()` promises. `except BaseException`, since `_step` raises bare `KeyError`/`TypeError`, `RecursionError` can fire anywhere, and caller code runs inside the walk. Not journaled: `_reference_memo` (a derived cache, cleared on both paths), the P12 claim sets (per-walk scratch), and the regex cache (keyed by pattern, a function of a fixed dialect and backend). On the way out the registry fills the error's `location_chain` (P11) and `schema_source` (D17) *before* the undo — the only moment the indexes they derive from still exist — and `_canonical` refuses to start a lazy bundled registration while a journal is live. | The half-indexed document left behind was still evaluable, and its partial `produces`/`consumes` fed D5's elision predicate, so the failure surfaced as a wrong answer rather than a loud one. P12 added two more ways to fail mid-walk. Journaling rather than snapshotting keeps the cost O(this document's writes) with no term in registry size, which matters because every lookup may lazily register a bundled metaschema; measured, registration is unchanged (1.27 ms either way on the OAS 3.1 corpus). | A caller needs a *successful* registration undone: that wants `unregister`, and an answer for resources an earlier registration also claims. | | P14 | Per-resource dialects | `$schema` governs the schema resource it roots, not the document (2020-12 core §8.1.1). `_walk` rebinds its `dialect` local wherever it rebinds `base_uri`, so the inner dialect supplies the keyword table, `ref_ignores_siblings`, and the identifiers minted into that resource, and `_document_dialects` records it. **The boundary is decided from the outside, the contents from the inside:** `base_id` comes from the *parent* extractor — its `$id` syntax is what decides a resource starts here at all, and a relative `$schema` has no base to resolve against until it has — while `anchors`, `dynamic_anchor` and `recursive_anchor` are re-read with the inner one. Honored under every dialect; draft-07/06 are silent on the placement rather than prohibitive. A `$schema` where no resource starts is **ignored**, not refused: the spec forbids the placement, but refusing it is strict-mode hygiene, which D14 keeps opt-in, and `{"$schema": X, "not": {"$schema": X}}` is how Bowtie spells "allows nothing" for every dialect it tests. Only the walk can discover an embedded `$schema`, and the registry holds no loaders, so it raises `UnknownDialectError` carrying `dialect_uri` and the engine assembles that dialect and registers again; P13 rollback is what makes the retry start from the state the first attempt found. Pointer navigation in `resolve_ref`/`child` refreshes its extractor at each base change through a non-raising lookup, so a base the walk never indexed falls back to the dialect in force rather than failing. | The walk threaded the document root's dialect through the whole recursion, so the bug cut both ways: a valid draft-07 resource embedded in a 2020-12 document was rejected for array-form `items`, and a 2020-12 resource embedded in a draft-07 document had `$id: "#foo"` accepted as an anchor. It also mislabeled any node reached by a pointer that crossed an `$id` under the wrong extractor as its resource's root. Evaluation and the compiler already keyed every dialect lookup on the unit's own base URI, so the walk was the only tier disagreeing — with them and with itself. | A dialect needs to *forbid* an embedded `$schema`: that wants a `Dialect` field beside `identifiers` and `ref_ignores_siblings`. A lint layer under D14 wants to flag a misplaced `$schema`, which this deliberately does not. | +| P15 | Aliases and bundled URIs are claims | P12's one-resource-one-schema rule extends to the two other ways a URI can be bound. A **retrieval alias** is consulted before the resource index, so an `$id` equal to an existing alias, or a retrieval URI that already names a resource or aliases a different one, would leave one of them unreachable; each is a `DuplicateResourceError` (`_claim_resource`, `_check_alias`). A **bundled metaschema's URI** is reserved (owner, 2026-09-23): it may be claimed only with content `json_equal` to the bundled document, checked against `_bundled` when it has not been lazily registered yet, so the answer no longer depends on whether the metaschema has been used. A custom metaschema goes under its own URI, as custom dialects already do. Matches the TypeScript engine's ADR 0005, which registers its metaschemas eagerly and so reaches the same answer without the `_bundled` check. | ## 2. System shape @@ -697,28 +698,6 @@ since the emitted code is the same for every level. twice deliberately. -10. **P12 has three gaps where an identifier still shadows silently** — all - found reviewing P12, none covered by its tests: - - - An embedded `$id` equal to another document's *retrieval URI* is - claimed and then unreachable. `_claim_resource` checks `_documents` - only, while `_canonical` prefers `_aliases`, so - `{"$id": "https://a/", ...}` registered as `https://r/` followed by a - document embedding `{"$id": "https://r/", "type": "string"}` registers - cleanly and every lookup of `https://r/` answers the first document. - Measured: `$ref: "https://r/"` validates a number, not a string. - - Claiming a bundled metaschema's URI is order-dependent. On a fresh - engine `register_schema({"$id": DIALECT_2020_12, ...})` succeeds and - shadows the bundled document (`_canonical` sees it in `_documents` and - never lazily registers the real one); after any evaluation that - touched the metaschema, the same call raises `DuplicateResourceError`. - Either answer may be right — a caller overriding a metaschema is a - real use — but it must be the same answer both times. - - A retrieval-URI alias is still last-write-wins: registering a second - document with a *different* `$id` under a retrieval URI an earlier one - used silently repoints `_aliases[retrieval]`. Not a resource - collision, so P12 does not see it, but it is the same shadowing shape. - 11. **No way to replace a registered document.** P12 turns a modified re-registration under the same URI into `DuplicateResourceError`, and nothing unregisters. The edit-and-re-register loop (a REPL, a test that @@ -744,11 +723,8 @@ since the emitted code is the same for every level. document. Doing this needs per-document ownership tracking (the TypeScript engine keeps `documentUri → resources claimed`, with `resourceLocations` as the truth for who owns what now), which the - registry does not have yet, and it is the natural moment to close the - alias gaps in "P12 has three gaps …" above the way ADR 0005 does: an - `$id` equal to an existing retrieval alias, or a retrieval URI that - already names or aliases a different resource, is a - `DuplicateResourceError`. + registry does not have yet. The alias gaps ADR 0005 closed alongside + it are closed here too (P15). 12. **Staged registration as a behavior-neutral refactor of P13.** The TypeScript engine's ADR 0005 chose a different shape for the same @@ -808,6 +784,11 @@ since the emitted code is the same for every level. scope is "an `$id` that sets a base URI". The bundled metaschemas are exempt, as they are from the D20 regex screen, because the draft-06/07 ones spell their own `$id` that way. +- "P12 has three gaps where an identifier still shadows silently": all + three are `DuplicateResourceError` now (P15). An `$id` equal to a + retrieval alias and a retrieval URI reused for a different document follow + the TypeScript engine's ADR 0005. A bundled metaschema's URI is reserved + for equal content, the owner's ruling on the order-dependence question. ### Resolved (owner, 2026-09-22) diff --git a/docs/reference.md b/docs/reference.md index 59660fa..4c2ee70 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -227,7 +227,12 @@ about two positions claiming one URI. `DuplicateResourceError`: two different schemas claim one resource URI (P12) — either a single document minting the same `$id` twice, or a later registration that would rebind a URI an earlier one bound to a different schema. -Re-registering an *equal* document is a no-op, not a duplicate. +Re-registering an *equal* document is not a duplicate. A retrieval URI is a +claim too (P15): an `$id` equal to another document's retrieval URI, and a +retrieval URI that already names a resource or aliases a different one, are +refused, since one of the two would be unreachable. A bundled metaschema's URI +is reserved for content equal to the bundled document, whether or not that +metaschema has been used yet; a custom metaschema goes under a URI of its own. `DuplicateAnchorError`: two different schema objects claim one anchor name within a resource (P12). Covers `$anchor`, `$dynamicAnchor`, and the diff --git a/src/json_schema_engine/core/registry.py b/src/json_schema_engine/core/registry.py index b04273d..d30ff98 100644 --- a/src/json_schema_engine/core/registry.py +++ b/src/json_schema_engine/core/registry.py @@ -483,14 +483,35 @@ def _claim_resource(self, base_uri: str, node: JsonValue, where: str) -> None: subschemas claiming one `$id` are still two resources; only a re-registration of the same document may rebind, and then only to an equal schema. + + A retrieval alias and a bundled metaschema's URI are claims too + (P15). Lookups prefer the alias, so a resource under an aliased URI + could never be reached; and a bundled URI is reserved for content + equal to the bundled document, whether or not it has been lazily + registered yet — otherwise the same call succeeds on a fresh engine + and fails once the metaschema has been used. """ if base_uri in self._claimed_resources: raise DuplicateResourceError( f"resource '{base_uri}' is claimed twice in one document", schema_location=where, ) + bound = self._aliases.get(base_uri) + if bound is not None: + raise DuplicateResourceError( + f"'{base_uri}' is the retrieval URI of resource '{bound}'", + schema_location=where, + ) existing = self._documents.get(base_uri) - if existing is not None and not json_equal(existing, node): + if existing is None: + existing = self._bundled.get(base_uri) + if existing is not None and not json_equal(existing, node): + raise DuplicateResourceError( + f"'{base_uri}' is a bundled metaschema's URI; register a " + "custom metaschema under a URI of its own", + schema_location=where, + ) + elif not json_equal(existing, node): raise DuplicateResourceError( f"resource '{base_uri}' is already registered as a different schema", schema_location=where, @@ -498,6 +519,23 @@ def _claim_resource(self, base_uri: str, node: JsonValue, where: str) -> None: self._claimed_resources.add(base_uri) self._put(self._documents, base_uri, node) + def _check_alias(self, retrieval: str, base_uri: str) -> None: + """Refuse a retrieval URI that cannot alias `base_uri` (P15).""" + where = schema_location(base_uri, "") + if retrieval in self._documents or retrieval in self._bundled: + raise DuplicateResourceError( + f"retrieval URI '{retrieval}' already names a registered " + f"resource; the document declares '{base_uri}'", + schema_location=where, + ) + bound = self._aliases.get(retrieval) + if bound is not None and bound != base_uri: + raise DuplicateResourceError( + f"retrieval URI '{retrieval}' is already bound to resource " + f"'{bound}'; the document declares '{base_uri}'", + schema_location=where, + ) + def _claim_anchor(self, key: str, here: SchemaRef) -> None: """Bind an anchor key, or refuse to shadow another object's (P12). @@ -599,6 +637,11 @@ def _index( root_ids.base_id, dialect, schema_location(base_uri, "") ) if base_uri != retrieval_resource: + # The retrieval URI becomes an alias of the declared base, so it + # must not already name a resource of its own, alias a different + # one, or be reserved for a bundled metaschema (P15): any of those + # would leave one of the two unreachable. + self._check_alias(retrieval_resource, base_uri) # Journaled like the rest: this runs *before* the claim below, # so a duplicate root used to leave an alias behind. self._put(self._aliases, retrieval_resource, base_uri) diff --git a/tests/core/test_registry.py b/tests/core/test_registry.py index a94de40..d4d6f7e 100644 --- a/tests/core/test_registry.py +++ b/tests/core/test_registry.py @@ -5,7 +5,7 @@ import pytest -from json_schema_engine.core import create_engine +from json_schema_engine.core import DIALECT_2020_12, Engine, create_engine from json_schema_engine.core.cursor import Cursor from json_schema_engine.core.dialect import ( DialectRegistry, @@ -27,6 +27,7 @@ UnresolvableReferenceError, ) from json_schema_engine.core.json_model import JsonValue, is_object +from json_schema_engine.core.metaschemas import bundled_metaschemas from json_schema_engine.core.registry import SchemaRegistry # A miniature dialect: enough applicators to exercise the walk without any @@ -789,6 +790,90 @@ def test_reject_id_fragments_exempts_the_bundled_metaschemas() -> None: assert engine.schemas.has("http://json-schema.org/draft-07/schema") +# --- aliases and bundled URIs are claims (P15) -------------------------- +# +# A retrieval alias is consulted before the resource index, so a resource +# under an aliased URI would be unreachable; and a bundled metaschema's URI +# answers the same way whether or not it has been lazily registered yet. + +ALIASED: JsonValue = {"$id": "https://x.example/alias-target"} +ALIAS = "https://x.example/alias-key" + + +def _aliased_engine() -> Engine: + engine = create_engine() + engine.register_schema(ALIASED, ALIAS) + return engine + + +def test_an_embedded_id_equal_to_a_retrieval_alias_is_refused() -> None: + engine = _aliased_engine() + with pytest.raises(DuplicateResourceError) as info: + engine.register_schema( + {"$defs": {"a": {"$id": ALIAS, "type": "string"}}}, + "https://x.example/claimer", + ) + assert "https://x.example/alias-target" in str(info.value) + assert info.value.schema_location == "https://x.example/claimer#/$defs/a" + assert not engine.schemas.has("https://x.example/claimer") + + +def test_a_document_under_an_aliased_uri_is_refused() -> None: + engine = _aliased_engine() + with pytest.raises(DuplicateResourceError): + engine.register_schema({"type": "string"}, ALIAS) + assert engine.schemas.root_ref(ALIAS).base_uri == "https://x.example/alias-target" + + +def test_a_retrieval_uri_naming_a_resource_is_refused() -> None: + engine = create_engine() + engine.register_schema({"type": "string"}, "https://x.example/taken") + with pytest.raises(DuplicateResourceError) as info: + engine.register_schema( + {"$id": "https://x.example/other"}, "https://x.example/taken" + ) + assert "already names a registered resource" in str(info.value) + assert not engine.schemas.has("https://x.example/other") + + +def test_a_retrieval_uri_aliasing_another_resource_is_refused() -> None: + engine = _aliased_engine() + with pytest.raises(DuplicateResourceError) as info: + engine.register_schema({"$id": "https://x.example/elsewhere"}, ALIAS) + assert "already bound" in str(info.value) + assert not engine.schemas.has("https://x.example/elsewhere") + + +def test_an_equal_re_registration_under_its_alias_is_fine() -> None: + engine = _aliased_engine() + assert engine.register_schema(ALIASED, ALIAS) == "https://x.example/alias-target" + + +@pytest.mark.parametrize("used", [False, True], ids=["fresh", "after-use"]) +def test_a_bundled_uri_is_reserved(used: bool) -> None: + engine = create_engine() + if used: + engine.evaluate(DIALECT_2020_12, {}) + with pytest.raises(DuplicateResourceError): + engine.register_schema( + {"$id": DIALECT_2020_12, "type": "string"}, "urn:x:override" + ) + with pytest.raises(DuplicateResourceError): + engine.register_schema({"$id": "urn:x:elsewhere"}, DIALECT_2020_12) + assert not engine.schemas.has("urn:x:elsewhere") + # Either way, the metaschema that answers is the bundled one. + assert engine.evaluate(DIALECT_2020_12, {"type": 1}).valid is False + + +@pytest.mark.parametrize("used", [False, True], ids=["fresh", "after-use"]) +def test_an_equal_copy_of_a_bundled_metaschema_is_fine(used: bool) -> None: + engine = create_engine() + if used: + engine.evaluate(DIALECT_2020_12, {}) + copy = bundled_metaschemas()[DIALECT_2020_12] + assert engine.register_schema(copy, DIALECT_2020_12) == DIALECT_2020_12 + + def test_two_positions_claiming_one_uri_is_still_a_duplicate() -> None: reg = make_registry() with pytest.raises(DuplicateResourceError): From d63bc29972c7c4cebebb005fd9f248a78ec1fb6b Mon Sep 17 00:00:00 2001 From: "Henry H. Andrews" Date: Wed, 23 Sep 2026 11:20:41 -0700 Subject: [PATCH 2/3] Track which document owns each resource; drop a stale range lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groundwork for `unregister`, matching the TypeScript engine's ADR 0005: - `_owned_resources` maps a document root to every resource its last registration claimed -- exactly P12's claim set, so nothing new is collected. `_resource_locations` stays the truth for who owns a resource now. - When an equal embedded copy takes over a resource that was a document of its own, that document stops being one and loses its ownership record and range lookup. - The journal gains `_pop`, a removal recorded like a write so undo puts it back. Only `_documents` promises an order, and nothing removes from it mid-registration. A re-registration without a range lookup now drops the old one: equal JSON can come from differently formatted text, and a caller who re-registers without positions has said there are none. Resolves DESIGN.md §7 "A stale range lookup survives a re-registration". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 ++++-- DESIGN.md | 18 +++++--------- src/json_schema_engine/core/registry.py | 29 +++++++++++++++++++++++ tests/core/test_atomic_registration.py | 12 +++++++--- tests/core/test_positions.py | 31 +++++++++++++++++++++++++ 5 files changed, 80 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9075bed..08bcd9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,11 @@ minor versions may change public API. shadowing: because a dynamic anchor is also a plain anchor, `$anchor: "n"` on one object and `$dynamicAnchor: "n"` on another left `$ref` and `$dynamicRef` resolving the same fragment to *different* schemas. - Re-registering an *equal* document is still a no-op, and one object carrying - both `$anchor` and `$dynamicAnchor` under one name is still fine. No case in + Re-registering an *equal* document is still allowed; it is walked again, and + its source-range lookup is replaced — dropped, when the re-registration + supplies none, since equal JSON can come from differently formatted text. + One object carrying both `$anchor` and `$dynamicAnchor` under one name is + still fine. No case in the official test suite, the bundled metaschemas, or this repo's fixtures is affected. **Breaking:** a document that registered before may now raise, and a *modified* document can no longer be re-registered under its URI — there diff --git a/DESIGN.md b/DESIGN.md index f5454b6..5db978e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -686,18 +686,6 @@ since the emitted code is the same for every level. `_dialect_after` already takes the first approach for the *dialect* lookup, so the shape exists; the base itself is what still drifts. -9. **A stale range lookup survives a re-registration** — `_document_ranges` - is written only when `get_range` is supplied, so registering a document - with ranges and then registering it again without them leaves the first - lookup in place, and `Engine.locate` keeps reporting offsets from the - earlier text. P12 means the second document must be `json_equal` to the - first, but equal JSON can come from differently formatted text, so the - positions can point at the wrong characters. Minor, and the fix is - probably to drop the entry when a re-registration supplies no lookup — - the question is whether that is a surprise for a caller who registered - twice deliberately. - - 11. **No way to replace a registered document.** P12 turns a modified re-registration under the same URI into `DuplicateResourceError`, and nothing unregisters. The edit-and-re-register loop (a REPL, a test that @@ -789,6 +777,12 @@ since the emitted code is the same for every level. retrieval alias and a retrieval URI reused for a different document follow the TypeScript engine's ADR 0005. A bundled metaschema's URI is reserved for equal content, the owner's ruling on the order-dependence question. +- "A stale range lookup survives a re-registration": a re-registration + that supplies no lookup drops the old one, as the TypeScript engine's + does. Equal JSON can come from differently formatted text, and a caller + who re-registers without positions has said there are none. A document + whose resource an equal embedded copy takes over stops being a document, + and loses its lookup and ownership record the same way. ### Resolved (owner, 2026-09-22) diff --git a/src/json_schema_engine/core/registry.py b/src/json_schema_engine/core/registry.py index d30ff98..f9d9704 100644 --- a/src/json_schema_engine/core/registry.py +++ b/src/json_schema_engine/core/registry.py @@ -99,6 +99,10 @@ class _Registration: `adds` holds `(index, member)` for the set indexes, recorded only when the member was new — a union cannot say who added what. + + A removal (`_pop`) records the same `(index, key, prior)` triple as a + write, so undo puts the entry back. Nothing removes from `_documents` + during a registration, so its order is never disturbed by one. """ writes: list[tuple[dict[str, Any], str, Any]] = field( @@ -242,6 +246,11 @@ def __init__( self._consumed_ids: set[str] = set() self._document_dialects: dict[str, str] = {} self._resource_locations: dict[str, DocumentLocation] = {} + # Document root -> every resource its last registration claimed + # (P15), which is what `unregister` removes. `_resource_locations` + # stays the truth for who owns a resource *now*: an equal copy in a + # later document takes a resource over without editing this. + self._owned_resources: dict[str, frozenset[str]] = {} # Position lookups by document URI (D17): only the outermost # registration installs one; embedded resources map back to their # document through `_resource_locations`. @@ -306,6 +315,7 @@ def snapshot(self) -> "SchemaRegistry": copy._consumed_ids = set(self._consumed_ids) copy._document_dialects = dict(self._document_dialects) copy._resource_locations = dict(self._resource_locations) + copy._owned_resources = dict(self._owned_resources) copy._document_ranges = dict(self._document_ranges) copy._aliases = dict(self._aliases) copy._read_only = True @@ -345,6 +355,13 @@ def _put[V](self, index: dict[str, V], key: str, value: V) -> None: self._journal.writes.append((index, key, index.get(key, _MISSING))) index[key] = value + def _pop[V](self, index: dict[str, V], key: str) -> None: + """Remove an index entry, journaled so it can be undone (§7).""" + if key in index: + if self._journal is not None: + self._journal.writes.append((index, key, index[key])) + del index[key] + def _add(self, index: set[str], member: str) -> None: """Add a set member, journaled when it is genuinely new. @@ -657,7 +674,13 @@ def _index( ) if get_range is not None: self._put(self._document_ranges, base_uri, get_range) + else: + # A re-registration is "this is the document now": one without + # positions has said there are none, and the earlier lookup may + # describe differently formatted text. + self._pop(self._document_ranges, base_uri) self._walk(schema, base_uri, "", base_uri, "", dialect, 0) + self._put(self._owned_resources, base_uri, frozenset(self._claimed_resources)) return base_uri def _walk( @@ -704,6 +727,12 @@ def _walk( # again — the inner extractor's own `base_id` is never used. ids = replace(dialect.identifiers(node), base_id=ids.base_id) self._claim_resource(base_uri, node, claimed_at) + prior = self._resource_locations.get(base_uri) + if prior is not None and prior.document_uri == base_uri: + # An equal copy embedded here takes over a resource that was + # a document of its own, which therefore stops being one. + self._pop(self._owned_resources, base_uri) + self._pop(self._document_ranges, base_uri) self._put(self._document_dialects, base_uri, dialect.uri) self._put( self._resource_locations, diff --git a/tests/core/test_atomic_registration.py b/tests/core/test_atomic_registration.py index 9cf7514..79da275 100644 --- a/tests/core/test_atomic_registration.py +++ b/tests/core/test_atomic_registration.py @@ -55,7 +55,10 @@ def _fingerprint(reg: SchemaRegistry) -> dict[str, object]: "dynamic_anchors": [(k, id(v)) for k, v in reg._dynamic_anchors.items()], "document_dialects": list(reg._document_dialects.items()), "resource_locations": list(reg._resource_locations.items()), - "document_ranges": [(k, id(v)) for k, v in reg._document_ranges.items()], + # Sorted: a journaled removal is undone by reinsertion at the end, + # and only `_documents` promises an order. + "owned_resources": sorted(reg._owned_resources.items()), + "document_ranges": sorted((k, id(v)) for k, v in reg._document_ranges.items()), "aliases": list(reg._aliases.items()), "recursive_roots": sorted(reg._recursive_roots), "produced_ids": sorted(reg._produced_ids), @@ -77,7 +80,9 @@ def _populated(**kwargs: object) -> SchemaRegistry: """A registry with something to lose: several documents, an embedded `$id`, anchors, a dynamic anchor, an alias, and a pending reference.""" reg = make_registry(**kwargs) # type: ignore[arg-type] - reg.register(dict(SHARED), "urn:shared") + # With a range lookup, so a failing document that absorbs `SHARED` as an + # equal embedded copy has a range and an ownership record to restore. + reg.register(dict(SHARED), "urn:shared", get_range=lambda pointer: None) reg.register( { "$id": "https://x.example/outer", @@ -286,7 +291,7 @@ def test_pending_references_are_rolled_back() -> None: def test_index_census() -> None: - # A twelfth index fails here until `snapshot()` and `_fingerprint` both + # A thirteenth index fails here until `snapshot()` and `_fingerprint` both # learn about it. reg = SchemaRegistry(DialectRegistry(), DIALECT) found = { @@ -304,6 +309,7 @@ def test_index_census() -> None: "_consumed_ids", "_document_dialects", "_resource_locations", + "_owned_resources", "_document_ranges", "_aliases", "_pending_resources", diff --git a/tests/core/test_positions.py b/tests/core/test_positions.py index 52e72eb..aef0af3 100644 --- a/tests/core/test_positions.py +++ b/tests/core/test_positions.py @@ -141,3 +141,34 @@ def test_locate_returns_none_for_an_unknown_anchor() -> None: {"$id": "https://anchor.example/bare"}, "https://anchor.example/bare" ) assert engine.locate("https://anchor.example/bare#nosuch") is None + + +def test_a_re_registration_without_ranges_drops_the_old_lookup() -> None: + # Equal JSON can come from differently formatted text, so the earlier + # lookup's offsets could point at the wrong characters: a + # re-registration that brings no lookup has said there is none. + loaded = parse_json_with_ranges(TEXT, "https://pos.example/root") + engine = create_engine() + uri = engine.register_schema(loaded.value, loaded.uri, get_range=loaded.get_range) + ranged = engine.locate(f"{uri}#/$ref") + assert ranged is not None and "range" in ranged + engine.register_schema(loaded.value, loaded.uri) + assert engine.locate(f"{uri}#/$ref") == { + "documentUri": "https://pos.example/root", + "pointer": "/$ref", + } + + +def test_an_absorbed_document_drops_its_lookup() -> None: + # An equal embedded copy takes a document's resource over, so that + # document is no longer one and its range lookup goes with it. + solo = "https://pos.example/solo" + loaded = parse_json_with_ranges(f'{{"$id": "{solo}", "type": "string"}}', solo) + engine = create_engine() + engine.register_schema(loaded.value, solo, get_range=loaded.get_range) + assert engine.schemas.range(solo, "") is not None + engine.register_schema( + {"$defs": {"s": {"$id": solo, "type": "string"}}}, + "https://pos.example/holder", + ) + assert engine.schemas.range(solo, "") is None From b0e60a1b2d979cfcb44b2c5fb34bce99db2c421a Mon Sep 17 00:00:00 2001 From: "Henry H. Andrews" Date: Wed, 23 Sep 2026 11:25:05 -0700 Subject: [PATCH 3/3] Add unregister, the explicit way to replace a registered document (P15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P12 made a modified re-registration a `DuplicateResourceError`, and nothing could undo a registration, so an edit-and-re-register loop needed a fresh engine. `SchemaRegistry.unregister(uri)` and `Engine.unregister_schema(uri)` take the shape the TypeScript engine's ADR 0005 decided: no `replace=`, so remove then register is the replacement path. `uri` must name a document root, directly or by retrieval URI. Anything else is `UnresolvableReferenceError`, naming the owning document for an embedded resource. Removal drops what the document still owns -- a resource an equal copy in a later document has taken over stays with that document -- plus the document's range lookup and every alias pointing at it. The produced/consumed unions and the pending queue are left alone. Snapshots, and so compiled artifacts, keep everything. It is refused on a snapshot, from caller code inside a registration (whose rollback would undo it), and for a bundled metaschema, whose URI is reserved. Resolves DESIGN.md §7 "No way to replace a registered document". Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 13 +- DESIGN.md | 44 +---- docs/guide/loaders.md | 30 +++ docs/guide/metaschemas.md | 20 ++ docs/reference.md | 18 +- src/json_schema_engine/core/engine.py | 10 + src/json_schema_engine/core/errors.py | 7 +- src/json_schema_engine/core/registry.py | 74 +++++++ tests/core/test_unregister.py | 248 ++++++++++++++++++++++++ 9 files changed, 423 insertions(+), 41 deletions(-) create mode 100644 tests/core/test_unregister.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 08bcd9f..0e35b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,10 +44,8 @@ minor versions may change public API. still fine. No case in the official test suite, the bundled metaschemas, or this repo's fixtures is affected. **Breaking:** a document that registered before may now raise, and - a *modified* document can no longer be re-registered under its URI — there - is not yet a way to replace or unregister one, so an edit-and-re-register - loop needs a fresh engine (DESIGN.md §7, "No way to replace a registered - document"). Retrieval URIs and bundled metaschema URIs are claims too + a *modified* document can no longer be re-registered under its URI; + unregister it first with `Engine.unregister_schema`. Retrieval URIs and bundled metaschema URIs are claims too (DESIGN.md P15): an `$id` equal to another document's retrieval URI, a retrieval URI reused for a different document, and a bundled metaschema's URI claimed with different content are all `DuplicateResourceError`. The @@ -108,6 +106,13 @@ minor versions may change public API. ### Added +- **`Engine.unregister_schema(uri)` (DESIGN.md P15).** It removes a + registered document and everything its registration claimed: embedded `$id` + resources, anchors, recursive roots, dialect and location entries, its + source-range lookup, and its retrieval aliases. Unregister then register is + how a document is replaced. A resource that an equal copy in a later document + has taken over stays with that document. Compiled artifacts keep what they + were compiled against. The shape matches the TypeScript engine's. - **Location chains (DESIGN.md P11).** A canonical `base_uri#pointer` names a position exactly and still may not locate it: in a bundled document the base may be an embedded `$id` the reader never knew was there, and a relative diff --git a/DESIGN.md b/DESIGN.md index 5db978e..fa2fce7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -88,10 +88,10 @@ not the intent), **N/A** (JavaScript-only). | P8 | Naming | Python API: snake_case functions, options, and attributes (`register_schema`, `error_params`). JSON Schema keyword names and output-document field names are wire formats and keep their spec spelling. Module names follow the TS modules where sensible (`registry`, `dialect`, `cursor`, `output`, `result`); `json_model.py` rather than `json.py`, `evaluator.py` for the TS `engine.ts` machinery, `engine.py` for the public `Engine` façade. | Idiomatic Python without renaming spec concepts. | — | | P10 | Location string encoding | A schema location is a **URI**. `SchemaRef.location` and every error's `schema_location` are built by `schema_location(base_uri, pointer)` in `core/uri.py`, which takes an RFC 6901 pointer and percent-encodes exactly what RFC 3986's `fragment` production disallows: space, `"`, `<`, `>`, `\`, `^`, `` ` ``, `{`, `|`, `}`, `#`, `%`, controls, and every non-ASCII character as UTF-8 bytes. `/ : @ ! $ & ' ( ) * + , ; = - . _ ~` stay literal, so an ordinary pointer reads unchanged. `pointer_from_fragment(fragment)` is the exact inverse and is the only way a fragment becomes a pointer (registry lookup, `Engine.locate`). Prose in error messages uses the same URI form for schema positions. `evaluationPath` and `instanceLocation` stay **plain-text** JSON Pointers, which their specs require and `$ref` never sees. | `schemaLocation`/`absoluteKeywordLocation` are specified as URIs, and callers paste them into `$ref` or feed them back to `Engine.locate`. Emitting the raw pointer made a property named `100%` come out as `#/properties/100%`, which is neither a URI nor round-trippable, while the registry already `unquote`d on the way in. Matching user-facing form to `$ref` usage is the rule wherever the spec does not fix a format. | The JSON Schema RFC adopts IRIs: widen to the IRI `ifragment` rules and leave non-ASCII literal. Until then a human-centric UI decodes for display; the engine emits the machine-round-trippable form. | | P11 | Embedded-resource location chain | A schema location names a position exactly and still may not locate it: in a compound document the base may be an embedded `$id` the reader never knew was there, and a relative `$id` resolves to a URI that appears nowhere in their file. `SchemaRegistry.location_chain(location)` returns a `tuple[LocationHop, ...]`, innermost first: the position, then each lexically enclosing resource with the pointer to the one below it *within that resource*, ending at a root. Every hop's `.location` is a canonical schema location, so it is a `$ref` value and an `Engine.locate` argument; a hop also carries the `$id` as written (the only way to find a relative one in the text) and, on the outermost hop, the retrieval URI when it differs. The parent link rides on `DocumentLocation`, which `snapshot()` already copies, rather than a parallel map. `Engine` attaches a chain to any error leaving `register_schema` or `evaluate` — no raise site holds a registry — and `str(error)` appends it only past one hop, so a single-resource message is byte-identical to before. Side API only: no output unit gains a field. | The bundled shape (many `$id` resources in one document's `$defs`) is the common real one, and it is exactly where a canonical URI stops being actionable. Deriving at the boundary rather than at the raise site avoids coupling the regex screen, the evaluator and keyword `analyze()` to a registry, and freezes the chain at the moment of failure. Hops carry no `SourceLocation` because its range comes from a loader closure over a parse tree, and an exception that outlives it must not pin it; `Engine.locate(hop.location)` is one call. | The chain and `Engine.locate` disagree — a test composes the hops back into the flat D17 pointer to make that a failure rather than a drift. | -| P12 | Duplicate identifiers are errors | Two different schemas may not claim one resource URI, and two different schema objects may not claim one anchor name within a resource. `DuplicateResourceError` covers a single document minting an `$id` twice and a later registration rebinding a URI an earlier one bound to a *different* schema (`json_equal`, so re-registering an equal document stays a no-op). `DuplicateAnchorError` covers `$anchor`, `$dynamicAnchor`, and the draft-07/06 `$id: "#name"` form alike, keyed on the `_anchors` entry that all three write; one object carrying both anchor kinds under one name claims the same key with the same target and is allowed. Claims are scoped to one registration walk, so a re-registered resource rewrites its own anchors without a false positive, and a `$ref` sibling under draft-07/06 claims nothing because it suppresses identifiers (D18). | Both were silent last-write-wins, so one schema shadowed the other and a reference resolved to whichever the walk reached last. The anchor case was worse than shadowing: because a dynamic anchor is also a plain anchor (D8), `$anchor: "n"` on one object and `$dynamicAnchor: "n"` on another left `$ref` and `$dynamicRef` resolving the same fragment to *different* schemas. The spec is silent on duplicate `$id` and deems a duplicate anchor undefined behavior an implementation MAY reject; the owner deems rejection correct for both. Duplicate `$id` is also what made the P11 parent relation cyclic. | A schema in the wild proves a duplicate is load-bearing somewhere (none known; the vendored suite, the bundled metaschemas, and every repo fixture are clean). | -| P13 | Registration is all-or-nothing | `SchemaRegistry.register` journals every index write and undoes them if anything escapes, so a document that fails leaves the registry exactly as it found it. A `_Registration` holds `(index, key, prior)` for the seven dict indexes and `(index, member)` for the four set ones, recording only genuinely new members because `_produced_ids`/`_consumed_ids` are unions. The undo restores prior values rather than deleting keys: a walk legitimately rebinds entries an earlier registration owns, and `_documents` order is what `resources()` promises. `except BaseException`, since `_step` raises bare `KeyError`/`TypeError`, `RecursionError` can fire anywhere, and caller code runs inside the walk. Not journaled: `_reference_memo` (a derived cache, cleared on both paths), the P12 claim sets (per-walk scratch), and the regex cache (keyed by pattern, a function of a fixed dialect and backend). On the way out the registry fills the error's `location_chain` (P11) and `schema_source` (D17) *before* the undo — the only moment the indexes they derive from still exist — and `_canonical` refuses to start a lazy bundled registration while a journal is live. | The half-indexed document left behind was still evaluable, and its partial `produces`/`consumes` fed D5's elision predicate, so the failure surfaced as a wrong answer rather than a loud one. P12 added two more ways to fail mid-walk. Journaling rather than snapshotting keeps the cost O(this document's writes) with no term in registry size, which matters because every lookup may lazily register a bundled metaschema; measured, registration is unchanged (1.27 ms either way on the OAS 3.1 corpus). | A caller needs a *successful* registration undone: that wants `unregister`, and an answer for resources an earlier registration also claims. | +| P12 | Duplicate identifiers are errors | Two different schemas may not claim one resource URI, and two different schema objects may not claim one anchor name within a resource. `DuplicateResourceError` covers a single document minting an `$id` twice and a later registration rebinding a URI an earlier one bound to a *different* schema (`json_equal`, so re-registering an equal document stays a no-op). `DuplicateAnchorError` covers `$anchor`, `$dynamicAnchor`, and the draft-07/06 `$id: "#name"` form alike, keyed on the `_anchors` entry that all three write; one object carrying both anchor kinds under one name claims the same key with the same target and is allowed. Claims are scoped to one registration walk, so a re-registered resource rewrites its own anchors without a false positive, and a `$ref` sibling under draft-07/06 claims nothing because it suppresses identifiers (D18). | Both were silent last-write-wins, so one schema shadowed the other and a reference resolved to whichever the walk reached last. The anchor case was worse than shadowing: because a dynamic anchor is also a plain anchor (D8), `$anchor: "n"` on one object and `$dynamicAnchor: "n"` on another left `$ref` and `$dynamicRef` resolving the same fragment to *different* schemas. The spec is silent on duplicate `$id` and deems a duplicate anchor undefined behavior an implementation MAY reject; the owner deems rejection correct for both. Duplicate `$id` is also what made the P11 parent relation cyclic. | A schema in the wild proves a duplicate is load-bearing somewhere (none known; the vendored suite, the bundled metaschemas, and every repo fixture are clean). Extended by P15: aliases and bundled URIs are claims, and `unregister` is the replacement path. | +| P13 | Registration is all-or-nothing | `SchemaRegistry.register` journals every index write and undoes them if anything escapes, so a document that fails leaves the registry exactly as it found it. A `_Registration` holds `(index, key, prior)` for the eight dict indexes (a `_pop` removal is recorded the same way) and `(index, member)` for the four set ones, recording only genuinely new members because `_produced_ids`/`_consumed_ids` are unions. The undo restores prior values rather than deleting keys: a walk legitimately rebinds entries an earlier registration owns, and `_documents` order is what `resources()` promises. `except BaseException`, since `_step` raises bare `KeyError`/`TypeError`, `RecursionError` can fire anywhere, and caller code runs inside the walk. Not journaled: `_reference_memo` (a derived cache, cleared on both paths), the P12 claim sets (per-walk scratch), and the regex cache (keyed by pattern, a function of a fixed dialect and backend). On the way out the registry fills the error's `location_chain` (P11) and `schema_source` (D17) *before* the undo — the only moment the indexes they derive from still exist — and `_canonical` refuses to start a lazy bundled registration while a journal is live. | The half-indexed document left behind was still evaluable, and its partial `produces`/`consumes` fed D5's elision predicate, so the failure surfaced as a wrong answer rather than a loud one. P12 added two more ways to fail mid-walk. Journaling rather than snapshotting keeps the cost O(this document's writes) with no term in registry size, which matters because every lookup may lazily register a bundled metaschema; measured, registration is unchanged (1.27 ms either way on the OAS 3.1 corpus). | A caller needs a *successful* registration undone: answered by P15's `unregister`, which resources go with their current owner. | | P14 | Per-resource dialects | `$schema` governs the schema resource it roots, not the document (2020-12 core §8.1.1). `_walk` rebinds its `dialect` local wherever it rebinds `base_uri`, so the inner dialect supplies the keyword table, `ref_ignores_siblings`, and the identifiers minted into that resource, and `_document_dialects` records it. **The boundary is decided from the outside, the contents from the inside:** `base_id` comes from the *parent* extractor — its `$id` syntax is what decides a resource starts here at all, and a relative `$schema` has no base to resolve against until it has — while `anchors`, `dynamic_anchor` and `recursive_anchor` are re-read with the inner one. Honored under every dialect; draft-07/06 are silent on the placement rather than prohibitive. A `$schema` where no resource starts is **ignored**, not refused: the spec forbids the placement, but refusing it is strict-mode hygiene, which D14 keeps opt-in, and `{"$schema": X, "not": {"$schema": X}}` is how Bowtie spells "allows nothing" for every dialect it tests. Only the walk can discover an embedded `$schema`, and the registry holds no loaders, so it raises `UnknownDialectError` carrying `dialect_uri` and the engine assembles that dialect and registers again; P13 rollback is what makes the retry start from the state the first attempt found. Pointer navigation in `resolve_ref`/`child` refreshes its extractor at each base change through a non-raising lookup, so a base the walk never indexed falls back to the dialect in force rather than failing. | The walk threaded the document root's dialect through the whole recursion, so the bug cut both ways: a valid draft-07 resource embedded in a 2020-12 document was rejected for array-form `items`, and a 2020-12 resource embedded in a draft-07 document had `$id: "#foo"` accepted as an anchor. It also mislabeled any node reached by a pointer that crossed an `$id` under the wrong extractor as its resource's root. Evaluation and the compiler already keyed every dialect lookup on the unit's own base URI, so the walk was the only tier disagreeing — with them and with itself. | A dialect needs to *forbid* an embedded `$schema`: that wants a `Dialect` field beside `identifiers` and `ref_ignores_siblings`. A lint layer under D14 wants to flag a misplaced `$schema`, which this deliberately does not. | -| P15 | Aliases and bundled URIs are claims | P12's one-resource-one-schema rule extends to the two other ways a URI can be bound. A **retrieval alias** is consulted before the resource index, so an `$id` equal to an existing alias, or a retrieval URI that already names a resource or aliases a different one, would leave one of them unreachable; each is a `DuplicateResourceError` (`_claim_resource`, `_check_alias`). A **bundled metaschema's URI** is reserved (owner, 2026-09-23): it may be claimed only with content `json_equal` to the bundled document, checked against `_bundled` when it has not been lazily registered yet, so the answer no longer depends on whether the metaschema has been used. A custom metaschema goes under its own URI, as custom dialects already do. Matches the TypeScript engine's ADR 0005, which registers its metaschemas eagerly and so reaches the same answer without the `_bundled` check. | +| P15 | Explicit unregister; aliases and bundled URIs are claims | P12's one-resource-one-schema rule extends to the two other ways a URI can be bound. A **retrieval alias** is consulted before the resource index, so an `$id` equal to an existing alias, or a retrieval URI that already names a resource or aliases a different one, would leave one of them unreachable; each is a `DuplicateResourceError` (`_claim_resource`, `_check_alias`). A **bundled metaschema's URI** is reserved (owner, 2026-09-23): it may be claimed only with content `json_equal` to the bundled document, checked against `_bundled` when it has not been lazily registered yet, so the answer no longer depends on whether the metaschema has been used. A custom metaschema goes under its own URI, as custom dialects already do. Matches the TypeScript engine's ADR 0005, which registers its metaschemas eagerly and so reaches the same answer without the `_bundled` check. **Replacement is explicit** (the ADR 0005 shape): `SchemaRegistry.unregister(uri)` / `Engine.unregister_schema(uri)`, no `replace=`. `uri` must name a document root, directly or by retrieval URI; anything else is `UnresolvableReferenceError`, naming the owning document for an embedded resource. `_owned_resources` records each document root's P12 claim set, and `_resource_locations` stays the truth for who owns a resource *now*: an equal embedded copy takes a resource over, and a document whose root is taken over that way stops being one. Unregister drops what the document still owns, and every alias pointing at it. The produced/consumed unions and the pending queue are left alone. It is refused on a snapshot, during a registration (caller code in a walk, which the rollback would undo), and for a bundled URI. **One divergence from TypeScript (owner, 2026-09-23):** an equal re-registration keeps every retrieval alias, where TypeScript's evicts all but the newest. A document reachable from two URLs would otherwise flip-flop, breaking one set of `$ref`s each time it is loaded through the other. This is filed on the TypeScript backlog. | ## 2. System shape @@ -686,35 +686,7 @@ since the emitted code is the same for every level. `_dialect_after` already takes the first approach for the *dialect* lookup, so the shape exists; the base itself is what still drifts. -11. **No way to replace a registered document.** P12 turns a modified - re-registration under the same URI into `DuplicateResourceError`, and - nothing unregisters. The edit-and-re-register loop (a REPL, a test that - mutates a fixture, an editor integration re-validating on save) now - needs a fresh engine per edit, which also discards every other - registration and any compiled artifact's snapshot lineage. P13's - revisit names `unregister`; this promotes it. **Shape decided - (owner, 2026-09-23, in the TypeScript engine's ADR 0005) and to be - matched here:** `SchemaRegistry.unregister(uri)` and - `Engine.unregister_schema(uri)`, no `replace=` option — remove then - register is the replacement path, so a duplicate is always an error - unless the caller acted first. `uri` must name a registered document - root (its retrieval URI or alias counts); an unknown URI, or one that - names a resource *embedded* in another document, raises - `UnresolvableReferenceError` naming the owning document. Removal drops - everything that document's registration claimed — its embedded `$id` - resources, anchors and dynamic anchors, recursive roots, dialect and - location entries, its range lookup, and every alias pointing at it — - except a resource an equal copy in a later document has since taken - over, which stays with its current owner. `_produced_ids`/ - `_consumed_ids` are left alone: they only widen retention, and nothing - re-derives them. A `snapshot()` taken earlier keeps the removed - document. Doing this needs per-document ownership tracking (the - TypeScript engine keeps `documentUri → resources claimed`, with - `resourceLocations` as the truth for who owns what now), which the - registry does not have yet. The alias gaps ADR 0005 closed alongside - it are closed here too (P15). - -12. **Staged registration as a behavior-neutral refactor of P13.** The +9. **Staged registration as a behavior-neutral refactor of P13.** The TypeScript engine's ADR 0005 chose a different shape for the same guarantee: the walk writes into a per-registration staging object (resources with their anchors, the union contributions, the pending @@ -732,8 +704,9 @@ since the emitted code is the same for every level. this engine than there — its snapshots are copies, not copy-on-write views that a journal must un-share before a walk that may fail — so this is filed as an option to take when P13's journal next needs - touching (item 11's ownership tracking is a likely moment), not as - work owed on its own. P11's `_describe` and D17's `schema_source` + touching, not as work owed on its own. `unregister` (P15) did not need + it: removal runs outside any registration, and the journal only gained + an undoable pop. P11's `_describe` and D17's `schema_source` would read from the stage plus the committed indexes instead of from the half-built ones. @@ -783,6 +756,9 @@ since the emitted code is the same for every level. who re-registers without positions has said there are none. A document whose resource an equal embedded copy takes over stops being a document, and loses its lookup and ownership record the same way. +- "No way to replace a registered document": `Engine.unregister_schema`, + in the TypeScript engine's ADR 0005 shape (P15). Unregister then register + replaces a document. ### Resolved (owner, 2026-09-22) diff --git a/docs/guide/loaders.md b/docs/guide/loaders.md index c0360b9..c721618 100644 --- a/docs/guide/loaders.md +++ b/docs/guide/loaders.md @@ -217,6 +217,36 @@ assert bundle_engine.evaluate(bundle_uri, {"city": "NYC"}).valid is True assert bundle_engine.evaluate(bundle_uri, {"city": 1}).valid is False ``` +## Replacing a registered schema + +A URI names one schema. Registering a *different* document under a URI that +is already registered raises `DuplicateResourceError`, whether it arrives +through `register_schema` or from a loader, so nothing is silently shadowed. +Registering an equal document again is fine. To replace one, for example an +editor re-validating on save, unregister it first. That removes the document +and everything it declared, including embedded `$id` resources, anchors, and +its retrieval-URI aliases: + +```python +from json_schema_engine.core import DuplicateResourceError + +editing = create_engine() +draft_uri = editing.register_schema({"type": "string"}, "https://example.com/draft") +try: + editing.register_schema({"type": "integer"}, draft_uri) + replaced_silently = True +except DuplicateResourceError: + replaced_silently = False +assert replaced_silently is False + +editing.unregister_schema(draft_uri) +editing.register_schema({"type": "integer"}, draft_uri) +assert editing.evaluate(draft_uri, 1).valid is True +``` + +A compiled artifact keeps the schemas it was compiled against, so compile +again after a replacement. + ## Bundled metaschemas never go through loaders The standard metaschemas (2020-12, 2019-09, draft-07, draft-06, and the diff --git a/docs/guide/metaschemas.md b/docs/guide/metaschemas.md index 1fc2967..c8cbe93 100644 --- a/docs/guide/metaschemas.md +++ b/docs/guide/metaschemas.md @@ -195,6 +195,26 @@ assert no_loader_engine.evaluate(meta_uri, {"type": "integer"}).valid is True assert no_loader_engine.evaluate(meta_uri, {"type": 1}).valid is False ``` +A bundled URI is reserved for its bundled content. You can register an +equal copy, but a different document under one of these URIs is a +`DuplicateResourceError`, whether or not the metaschema has been used yet. A +bundled metaschema cannot be unregistered either. A customized metaschema +goes under a URI of its own, as the `$vocabulary` section above does. + +```python +from json_schema_engine.core import DuplicateResourceError + +try: + create_engine().register_schema( + {"$id": "https://json-schema.org/draft/2020-12/schema", "type": "object"}, + "https://ex.example/my-meta", + ) + overridden = True +except DuplicateResourceError: + overridden = False +assert overridden is False +``` + See [Loaders](loaders.md) for how a `$ref` to a *non*-bundled resource gets resolved instead. diff --git a/docs/reference.md b/docs/reference.md index 4c2ee70..7ae27e7 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -60,6 +60,18 @@ same keyword-only parameters. Registration is synchronous and local; registered or bundled metaschema; `$ref` targets are not followed (use `load_schema` for that). `get_range` is the D17 position capability for this document. +- `Engine.unregister_schema(uri) -> None`: removes a registered document and + everything its registration claimed: embedded `$id` resources, anchors and + dynamic anchors, recursive roots, dialect and location entries, its range + lookup, and every retrieval alias for it (P15). `uri` is the document's + canonical or retrieval URI. A resource that an equal copy in a later document + has since taken over stays with that document. Unregister then + `register_schema` is how a document is replaced. Compiled artifacts keep what + they were compiled against, and a dialect assembled from an unregistered + metaschema stays registered. Raises `UnresolvableReferenceError` for a URI + that names no document root (including a resource embedded in another + document, which the message names), and `ReadOnlyRegistryError` for a + bundled metaschema. - `Engine.load_schema(schema, retrieval_uri, dialect_uri=None, get_range=None) -> str`: registers a document and loads every resource it references (P4). - `Engine.load(uri) -> str`: loads and registers a resource by URI through the @@ -247,8 +259,10 @@ caught registration error. Because the document is gone, `Engine.locate` cannot place such an error afterwards — the error carries its own `schema_source`, captured before the rollback. -`ReadOnlyRegistryError`: registration was attempted on a compiled artifact's -registry snapshot. +`ReadOnlyRegistryError`: a registry, or an entry in one, cannot be modified: +registration or unregistration on a compiled artifact's registry snapshot, +unregistering from caller code running inside a registration, or unregistering +a bundled metaschema (P15). `UnknownDialectError`: a schema names a `$schema` dialect URI that no registered dialect claims, or that cannot be assembled. `dialect_uri: str | diff --git a/src/json_schema_engine/core/engine.py b/src/json_schema_engine/core/engine.py index f230996..f500d10 100644 --- a/src/json_schema_engine/core/engine.py +++ b/src/json_schema_engine/core/engine.py @@ -256,6 +256,16 @@ def _register_assembling_dialects( failure.schema_source = error.schema_source raise + def unregister_schema(self, uri: str) -> None: + """Remove a registered document and everything it claimed (P15). + + `uri` is the document's canonical URI or its retrieval URI. Unregister + then `register_schema` is how a registered document is replaced. A + compiled artifact keeps the schemas it was compiled against, and a + dialect assembled from an unregistered metaschema stays registered. + """ + self.schemas.unregister(uri) + def load_schema( self, schema: JsonValue, diff --git a/src/json_schema_engine/core/errors.py b/src/json_schema_engine/core/errors.py index fa9a23f..180effb 100644 --- a/src/json_schema_engine/core/errors.py +++ b/src/json_schema_engine/core/errors.py @@ -137,7 +137,12 @@ class DuplicateAnchorError(JsonSchemaEngineError): class ReadOnlyRegistryError(JsonSchemaEngineError): - """Registration attempted on a compiled artifact's registry snapshot.""" + """A registry, or an entry in one, cannot be modified. + + Raised for registration or unregistration on a compiled artifact's + registry snapshot, for unregistering while a registration is in + progress, and for unregistering a bundled metaschema (P15). + """ class UnknownDialectError(JsonSchemaEngineError): diff --git a/src/json_schema_engine/core/registry.py b/src/json_schema_engine/core/registry.py index f9d9704..6621b74 100644 --- a/src/json_schema_engine/core/registry.py +++ b/src/json_schema_engine/core/registry.py @@ -344,6 +344,80 @@ def register( ) return self._register(schema, retrieval_uri, dialect_uri, get_range) + def unregister(self, uri: str) -> None: + """Remove a registered document and everything it claimed (P15). + + `uri` names a document root, directly or by its retrieval URI. + Removal drops the root, every embedded `$id` resource, their + anchors and dynamic anchors, recursive roots, dialect and location + entries, the range lookup, and every alias pointing at it — except + a resource an equal copy in a later document has since taken over, + which stays with its current owner. Unregister then `register` is + how a document is replaced: there is no `replace=`, so a duplicate + is always an error unless the caller acted first. + + `_produced_ids`/`_consumed_ids` and the pending queue are left + alone: the unions only widen retention, nothing re-derives them, + and a queued reference is still an unmet reference. A `snapshot()` + taken earlier keeps everything, since its indexes are copies. + + Raises `UnresolvableReferenceError` for a URI that names no + document root, including a resource embedded in another document; + `ReadOnlyRegistryError` on a snapshot, during a registration, or + for a bundled metaschema, whose URI is reserved. + """ + if self._read_only: + raise ReadOnlyRegistryError( + "this schema registry is a compiled artifact's snapshot; " + "unregister on the engine" + ) + if self._journal is not None: + # Caller code inside a walk: the enclosing registration's undo + # would resurrect whatever this removed. + raise ReadOnlyRegistryError( + "cannot unregister while a registration is in progress" + ) + resource = _resource_of(uri) + # Before any lookup that goes through `_canonical`, which would + # lazily register the very metaschema being refused. + if resource in self._bundled: + raise ReadOnlyRegistryError( + f"'{resource}' is a bundled metaschema and cannot be unregistered" + ) + doc = self._aliases.get(resource, resource) + location = self._resource_locations.get(doc) + if location is None: + raise UnresolvableReferenceError(f"unknown schema '{doc}'") + if location.document_uri != doc: + raise UnresolvableReferenceError( + f"'{doc}' is a resource embedded in document " + f"'{location.document_uri}'; unregister that document" + ) + self._evict(doc) + self._reference_memo.clear() + + def _evict(self, doc: str) -> None: + """Drop what `doc`'s registration claimed and still owns.""" + removed: set[str] = set() + for resource in self._owned_resources.pop(doc, frozenset()): + location = self._resource_locations.get(resource) + if location is None or location.document_uri != doc: + continue + removed.add(resource) + del self._documents[resource] + del self._resource_locations[resource] + self._document_dialects.pop(resource, None) + self._recursive_roots.discard(resource) + # Anchors are keyed `resource#name` in one flat index, so this is a + # pass over all of them; unregistering is rare enough not to keep a + # per-resource index for it. + for index in (self._anchors, self._dynamic_anchors): + for key in [k for k in index if k.partition("#")[0] in removed]: + del index[key] + self._document_ranges.pop(doc, None) + for alias in [a for a, target in self._aliases.items() if target == doc]: + del self._aliases[alias] + def _put[V](self, index: dict[str, V], key: str, value: V) -> None: """Write an index entry, journaled so it can be undone (§7). diff --git a/tests/core/test_unregister.py b/tests/core/test_unregister.py new file mode 100644 index 0000000..2d3b6c8 --- /dev/null +++ b/tests/core/test_unregister.py @@ -0,0 +1,248 @@ +# Unregistering a document (DESIGN.md P15), matching the TypeScript +# engine's ADR 0005: remove then register is how a document is replaced, +# a resource goes with whichever document owns it now, and a snapshot keeps +# everything it had. +# +# pyright: reportPrivateUsage=false + +from collections.abc import Mapping + +import pytest +from tests.core.test_registry import DIALECT, KEYWORDS, VOCAB, _accept + +from json_schema_engine.core import ( + DIALECT_2019_09, + DIALECT_2020_12, + DuplicateResourceError, + JsonValue, + ReadOnlyRegistryError, + UnresolvableReferenceError, + create_engine, + parse_json_with_ranges, +) +from json_schema_engine.core.dialect import ( + DialectRegistry, + KeywordBehavior, + StaticFacts, +) +from json_schema_engine.core.registry import SchemaRegistry + +DECLARED = "https://u.example/declared" +RETRIEVAL = "https://u.example/retrieval" +INNER = "https://u.example/inner" +EXTERNAL = "https://elsewhere.example/thing" + + +def test_removes_a_document_and_everything_it_owned() -> None: + text = f"""{{ + "$id": "{DECLARED}", + "$recursiveAnchor": true, + "$defs": {{ + "a": {{"$anchor": "plain"}}, + "i": {{"$id": "{INNER}", "$anchor": "in", "$recursiveAnchor": true}} + }} + }}""" + loaded = parse_json_with_ranges(text, RETRIEVAL) + engine = create_engine(default_dialect=DIALECT_2019_09) + engine.register_schema(loaded.value, RETRIEVAL, get_range=loaded.get_range) + reg = engine.schemas + view = reg.snapshot() + located = engine.locate(f"{DECLARED}#") + assert located is not None and "range" in located + + engine.unregister_schema(RETRIEVAL) + + for uri in (DECLARED, RETRIEVAL, INNER): + assert not reg.has(uri) + with pytest.raises(UnresolvableReferenceError): + reg.root_ref(RETRIEVAL) + with pytest.raises(UnresolvableReferenceError): + reg.resolve_ref("#plain", DECLARED) + with pytest.raises(UnresolvableReferenceError): + reg.resolve_ref("#in", INNER) + assert not reg.has_recursive_root(DECLARED) + assert not reg.has_recursive_root(INNER) + assert engine.locate(f"{DECLARED}#") is None + assert list(reg.resources()) == [] + + # The snapshot's indexes are copies, so it keeps all of it. + assert view.root_ref(RETRIEVAL).base_uri == DECLARED + assert view.resolve_ref("#plain", DECLARED).pointer == "/$defs/a" + assert view.resolve_ref("#in", INNER).pointer == "" + assert view.has_recursive_root(DECLARED) + assert view.has_recursive_root(INNER) + inner = view.document_location(INNER) + assert inner is not None and inner.pointer == "/$defs/i" + assert view.range(DECLARED, "") is not None + + +def test_removes_dynamic_anchors() -> None: + engine = create_engine() + uri = engine.register_schema( + {"$defs": {"d": {"$dynamicAnchor": "node"}}}, "https://u.example/dyn" + ) + assert engine.schemas.dynamic_anchor(uri, "node") is not None + engine.unregister_schema(uri) + assert engine.schemas.dynamic_anchor(uri, "node") is None + + +def test_is_how_a_document_is_replaced() -> None: + engine = create_engine() + uri = "https://u.example/replace" + engine.register_schema({"type": "string"}, uri) + with pytest.raises(DuplicateResourceError): + engine.register_schema({"type": "integer"}, uri) + engine.unregister_schema(uri) + engine.register_schema({"type": "integer"}, uri) + assert engine.evaluate(uri, 1).valid is True + + +def test_drops_the_range_lookup_with_the_document() -> None: + uri = "https://u.example/ranges" + loaded = parse_json_with_ranges("{}", uri) + engine = create_engine() + engine.register_schema(loaded.value, uri, get_range=loaded.get_range) + engine.unregister_schema(uri) + engine.register_schema({}, uri) + assert engine.locate(f"{uri}#") == {"documentUri": uri, "pointer": ""} + + +def test_refuses_an_unknown_uri_and_an_embedded_resource() -> None: + engine = create_engine() + holder = engine.register_schema( + {"$defs": {"s": {"$id": "https://u.example/embedded"}}}, + "https://u.example/embedder", + ) + with pytest.raises(UnresolvableReferenceError, match="unknown schema"): + engine.unregister_schema("https://u.example/nowhere") + with pytest.raises(UnresolvableReferenceError, match=holder): + engine.unregister_schema("https://u.example/embedded") + assert engine.schemas.has(holder) + assert engine.schemas.has("https://u.example/embedded") + + +def _unions(engine_schemas: SchemaRegistry) -> tuple[set[str], set[str]]: + return ( + set(engine_schemas._produced_ids), + set(engine_schemas._consumed_ids), + ) + + +def test_leaves_the_unions_and_the_pending_queue_alone() -> None: + engine = create_engine() + uri = engine.register_schema( + {"$ref": EXTERNAL, "properties": {"p": True}, "unevaluatedProperties": False}, + "https://u.example/unions", + ) + before = _unions(engine.schemas) + engine.unregister_schema(uri) + assert _unions(engine.schemas) == before + assert engine.schemas.take_unresolved() == [EXTERNAL] + + +# --- a resource goes with its current owner ---------------------------------- + + +def test_an_equal_embedded_copy_takes_a_resource_over() -> None: + engine = create_engine() + shared = "https://u.example/shared" + body: JsonValue = {"$defs": {"s": {"$id": shared, "type": "string"}}} + a = engine.register_schema(body, "https://u.example/eq-a") + b = engine.register_schema(body, "https://u.example/eq-b") + location = engine.schemas.document_location(shared) + assert location is not None and location.document_uri == b + # Removing the older holder keeps it; removing the newer takes it away. + engine.unregister_schema(a) + assert engine.schemas.has(shared) + engine.unregister_schema(b) + assert not engine.schemas.has(shared) + + +def test_a_root_registration_of_an_embedded_resource_takes_it_over() -> None: + engine = create_engine() + sub = "https://u.example/sub" + holder = engine.register_schema( + {"$defs": {"s": {"$id": sub, "type": "string"}}}, + "https://u.example/sub-holder", + ) + assert engine.register_schema({"$id": sub, "type": "string"}, sub) == sub + engine.unregister_schema(holder) + assert engine.schemas.has(sub) + assert engine.evaluate(sub, "s").valid is True + + +def test_a_document_absorbed_by_an_equal_copy_is_no_longer_one() -> None: + engine = create_engine() + solo = "https://u.example/solo" + engine.register_schema({"$id": solo, "type": "string"}, solo) + holder = engine.register_schema( + {"$defs": {"s": {"$id": solo, "type": "string"}}}, + "https://u.example/absorber", + ) + with pytest.raises(UnresolvableReferenceError, match=holder): + engine.unregister_schema(solo) + + +# --- aliases ------------------------------------------------------------------- + + +def test_every_alias_survives_an_equal_re_registration() -> None: + # Unlike the TypeScript engine, whose equal re-registration evicts every + # alias but the newest: a document reachable from two URLs would + # otherwise break one set of `$ref`s each time it is loaded through the + # other. + engine = create_engine() + doc: JsonValue = {"$id": DECLARED, "type": "string"} + engine.register_schema(doc, "https://u.example/url-1") + engine.register_schema(doc, "https://u.example/url-2") + for alias in ("https://u.example/url-1", "https://u.example/url-2"): + assert engine.schemas.root_ref(alias).base_uri == DECLARED + engine.unregister_schema("https://u.example/url-2") + for uri in (DECLARED, "https://u.example/url-1", "https://u.example/url-2"): + assert not engine.schemas.has(uri) + + +# --- refusals ------------------------------------------------------------------ + + +@pytest.mark.parametrize("used", [False, True], ids=["fresh", "after-use"]) +def test_a_bundled_metaschema_cannot_be_unregistered(used: bool) -> None: + engine = create_engine() + if used: + engine.evaluate(DIALECT_2020_12, {}) + with pytest.raises(ReadOnlyRegistryError, match="bundled"): + engine.unregister_schema(DIALECT_2020_12) + # Refusing never registered it as a side effect. + assert (DIALECT_2020_12 in engine.schemas.resources()) is used + + +def test_a_snapshot_cannot_unregister() -> None: + engine = create_engine() + uri = engine.register_schema({}, "https://u.example/snap") + with pytest.raises(ReadOnlyRegistryError): + engine.schemas.snapshot().unregister(uri) + + +def test_unregistering_from_inside_a_walk_is_refused() -> None: + # Caller code runs inside the walk; an unregister there would be + # silently undone, or worse redone, by the enclosing rollback. + reg: SchemaRegistry | None = None + + def analyze(value: JsonValue, _ctx: object) -> StaticFacts: + assert reg is not None + reg.unregister("urn:kept") + return StaticFacts() + + keywords: Mapping[str, KeywordBehavior] = { + **KEYWORDS, + "meddle": KeywordBehavior("urn:test:meddle", _accept, analyze), # type: ignore[arg-type] + } + dialects = DialectRegistry() + dialects.register_vocabulary(VOCAB, keywords) + dialects.register_dialect(DIALECT, [VOCAB]) + reg = SchemaRegistry(dialects, DIALECT) + reg.register({}, "urn:kept") + with pytest.raises(ReadOnlyRegistryError, match="in progress"): + reg.register({"meddle": 1}, "urn:meddler") + assert reg.has("urn:kept") + assert not reg.has("urn:meddler")