Summary
KGObject.save() restricts its existence check to the single space it is about to write to (kgobject.py:742):
if self.exists(client, ignore_duplicates=ignore_duplicates, in_spaces=[space]):
in_spaces is passed through to client.query(..., restrict_to_spaces=...) (kgobject.py:602), so the restriction is applied server-side by the KG query API. As a result, a locally-constructed object whose remote counterpart lives in a different space is not recognised, and a duplicate is created in the target space.
This bites hardest under recursive=True, where the children being saved are often newly constructed rather than fetched.
Which children are affected
In the recursive loop (kgobject.py:715-719), the space a child is checked in depends on what the child knows about itself:
- Child was fetched from the KG →
value.space is set → target_space = value.space → checked in its own space, found, no duplicate.
- Child is newly constructed locally (no
.space, no .id) → target_space falls back to the parent's space, the explicitly passed space, or default_space. The existence query then runs only there, and a remote counterpart elsewhere is invisible. → duplicate.
Common real-world case: a Person, Organization or File created locally and attached to a dataset being saved into a different space from the one where that person/file already lives.
What already masks it
Three paths bypass the restriction, which is why this doesn't show up constantly:
- Child has an
id — exists() takes the instance_from_full_uri branch (kgobject.py:555-566) and never queries by space.
save_cache hit — the lookup by existence-query key (kgobject.py:580-593) happens before the query and is space-independent, so within one session an object found or saved once is recognised wherever it lives.
- Controlled terms — deliberately checked with an unrestricted
exists() (kgobject.py:710, kgobject.py:723) so they are found in controlled regardless of the target space.
Background
The restriction was introduced in 1ac7b56 (2024-10-18) for good reasons, which any fix needs to preserve:
- The cross-space case has no correct action. If
exists() finds the object in space Y while the user called save(space=X), the update branch calls client.update_instance(self.uuid, ...), which writes to Y and silently ignores the requested space. This is the still-open question recorded at kgobject.py:850-851: "if an existing object is in a different space to the one specified here, should we move it to the new space, or raise an Exception?"
- Duplicate-match exception. eb25877 had just made
exists() raise when more than one instance matches. The same metadata present in two spaces (staging + real, or either side of a move_all_to_space) makes that fire on every save.
- Read permission ≠ write permission. The core API searches every space the user can read, so an unrestricted check can bind to an instance in a curated space the user cannot modify.
- Cost.
exists() runs at release_status="any", which executes the query twice at size=100000 (client.py:289-300); narrowing to one space bounds that, which matters for file-heavy ingestion.
Possible directions
- Only restrict when the object's location is actually known — i.e. skip
in_spaces when self.space is None — at the cost of the query optimisation for exactly the ambiguous case.
- Do the restricted query first and fall back to an unrestricted one only on a miss, so the fast path stays fast and the cross-space hit is at least detected.
- Either way, decide what a cross-space hit should do (link to the existing instance, refuse, or move), which resolves the
kgobject.py:850 TODO.
Related to #134, which concerns a different failure in the same exists() method (an object that is recognised, via the save cache, having its unset properties written back as null).
Summary
KGObject.save()restricts its existence check to the single space it is about to write to (kgobject.py:742):in_spacesis passed through toclient.query(..., restrict_to_spaces=...)(kgobject.py:602), so the restriction is applied server-side by the KG query API. As a result, a locally-constructed object whose remote counterpart lives in a different space is not recognised, and a duplicate is created in the target space.This bites hardest under
recursive=True, where the children being saved are often newly constructed rather than fetched.Which children are affected
In the recursive loop (
kgobject.py:715-719), the space a child is checked in depends on what the child knows about itself:value.spaceis set →target_space = value.space→ checked in its own space, found, no duplicate..space, no.id) →target_spacefalls back to the parent's space, the explicitly passedspace, ordefault_space. The existence query then runs only there, and a remote counterpart elsewhere is invisible. → duplicate.Common real-world case: a
Person,OrganizationorFilecreated locally and attached to a dataset being saved into a different space from the one where that person/file already lives.What already masks it
Three paths bypass the restriction, which is why this doesn't show up constantly:
id—exists()takes theinstance_from_full_uribranch (kgobject.py:555-566) and never queries by space.save_cachehit — the lookup by existence-query key (kgobject.py:580-593) happens before the query and is space-independent, so within one session an object found or saved once is recognised wherever it lives.exists()(kgobject.py:710,kgobject.py:723) so they are found incontrolledregardless of the target space.Background
The restriction was introduced in 1ac7b56 (2024-10-18) for good reasons, which any fix needs to preserve:
exists()finds the object in space Y while the user calledsave(space=X), the update branch callsclient.update_instance(self.uuid, ...), which writes to Y and silently ignores the requested space. This is the still-open question recorded atkgobject.py:850-851: "if an existing object is in a different space to the one specified here, should we move it to the new space, or raise an Exception?"exists()raise when more than one instance matches. The same metadata present in two spaces (staging + real, or either side of amove_all_to_space) makes that fire on every save.exists()runs atrelease_status="any", which executes the query twice atsize=100000(client.py:289-300); narrowing to one space bounds that, which matters for file-heavy ingestion.Possible directions
in_spaceswhenself.space is None— at the cost of the query optimisation for exactly the ambiguous case.kgobject.py:850TODO.Related to #134, which concerns a different failure in the same
exists()method (an object that is recognised, via the save cache, having its unset properties written back asnull).