Skip to content

Implement the Subject Sources foundation - #1265

Draft
JeroenDeDauw wants to merge 3 commits into
masterfrom
subject-sources-foundation
Draft

Implement the Subject Sources foundation#1265
JeroenDeDauw wants to merge 3 commits into
masterfrom
subject-sources-foundation

Conversation

@JeroenDeDauw

@JeroenDeDauw JeroenDeDauw commented Aug 7, 2026

Copy link
Copy Markdown
Member

Fixes #993

The ADR 23 foundation. With
only the local Source registered, every path resolves what it did before: nothing user-visible changes and nothing
stored changes.

What lands

T1 — SubjectId is a (source, localId) pair. A bare s + 14 nanoid characters still means a local Subject and
is still stored as that bare string; anything else is sourceKey:localId. Every place an id arrives as text — a REST
path, a revision slot, a relation target, a Lua call — goes through SubjectIdParser, which is handed the local Source
key and resolves nothing. Local slot JSON, Neo4j id and API output are unchanged, pinned by a byte-identical slot
test.

T2 — the Source contract, its registry, and LocalSource. One interface, one registry keyed by Source key, the
local revision slot refactored into the default registered Source under the MediaWiki Wiki ID. Extensions contribute
Sources through NeoWikiRegistrar::addSource(), alongside the Property Type and Page Property Provider registries.
ADR 23's "Source interface contract" open question is resolved in the ADR.

T3 — a Schema reference carries its Source. SchemaReference pairs a Source with the unchanged SchemaName, in
the two places a reference is persisted: a Subject's schema and a relation property's targetSchema. Resolution
routes through the Source the reference names, which need not be the Subject's.

T4 — a relation target naming an unregistered Source is an error-severity violation, raised on the write and
validate paths and never on read of persisted data. It blocks like every other error (ADR 26): under
$wgNeoWikiEnforceValidation, and only when the edit introduces it, so a Subject already carrying such a target stays
editable. Where one is stored anyway, it degrades on read — the projections drop the edge and the triple, and
resolution yields no Subject.

Design calls

  • Serialized form sourceKey:localId, split at the first colon. Source key [A-Za-z][A-Za-z0-9_-]{0,63}; a
    foreign localId is RFC 3986 pchar, minus percent-encoding and minus & and ', capped at 256 characters. Every
    PHP grammar is \z-anchored, so a trailing newline cannot pass.
  • The local key is the Wiki ID, and is exempt from that grammar. A wiki whose id is not a well-formed Source key
    still resolves its own bare ids; only the explicit <wikiId>:<localId> spelling is unusable there.
  • An explicitly-local reference canonicalizes to bare, for Subject ids and Schema references alike, so one thing
    has one identity — which the id-keyed maps rely on.
  • A foreign Schema reference persists as {source, name}. A bare string is always a local name, so a Schema title
    containing a colon (ISO:9001) stays valid.
  • No client-side source validation (ADR 25). A server violation renders through the field-error path the editor
    already has.
  • Sources register lazily, as closures built on first resolution: constructing the local Source reaches the
    subject-to-page index, which lives in the graph projection.
  • isEditable() and foreign-source isValidLocalId() are contract surface with no consumer until sourced-subject
    rendering lands. They are frozen now because a Source author must answer them from the start.
  • Foreign relation edges and unresolvable Schema references degrade — skipped with a warning — rather than
    project. The projection skip covers every foreign target, resolvable or not; T4 refuses only the unresolvable ones.
  • RelationId shared SubjectId's $-anchor newline flaw, fixed here. Pre-existing, one line.

Considered, omitted

  • A write-capability stub on Source. Write-back brings its own method when it is built.
  • A TypeScript SubjectIdParser. Only canonical ids reach the frontend, so there is nothing to canonicalize there.
  • Registry-aware colon splitting of Schema-name strings. Rejected: registering a Source later would retroactively
    reinterpret a Schema name that already contains a colon.
  • Neo4j stub nodes for foreign relation targets. The stub would carry this wiki's wiki_id, and one without it scopes
    no query correctly.

Follow-ups filed

Reviewing

The three commits stage the foundation — identity, the Source contract, Schema references and the target guard —
with the review findings from #1330 folded in and co-authored. PHP
and TypeScript assert the id grammars against one shared fixture, tests/vectors/subject-ids.json.

AI-authored — Claude Code, Opus 5 (max); detailed in-session spec from @JeroenDeDauw; diff not yet human-reviewed; phpcs, phpstan, PHPUnit and the TypeScript gates green locally at each revision, and all CI checks green on the current head.

Production notes

Design and orchestration by Fable 5 (max); implementation and the post-review fix batch by Opus 5 (max) subagents.
Four independent AI review passes — code, security, tests, and a diff scan against a superseded branch — with every
blocking finding fixed before this description was written.

@JeroenDeDauw

Copy link
Copy Markdown
Member Author

I did not fully review this yet

@JeroenDeDauw
JeroenDeDauw force-pushed the subject-sources-foundation branch from 2392e27 to 1e809f2 Compare August 25, 2026 20:55
@alistair3149 alistair3149 self-assigned this Aug 31, 2026
@alistair3149
alistair3149 force-pushed the subject-sources-foundation branch from 1e809f2 to cf92872 Compare August 31, 2026 17:19
@JeroenDeDauw

Copy link
Copy Markdown
Member Author

Note: before merging I'd like to ALSO finish my own review. Prior/additional review by others is useful and welcome

JeroenDeDauw and others added 3 commits September 2, 2026 00:52
A Subject's id becomes the Source that produced it paired with that Source's
own id for it (ADR 23). A bare `s` + 14 nanoid characters keeps meaning a local
Subject, so nothing stored changes; everything else serializes as
`sourceKey:localId`, split at the first colon.

Canonicalizing an id that names the local wiki explicitly needs to know which
Source key is local, which the value object deliberately does not, so that is
SubjectIdParser's job. Every place an id arrives as text - a REST path, a
revision slot, a relation target, a Lua call - goes through the parser rather
than constructing a SubjectId directly, so an id naming this wiki explicitly
resolves to the same identity as its bare form. A caller-supplied id on
creation must be a local one, since no path creates a Subject in another
Source.

Both suites run the same parse vectors, so PHP and TypeScript are asserted
equal rather than assumed so, and the slot serialization of a local-only page
is pinned byte for byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: alistair3149 <alistair31494322@gmail.com>
A Subject now comes from a Source, per the contract ADR 23 freezes: the object
that knows how to fetch it, what its ids look like, which Schemas come with it,
and whether it may be edited. The local revision slot becomes one such Source,
registered under the MediaWiki Wiki ID, which is what a bare Subject id
resolves to.

Behaviour-preserving: with only the local Source registered, resolving through
the registry reaches exactly what the repository did. An id naming a Source this
wiki does not have resolves to no Subject and logs a warning, rather than
breaking the page that names it.

Sources are registered up front but built on first use, because building the
local one reaches the graph projection, which a wiki may not have configured.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…targets

A Schema is now referenced as (Source, name) rather than by name alone (ADR 23),
in the two places a reference is stored: a Subject's `schema` field and a
relation property's `targetSchema`. Schema identity stays the name (ADR 17), so
SchemaName is untouched; the reference wraps it.

A Schema of this wiki keeps being written bare, which is also its page title, so
stored Schemas and Subjects are unchanged. Resolution routes through the Source
the reference names, which need not be the Source of the Subject using it; a
reference this wiki cannot resolve reports schema-not-found, the degraded state
the editor already renders, rather than failing.

A local Schema name may itself contain a colon (`ISO:9001`), so a stored string
is never split: it is always one local name. A Schema from elsewhere is stored
as a `{source, name}` object instead, which no local name can be mistaken for.
The qualified `source:name` spelling survives only as a one-way rendering for
people.

Resolution reaches the projection boundary too: the Neo4j and RDF projectors now
resolve a Subject's Schema through its own Source. A relation to a Subject of
another Source gets no Neo4j edge, since the stub node would carry this wiki's
wiki_id, and its RDF triple is named under that Source's own base URI.

Relation targets get ADR 23's v1 guard: a Relation whose target names a Source
this wiki has not registered is refused on the write and validate paths, since
nothing can resolve it. Cross-Source relations open up once resolution for them
exists.

Refused whatever $wgNeoWikiEnforceValidation is set to, because enforcement is
about how strictly a wiki holds data to its Schemas and this is not a Schema
question. Only a violation the edit introduces blocks it, so a Subject that
already carries such a target stays editable, and no read of persisted data is
ever rejected.

For the same reason the check is not Schema-scoped, unlike every other one here:
it runs over every relation value on the proposed Subject, including a Statement
the Schema does not declare and a Subject whose Schema cannot be loaded at all.
Such a target would be unreadable from the moment it is written, which no later
Schema change fixes.

The server is the only validator (ADR 25); the editor renders the violation
through the field-error path it already has.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: alistair3149 <alistair31494322@gmail.com>
@JeroenDeDauw
JeroenDeDauw force-pushed the subject-sources-foundation branch from 4402842 to 087bcdd Compare September 1, 2026 23:01
@JeroenDeDauw

Copy link
Copy Markdown
Member Author

Thanks @alistair3149. I've resumed my review and identified some structural issues (not related to your changes). Will continue on this tomorrow.

@JeroenDeDauw
JeroenDeDauw marked this pull request as draft September 1, 2026 23:42
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.

Implement the Subject Sources foundation (ADR 23)

2 participants