Skip to content

Implement the Subject Sources foundation - #1265

Open
JeroenDeDauw wants to merge 12 commits into
masterfrom
subject-sources-foundation
Open

Implement the Subject Sources foundation#1265
JeroenDeDauw wants to merge 12 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 — relation targets naming an unregistered Source are refused, on the write and validate paths and never on read
of persisted data. The refusal stands whatever $wgNeoWikiEnforceValidation is set to: enforcement governs how
strictly a wiki holds data to its Schemas, and a reference nothing can resolve is not a Schema question. Only
violations the edit introduces block, so a Subject already carrying such a target stays editable.

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 commits stage T1→T4, mechanical changes separated from behavioural ones. 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; PHPUnit (2896 tests), phpcs and phpstan green locally, and all 17 CI checks green including the TypeScript build, tests and lint.

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 and others added 8 commits August 7, 2026 21:03
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. Both suites run the same parse vectors, so PHP and
TypeScript are asserted equal rather than assumed so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every place an id arrives as text - a REST path, a revision slot, a relation
target, a Lua call - now goes through SubjectIdParser rather than constructing
a SubjectId directly, so an id naming this wiki explicitly resolves to the same
identity as its bare form.

Mechanical apart from two points: a caller-supplied id on creation must be a
local one, since no path creates a Subject in another Source, and the REST id
parameter descriptions now describe both id forms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A Subject now comes from a Source (ADR 23): 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 Opus 5 <noreply@anthropic.com>
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.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
@JeroenDeDauw
JeroenDeDauw force-pushed the subject-sources-foundation branch from 3dbeeab to 68c40df Compare August 7, 2026 19:05
Meaning-preserving pass over the prose this branch adds. The ADR's amendment
note pointed at an open question the same change removed; three sentences that
restated a Consequence, a rationale carried twice, and two garbled clauses are
gone or fixed. The graph-model and REST-API cross-references lose their
center-embedded parentheticals, the glossary entry drops storage mechanism a
first-time reader cannot use, and ADR references match the style of the file
they sit in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JeroenDeDauw
JeroenDeDauw marked this pull request as ready for review August 9, 2026 21:23
JeroenDeDauw and others added 3 commits August 9, 2026 23:32
The splitSubjectId helper moves below the class it serves (callers before
callees). The $localSourceKey doc comment now says what the key is before
why deserialization needs it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Drop prose defending against forms the final design never had, and
clauses restating facts whose home is the linked entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Also align the planning doc's Source-interface summary with the contract
ADR 23 froze: no query role, no write capability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@JeroenDeDauw

Copy link
Copy Markdown
Member Author

I did not fully review this yet

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)

1 participant