Reject relation targets with an unresolvable Source#1043
Draft
malberts wants to merge 2 commits into
Draft
Conversation
Implement the v1 guard of ADR 23 ("Relations across Sources"): a
Relation may only target a Subject whose Source resolves through the
registry. SubjectValidator now consults the SourceRegistry for each
relation target and emits a blocking `relation-target-source-unresolvable`
violation when the target's id names a source key that is non-null and
not registered.
Because the check lives on SubjectValidator, it fires on both the
validate endpoints (dry-run) and the create/replace write paths that run
ProposedSubjectValidator, so a hand-crafted API write with an
unresolvable-source target is rejected under enforcement rather than
persisted. The guard runs against every statement in the payload rather
than only schema-declared ones: schema membership says nothing about
whether a target's Source resolves, and schema-scoping would let an
out-of-schema or type-mismatched relation statement smuggle an
unresolvable target past enforcement. Bare targets, and ids explicitly
qualified with the local Source (canonicalized to bare by
SubjectIdParser), carry no source key and pass untouched; registered
Sources pass too.
The read path is deliberately left untouched: StatementDeserializer
never revalidates, so an already-persisted foreign-qualified target
still deserializes and round-trips, and cross-source relations can open
up later.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Register the `neowiki-field-relation-target-source-unresolvable` message (en.json, qqq.json, and the ext.neowiki ResourceLoader messages array) so the backend's new violation renders as a localized, source-key-aware field error through the existing validation-response rendering. The client does not hard-reject foreign targets: the server owns the Source registry (extensions can register Sources), so the client only mirrors the server's verdict. RelationInput already routes a relation property's server violation to its field via the `neowiki-field-<code>` convention; a spec pins that the new code surfaces with its source-key argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
For #993 (work order T4). Builds on the T2 Source
contract PR and targets its branch, so the diff shows only this track.
Implements the v1 guard from ADR 23 "Relations across Sources": a Relation target whose source is
not registered gets a blocking
relation-target-source-unresolvableviolation at write andvalidation time; already-persisted data is never rejected on read; cross-source relations open up
later.
Design calls recorded here:
The guard is a validation concern, produced by
SubjectValidatorwith theSourceRegistryinjected from the composition root, so one seam covers the write path and both validate
endpoints. Placing it in
RelationTypeinstead would have pulled the registry into the guard-freeread/deserialize paths and tripped the hook-ordering constraint ADR 027 documents.
Statement-scoped, not schema-scoped: the check runs for every relation-typed statement in the
payload, including out-of-schema and type-mismatched ones. Schema membership says nothing about
whether a target's Source resolves, and schema-scoping would let a hand-crafted out-of-schema
relation statement smuggle an unresolvable target into the slot even with enforcement on
(probe-confirmed during review, then closed test-first).
The guard rides the standard enforcement gate:
$wgNeoWikiEnforceValidation(default off)decides whether the blocking violation rejects the write, exactly like every other blocking
constraint. A wiki that disables enforcement is knowingly disabling the whole write-validation
regime, so this guard gets no special-case bypass of that choice; the docs state the qualifier
explicitly. Flipping this one violation to unconditional rejection is a small change if review
prefers ADR 23's stricter reading.
A post-implementation audit probed the consequences of this gate against the running stack, and
they are narrower than they might look. Enforcement diffs violations against the persisted subject
(
ViolationDiff::newViolations), so a subject that already carries a foreign target does notbecome un-editable when enforcement is on: a label-only re-save still succeeds, reporting the
pre-existing violation without blocking. The gate therefore trades against exactly one thing: in
default (enforcement-off) config, a write with an unresolvable-source target persists the qualified
target verbatim and projects into Neo4j as a label-less,
wiki_id-less stub node plus a real typededge, and that stub outlives removal of the statement (the edge is deleted on re-projection, the
orphan node is not garbage-collected). So the decision is purely: stop unresolvable targets at
write time by default, or let them accumulate as orphanable graph stubs until an operator enables
enforcement.
Bare and explicitly-local-qualified targets pass (the id parser canonicalizes before the
check), registered Sources pass, and
StatementDeserializerstays untouched so persistedforeign-qualified targets keep round-tripping.
The frontend does not hard-reject foreign targets: the client cannot know the server's
registry (extensions register Sources), so the server stays the authority and the editor surfaces
the violation through the existing validation rendering with a localized message
(
neowiki-field-relation-target-source-unresolvable, registered in en.json, qqq.json, and theResourceLoader messages array).
Docs: the new code is documented in
docs/api/validation-codes.md(including that it fires forout-of-schema statements, as an exception to their usual silent skip) and the relation-target row
in
docs/api/subject-format.mdcarries the enforcement qualifier.Production notes
Design and review adjudication by
Fable 5 (max); implementation and the fix round byOpus 4.8 (max)subagents; twoOpus 4.8 (max)review lenses (runtime correctness with probes,tests/conventions/docs) stood in for the usual pre-PR human review at @malberts' direction. The
correctness lens probe-confirmed that the initial schema-scoped guard placement was bypassable via
out-of-schema relation statements under enforcement; the shipped guard is statement-scoped for that
reason.