Skip to content

Reject relation targets with an unresolvable Source#1043

Draft
malberts wants to merge 2 commits into
source-registry-and-local-sourcefrom
restrict-relation-targets
Draft

Reject relation targets with an unresolvable Source#1043
malberts wants to merge 2 commits into
source-registry-and-local-sourcefrom
restrict-relation-targets

Conversation

@malberts

@malberts malberts commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

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-unresolvable violation at write and
validation 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 SubjectValidator with the SourceRegistry
    injected from the composition root, so one seam covers the write path and both validate
    endpoints. Placing it in RelationType instead would have pulled the registry into the guard-free
    read/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 not
    become 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 typed
    edge, 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 StatementDeserializer stays untouched so persisted
    foreign-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 the
    ResourceLoader messages array).

Docs: the new code is documented in docs/api/validation-codes.md (including that it fires for
out-of-schema statements, as an exception to their usual silent skip) and the relation-target row
in docs/api/subject-format.md carries the enforcement qualifier.

AI-authored — Claude Code, Fable 5 (max); standing directive from @malberts to complete the #993 tracks without blocking on inter-track human review, design calls delegated; diff not yet human-reviewed; TDD with watched failures on all guard paths, two adversarial AI review lenses with live REST/eval probes (which caught and closed the out-of-schema bypass), make ci and make ts-ci green locally, branch CI pending at posting time.

Production notes

Design and review adjudication by Fable 5 (max); implementation and the fix round by
Opus 4.8 (max) subagents; two Opus 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.

malberts and others added 2 commits July 15, 2026 11:22
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>
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