← Blog

Legacy sites

Oracle Forms naar chat-agent: een read-only CDC-playbook

De Oracle Forms-backend was 22 jaar oud. De gemeente wilde een chat-agent voor vergunningstatussen. We mochten geen enkele regel aan de database toevoegen.

Jacob Molkenboer· Oprichter · A Brand New Company· 11 jun 2026· 10 min
Leren logboek met groen lint, koperen sleutel op indexkaart, ijzeren label op ivoor linnen, donkere schaduw rechts.

De baliemedewerkers bij Gemeente X hadden een dinsdagochtendritme. Telefoon gaat. Inwoner aan de lijn. Wat is de status van mijn dakkapelvergunning? Eén medewerker opent een Oracle Forms-scherm uit 2004, tabt door zes formulieren, typt het BSN, wacht op de groene statusbalk onderin, leest een code voor. Vertaalt die code daarna in gewoon Nederlands. En legt vervolgens uit waarom het al vijf weken duurt.

Ze wilden een chat-agent. Inwoners zouden de vraag zelf stellen, een antwoord in gewoon Nederlands krijgen, en alleen nog bellen als de agent er niet uitkwam. De vangst: de Oracle Forms-backend was 22 jaar oud, de database eronder was een handmatig getunede 11g-instance die niemand wakker wilde maken, en de oorspronkelijke leverancier rekende per formulier af. We mochten geen enkele regel aan die database toevoegen.

Drie regels vanaf week één:

  1. De legacy-database is read-only. Geen nieuwe writes, geen schema-aanpassingen, geen triggers.
  2. De agent mag nooit een vergunningstatus verzinnen.
  3. De vertraging tussen Oracle en het chatantwoord blijft onder de 90 seconden.

Dit is de bedrading, van begin tot eind.

De beperking die alles bepaalt

Oracle Forms-applicaties uit de vroege jaren 2000 praten meestal direct met een Oracle-database. CHAR(1)-statusvelden. Opgevulde getallen. Handgemaakte audittabellen. Stored procedures die state op vijf plekken muteren. Refactoren is niet het juiste woord voor wat moderniseren zou kosten. Het is een politieke kwestie, en de gemeente was duidelijk: dit gaat dit budgetjaar niet gebeuren.

Wat we wel kregen was één concessie van het DBA-team: Oracle LogMiner aanzetten en ons een read-only rol geven met SELECT_CATALOG_ROLE plus LOGMINING. Geen agent op de databaseserver. Geen daemon. Geen cron. Een JDBC-verbinding over een site-to-site IPSec-tunnel, die de DBA in dertig seconden kon intrekken.

Dat was het hele budget. Al het andere moest erbuiten draaien.

Debezium als de read-only deur

Het standaardpatroon hier is change data capture. De Oracle-connector van Debezium leest het redo log via LogMiner en stuurt change events op rijniveau uit. Vanuit het perspectief van de database draait alles client-side. We hostten het op een kleine Ubuntu-VM (Java 17, Kafka 3.6, Debezium 2.5) binnen ons eigen subnet, met een uitgaande verbinding naar de Oracle SCAN listener.

De minimale config die ons een schone stream gaf:

name=permits-oracle
connector.class=io.debezium.connector.oracle.OracleConnector
database.hostname=oracle.gemeente.internal
database.port=1521
database.user=cdc_reader
database.password=${file:/run/secrets/cdc:pwd}
database.dbname=GEMPROD
database.pdb.name=GEMPDB
snapshot.mode=schema_only_recovery
log.mining.strategy=online_catalog
log.mining.batch.size.default=20000
log.mining.batch.size.max=100000
table.include.list=GEM.VERGUNNINGEN,GEM.VERGUNNING_STATUS,GEM.AANVRAGER
schema.history.internal.kafka.bootstrap.servers=kafka:9092
schema.history.internal.kafka.topic=schema-history.gem

Een paar aantekeningen uit de productie. online_catalog is dramatisch goedkoper dan redo_log_catalog, maar alleen valide als het schema onder je niet verandert. Voor een 22 jaar oud systeem waar sinds 2017 geen kolom meer aan is toegevoegd, was die gok veilig. We hielden de LogMiner-sessie in de gaten met een dagelijkse query tegen V$LOGMNR_STATS, voor de zekerheid.

De andere knop die het tunen waard is: log.mining.batch.size. De default staat op 20.000. In de Nederlandse vergunningspiek (maart tot mei, als om de andere huiseigenaar een dakkapel of terras aanvraagt) zagen we het redo-volume met een factor 6 toenemen. De connector hield het bij door de max batch size naar 100k te tillen. Daaronder kregen we lag-pieken in de orde van minuten.

Let op

De Oracle-connector van Debezium heeft het redo log lang genoeg nodig om na een herstart van de connector te kunnen herstellen. Roteert je DBA-team archive logs agressief, spreek dan een minimale retentie van 24 uur af voordat je live gaat.

Een 90-seconden-mirror in Postgres

Kafka-topics komen terecht in een Postgres 16-database met de pgvector-extensie. Het schema is geen 1:1-kopie van Oracle. We bouwen het opnieuw op voor de queries die de agent daadwerkelijk gaat doen.

create table permit (
  id              bigint primary key,
  reference       text not null unique,        -- "OV/2024/00472"
  bsn_hash        bytea not null,              -- never store raw BSN
  type            text not null,               -- "dakkapel", "terras", ...
  submitted_at    timestamptz not null,
  status_code     text not null,               -- raw Oracle code
  status_nl       text not null,               -- plain-Dutch translation
  last_event_at   timestamptz not null,
  embedding       vector(1024)                 -- only on the free-text fields
);

create index permit_bsn_hash_idx   on permit (bsn_hash);
create index permit_reference_idx  on permit (reference);
create index permit_embedding_idx  on permit
  using hnsw (embedding vector_cosine_ops);

De consumer is een klein Go-servicetje dat het Debezium Kafka-topic leest, de rij normaliseert (de CHAR(1)-statuscodes van Oracle worden vertaald naar gewoon Nederlands via een lookuptabel die het communicatieteam van de gemeente bijhoudt), en upsert in Postgres. 240 regels code en in vier maanden niet meer aangeraakt.

Eén ontwerpkeuze die verdediging waard is: we slaan het rauwe BSN niet op in de mirror. Inwoners authenticeren via DigiD voordat ze met de agent praten, en de agent zoekt op bsn_hash met een sessie-specifieke salt. De Oracle-kant heeft het BSN nog steeds in cleartext, want zo werkt het oorspronkelijke systeem nu eenmaal. De mirror is een read model. Die hoeft het niet te weten.

Het 90-secondencontract

De 90 seconden is geen technische limiet. Debezium geeft ons onder normale omstandigheden sub-seconde lag. De 90 seconden is een contract met de inwoner: als de mirror achterloopt, stopt de agent met antwoorden en zegt hij dat de gebruiker moet bellen.

De check die dat afdwingt:

-- Run on every tool call from the agent
select extract(epoch from (now() - max(last_event_at))) as lag_seconds
from permit
where last_event_at > now() - interval '24 hours';

Komt lag_seconds boven de 90, dan geeft de eerste tool call van de agent (die de vergunning opzoekt) een gestructureerde fout terug en leest de agent een fallback-tekst voor: Het vergunningensysteem is op dit moment niet bereikbaar. Belt u alstublieft 14 0XX. We hebben dit getest door de Debezium-VM op een vrijdagmiddag op staging af te schieten. Tijd tot fallback: 14 seconden. De agent improviseerde nooit een status.

Inzicht

Een read model is geen cache. Het is een contract over actualiteit. Schrijf dat contract op en laat de agent fail closed gaan als het contract breekt.

Embeddings alleen waar ze hun plek verdienen

pgvector zit in deze stack voor één klus: een vrije tekstvraag van een inwoner (Wat is er met mijn vergunning voor de dakkapel aan de zijkant?) koppelen aan het juiste vergunningsrecord, als de aanvrager het kenmerk niet bij de hand heeft. We embedden drie velden: type, status_nl, en de meest recente opmerkingtekst uit het dossier. That's it. Geen embeddings van gestructureerde kolommen. Geen embeddings van metadata.

De reden is kosten en drift. Embeddings van korte gestructureerde waarden leveren valse buren op. Een vergunning voor een terras en een voor een dakkapel kunnen op een haarbreedte van elkaar in de vector space liggen, terwijl ze in het bestuursrecht juist heel verschillend zijn. Dat leerden we tijdens de pilot, waarna we overstapten op exact-match op type, met vector search alleen voor het vrije opmerkingenveld.

De agent stelt nooit zelf een antwoord op

De juridische afdeling van de gemeente had één harde eis: de agent mocht geen vergunningstatus produceren uit de eigen redenering van het model. Zelfs geen voorzichtig geformuleerde. Onder de EU AI Act valt een publieke tool die inwoners informeert over bestuurlijke beslissingen in een hogere risicocategorie, en het is de afnemer, in dit geval de gemeente, die verantwoording moet afleggen voor wat er gezegd wordt. De modelleverancier is een voetnoot zodra een burger met een klacht naar de ombudsman stapt.

Dus de agent mag geen enkele feitelijke statuszin genereren. Elk statusantwoord moet uit een gestructureerde tool call komen die een record uit Postgres teruggeeft, en het werk van de agent is dat record in Nederlands renderen. Geeft de tool niets terug, dan leest de agent de fallback-tekst voor. Geeft de tool een record terug dat ouder is dan 90 seconden, idem.

We dwingen dit af op de prompt-laag met forced tool use en een systeeminstructie die zegt dat het model geen statuszinnen uit eigen kennis mag samenstellen. De fallback-tekst staat in een JSON-bestand dat het communicatieteam beheert. Zij wijzigen het zonder bij ons een ticket aan te maken.

Het bredere principe: laat het vertrouwen van de agent geen dragende muur worden. Maak de data de dragende muur, en laat de agent glas zijn.

Wat we monitoren

Drie dashboards houden de pipeline eerlijk. Het eerste is de freshness-query hierboven, elke vijftien seconden gesampled en gegrafied in Grafana. Het tweede haalt de JMX-metrics van Debezium op: MilliSecondsSinceLastEvent, NumberOfCommittedTransactions, en de LogMiner-specifieke CurrentScn tegen de SCN bij de laatste commit. Het derde is een reconciliatie-job die elk uur honderd willekeurige vergunningskenmerken uit de mirror trekt, dezelfde honderd direct in Oracle opvraagt, en controleert of de statuscodes matchen.

De reconciliatie heeft in productie nog nooit afwijkingen laten zien. We draaien hem toch, want de dag dat hij wel afwijkt, willen we dat om 14:00 weten, niet om 17:30 op vrijdag van een inwoner.

De alerting is met opzet saai. Lag boven de 60 seconden langer dan twee minuten paged de on-call. Lag boven de 90 op enig moment activeert de fallback voor de inwoner. Een mismatch in de reconciliatie paged direct. De agent zelf weet nooit dat er een engineer is gepaged. Hij weet alleen dat zijn tool een gestructureerde fout teruggaf en dat hij een tekst heeft om voor te lezen.

Wat we anders zouden doen

Het venster van 90 seconden is conservatiever dan strikt nodig. We zouden het naar 30 seconden kunnen halen zonder een regel code te veranderen, omdat Debezium in steady state al sub-seconde draait. We hielden het venster ruim omdat het DBA-team marge wilde tijdens de kwartaalsgewijze Oracle-patching, en we vonden het goedkoper om te ruim te beloven op lag dan die discussie te voeren.

Als we opnieuw zouden beginnen, zouden we de embedding-kolom de eerste zes maanden overslaan. Inwoners weten bijna altijd hun kenmerk of hun BSN. Vector search is de long tail. Bouw 'm pas nadat je live bent.

De versie van vijf minuten

Zit je op een legacy-backend die je niet mag aanpassen, en moet je er een chat-agent voor zetten, dan ziet de vorm er zo uit:

  1. Een read-only CDC-feed uit de legacy-database (Debezium dekt Oracle, MySQL, SQL Server, Postgres).
  2. Een doelgericht read model in een database die jij beheert, continu vernieuwd.
  3. Een freshness-contract dat de agent afdwingt door fail closed te gaan.
  4. Een agent die records alleen rendert, nooit opstelt.

Toen we dit voor de gemeente bouwden, was wat we niet hadden zien aankomen de politieke prijs van het verzoek aan het DBA-team om LogMiner aan te zetten. Het kostte zes weken vergaderen voordat de verbinding live ging. We losten het uiteindelijk op door de role-grant SQL van tevoren te schrijven en de DBA elke regel te laten reviewen voordat hij die uitvoerde. Dat soort werk, het legacy-migratie-pad dat om de database heen gaat in plaats van erdoorheen, is inmiddels het grootste deel van wat we bij ABN doen.

Het kleinste wat je morgen kunt doen: open de documentatie van je legacy-database en zoek het CDC-verhaal op. Oracle heeft LogMiner. SQL Server heeft Change Tracking. MySQL heeft de binlog. Postgres heeft logical replication. Wat het ook is, dat is jouw read-only deur. De rest is leidingwerk.

Kern

Een read model is geen cache. Het is een contract over actualiteit. Schrijf dat contract op en laat de agent fail closed gaan als het contract breekt.

FAQ

Waarom roept de agent de Oracle-database niet gewoon rechtstreeks aan?

Twee redenen. De leeslatency op een 22 jaar oude Oracle Forms-backend is onder belasting onvoorspelbaar. En elke directe query koppelt de uptime van je agent aan het patchschema van het DBA-team.

Heeft Debezium een agent nodig die binnen Oracle wordt geïnstalleerd?

Nee. De Oracle-connector draait als JDBC-client. Hij heeft een databasegebruiker met LogMiner-rechten nodig en netwerktoegang tot de listener. Op de Oracle-server zelf wordt niets geïnstalleerd.

Hoe voorkom je dat de chat-agent een status verzint als de mirror achterloopt?

Een freshness-check op elke tool call. Loopt de mirror meer dan 90 seconden achter op het redo log, dan geeft de tool een gestructureerde fout terug en leest de agent een fallback-tekst voor die door de gemeente is geschreven.

Waarom pgvector en niet een aparte vector-database?

De mirror had sowieso al Postgres nodig voor de gestructureerde vergunningsrecords. Eén database draaien in plaats van twee halveerde het beheeroppervlak, en de HNSW-index van pgvector is snel genoeg voor ons queryvolume.

legacy sitesai agentschat agentsmigrationarchitectureintegrations

Iets bouwen?

Start een project