Skip to content

feat(editor): wet zoeken in centraal corpus, promoten naar traject en harvest-fallback via taken#952

Merged
tdjager merged 10 commits into
mainfrom
feature/issue-4-corpus-promote-harvest
Jul 20, 2026
Merged

feat(editor): wet zoeken in centraal corpus, promoten naar traject en harvest-fallback via taken#952
tdjager merged 10 commits into
mainfrom
feature/issue-4-corpus-promote-harvest

Conversation

@tdjager

@tdjager tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Closes tdjager/development#4

Wat

Vanuit een traject een wet toevoegen in één flow:

  1. Zoeken — de "+" onder de wettenlijst wordt een menu; "Zoeken in het centrale corpus…" opent een popover (AddLawPopover) dat de bestaande traject-scoped corpus-zoek-API hergebruikt (dezelfde die de bibliotheekzoeker voedt — geen tweede zoekpad). Een BWB-id mag ook, direct getypt of via de wetten.overheid.nl-fallback.
  2. PromotenPOST /api/trajects/{ref}/corpus/laws/{law_id}/promote kopieert de volledige wet-map uit de centrale-corpus-seed naar de traject-repo: alle versie-YAML's inclusief machine_readable, plus de scenarios/*.feature-bestanden, in één commit via het bestaande traject-schrijfpad (zelfde autorisatie/commit-gedrag als save_law, inclusief user-token-override). Staat de wet al in de traject-repo (source_priority 0), dan is de knop uitgeschakeld en weigert de backend met 409 — geen dubbele bestanden.
  3. Harvest-fallbackPOST /api/trajects/{ref}/corpus/harvest start met een BWB-id een traject-scoped harvest via het taken-mechanisme: nieuw jobtype traject_harvest (migratie 0031) op de harvest-worker, dat de geharveste basis-wet valideert en expliciet een taak-flow-enrich ketent via het uit law_convert gedeelde chain_enrich_and_complete (een directe harvest chain't immers geen enrich). De aanvraag is direct zichtbaar in het takenpaneel ("Wet ophalen loopt – BWBR…"); het resultaat komt terug als law_create-review-taak en goedkeuren landt via het gewone law-create-pad (create_traject_law).

Waarom de traject-seed en niet de globale corpus-state

De promote leest uit het traject-gefedereerde corpus (de minbzk-central-seed): die index dekt de volledige centrale corpus (metadata-only, lazy bodies), terwijl de globale state in productie alleen favorieten materialiseert (load_favorites_async). Dit is ook exact de write-routing die save_law voor gefedereerde wetten gebruikt, dus paden blijven per constructie consistent.

Guards

  • Membership + editor-writer-rol op beide endpoints (bestaande route-middleware).
  • Promote: 409 op index-treffer in de eigen repo én belt-and-braces per doelbestand; 404 wanneer de wet niet in de seed staat.
  • Harvest: BWB-id-vormvalidatie (400), dedup per (traject, wet) onder advisory lock (409), bestaande per-account-taakcap telt traject_harvest nu mee.
  • Reaper-nazorg: een gereapte traject_harvest-job levert alsnog een job_failed-taak.

Tests

  • packages/editor-api/tests/promote_law_test.rs — kopieert versies + scenario's byte-voor-byte, 409 bij herhaalde promote, 404 bij onbekende wet (testcontainers).
  • packages/editor-api/tests/traject_harvest_request_test.rs — 202 + jobinhoud (deliver=task, prio 80), dedup-409, 400 op ongeldig id.
  • Unit: BWB-id-normalisatie, TrajectHarvestPayload-serde, taakcap/reaper-titels.
  • Vitest: AddLawPopover.test.js (zoekpad, sortering, al-in-traject, promote/409, BWB-fallback, harvest/409, direct BWB-id) en TasksPane.test.js (nieuw running-label). Volledige suite: 57 files / 579 tests groen.
  • Geen e2e toegevoegd: de flow vereist een draaiende editor-api + Postgres + harvest-worker; de Playwright-harness draait alleen Vite met route-interceptie, waarmee een e2e de component-tests zou dupliceren zonder extra dekking. Zeg het gerust als je hem toch wilt.

just check is groen (de drie untranslatables-lib-tests vereisen lokaal TESTCONTAINERS_HOST_OVERRIDE, zoals gedocumenteerd in test_utils.rs); ook editor-api-test en pipeline-integration-test draaien groen.

Custom CSS (criterium 7)

Geen. AddLawPopover is volledig opgebouwd uit nldd-*-componenten (zelfde popover/listbox-patroon als SearchPopover) en heeft geen <style>-blok; ook in LibraryView is geen styling toegevoegd.

Buiten scope (conform ticket)

Geen sync/versie-drift van gepromote wetten, geen recursieve harvest van gedelegeerde regelingen, geen bulk-promote, geen harvester-admin-wijzigingen.

tdjager added 4 commits July 20, 2026 12:23
…w-enrich

Nieuw jobtype traject_harvest: haal een wet uit BWB op voor één traject
via het taken-mechanisme, zonder de centrale corpus-repo aan te raken.
De harvest-worker draait deze jobs vóór de corpus-brede queue, valideert
de geharveste YAML met dezelfde validator als law-convert en ketent er
expliciet een taak-flow-enrich aan (new_law: het law_convert-patroon) —
een directe harvest chain't immers geen enrich. Het kettingstuk is uit
finish_law_convert_job getrokken naar chain_enrich_and_complete zodat
beide producenten dezelfde keten delen. Terminale fouten en gereapte
jobs leveren een job_failed-taak; lopende aanvragen zijn zichtbaar via
list_running_task_jobs_for_account.
POST /api/trajects/{ref}/corpus/laws/{law_id}/promote kopieert een wet
uit de centrale-corpus-seed van het traject naar de traject-repo: alle
versie-YAML's (inclusief machine_readable) plus de scenarios/*.feature-
bestanden, in één commit via het bestaande traject-schrijfpad (zelfde
autorisatie en commit-gedrag als save_law). Staat de wet al in de
traject-repo, dan 409 — geen dubbele bestanden. De bron is bewust het
traject-gefedereerde corpus: dat dekt de volledige seed, waar de globale
state in productie alleen favorieten materialiseert.

POST /api/trajects/{ref}/corpus/harvest start met een BWB-id een
traject-scoped harvest via het taken-mechanisme (prioriteit 80, per
(traject, wet) gededupliceerd, onder de bestaande per-account-taakcap
die nu ook traject_harvest meetelt).
…back

De "+" onder de wettenlijst wordt een menu met twee routes: zoeken in
het centrale corpus (nieuw AddLawPopover) en de bestaande document-
upload. Het popover hergebruikt de traject-scoped corpus-zoek-API die
ook de bibliotheekzoeker voedt (geen tweede zoekpad): treffers uit de
centrale seed krijgen "Toevoegen aan traject" (promote), wetten die al
in de traject-repo staan (source_priority 0) zijn uitgeschakeld met
"Al in dit traject", en zonder treffer start een BWB-id (direct getypt
of via de wetten.overheid.nl-zoeker) een traject-scoped harvest via het
taken-mechanisme. Het takenpaneel toont de lopende aanvraag als "Wet
ophalen loopt"; het resultaat komt als law_create-review-taak terug
langs het bestaande reviewpad.
…liceert de BWB-rij

Review-fixes op de wet-toevoegen-flow:

- promote_corpus_law overschreef een scenario-file die al in de
  traject-repo stond stilletjes met de seed-versie. save_scenario
  routeert scenario's van gefedereerde wetten naar de writable-own
  zonder dat er een wet-YAML in de traject-repo staat, dus zo'n
  traject-edit ontsnapte aan beide 409-checks. Bestaande bestanden
  worden nu overgeslagen (traject-edit wint); voor wet-YAML's blijft
  het een 409. Regressietest toegevoegd.
- AddLawPopover toonde twee rijen voor hetzelfde BWB-id zodra de
  externe zoeker het getypte id ook vond; de directe rij verdwijnt nu
  zodra het externe resultaat er is. De begeleidende tekst claimde
  bovendien 'Niet in het centrale corpus' terwijl de corpus-zoeker
  niet op BWB-id matcht — neutraal geformuleerd.
@tdjager

tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Review-ronde afgerond (volledige diff + omliggende code, checks lokaal gedraaid). Fixes gepusht in 0675278.

Gevonden → gefixt

  1. Promote overschreef traject-bewerkte scenario's (promote_corpus_law): save_scenario routeert scenario's van gefedereerde wetten naar de writable-own zónder dat er een wet-YAML in de traject-repo staat. Zo'n traject-edit ontsnapte aan zowel de index-409 als de per-bestand-check (die alleen .yaml bekeek) en werd bij promote stilletjes vervangen door de seed-versie. Nu: bestaande bestanden worden overgeslagen (traject-edit wint, zelfde regel als source_priority 0); voor wet-YAML's blijft het een harde 409. copied_files telt alleen echt geschreven bestanden. Regressietest toegevoegd (promote_keeps_a_traject_edited_scenario_of_a_federated_law).
  2. Dubbele rij voor hetzelfde BWB-id (AddLawPopover): de direct-getypte BWB-rij bleef staan naast het wetten.overheid.nl-resultaat voor hetzelfde id (zelfde knop, zelfde state). De directe rij verdwijnt nu zodra het externe resultaat binnen is; test toegevoegd.
  3. Misleidende tekst "Niet in het centrale corpus" bij de directe BWB-rij: de corpus-zoeker matcht niet op BWB-id (alleen law-id/naam), dus nul treffers bewijst niet dat de wet buiten het corpus valt — een wet die er wél in staat werd zo als afwezig gepresenteerd. Tekst neutraal geformuleerd.

Beoordeeld en bewust gelaten

  • Zoeken op BWB-id vindt geen corpus-treffers (de index draagt geen bwb_id; centrale-seed-entries zijn metadata-only, dus die matching zou per zoekopdracht body-fetches vergen). "Een BWB-id mag ook" wordt gedekt via de directe harvest-rij; wie via naam zoekt vindt de corpus-wet gewoon. Punt 3 hierboven haalt de valse claim weg.
  • Promote leest uit de traject-seed i.p.v. de globale corpus-state: klopt — de traject-index dekt de volledige seed (lazy bodies, incl. lazy fetch in collect_promote_files), de globale state materialiseert in productie alleen favorieten, en het is dezelfde routing als save_law. Niet-geseede wet ⇒ nette 404.
  • Geen-e2e-motivering is houdbaar: de Playwright-harness draait alleen Vite met route-interceptie (frontend/playwright.config.js), dus een e2e zou dezelfde mocks gebruiken als de componenttests.
  • Lock-orde, foutmatch "er loopt al een verrijking" (lowercase, klopt met law_convert.rs), reaper-nazorg, taakcap-telling, typed claim_job-filters, migratie-idempotentie en de 404-op-lege-scenarios-dir (list_directoryOk([])) allemaal geverifieerd — geen issues.

Checks: clippy alle packages groen; pipeline-test 89/89; editor-api-tests groen incl. integratie (promote 4/4, traject-harvest 3/3, testcontainers); frontend-vitest volledig 580/580; cargo fmt --check --all schoon.

@tdjager
tdjager marked this pull request as ready for review July 20, 2026 15:13
@tdjager

tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Ship-pipeline afgerond: PR uit draft gehaald, de CI-getriggerde claude-review draaide op HEAD 0675278 en leverde geen bevindingen op, en alle CI-checks op de laatste commit zijn groen. Klaar voor beoordeling.

…taten

Een zoektreffer die wél in het centrale corpus zit maar nog niet in de
eigen traject-repo krijgt nu direct een "Toevoegen aan traject"-knop in
de gewone zoeker (SearchPopover) — de aparte "Wet toevoegen"-flow via
het plusmenu is niet langer de enige route. De promote-logica (POST
/promote, per-wet busy-state, al-in-traject-set op source_priority 0,
409-afhandeling) is uit AddLawPopover losgetrokken naar een gedeelde
composable useLawPromote; beide flows gebruiken dezelfde implementatie.
Na een geslaagde promote sluit de popover en emit hij "promoted" met
dezelfde deferral als select-law; LibraryView ververst de bronnenlijst
en opent de wet, EditorView ververst de gedeelde corpus-lijst en
navigeert naar de bibliotheek. Buiten een traject (globale corpus-
weergave) verschijnt de knop niet.
@tdjager

tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Revisie n.a.v. feedback op het ticket: de "Toevoegen aan traject"-knop zit nu ook direct in de gewone zoekresultaten — een treffer die wél in het centrale corpus zit maar nog niet in de eigen traject-repo krijgt de knop meteen, zonder eerst de "Wet toevoegen"-flow via het plusmenu te openen (die flow blijft bestaan voor de harvest-fallback).

Wat er wijzigde (commit 352ebc4):

  • Promote-logica uit AddLawPopover.vue losgetrokken naar een gedeelde composable useLawPromote.js (POST /promote, per-wet busy-state, al-in-traject-set op source_priority 0, 409-afhandeling) — één implementatie voor beide flows.
  • SearchPopover.vue: promote-knop per federatie-treffer (click.stop, dus de rij-navigatie blijft werken), "Al in dit traject" na een 409, foutbanner bij andere fouten, en een promoted-emit met dezelfde close-deferral als select-law. Buiten een traject verschijnt de knop niet.
  • LibraryView.vue (bronnenlijst verversen + wet openen) en EditorView.vue (corpus-lijst verversen + naar bibliotheek) verwerken de nieuwe emit.

Geen custom CSS toegevoegd: uitsluitend bestaande ndd-componenten (nldd-button/nldd-cell/nldd-list-item-patroon uit AddLawPopover). Vitest: 4 nieuwe traject-scoped tests + 1 globale-scope-test (knop-zichtbaarheid, promote-POST/emit, 409, foutpad); volledige frontend-suite 585/585 groen, just check groen.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Reviewed the diff end-to-end: promote_corpus_law + collect_promote_files (editor-api), request_traject_harvest (task_requests.rs), the new traject_harvest pipeline module + worker integration, migration 0031, and the frontend (AddLawPopover.vue, useLawPromote.js, SearchPopover.vue, TasksPane.vue).

No blocking issues found. Specifically checked and confirmed sound:

  • Auth: both new routes (/promote, /corpus/harvest) sit under the existing account_middleware + require_role("editor-writer") route_layer group in main.rs, matching the PR description.
  • Race safety on promote: resolve_traject_documents_writer takes an owned lock on the writable-own backend before the per-file existence check, and that lock is held through the write+persist. Two concurrent promotes for the same law correctly serialize — the second sees the just-written files and 409s. collect_promote_files (which reads from seed backends) intentionally runs before the lock is acquired, per the documented writable-own → seed lock-order invariant.
  • "Already in traject" guard correctness: source_map's single-entry get_law() always prefers the lowest source_priority (own repo = 0 beats seed), so checking get_law(law_id).source_id == writable_own_source_id correctly detects "already promoted" regardless of which source has the in-force version — verified against SourceMap::insert's conflict resolution.
  • Version/file collection: get_law_versions is documented and implemented as newest-first, so versions[0] (used for both the scenario directory and record_save's "newest" pick) is correct.
  • Path safety: seed-sourced relative_path values are checked for .. and absoluteness before being reused as traject-repo write paths.
  • Dedup/cap SQL: the new traject_harvest advisory-lock + dedup query and the job-cap query are parameterized (no injection), and mirror the existing harvest_request/enforce_task_job_cap pattern exactly.
  • Exhaustiveness: all match JobType { ... } sites (law_status.rs, tasks.rs) got explicit new arms for TrajectHarvest; no wildcard arm silently swallows it. String-literal job-type IN-lists were checked individually — document_convert.rs's upload-GC list correctly excludes traject_harvest (it has no upload row), while the task-list/job-cap queries correctly include it.
  • Deterministic-vs-transient error classification in process_next_traject_harvest_job uses ad hoc string matching (msg.contains("valideert niet tegen het schema"), "er loopt al een verrijking") rather than the existing is_deterministic_content_failure helper — but that helper targets a different error domain (enrich/Yaml errors), so this isn't duplicated logic, and the matched substrings do correspond to the actual PipelineError::Enrich/harvester error text.
  • Frontend: no v-html/raw HTML injection surface (all custom-element bindings via :text), no custom CSS added (consistent with the PR's own criterion-7 claim), icon aliases (harvest) used correctly per CLAUDE.md.

Test coverage (Rust integration tests for promote 409/404/scenario-preservation, harvest dedup/400, Vitest for both popovers) looks proportionate to the risk surface, including the trickier case (a traject-edited scenario surviving a promote of the parent law).

Nothing else stood out as in-scope for this diff.

…ens de promote-POST

Sluit de gebruiker de zoekpopover terwijl de promote-POST nog loopt, dan
resolvet die daarna alsnog 'done' en bleef pendingPromotedLawId hangen:
close() op een al gesloten nldd-popover is een no-op (geen 'close'-event),
dus de eerstvolgende ongerelateerde close emitte een stale 'promoted' en
verdrong daarbij ook een legitieme select-law. onPopoverOpen reset nu de
pending emits van een vorige sessie; regressietest toegevoegd.

Daarnaast focust LibraryView na een promote uit de gewone zoekresultaten
nu ook het sidebar-item (focusAfter), net als select-law uit dezelfde
popover - dat is precies waarvoor de 'promoted'-deferral bestaat. De
AddLawPopover-route blijft ongewijzigd (geen focus).

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correctness / Security

🔴 Critical — unsanitized traject_ref reaches remove_dir_all/create_dir_all on the harvest worker

packages/pipeline/src/traject_harvest.rs, execute_traject_harvest:

let work_dir = std::env::temp_dir().join(format!(
    "trajectharvest-{}-{}",
    payload.traject_ref, payload.bwb_id
));
let _ = tokio::fs::remove_dir_all(&work_dir).await;
tokio::fs::create_dir_all(&work_dir).await?;
...
let _ = tokio::fs::remove_dir_all(&work_dir).await;

payload.traject_ref is the raw {traject_ref} URL path segment from POST /api/trajects/{traject_ref}/corpus/harvest, carried unmodified from task_requests::request_traject_harvest into the job payload and from there into this worker path. It is not validated for path-unsafe characters before being embedded in a filesystem path:

  • resolve_traject_ref (packages/editor-api/src/trajects.rs) only validates the trailing 8-hex-char suffix of the ref (used for the DB lookup); the "slug" prefix is explicitly documented as "cosmetic" and accepts any ASCII content, including / and ...
  • bwb_id is tightly validated (normalize_bwb_id: BWB + alnum only), so that half is safe — but traject_ref gets no equivalent treatment.
  • axum's Path<String> extractor percent-decodes the matched segment after routing has split on the raw (undecoded) path, so a percent-encoded slash (%2F) in the URL survives routing and becomes a literal / in the extracted traject_ref string, at which point it is a real path-traversal-capable value.

Since a valid ref only needs to end in the 8 hex characters of a traject the caller is a member of, an authenticated member can freely choose the rest of the string. Combined with remove_dir_all being called on the resulting path (both before and after the harvest), this lets an authenticated traject member cause the worker to recursively delete a directory of their choosing on the harvest-worker host (any directory the worker process can write to), not just its own temp scratch dir.

The sibling implementations this code explicitly mirrors (document_convert, law_convert) avoid this entirely by keying their temp directory off a server-generated UUID (upload_id), not user/URL-supplied text:

std::env::temp_dir().join(format!("docconvert-{}", payload.upload_id))
std::env::temp_dir().join(format!("lawconvert-{}", payload.upload_id))

TrajectHarvestPayload already carries traject_id: Uuid — swapping payload.traject_ref for payload.traject_id in the work_dir format string removes the vulnerability with no behavior change, consistent with the existing pattern.

Code quality

🟡 Minor — fragile string-matching for retry-vs-terminal classification

packages/pipeline/src/worker.rs, process_next_traject_harvest_job:

let deterministic = matches!(
    e,
    PipelineError::Harvester(regelrecht_harvester::HarvesterError::NoConsolidatedText { .. })
) || msg.contains("valideert niet tegen het schema")
    || msg.contains("er loopt al een verrijking");

Unlike the NoConsolidatedText arm (matched on the typed error variant), the other two branches classify by matching substrings of the formatted, Dutch, user-facing error message produced elsewhere (traject_harvest.rs's schema-validation error and law_convert::chain_enrich_and_complete's dedup error). A future wording change to either message silently breaks this classification (a deterministic failure would then retry 3× instead of failing fast, or vice versa) with no compiler signal. Not incorrect today, but worth a typed error variant or error code instead of string matching if this is expected to be maintained going forward.


No other blocking issues found. The promote endpoint's write path is correctly serialized through the writable-own backend's mutex (resolve_traject_documents_writerlock_owned()), so I don't think concurrent promote requests for the same law are actually racy despite there being no explicit advisory lock (unlike the harvest-request dedup) — the per-file existence check plus the held mutex through persist() covers it.

…niet op de ref

De work-dir van execute_traject_harvest gebruikte payload.traject_ref - een
URL-pad-segment waarvan alleen het 8-hex-suffix gevalideerd wordt. Het
slug-deel is vrije ASCII-tekst en een percent-encoded '/' overleeft axum's
routing, waarna de ref als path-traversal-waarde in een pad terechtkwam dat
door remove_dir_all/create_dir_all gaat. Nu keyt de work-dir op het
server-gegenereerde payload.traject_id (Uuid), hetzelfde patroon als
docconvert/lawconvert (upload_id).

Bijvangst uit dezelfde review-ronde: de deterministisch-vs-transiënt-
classificatie in process_next_traject_harvest_job matchte op losse
string-literals van foutteksten elders; die markers zijn nu gedeelde consts
(SCHEMA_MISMATCH_MARKER, ENRICH_IN_PROGRESS_MARKER) zodat een herformulering
de classificatie niet stilletjes kan breken.
@github-actions github-actions Bot deleted a comment from claude Bot Jul 20, 2026
@github-actions github-actions Bot deleted a comment from claude Bot Jul 20, 2026
De snapshot-refresh is stale-while-revalidate: een request na de TTL
serveert de stale snapshot en start de her-enumeratie in een gespawnde
background-task. De test asserteerde in één shot direct na de
upstream-writes, maar een refresh die door een eerdere request gestart is
kan de bronnen net vóór die writes enumereren - dan serveert de volgende
request een verse snapshot zónder de nieuwe wet en faalde de test op
task-scheduling (flaky; zo op CI gefaald op een commit die editor-api
niet raakt). Poll nu bounded (max 5s) tot een request de post-write
snapshot geserveerd krijgt; zelfde aanpak voor de convergentie-assert.
De asserties zelf zijn ongewijzigd.
@tdjager

tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Review-ronde over de revise-commit 352ebc4 afgerond (diff in volle context, frontend-suite lokaal gedraaid, CI-review verwerkt).

Gevonden → gefixt

  1. Stale promoted-emit in SearchPopover (3c29b0a): sloot de gebruiker de popover terwijl de promote-POST nog liep, dan bleef pendingPromotedLawId hangen (close() op een al gesloten nldd-popover is een no-op zonder close-event) en vuurde de eerstvolgende ongerelateerde close een stale promoted af, die bovendien een legitieme select-law verdrong. onPopoverOpen reset nu de pending emits; regressietest toegevoegd (bewezen falend zonder de fix).
  2. Focus-consistentie LibraryView (3c29b0a): promote vanuit de gewone zoekresultaten focust nu — net als select-law uit dezelfde popover — het sidebar-item (focusAfter); dat is precies waarvoor de emit-deferral bestaat. De AddLawPopover-route is ongewijzigd.
  3. Kritieke bevinding CI-review (c6cadf6): de work-dir van execute_traject_harvest keyde op de raw traject_ref (alleen het 8-hex-suffix wordt gevalideerd; een percent-encoded / overleeft axum-routing) in een pad dat door remove_dir_all/create_dir_all gaat. Nu gekeyd op het server-gegenereerde traject_id (Uuid), zelfde patroon als docconvert/lawconvert. De minor uit dezelfde review (string-matching in de deterministisch-classificatie) is opgelost met gedeelde marker-consts.
  4. Flaky CI-test (68837e1, buiten de PR-diff maar hij blokkeerde CI): ttl_refresh_picks_up_upstream_laws_and_reconciles_saves asserteerde one-shot op een stale-while-revalidate background-refresh en faalde op task-scheduling — reproduceert ook op main. Pollt nu bounded; asserties ongewijzigd.

Gecheckt, geen bevinding: gedragsbehoud van de useLawPromote-refactor in AddLawPopover (busy-guard, 409→al-in-traject, foutbanner identiek), knop-zichtbaarheid (alleen federatie-treffers binnen een traject, eigen repo/409 → "Al in dit traject"), hide?.()-bijvangst (alleen test-omgeving, prod ongewijzigd), EditorView-afronding consistent met onSearchHarvestAvailable.

Frontend 586/586 groen, pipeline/editor-api-tests lokaal groen, CI volledig groen op 68837e1.

tdjager added 2 commits July 20, 2026 19:51
…ice-token — promote 403-fix

Op de pr952-preview faalde POST …/promote met 403 "Source is read-only"
(traject duo-46921d4d, git-backed met subpath). Rootcause, bevestigd in de
deployment-logs ("traject writable-own source resolved NO token — push
will fail"): het traject is aangemaakt op een user-gekozen repo, dus er
bestaat bewust géén CORPUS_AUTH_*-service-token voor die repo
(fail-closed). De writability-gate keek uitsluitend naar het rest-token
(BackendEntry.writable), terwijl de deployment in de
user-token-schrijfmodus staat (github.user_oauth aan): elke write hoort
daar via het gekoppelde GitHub-token van de acterende gebruiker te lopen
(WriteContext::token_override). Elke traject-write — promote én
save_law/save_scenario/documents — 403'de dus op deze config; de lokale
tests dekten alleen local-source writable-owns.

- corpus_handlers: één gedeelde gate (require_traject_backend_writable):
  read-only-at-rest mag door wanneer de backend token_override
  ondersteunt én de deployment in de user-token-modus staat; daarna
  handhaaft user_write_token_for_backend het gekoppelde token (428).
- github_oauth: user_token_write_mode helper (write_requires_user_token,
  tolerant voor een niet-geconfigureerde OAuth-integratie).
- GitHubApiBackend: write_file/delete_file bufferen zonder rest-token
  (de token-guard leeft nu in persist: ReadOnly zonder énig token);
  persist bootstrapt de traject-branch lazy met het effectieve token
  (ensure_branch gedeeld met ensure_ready) — zonder rest-token maakt
  ensure_ready de branch immers niet aan.
- GitHubFetcher: GITHUB_API_BASE-override (test-seam voor wiremock diep
  in build_traject_corpus; tevens GHES-seam). Productie laat 'm ongezet.
- Integratietest promote_user_token_write_test: deployed-achtige config
  (lokale read-seed + GitHub writable-own met gh_path-subpath en zonder
  token-env, user-token-modus aan, wiremock als GitHub). Zonder de fix
  faalt hij met exact deze 403; met de fix: 428 zonder gekoppeld token,
  promote kopieert de wet-map met het user-token onder de subpath, en
  een law-save volgt aantoonbaar hetzelfde schrijfpad.
…d-route in de popover

Tims feedback: de plus in het linkermenu opende eerst een dropdown-menu —
die tussenstap is weg. De "+" triggert nu direct de AddLawPopover
(zoeken in het centrale corpus, promoten, harvest-fallback). Het tweede
menu-item ("PDF of DOCX uploaden…", de conversie-naar-wet-keten) is
niet stilletjes verdwenen maar verhuisd naar de popover als
secundaire-knop onder de zoekresultaten; de popover sluit vóór de emit
zodat de file-picker (die in LibraryView leeft) niet achter het popover
opent. Vitest dekt de nieuwe route (emit + sluiten).
@tdjager

tdjager commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator Author

Rework verwerkt (2 punten):

1. Promote-403 op de preview gefixt (f5765f1). Rootcause — bevestigd in de pr952-deployment-logs (traject writable-own source resolved NO token — push will fail) — wijkt af van de eerdere hypothese: promote en save gebruikten al exact dezelfde write-routing, maar de writability-gate keek alleen naar het service-token, terwijl deze deployment in de user-token-schrijfmodus staat (github.user_oauth aan) en het traject op een user-gekozen repo draait waarvoor bewust géén CORPUS_AUTH_*-token bestaat. Daardoor 403'de élke traject-write op deze config (save incl.). Fix: één gedeelde gate die de user-token-modus kent, GitHubApiBackend die zonder rest-token buffert en bij persist met het override-token commit én de traject-branch lazy bootstrapt. Nieuwe integratietest promote_user_token_write_test bootst de deployed config na (GitHub writable-own met subpath zonder token-env, read-only seed, user-token-modus, wiremock): zonder de fix faalt hij met exact 403 Source is read-only; met de fix: 428 zonder gekoppeld token (koppel-flow), geslaagde promote onder de subpath met het user-token, en een law-save over hetzelfde schrijfpad.

2. Plus-knop zonder dropdown (5012573). De "+" in het linkermenu opent nu direct de "Wet toevoegen"-zoeker. Het tweede menu-item (PDF/DOCX-upload → conversieketen) is verhuisd naar de popover als secundaire knop onder de zoekresultaten.

Checks: just check groen, alle editor-api-tests (incl. testcontainers) groen, corpus-tests groen, frontend-vitest 587/587. Geen custom CSS (alleen ndd-componenten).

Kanttekening voor de preview: het ontbrekende service-token betekent ook dat reads van de private traject-repo daar blind blijven (index/werkdocumenten/dedup-check zien de repo niet — pre-existing, ook op main). Schrijven werkt nu wél, als de gebruiker; voor volledige functionaliteit blijft een read-token (CORPUS_AUTH_…) in de omgeving aan te raden.

@claude

claude Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Review

Went through the full diff: the AddLawPopover/SearchPopover promote UI, the shared useLawPromote composable, the promote_corpus_law handler and its file-collection/lock-ordering logic, the GitHubApiBackend lazy branch-bootstrap + deferred token-guard change, the new traject_harvest job type end-to-end (request handler → pipeline payload → worker → chained enrich), and migration 0031.

No Critical or Significant issues found. Specifically checked and found sound:

  • Authz: /promote and /corpus/harvest routes sit under traject_writer_routes, gated by account_middleware + require_role("editor-writer") — same as save_law.
  • Races: the promote write path takes the writable-own backend's per-source mutex before writing/persisting, so a second concurrent promote for the same law correctly re-observes the just-written file and 409s; collect_promote_files only ever holds one seed-backend lock at a time and releases it before the writable-own lock is acquired, so the documented "writable-own → seed" lock-order invariant can't be violated. The traject_harvest request dedup uses pg_advisory_xact_lock keyed on (traject_ref, bwb_id), closing the same TOCTOU window called out in the PR description.
  • SQL: all new queries are parameterized (no string-built SQL).
  • Input validation: normalize_bwb_id bounds match the frontend's bwbIdQuery regex (6–20 chars incl. BWB prefix); law_name is capped at 200 chars before landing in job payloads/titles.
  • Path safety: collect_promote_files rejects ../absolute relative_path from the index before using it as a write path; the harvest work-dir is keyed by the traject's UUID, not the user-controlled traject-ref slug.
  • GitHub backend change: moving the "no token" guard from write_file/delete_file into persist is consistent — writes still hard-fail (as ReadOnly) if neither a configured token nor a per-call override is present by persist time, and the pending buffer is dropped on that error.

Minor: none worth flagging beyond the above — the tricky bits (409 dedup, deferred-emit-after-close focus handling, stale-pending-emit reset, TOCTOU on branch creation) all have direct test coverage.

@tdjager
tdjager merged commit 5c1272c into main Jul 20, 2026
30 checks passed
@tdjager
tdjager deleted the feature/issue-4-corpus-promote-harvest branch July 20, 2026 20:23
tdjager added a commit that referenced this pull request Jul 21, 2026
…can-falen per source (#953)

* fix(editor): traject-eigen indexscan resolvet token strikt en meldt scan-falen per source

De promote-flow (#952) schrijft een wet met het user-OAuth-token naar de
traject-repo, maar de server-side indexscan van de writable-own source
las met een ánder token-resolutiepad: 'resolve_token_for_source' mét
legacy 'CORPUS_GIT_TOKEN'-fallback, waar push/backend strikt resolven.
Zonder geconfigureerd per-repo token scant de server een privé-repo dan
unauthenticated, krijgt 404 van de Trees-API, en valt de wet stil terug
op het centrale corpus — de traject-source toont alleen 'law_count: 0'.

- 'Source.strict_auth' (gezet voor writable-own rows) laat élk
  token-resolutiepad — indexscan, favorites-fetch, backendconstructie —
  dezelfde strikte regels volgen via 'auth::resolve_source_token'. Dit
  sluit ook het leespad-lek waarbij de legacy-fallback het centrale
  token naar een user-gekozen repo zou sturen.
- 'index_all_sources_async' geeft per gefaalde source de fout terug
  ('SourceIndexFailure'); 'build_traject_corpus' bewaart die in
  'CorpusState.index_failures' en GET /api/trajects/{ref}/sources (en
  /api/sources) tonen ze als 'index_error' per source — scan-falen is
  niet langer stil.
- error!-log bij een gefaalde writable-own-scan draagt nu de fout mee;
  de bestaande 'NO token'-diagnostiek benoemt naast pushes ook reads.
- Integratietest reproduceert het prod-symptoom (promote slaagt,
  onleesbare repo → fallback naar seed met priority 2 + index_error) en
  pint het happy path: gepromote wet zichtbaar op source_priority 0.

* build(deps): bump brace-expansion naar 2.1.2 voor GHSA-3jxr-9vmj-r5cp
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