Skip to content

Retract documents a source took off a meeting - #329

Merged
joepio merged 1 commit into
mainfrom
claude/project-thread-7tebvm
Oct 1, 2026
Merged

joepio merged 1 commit into
mainfrom
claude/project-thread-7tebvm

Conversation

@joepio

@joepio joepio commented Oct 1, 2026

Copy link
Copy Markdown
Member

Requested by Joep · project thread

Before: when a griffie deleted a document at the source, it stayed in search, in object storage and in the export feed until someone removed it by hand. That held even though the nightly import fetched its meeting again.

After: if the nightly import finds a document taken off a meeting and the supplier confirms the file is gone, the document disappears from search, its stored files are deleted, and the export feed gets a tombstone. Nothing is blocklisted, so if the source publishes the document again it comes back.

How: SourceRemovalTracker (src/pipeline/source_removals.ts) compares each meeting's attachment list with the copy in the export log, just before the new version is committed. After extraction, the dropped documents go through these guards in order:

  • The meeting must still list at least one of its earlier documents. An agenda that loses every document is skipped and logged, because an emptied agenda or a changed id scheme looks the same.
  • The document must not have been emitted elsewhere in the same run (moved).
  • No other live meeting or motion of the source may still list it. This uses one pass over those export-log rows (findReferencedEntityIds).
  • The run must have at most WOOZI_SOURCE_REMOVALS_MAX_PER_RUN candidates (default 25). Above that, nothing is removed and a run warning is logged.
  • The supplier has to say the document is gone, using the revalidation sweep's calibrated answers: iBabs 403/404, Notubiz 400 with <error_code> (src/documents/source_presence.ts). Before any candidate is checked, a document the meeting still lists must answer "live". That way an outage or an iBabs block cannot pass for a removal. A document that was only unlinked from the agenda but still downloads is kept.

Retraction reuses the takedown steps in src/ops/delete_document.ts except the blocklist. It ingests delete markers with a removed_at_source kind and creates one batched Quickwit delete task per run, since a marker alone does not hide full-text hits. It then deletes the S3 prefixes and records the tombstone before the run's export flush.

Scope and limits:

  • iBabs and Notubiz only; the other suppliers have no calibrated "gone" answer.
  • It only sees meetings inside the run's window (daily: -7..+7 days).
  • Register-only documents are not covered.
  • WOOZI_SOURCE_REMOVALS=0 turns it off.
  • iBabs probes go through ibabsDownloadRateLimiter, at most 26 per run.

Checks: deno test --no-run -A is clean, deno test -A tests/ gives 371 passed, oxlint adds no warnings and oxfmt --check is clean on the changed files. The new test file is tests/source_removals.test.ts with 11 cases.


Generated by Claude Code

An import only ever added: a document the supplier stopped listing stayed
in search, storage and the export feed until someone removed it by hand,
even when the meeting it belonged to was fetched again the next night.

Each run now compares a meeting's attachment list with the one in the
export log. A dropped document is retracted (delete marker, one batched
Quickwit delete task, object storage, export tombstone; no blocklist, so a
republished document comes back) only when:

- the meeting still lists at least one of its earlier documents,
- the document was not emitted elsewhere in the run and no other live
  meeting or motion of the source lists it,
- the run has at most WOOZI_SOURCE_REMOVALS_MAX_PER_RUN (25) candidates,
- the supplier confirms it is gone with the revalidation sweep's
  calibrated responses, after a still-listed document answered "live".

iBabs and Notubiz only; WOOZI_SOURCE_REMOVALS=0 turns it off.
@joepio joepio self-assigned this Oct 1, 2026
@guidol-ive

Copy link
Copy Markdown

Kleine aanvulling qua auditability / herleidbaarheid en controleerbaarheid.

Als documenten ingetrokken worden, dan is dat niet erg. Ik lees echter niks over een audit trail die dit beschrijft. Ik zal een analyse toevoegen, want het raakt de rechtszekerheid als openbesluitvorming.nl ook dient als middel om aan wettelijke eisen te voldoen.

@guidol-ive

Copy link
Copy Markdown

Intrekken bij de bron: wel doen, maar met een controleerbaar spoor

Dat een document verdwijnt als de bron het intrekt, is juist. Deze reactie gaat over wat er daarna overblijft. Na een intrekking moet nog vast te stellen zijn dat het document bestaan heeft, in welke periode, bij welke vergadering en waarom het verdween. Dat lukt nu maar gedeeltelijk. Deze PR voegt een pad toe dat documenten verwijdert zonder reden en zonder bewijs.

Alle verwijzingen gaan naar commit a8f9bdf.

Waarom dit nodig is

  • Raadsleden, journalisten, onderzoekers, Woo-verzoekers en procespartijen gebruiken openbesluitvorming.nl om vast te stellen wat op een bepaald moment openbaar was. Trekt de bron een stuk in, dan is het platform vaak de enige onafhankelijke plek waar nog blijkt dat het bestond. Rechtszekerheid vraagt dat die vaststelling reproduceerbaar is en op bewijs berust, niet op een logregel die na rotatie weg is.
  • De Woo legt voor bestuursorganen hetzelfde patroon vast: een niet-openbaarmaking wordt gemeld (art. 3.3 lid 8), een onjuistheid ook (art. 2.4 lid 5). Een stuk dat zonder spoor verdwijnt, past niet in dat patroon.
  • Ter afbakening: dit is geen wettelijke plicht van het platform zelf. Art. 3.3 lid 2 onder c (vergaderstukken van gemeenteraden, provinciale staten en waterschappen) is in de versie die geldt vanaf 15 augustus 2026 nog niet in werking. Art. 3.3b wijst daarvoor straks de infrastructuur van de minister aan. Het belang van de vaststelling staat daar los van.

Wat na een intrekking nog vast te stellen is

Wel:

  • De changes-feed bewaart in de onveranderlijke segmenten (exports/{source}/changes/*.ndjson) het eerdere upsert-record met naam, original_url, bestandsnaam, datum en organisatie (project.ts L253-267), plus de tombstone met tijdstip. De eerdere versie van de vergadering noemt het document in attachment.
  • Bestaan en periode zijn dus te reconstrueren, maar alleen door de feed vanaf het begin terug te lezen.

Niet:

  • Via de API. GET /api/entities/{id} leest uit Quickwit (search_api.ts L1546-1557) en geeft na de intrekking 404 entity_not_found (server.ts L855-860). Dat is hetzelfde antwoord als voor een id dat nooit bestond.
  • De reden. De tombstone heeft geen veld dat "door de bron ingetrokken" onderscheidt van een takedown (log.ts L152-168), terwijl API.md in deze PR beide oorzaken noemt.
  • Het bewijs dat de bron het document niet meer levert: de HTTP-status, de Notubiz-<error_code> en het antwoord van het controledocument. Dat gaat alleen naar stdout (ingest.ts L319-380).
  • De inhoud. De S3-prefixen worden geleegd en er is geen hash van het originele bestand. content_hash is een sha256 over het entity-record (entity_commit.ts L38-45), niet over het bestand.

Technische discrepanties

  1. AGENTS.md spreekt zichzelf tegen (L148-170). Volgens de nieuwe alinea verwijdert de import automatisch na één bevestiging. Volgens de alinea erna meldt de sweep pas na 3 opeenvolgende dagelijkse misses, met "never auto-deleted — review and delete via scripts/delete_document.ts". Deze PR verandert het beleid van een menselijk besluit naar een automatisme, maar zegt dat nergens.
  2. Dezelfde kalibratie staat er twee keer in. scripts/revalidate_documents.ts heeft een eigen classifyIbabs en classifyNotubiz met een timeout van 10 s (L57, L101-129). src/documents/source_presence.ts doet hetzelfde met 30 s (L17). De sweep is niet omgezet, dus de twee antwoorden op dezelfde vraag kunnen uit elkaar gaan lopen.
  3. De storingsdetectie verschilt. De sweep herkent een storing aan de vorm (minstens 95 % "gone" bij minstens 10 documenten per bron) en meldt pas na 3 runs (L60-62). De import gebruikt één controledocument per run en één probe per kandidaat.
  4. De reden staat wel in Quickwit, maar verdwijnt daar weer. De delete-marker krijgt removed_at_source, maar de delete-task van dezelfde stap verwijdert "all copies, including the markers" (delete_document.ts L10-11). In de feed komt de reden nooit.
  5. Een takedown legt een reden vast, een intrekking niet. De takedown schrijft reason naar de blocklist (L184). retractDocumentsGoneAtSource schrijft nergens een reden.
  6. Een geslaagde intrekking levert geen run issue op. Alleen een overschreden cap (L333) en een exceptie (L374-378, met een algemene melding zonder document-id's) worden een run issue. Ingetrokken, behouden en overgeslagen documenten gaan alleen naar console.log.
  7. Een intrekking die halverwege mislukt, is niet te herstellen. retractDocumentsGoneAtSource maakt eerst de markers en de delete-task voor alle documenten aan en verwijdert daarna per document S3 en schrijft de tombstone (L237-275). Faalt S3 bij document k, dan zijn de documenten k tot en met n verdwenen uit zoeken en misschien deels uit S3, terwijl de feed ze nog als upsert toont. De nieuwe versie van de vergadering is in dezelfde run al vastgelegd, dus de volgende run ziet geen kandidaat meer. De sweep ziet ze ook niet meer, want die controleert alleen wat Quickwit nog serveert.
  8. Hetzelfde feit, twee uitkomsten. Binnen het importvenster van −7 tot +7 dagen verwijdert de import automatisch en zonder reden. Daarbuiten meldt de sweep het en verwijdert een mens met reden.
  9. Het verwijderpad is niet getest. tests/source_removals.test.ts importeert alleen SourceRemovalTracker en confirmRemovalsAtSource (L5). Voor retractDocumentsGoneAtSource en de koppeling in ingest.ts is er geen test, ook niet voor een mislukking halverwege.

Voorgestelde aanpak

Vóór de merge:

  1. Intrekkingsregister, alleen aanvullen, nooit wijzigen. Per document leg je vast:

    • document-id, vergadering-id, run-id en tijdstip (UTC);
    • het mechanisme: removed_at_source of takedown;
    • het bewijs: probe-URL, HTTP-status, foutcode en het controledocument met zijn status;
    • seq en content_hash van het laatste upsert-record.

    Geen inhoud. De status is gepland vóór de eerste stap en voltooid na de tombstone. Zo is een halve intrekking zichtbaar en maakt de volgende run haar af (lost punt 7 op). Bewaar het register zoals de feed, als onveranderlijke segmenten in object storage, niet alleen in de ops-SQLite, waarvan de backup na 14 dagen vervalt.

  2. Reden op de tombstone: reason: "removed_at_source" | "takedown" en bij removed_at_source ook meeting_id, gedocumenteerd in API.md. Het veld is een toevoeging, dus geen enkele consumer breekt.

  3. Eén beleid en één kalibratie. Laat de import kandidaten aanmelden bij document_revalidation, en verwijder pas na dezelfde drempel van 3 opeenvolgende "gone"-antwoorden. Laat de sweep source_presence.ts gebruiken en werk de alinea over de sweep in AGENTS.md bij.

  4. Elke intrekking zichtbaar in de run: aantal en document-id's van ingetrokken, behouden en overgeslagen documenten. ExtractionIssue kent nu alleen warning en error. Dit vraagt dus een ernstniveau info of een teller op de run.

  5. Tests voor retractDocumentsGoneAtSource, ook voor een mislukking halverwege.

Daarna:

  1. Bestaan opvraagbaar maken. GET /api/entities/{id} geeft voor een ingetrokken document 410 Gone met id, vergadering, periode van beschikbaarheid, reden en content_hash, zonder inhoud. Dan is een ingetrokken document te onderscheiden van een id dat nooit bestond.
  2. Hash van het origineel. Leg bij het downloaden een sha256 over het bestand vast en neem die op in het register. Dan is later aan te tonen welk bestand er stond, zonder het bestand te bewaren.

@joepio
joepio marked this pull request as ready for review October 1, 2026 10:57
@joepio
joepio merged commit 62ab746 into main Oct 1, 2026
3 checks passed
joepio pushed a commit that referenced this pull request Oct 1, 2026
Review of #329: a retraction removed documents without a recorded reason or
evidence, and a failure halfway could hide a document from search while the
export feed still served it as an upsert, with no later run to notice.

- Each document is finished before the next: delete marker, tombstone, then
  object storage. A failure stops that document at the step it reached and
  the outcome says which; one delete task covers every tombstoned document.
- Delete records carry `reason` ("takedown", "removed_at_source",
  "source_purged") and, for a source removal, `meeting_id`. Additive.
- Every retracted, kept or half-finished document becomes a run issue
  (step source_removals, new severity "info", or "warning" when unfinished)
  with the supplier's answer, the control document's answer and the step
  reached as details.
- The revalidation sweep uses src/documents/source_presence.ts, so there is
  one calibration (and one timeout, 30 s) instead of two.
- AGENTS.md says the automatic path is a deliberate policy change and how
  it relates to the sweep; API.md documents `reason` and `meeting_id`.
- Tests for the retraction itself, including a storage failure halfway and
  a failure before the marker.
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.

3 participants