docs(adr): ADR-0003 — node-anchored wikitext editing for content actions#1
Conversation
…actions Proposed ADR: replace first-occurrence `replacen` content edits with node-anchored editing behind a new `WikitextEditor` trait (Parsoid-backed via the `parsoid` crate), plus an immediate exactly-one-occurrence guard. Motivated by an incoming citation-verification collaboration. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ng consequence
Crate-level parsoid spike (en.wp Cosmic latte rev 1357330394) confirms the
fidelity gates: byte-lossless no-op round-trip, node-scoped param edit, and a
clean bare-URL -> {{cite web}} wrap. Remaining open item is the licensing
decision: parsoid is GPL-3.0 and statically linked, making distributed
binaries a GPL-3.0 combined work (no in-process boundary escape).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the GPL questionthe Three ways through it:
The same GPL reality applies to the documented WASM- My read: if SP42-goes-GPL is on the table, #1 is simplest and keeps the best ergonomics; if SP42 must stay permissive, #2 is the path. Either way the |
Relicensed done per : 766f154 if you prefer another or even recommend a dual license I'm happy to do it. Copyleft is absolutely fine here. |
|
I'm merging PR with ADR as is but in implementing we will need to update the citation feature to keep it working. |
Session validity was spread across five layers (cookie, server session,
upstream token, SW cache, the App-local auth signal) with no single authority:
the only re-gate path was polling /auth/session, and one page worked around it
with error.contains("401") string-matching. The HTTP layer also collapsed every
non-2xx into a String, losing the status.
Make the server session the one authority and a 401 the one re-gate trigger:
- http.rs: a thread-local unauthorized handler (mirroring the CSRF one) +
finish_response() that invokes it on HTTP 401. set_unauthorized_handler is
registered once by App at mount to flip auth -> anonymous, so any API 401 from
any page drops to the login gate. The per-layer cookie/session/token timers
are now belt-and-suspenders, not the load-bearing trigger.
- action_controller.rs: drop the bespoke is_auth_error string sniff and
dev-session retry; the global handler covers it.
- revision_artifacts.rs: the two inline StatusCode::UNAUTHORIZED returns now use
the shared unauthorized_error() helper, so every 401 has one shape/message.
Adds a wasm-gated test: only 401 fires the handler; other errors/success do not.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…in (#90) * feat(auth,wiki): any Wikimedia project + required Wikimedia OAuth login Two capabilities (ADR-0014): Any Wikimedia project - Embed an authoritative SiteMatrix snapshot (crates/sp42-wiki/data/ wikimedia-sites.json, 1070 sites; refresh via scripts/sync-wikis.sh) and add WikiRegistry::resolve(): a hand-configured wiki wins, otherwise a WikiConfig is derived from the embedded dbname->url map (api/parsoid URLs from the host, shared Wikimedia endpoints as constants, default namespaces, a universal language-agnostic scoring policy). SSRF-safe: hosts only come from vendored data, unknown wiki_id derives nothing. The server now resolve()s, so e.g. dewiki/commonswiki/eswiktionary work out of the box. - Add active/default-language-agnostic to the embedded compiled-policy set. Required Wikimedia OAuth login - The server's OAuth2 PKCE flow already existed; wire the browser app to it: a required login gate (fetch_auth_session -> "Log in with your Wikimedia account" -> /auth/login), session header with username + logout, and the dev-token bootstrap demoted to a local-mode secondary. Add the missing /auth proxy to Trunk.toml (login 404'd in dev without it). - Add a header wiki picker (?wiki=<dbname> override) so the workspace can point at any project. Docs: ADR-0014, CONTRIBUTING (OAuth consumer registration + sync-wikis), docs map. Verified: ci-build, ci-clippy (pedantic), ci-doc, ci-test, wasm32 --tests gate, layer check, link check. The OAuth consumer registration + credentials are an operational prerequisite for the live login (owner-only suffices for self-test). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(auth): first-run local setup window writes .env.wikimedia.local When running in local deployment mode with no Wikimedia credentials configured, the login gate now shows a setup window: a "where to get a token" explainer + link, and fields for a personal access token and/or OAuth consumer key+secret. Saving POSTs to a new local-only endpoint that writes the values into .env.wikimedia.local; it takes effect on the next dev-server start. Server: POST /dev/auth/local-credentials, hard-gated to SP42_DEPLOYMENT_MODE=local (same localhost-only trust as the dev bridge), accepts only the three known WIKIMEDIA_* keys, preserves existing lines, writes 0600, never echoes the secret. write_local_credentials + merge_env in local_env.rs with unit tests. Frontend: LocalSetupPanel + save_local_credentials; is_local_deployment() reads the existing deploymentMode runtime config. The panel shows only in local mode when neither an OAuth client nor a local token is configured. Verified: ci-build, ci-clippy (pedantic), ci-doc, wasm32 --tests gate, layer check, link check, and sp42-server/sp42-wiki unit tests (incl. the env-merge + unknown-key-rejection tests). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(app): wiki picker is a type-to-filter dropdown over all projects Replace the 6 hardcoded picker suggestions with the full embedded site list: add GET /wikis (the SiteMatrix snapshot's wiki_ids, ADR-0014), fetch it once on mount, and populate the picker's <datalist> from it. The native combobox then shows a dropdown and filters live as you type across all ~1070 Wikimedia projects. Adds the /wikis Trunk proxy. Verified: ci-build, ci-clippy (pedantic), ci-doc, wasm32 --tests gate, link check; /wikis returns 1070 ids locally. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth,wiki): address Codex review on #90 - OAuth login now requests the full action grants (basic, editpage, patrol, rollback), so OAuth-logged-in users pass the edit/patrol/rollback capability gates instead of being read-only (oauth_runtime.rs). [P1] - current_login_next() preserves the path + ?wiki= query + #view hash, so OAuth login returns to the requested project/view rather than the default. [P2] - Article and Citation surfaces initialize from selected_wiki_id(), so the header wiki picker applies to every workspace view, not just Patrol. [P2] - Derived wikis use a broader patrol-relevant namespace allowlist (0/4/6/10/14) so e.g. Commons File edits aren't dropped by the filter. [P2] Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): re-gate workspace on idle-session expiry Re-check /auth/session when the tab regains visibility so an expired session returns the user to the login gate instead of a stale workspace whose next API call 401s. The re-check only swaps auth state when the authenticated status actually flips, so a still-valid session does not remount (and reset) the workspace on every focus. Codex review #90. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(wiki): align derived namespace allowlist with patrol filter default Add User (2) to the dynamically-derived namespace allowlist so it mirrors the patrol filter UI default (DEFAULT_NAMESPACES). A derived wiki has no curated config, so leaving filters at their default sends no override and the server falls back to this allowlist; without User (2) here, User- namespace edits were silently dropped even though the filter showed that namespace as included. Codex review #90. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(wiki): accept a derivable default wiki id at startup SP42_DEFAULT_WIKI_ID may now be any known Wikimedia project, not just a hand-configured one: from_configs resolves the default once (configured config wins, else derive from the embedded SiteMatrix snapshot) and stores it, so a deployment can default to e.g. dewiki without a YAML config. An id that is neither configured nor derivable is still rejected at startup. default_config() returns the stored resolved config (now infallible). The derived default is not folded into the hand-configured set, so config() and wiki_ids() keep their curated-only contract. Codex review #90. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): split-origin OAuth return + hide dev-token outside local mode Two Codex review findings on #90: - Split frontend/API deployments: the login button navigates to the API origin, but `next` carried only path/search/hash, so after /auth/callback the server redirected to that relative path on the API host and stranded the user on the backend. current_login_next() now returns an absolute frontend-origin URL when the API base is cross-origin, and the server's sanitize_redirect_target honors an absolute target only when its origin is in the configured allowed-origins list (open-redirect guard); same-origin deployments keep the relative path. - Dev-token bootstrap button: shown whenever a local token was reported available, even in vps/desktop where post_bootstrap_session rejects the route — a dead button. It is now gated on is_local_deployment(). Adds unit tests for the redirect sanitizer (relative kept, protocol-relative and off-origin absolute rejected, allowed-origin absolute honored). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): honor configured non-HTTP app origins in redirect sanitizer The split-origin redirect guard only accepted http/https targets, so a packaged Tauri webview reporting location.origin as tauri://localhost (a configured desktop allowed origin) had its return target rejected and the OAuth callback landed on the sidecar root instead of the desktop app. Compare origins as reconstructed scheme://host[:port] strings instead of requiring an http(s) tuple origin: configured app schemes such as tauri://localhost now match the allow list, while authority-less targets (javascript:, mailto:) still yield None and fall back to "/". The allow list remains the open-redirect boundary. Adds a desktop-origin test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): compare parsed origins for split-deployment detection is_split_origin_deployment() compared the API base against the frontend origin with starts_with, which misclassifies a different origin that merely shares a string prefix (e.g. :4173 vs :41730, or app.example.org vs app.example.org.evil) as same-origin — so current_login_next() sent a relative next and the OAuth callback stranded split-origin users on the API host. Compare parsed scheme://host[:port] origins instead. Adds tests for the port- and host-suffix prefix pitfalls. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): cross-site session cookie for split-origin deployments The session cookie was always SameSite=Lax, which browsers drop on credentialed cross-site fetches — so after a split-origin OAuth login (a different-site vps frontend/API pair, or the desktop tauri://localhost → loopback sidecar) /auth/session and subsequent API calls looked unauthenticated. Session cookies now use SameSite=None; Secure for the cross-site modes (vps, desktop) and keep SameSite=Lax for local (same-site localhost across ports, plain http). Secure is forced whenever SameSite=None since browsers require the pairing. CORS already allows credentials with a specific-origin list and the client already sends credentials, so this was the missing piece. Tests cover all three modes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): session cookie Max-Age tracks absolute timeout, not idle The cookie Max-Age was pinned to the 30-minute idle timeout, set only at login/bootstrap. Authenticated reads slide the server-side idle window (last_seen_at_ms) but emit no new Set-Cookie, so the browser dropped sp42_dev_session 30 minutes after login even for an actively-working user, bouncing them well before the 8h absolute timeout. The cookie now lives for the absolute timeout (the longest a session can possibly last); the server still enforces the finer sliding-idle policy, so an idle-expired session just yields a cookie the server rejects. Adds a Max-Age coverage test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(desktop): accept custom app-scheme origins from SP42_ALLOWED_ORIGINS The desktop sidecar sets SP42_ALLOWED_ORIGINS to include tauri://localhost, but the env-origin parser used parse_http_url, which rejects non-HTTP(S) schemes — so DeploymentConfig::load() errored and the packaged desktop app could not boot, even though the default desktop allow list already trusts tauri://localhost. The SP42_ALLOWED_ORIGINS path now canonicalizes origins via a lenient parser that accepts custom app schemes (scheme://host[:port]), mirroring the redirect sanitizer; SP42_PUBLIC_BASE_URL stays HTTP(S)-only since it builds the OAuth redirect URI. Adds a parser test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): keep auth out of SW cache + cap session at token expiry Two Codex review findings on #90: - The PWA service worker's network-only list covered /dev/ and /oauth/ but not /auth/, so networkFirst could cache /auth/session JSON (incl. the CSRF token) and replay it offline, leaving stale authenticated state instead of re-gating. Added /auth/ to NETWORK_ONLY_PATTERNS. - The SP42 session could outlive the upstream OAuth access token: when the token endpoint returns a shorter expires_in, the session stayed valid for up to 8h while every wiki API call failed with an expired bearer. session_expires_at_ms now caps the deadline at upstream_access_expires_at_ms so the session re-gates when the token dies (refresh is out of scope, ADR-0014). Adds a cap test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(platform): one shared origin primitive (root-cause #2) Origin parsing/comparison was copied ad hoc in three places, which is why the same bugs (string-prefix matching, HTTP-only-scheme assumptions) each recurred twice during review. Extract a single primitive in sp42-platform (origin.rs: origin_of / origin_of_url / origins_match) — canonical scheme://host[:port], default ports omitted, accepts custom app schemes like tauri://localhost, rejects authority-less targets — and delegate the three copies to it: - sp42-server oauth_runtime.rs (redirect sanitizer allow-list match) - sp42-server deployment.rs (SP42_ALLOWED_ORIGINS canonicalization) - sp42-app config.rs (split-origin detection) Behavior is unchanged; tests now live with the primitive. Layering clean (both shells depend on the platform layer per ADR-0013). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(wiki): one WikiConfig contract for authored + derived (closes #91) Derived WikiConfigs bypassed the authored contract, leaving three default sources that drifted: WikiTemplates::default() (French), the authored scoring default (active/frwiki-vandalism), and the derived path (English + language- agnostic), plus a hand-duplicated [0,2,4,6,10,14] namespace list in two crates. Unify on a single source in sp42-platform: - DEFAULT_PATROL_NAMESPACES — used by both the derived allowlist (sites.rs) and the frontend filter default (filter_bar.rs); they can no longer drift. - DEFAULT_SCORING_POLICY_REF / DEFAULT_SCORING_POLICY_WIKI_ID — the authored default ref now equals the derived one, and parse_wiki_config accepts the universal "default" policy for any wiki, so authored and derived share the same language-agnostic fallback. - WikiTemplates.citation_needed is now Option<String> (no French default). Derived wikis carry None and the tag-citation-needed action refuses with citation-needed-not-enabled rather than inserting a wrong-language template, mirroring how bare_url_citation already gates bare-URL repair. Closes #91. frwiki/testwiki keep their explicit tuned policies and templates. Tests cover the refusal + configured paths. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(auth): single 401-driven re-gate authority (root-cause #1) Session validity was spread across five layers (cookie, server session, upstream token, SW cache, the App-local auth signal) with no single authority: the only re-gate path was polling /auth/session, and one page worked around it with error.contains("401") string-matching. The HTTP layer also collapsed every non-2xx into a String, losing the status. Make the server session the one authority and a 401 the one re-gate trigger: - http.rs: a thread-local unauthorized handler (mirroring the CSRF one) + finish_response() that invokes it on HTTP 401. set_unauthorized_handler is registered once by App at mount to flip auth -> anonymous, so any API 401 from any page drops to the login gate. The per-layer cookie/session/token timers are now belt-and-suspenders, not the load-bearing trigger. - action_controller.rs: drop the bespoke is_auth_error string sniff and dev-session retry; the global handler covers it. - revision_artifacts.rs: the two inline StatusCode::UNAUTHORIZED returns now use the shared unauthorized_error() helper, so every 401 has one shape/message. Adds a wasm-gated test: only 401 fires the handler; other errors/success do not. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(platform): don't link a public const to a private fn The DEFAULT_SCORING_POLICY_REF doc linked to the private default_scoring_policy_ref fn, which rustdoc rejects under -D warnings (the CI Docs gate). Use plain code formatting instead of an intra-doc link. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth,wiki): CSPRNG session id, gate metadata refresh, ns override Four Codex findings on #90: - P1: session ids were derived from millisecond + atomic counter + pid, so an attacker who can approximate a login time could guess another user's sp42_dev_session cookie and use the stored Wikimedia bearer token. Generate a 256-bit CSPRNG identifier (ServerRng) instead; drop the now-unused counter field from AppState. - The login gate ignored unauthenticated metadata changes: the refresh guard only updated state when the authenticated bool flipped, so oauth_client_ready / local_token_available changes (after local setup or a server restart) left the gate stuck on a stale "not configured"/setup screen. Now refresh whenever unauthenticated, and skip updates only while staying authenticated (still avoiding a workspace remount on every probe). - The 401 handler installed a synthetic AuthSession::default() (oauth_client_ready false, empty paths), stranding users on a "not configured" gate after an expired-session 401. It now re-fetches the real /auth/session so the correct login UI shows. - parse_recent_changes_response treated config.namespace_allowlist as a hard cap, dropping namespaces (Talk/User-talk) the user explicitly selected via the filter even though the request already included them. It now honors the query namespace override, falling back to the allowlist only as a default. Tests: namespace-override honored; gate metadata/401 paths via the wasm gate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): gate shared env-token fallbacks to local mode Gating the dev-bootstrap endpoint wasn't enough: access_token_for_request and the capability probe still fell back to state.local_oauth.access_token() regardless of deployment mode. With WIKIMEDIA_ACCESS_TOKEN present in vps/desktop, an unauthenticated request could borrow the shared bearer for /operator/live, article inventory, and capability probes — even though this PR makes per-user OAuth the required identity. Apply the same local-only gate (permits_dev_token_bootstrap) to those fallbacks: access_token_for_request and capability_probe_token return None outside local mode, and the shared-token capability cache is not served there. Adds a gate test. Codex review #90. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(auth): centralize the shared-token mode gate (root cause) Follow-up to baa236c: patching access_token_for_request + capability_probe_token fixed the two flagged sites but not the cause — eight consumers each read local_oauth.access_token() raw and decided independently, so the availability flags (local_token_available, bootstrap_ready, readiness "available") still advertised the env token outside local mode, and the gate logic was duplicated. Introduce one gated accessor, AppState::shared_local_access_token(), that returns the env token only in local mode, and route every identity- and availability-bearing consumer through it: the request fallback, the capability probe, to_status, auth_session_view, bootstrap_status, and server readiness. to_status now takes a precomputed local_token_available bool. So no caller can treat the shared token as identity (or advertise it) outside local mode, and the frontend is_local_deployment() gate becomes defense-in-depth rather than the only guard. The three remaining raw reads are intentional and documented: the accessor's own body, the bootstrap endpoint (already hard-gated to local mode), and the background poll of *public* recentchanges (not a per-user identity). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(auth): fail-closed local-mode gate + 401 from /operator/live Two Codex findings on #90: - is_local_deployment() treated an absent deploymentMode as local, so a split or static frontend that doesn't load /runtime-config.js from the API origin would show local-only setup/dev-token controls a vps/desktop server rejects. Fail closed: only an explicit `local` enables them (unknown -> non-local). - After the shared-token gating, /operator/live with no session returned the assembly's generic 502 (missing token), but the wasm auth refresh only re-gates on 401 — leaving the user stranded on a Patrol load error. The handler now returns 401 (token-gated like the action/citation routes) so the app drops back to the login gate. Adds an integration test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(patrol,citation): per-wiki namespace defaults + local-only citation bootstrap Two Codex findings on #90: - The patrol filter's default namespace checkboxes used a static [0,2,4,6,10,14] that only matches derived/frwiki configs. For a hand-configured wiki with a different allowlist (enwiki/testwiki are [0,1]), untouched checkboxes showed the wrong set while the server (empty query -> config.namespace_allowlist) returned the curated one — UI and reality disagreed. Add GET /wikis/{wiki_id} returning the resolved namespace allowlist, fetch it for the active wiki, and drive the FilterBar default from it so the checkboxes match server behavior. - The Citations view bootstrapped a dev session (/dev/auth/session/bootstrap) whenever unauthenticated, regardless of mode. In vps/desktop that route is forbidden (403, not 401), so the global 401 handler never re-gated and the user was stuck with "Auth bootstrap failed". The bootstrap is now gated to local mode; outside local mode the verify call 401s and the app re-gates to login. Tests: /wikis/{id} resolved namespaces. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Net-new Wikidata convenience verb (no prior Wikidata path in SP42). Fetches the entity via the keyless Special:EntityData endpoint, renders the statement into a natural-language claim (resolving property/item-value labels via wbgetentities), extracts the P854 reference URL, and verifies the claim against it with verify_claim. A statement with no reference URL returns SourceUnavailable (no model call). The rendered claim is always returned for inspection (rendering is best-effort; richer datatypes are follow-on — PRD-0010 open question #1). - generic over HttpClient + ModelClient; fully stub-tested (3 cases: full-path render+verify, no-reference SourceUnavailable, missing-entity error) - exposed as the third MCP tool; tool-router test asserts all three verbs Deferred (per review): verify_wikipedia_page needs the Parsoid editor extracted from sp42-server to a shared crate; Phase 6 (Tasks/progress/estimate_only) rides on the page decomposition. Both are a focused follow-up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ge3js4ukPTvs9hBBtnoGj
…okup join, distinct failure labels 1. Books consulted section: render book_resolutions (resolved outcomes only) with edition title and scan state (exact, similar, none, unknown) via new render_books_section() fn, positioned after skipped section before extraction failures. Adds 6 copy constants for scan-state vocabulary; keeps render_ga_appendix at 98 lines (under 100-line budget by extracting fn). 2. Per-source lookup-failure join: replace ref_id-only key with (ref_id AND identifiers) matching. For URL-less refs citing multiple books, each skip now joins to the matching BookResolution (not all LookupFaileds), so NotFound and LookupFailed outcomes render their distinct copy. Falls back to ref_id-only when skip.book_sources is empty. 3. Extraction-failure labels: unnamed cite_ref-<digits> now render as "an unnamed ref (block N)" using BlockFailure.block_ordinal (not global "ref #1"), so distinct failures stay distinct. Named refs stay on derived-name path. Sanitize_reason now takes block_ordinal parameter. Test coverage: added distinct_unnamed_refs_in_same_output_render_distinct_labels(), per_source_lookup_join_distinguishes_multiple_books_in_same_ref(), books_consulted_section_renders_resolved_editions(), plus block-anchored helper tests. All tests pass; cargo fmt, clippy -D warnings, check-design-system.sh, check-links.sh all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Summary
SP42's two content-edit actions —
InlineEditandTagCitationNeeded— currently apply edits via first-occurrence literalString::replacen(…, 1)(sp42-server/src/action_routes.rs:378,:411-423). That's fine for patrol/revert, but it can target the wrong occurrence, is brittle to whitespace/TOCTOU, can't express structural edits, and on a whole-page save a mis-located swap silently corrupts an article.ADR-0003 proposes replacing that with node-anchored editing behind a new
WikitextEditortrait (consistent with ADR-0001 §7 trait-DI), Parsoid-backed via theparsoidcrate, plus an immediate, parser-independent hardening: swap blindreplacen(…, 1)for an exactly-one-occurrence guard, closing the wrong-target / silent-corruption risk now.Motivation: an incoming collaboration (with @schiste — discussed beforehand) bringing LLM-assisted, human-confirmed citation verification & repair onto SP42, which needs structural, anti-drift edits
replacencannot express.This is a Proposed ADR for discussion per CONSTITUTION §4 — docs only, no code yet. It records the decision and the one protected contract change (a node
locatoronSessionActionExecutionRequest).Why Parsoid-first, in SP42's terms
parsoidcrate (mwbot-rs, GPL-3.0) — typedcite/Reference/TemplateAPI, Rust-native, no JS engine. A WASM build ofwikiparser-nodeis documented as the offline fallback; the trait seam makes the choice reversible.Validation evidence (2026-06-04, AI-assisted)
Round-trip spike against en.wikipedia
Cosmic latte(rev 1357330394), via the Parsoid REST transform with?stash=true+If-Match:{{cite}}templates: re-serialized only the edited template; all other refs/templates/prose byte-identical; the new param inserted in the template's existing|key=valstyle. Residual: light whitespace normalization within the edited template only.parsoidcrate (parsoid/src/api.rs): it transforms via core REST withflavor=editHTML but without the explicit stash/If-Matchselser flow — so its byte-level cleanliness isn't yet proven equal to the raw spike's (acceptance gate 2 below).Acceptance gates (resolve in review, before merge)
<ref>body into{{cite web|url=…}}) round-trips cleanly; the spike covered param modify/add only. [Update: I've validated these.]parsoid-crate serialization fidelity — doestransform_to_wikitextpreserve untouched wikitext byte-for-byte, or normalize more than selser? If it under-preserves: contribute explicit selser upstream, or pair the crate's manipulation with a bespoke stash/If-Matchtransform (record in Decision 6). [Update: I've validated these.]<ref>and cite-template ordinal spaces in the locator union now, or ship<ref>first and extend? (fold into Decision 3).If-Matchstash has aged out (pass the fulloriginalin the POST body).Per ADR hygiene these gates live in this PR (not the ADR body); their resolutions get folded into the body before merge so the immutable record carries no open TODOs.
Transparency: the ADR draft and the validation spike were AI-assisted (Claude). The spike was read-only — it used the stateless Parsoid HTML→wikitext transform and never wrote to any wiki.
🤖 Generated with Claude Code