Skip to content

Widen SubjectId to a source-qualified pair#1005

Draft
malberts wants to merge 7 commits into
masterfrom
subject-id-source-pair
Draft

Widen SubjectId to a source-qualified pair#1005
malberts wants to merge 7 commits into
masterfrom
subject-id-source-pair

Conversation

@malberts

@malberts malberts commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

For #993 (work order T1).

SubjectId (PHP and TS) is now a (source, localId) pair per the Identity section of
ADR 23:
the bare nanoid form stays valid everywhere and means the local source, and a qualified
source:localId form can be carried across every boundary. This is a type-and-wire-format change
only: nothing can be done with a sourced id yet, no path creates sourced Subjects, and local
Subjects stay stored bare (slot JSON, Neo4j id properties, and API output unchanged, per ADR 22).

Owner design calls made in this PR, within the ADR's boundaries:

  • Serialized form: source:localId, splitting on the first : only, so future URI-shaped
    localIds stay expressible. : is absent from both the nanoid alphabet and the key grammar, so
    the two forms cannot collide.
  • Source-key grammar: [A-Za-z0-9+_-]+, compared case-sensitively. The authority for what a
    wiki's key can look like is the installer, not WikiMap (which concatenates verbatim): MySQL
    dbnames validate as /^[a-z0-9+_-]+$/i and SQLite dbnames are unchecked. Case-sensitivity is
    load-bearing rather than style: with lower_case_table_names=0 (the MySQL/MariaDB default on
    Linux) two dbnames differing only in case are distinct databases, so two wikis' keys can differ
    only in case too, and the graph's wiki_id property stores the wiki ID verbatim, which
    forecloses case-folding. Because hand-set dbnames are unconstrained, a configured local key
    outside the grammar never throws; it is compared verbatim, and such keys simply cannot appear in
    qualified input strings.
  • Canonicalization: an id that explicitly names the local source normalizes to bare at parse
    time, so one Subject never has two textual identities (equals() and the text-keyed collections
    stay sound). Parsing happens in the new SubjectIdParser (PHP and TS), constructed with the
    local source key (WikiMap::getCurrentWikiId() server-side, wgWikiID client-side, which is
    the same value by construction) and threaded to the boundary parse sites from the composition
    root. Ids read back from the local graph keep the plain constructor, being canonical by
    construction.
  • Interim localId rule (until T2 delegates grammar to Sources): non-empty and restricted to
    URL-path-segment-safe characters (RFC 3986 pchar minus percent-encoding). Looser rules cannot
    round-trip: MediaWiki REST rawurldecodes path parameters after routing, so for example %2F
    would reach the handler as /. RestSubjectRepository now also percent-encodes ids in URLs,
    as the schema and layout repositories already do (a byte-for-byte no-op for bare ids).
  • Placeholder hydration: {{#view:}} injects arbitrary wikitext strings into the placeholder
    attribute, and the subject store is keyed by canonical id text, so hydration now parses ids
    instead of constructing them raw: an explicitly-local-qualified id resolves to the same store
    key as its bare form, and invalid input keeps degrading to a skipped view. The hydration logic
    moved to components/viewData.ts to be testable.

PHP/TS grammar parity is asserted rather than assumed: both suites consume the shared vector
fixture tests/subject-id-vectors.json (a new convention, per the work-order checklist item).
Object-level semantics stay in explicit per-language tests, and
testLocalOnlyPageSlotJsonRoundTripsByteIdentically pins that a local-only page's slot JSON
serialization is byte-identical through the new parse path.

Result of extensive back and forth with @malberts, building on the T1 work order.
Context: the NeoWiki codebase, ADRs 14/22/23 and the SubjectSources planning doc, the MediaWiki
core checkout (REST router, installer, ResourceLoader), and an adversarial multi-agent review of
the implementation plan.
Written by Claude Code, Fable 5 (max)

malberts and others added 7 commits July 15, 2026 00:54
A Subject id is now a (source, localId) pair per ADR 23: the bare nanoid
form stays valid everywhere and means the local source, and a qualified
source:localId form parses and serializes alongside it. The new
SubjectIdParser canonicalizes ids that explicitly name the local source
to the bare form, so one Subject never has two textual identities.

Shared test vectors in tests/subject-id-vectors.json are consumed by both
the PHPUnit and vitest suites, so PHP/TS grammar parity is asserted
rather than assumed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Every place that turns an untrusted or persisted id string into a
SubjectId now goes through the parser wired in NeoWikiExtension, so an
explicitly-local-qualified id canonicalizes to bare at every boundary:
the REST handlers, the queries and actions taking payload ids,
StatementListBuilder relation targets, SubjectResolver, and the slot
deserializers. Ids read back from the local graph keep the plain
constructor, since those are canonical by construction.

Test-only construction sites thread TestData::newSubjectIdParser().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The {{#view:}} parser function injects arbitrary wikitext strings into
data-mw-neowiki-subject-id without server-side validation, and the
subject store is keyed by canonical id text, so placeholder hydration
now parses ids with SubjectIdParser (local key from wgWikiID): an
explicitly-local-qualified id resolves to the same store key as its
bare form instead of producing a broken view, and invalid input keeps
degrading to a skipped view. The hydration logic moves out of
NeoWikiApp.vue into components/viewData.ts so it is testable.

RestSubjectRepository now percent-encodes subject ids in URL paths,
as the schema and layout repositories already do; this is a
byte-for-byte no-op for bare nanoid ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The round-trip pin runs a full local-only page through the
parser-threaded deserializer and asserts the re-serialized slot JSON is
byte-identical. The serializer fixture's 'string' writer's-schema type
becomes 'text' so the fixture is deserializable; the serializer writes
the type verbatim either way.

Docs now describe both id forms: subject-format.md carries the grammar,
rest-api.md and the REST param descriptions say either form is
accepted, graph-model.md notes stored ids stay bare, and the glossary's
always-15-characters claim is scoped to local ids.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Keys differing only in case denote different Sources: dbnames on Linux
MySQL/MariaDB (lower_case_table_names=0) can differ only in case, and
the graph's wiki_id stores the wiki ID verbatim, so case-folding is not
an option.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PCRE's $ anchor matches before a final newline, unlike the JS anchor,
so the PHP grammar accepted ids like "s...\n" that TS rejects, and a
qualified id kept the newline in its text while getLocalId() lost it.
Anchoring with \z makes end-of-string strict and the two
implementations identical; the shared vectors now pin both newline
cases as invalid.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SetSubjectsOrderingAction compared the canonical stored id texts
against the raw request strings, so a request carrying the
qualified-local alias of the current ordering skipped the no-change
check, saved a semantically identical ordering, and reported it as
changed. Ids are now parsed once up front, so the no-change comparison
and the write agree on canonical text; syntactically invalid ids keep
reporting an invalid ordering, and the permission check still runs
first.

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