47 KiB
Phase 10: Ausschreibungs-Radar Foundation & DÖE Ingestion - Research
Researched: 2026-07-21
Domain: German public-procurement (DÖE OpenData) live API ingestion, OCDS/eForms-DE normalization, multi-tenant poll-once-fan-out-many scheduler, module-registry integration on the existing Tessera NestJS/Prisma platform
Confidence: HIGH (DÖE API mechanics — verified live against the production API this session, not just the JS-rendered Swagger UI); HIGH (platform integration — verified against existing dkv//module-registry/ codebase); MEDIUM (OCDS field completeness — verified against real live export, one day's sample; genuinely variable by buyer/portal)
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
DÖE-Ingest-Umfang
- D-01: Initialer Backfill = nur ab jetzt. Beim ersten plattformweiten DÖE-Poll werden nur ab diesem Zeitpunkt veröffentlichte Ausschreibungen aufgenommen — kein historischer Import. Da die Tender-Daten global sind, sieht ein später aktivierender Mandant den seit Plattformstart aufgelaufenen Bestand. Passt zur späteren Backfill-Unterdrückung bei Benachrichtigungen (Phase 12).
- D-02: Nur offene Ausschreibungen (aktive Vergaben, auf die man bieten kann — tender/contract notices). Vergabeergebnisse, Zuschläge und Aufhebungen (award/result notices) werden in dieser Phase NICHT aufgenommen.
- D-03: Ingest lädt ganz Deutschland global (plattformweit einmal), keine Vor-Eingrenzung nach Region/CPV beim Ingest — Eingrenzung passiert erst pro Suchprofil (Phase 11). Bewusst wegen Multi-Tenant-Wiederverkauf.
- D-04: Standard-Poll-Intervall = stündlich, admin-konfigurierbar (INGEST-06).
Datenaufbewahrung
- D-05: Ausschreibungen nach Ablauf der Abgabefrist werden 90 Tage aufbewahrt, dann gelöscht. Vor Fristablauf: aktiv. Nach Fristablauf: als „abgelaufen" markiert, standardmäßig aus der aktiven Liste ausgeblendet, aber bis zur Löschung recherchierbar.
Claude's Discretion
- Exakte Prisma-Schema-Felder (OCDS-orientiert, Form gemäß ARCHITECTURE.md), Adapter-Interface (
TenderSourceAdapter), Normalizer-Interna, Dedup-Key-Berechnung (OCID → Quelle:NoticeId → …) und contentHash-Änderungserkennung. - DÖE-API-Client (native
fetch+fast-xml-parser, CSV-Fallback), Pagination/Rate-Limit-Handling (Detail per Phase-Research live zu klären). - Scheduler-Implementierung: poll-once-fan-out-many, mehrmandantensicher — NICHT das DKV-
findFirst()-Single-Tenant-Muster. - Modul-Registrierung im Marketplace nach DKV/Cert-Manager-Vorbild.
Deferred Ideas (OUT OF SCOPE)
- Vergabeergebnisse/Zuschläge/Aufhebungen (award notices) aufnehmen — spätere Erweiterung, außerhalb v1.1-Scope.
- Historischer Backfill (letzte 30 Tage / alles) — bei Bedarf später als Konfig-Option.
- Vor-Eingrenzung des Ingest nach Region/CPV — bewusst verworfen (widerspricht Multi-Tenant-Wiederverkauf); Eingrenzung bleibt Sache der Suchprofile.
- Aufbewahrungsdauer (90 Tage) später ggf. admin-konfigurierbar machen. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| CONFIG-01 | Modul im Marketplace registriert, pro Mandant aktivierbar (wie DKV-Fleet/Cert-Manager) | tenders.seed.ts follows verbatim cert-manager.seed.ts/dkv.seed.ts pattern (isSystem: true, category, i18n description). module-loader.ts MODULE_REGISTRY entry required or the page 404s even when activated (verified codebase pattern, see Architecture Patterns §1) |
| INGEST-01 | Zeitgesteuerter Abruf via DÖE OpenData API (eForms/OCDS, auth-frei) | Live-verified in this session: real endpoint GET /api/notice-exports, auth-free (no security scheme in spec, confirmed 200 with no credentials), format negotiation via Accept header or format query param — see DÖE API Live Findings |
| INGEST-06 | Poll-once-fan-out-many, Intervall pro Quelle admin-konfigurierbar | See Scheduler Design — day-cursor gated single global cron (not per-tenant), admin-configurable interval field per D-04 with an adapter-level no-op guard for the DÖE source's actual day-granularity |
| SCHEMA-01 | Einheitliches OCDS-orientiertes Schema, plattform-global | See Prisma Schema Fields — extends ARCHITECTURE.md's Tender model with live-verified nullable-field realities (deadline/value frequently absent in OCDS export) |
| SCHEMA-02 | Content-Hash-Änderungserkennung (Fristverlängerung, Aufhebung) | See Dedup & Change Detection — DÖE re-publishes a changed notice as a new noticeVersion under the same ocid; contentHash diff on re-poll triggers update path |
| </phase_requirements> |
Summary
This research supersedes the "open verification point" flagged in .planning/research/SUMMARY.md, ARCHITECTURE.md, and PITFALLS.md: the DÖE OpenData API's Swagger UI was live-queried this session (its JS-rendered UI was bypassed by reading swagger-initializer.js directly, which reveals the real spec URL /documentation/api/opendata). The live OpenAPI 3.1 spec was fetched, and — critically — a real export ZIP for a real past day was downloaded and inspected (105 real German tender notices from 2026-07-20, in both OCDS and eForms-DE XML format). This is ground truth, not documentation inference.
The single most important finding, which changes the phase's architecture from what ARCHITECTURE.md assumed: the DÖE OpenData API is not a paginated/incremental REST feed. It is a daily batch-export API: one call returns a ZIP of all notice versions processed on a given calendar day (pubDay=YYYY-MM-DD) or month (pubMonth=YYYY-MM) — there is no since/cursor/page parameter of any kind, and the current day and any future day are rejected with HTTP 400 ("must lie in the past"). New data for "today" only becomes retrievable starting tomorrow. This means the TenderSourceAdapter.fetchTenders(config, since?: Date) signature sketched in ARCHITECTURE.md should track a day cursor (lastIngestedDay: Date, day-granularity), not an arbitrary timestamp — and the admin-configurable poll interval (D-04, default hourly, INGEST-06) is architecturally harmless to run hourly (idempotent day-cursor check: no-op if the next day isn't past yet) but will only ever produce new data once every ~24h for this source. This must be explicitly designed into the scheduler (cron tick frequency ≠ actual-fetch frequency for this specific source) and documented so a future contributor doesn't "fix" the apparent 23-hours-of-no-op as a bug.
Second critical finding: live inspection of the real OCDS export shows that tender.tenderPeriod.endDate (deadline) and tender.value (estimated value) are frequently null in the OCDS conversion — even for open ("tender"-tagged) notices — while the same information is present in free text inside tender.description/lots[].description, and, for at least one directly cross-checked notice, present as a proper structured field (TenderSubmissionDeadlinePeriod/EndDate) in the corresponding eForms-DE XML that OCDS silently drops. This directly affects SCHEMA-01 (deadline/value are supposed to be first-class normalized fields) and D-05 (90-day post-deadline retention needs a deadline value to compute from). The recommendation below is to parse eForms-DE XML as the primary structured-field source for deadline/value/procedure-type, using OCDS JSON primarily for the ocid (stable dedup key across notice versions) and party/organization resolution — a reversal of ARCHITECTURE.md's blanket "prefer OCDS" guidance, based on this session's live cross-check, not assumption.
Third finding: the export format is a ZIP archive (confirmed: real Zip archive data file, one JSON/XML file per notice inside). No ZIP-handling library was included in STACK.md — this is a genuine gap in the milestone-level stack research (it assumed pagination, not archive downloads). adm-zip is recommended below.
Fourth finding, directly informing D-02: live data confirms releases[].tag is the correct discriminator for "open tender" (tag: ["tender"], 74/105 = 70% of one real day's sample) vs. ["award"] (17/105) vs. ["planning"] (3/105) vs. no tag field at all (11/105 — untagged notices that still carry populated awards/contracts arrays, e.g. direct-award justification notices/"Vergabevermerke"). D-02's filter must therefore be: tag array contains "tender" and treat a missing tag conservatively (exclude, don't default to "open") since several of the untagged samples are in fact awarded/closed procurements.
Primary recommendation: Build the DoeOpenDataAdapter around the single real endpoint GET /api/notice-exports?pubDay=YYYY-MM-DD&format=eforms.zip (eForms-DE XML as primary parse target for deadline/value; OCDS JSON fetched in parallel or CSV as secondary cross-check per STACK.md's original recommendation), with a day-cursor scheduler (not a since-timestamp), adm-zip for archive extraction, and D-02 filtering on tag=["tender"] with awards/contracts emptiness as a secondary guard.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| DÖE polling (HTTP fetch of daily ZIP) | API / Backend (TenderSourceAdapter) |
— | Server-side scheduled job; no browser/CDN involvement |
| ZIP extraction + eForms-XML/OCDS parsing | API / Backend (TenderNormalizerService) |
— | CPU/IO-bound parsing belongs in the Node process, not the DB or client |
| Dedup key + contentHash computation | API / Backend (TenderNormalizerService) |
— | Pure computation on normalized fields before persistence |
Tender persistence (global, no tenantId) |
Database / Storage | — | Platform-wide reference data; RLS/tenant scoping deliberately does NOT apply here (see Pattern 3 in ARCHITECTURE.md) |
| Scheduler / cron lifecycle | API / Backend (TenderSchedulerService via SchedulerRegistry) |
— | Server-only concern; no client involvement |
| Module registry self-seed + tenant activation gate | API / Backend (ModuleRegistryService, reused unmodified) |
Database (Module/TenantModuleActivation tables) |
Existing platform mechanism, not phase-specific |
| Admin poll-interval config UI | Frontend Server (Next.js settings/page.tsx) |
API / Backend (TendersController admin routes) |
Mirrors DKV's InboxConfigForm — form on the client, persisted via API |
| Marketplace listing/activation UI | Frontend Server (existing Marketplace page) | — | Unmodified platform feature; this phase only adds a Module row |
Standard Stack
Core (new to this phase; extends STACK.md's v1.1 delta)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
fast-xml-parser |
5.10.1 [CITED: STACK.md, npm registry 2026-07-17] | Parse eForms-DE XML notices (primary structured-field source per this session's live finding) | Already selected in milestone STACK.md for this exact purpose; zero-dependency, pure-TS, CJS-safe |
adm-zip |
0.6.0 [VERIFIED: npm registry, checked this session 2026-07-21] | Synchronous in-memory extraction of the DÖE daily export ZIP (typically tens of KB to a few MB — one day's sample was 161KB compressed / 105 files) | New gap identified this session — STACK.md did not anticipate the DÖE API returning an archive rather than a paginated feed. adm-zip is a simple, synchronous, CJS-native API (new AdmZip(buffer).getEntries()) well suited to fully-in-memory extraction of modest archives; MIT-licensed, 19.2M weekly downloads, actively maintained (github.com/cthackers/adm-zip). The automated package-legitimacy check seam flagged it SUS with reason "too-new" — manually confirmed as a false positive: the flagged date is the latest patch release (2026-07-10), not the package's origin; this is the same false-positive pattern STACK.md already documented for fast-xml-parser/csv-parse/playwright. [ASSUMED] provenance for the package-name choice itself (discovered via training knowledge, not an official doc) — planner should gate the pnpm add adm-zip step behind a checkpoint:human-verify per the package-legitimacy protocol. |
csv-parse |
7.0.1 [CITED: STACK.md] | DÖE CSV export (format=csv.zip) as a cross-check/fallback format |
Unchanged from STACK.md; confirmed this session that csv.zip is a real, working format enum value in the live spec |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
native fetch |
Node 22 built-in | HTTP client for the DÖE endpoint | Already the established pattern (icon-discovery.service.ts, ics.provider.ts); DÖE requires no cookies/session state, so fetch-cookie/tough-cookie (needed for the scraping adapters in a later phase) are not needed for this phase |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
adm-zip (sync, in-memory) |
unzipper (streaming) [VERIFIED: npm registry — 16.7M weekly downloads, same false-positive SUS flag] |
Streaming extraction only pays off for very large archives; DÖE daily exports are small (low hundreds of files, sub-MB to low-MB compressed) — sync in-memory is simpler and matches the "wartbar/verständlich" project constraint |
| eForms-DE XML as primary parse target | OCDS JSON as primary parse target (ARCHITECTURE.md's original recommendation) | Reversed this session based on live cross-check: OCDS conversion measurably drops structured deadline data present in the source eForms-DE XML for at least one directly-verified notice. OCDS remains useful for ocid (dedup key) and resolved party/organization names, but should not be the sole source for deadline/value. |
Installation:
pnpm --filter @tessera/api add fast-xml-parser adm-zip csv-parse
Version verification: confirmed live via npm view <pkg> version this session (2026-07-21): adm-zip@0.6.0 (published 2026-07-10), unzipper@0.12.5 (published 2026-06-21). fast-xml-parser/csv-parse versions carried over from STACK.md (2026-07-17, 4 days prior — still current).
Package Legitimacy Audit
| Package | Registry | Age (latest release) | Downloads | Source Repo | Verdict | Disposition |
|---|---|---|---|---|---|---|
| adm-zip | npm | 11 days (0.6.0, 2026-07-10) | 19.2M/wk | github.com/cthackers/adm-zip | SUS (too-new — false positive, see rationale above) |
Flagged — planner adds checkpoint:human-verify before pnpm add adm-zip, per protocol |
| fast-xml-parser | npm | (per STACK.md, 2026-07-17) | — | — | SUS (per STACK.md, already assessed false-positive) | Approved (carried over from milestone research, no re-flag needed) |
| csv-parse | npm | (per STACK.md, 2026-07-17) | — | — | SUS (per STACK.md, already assessed false-positive) | Approved (carried over) |
Packages removed due to [SLOP] verdict: none.
Packages flagged as suspicious [SUS]: adm-zip — planner must add a checkpoint:human-verify task before pnpm add adm-zip (spot-check the GitHub repo / Socket.dev score at implementation time, per the repo's existing dependency-hygiene bar already applied to the other v1.1 packages in STACK.md).
adm-zip was discovered via training knowledge (not an official doc), so per the package-name provenance rule it is tagged [ASSUMED] regardless of its clean npm registry / GitHub signals.
Architecture Patterns
System Architecture Diagram
[TenderSchedulerService — 1 global cron, e.g. hourly per D-04]
|
v
[DoeOpenDataAdapter.fetchTenders(config, dayCursor)]
| 1. if dayCursor >= today(Europe/Berlin) -> no-op, return [] (nothing new yet)
| 2. else: GET /api/notice-exports?pubDay={dayCursor}&format=eforms.zip (+ ocds.zip for ocid/orgs)
v
[adm-zip: extract ZIP -> N per-notice XML (+ JSON) buffers]
v
[fast-xml-parser: parse each eForms-DE XML -> RawTenderRecord[]]
v
[TenderNormalizerService.normalize()]
| - filter: keep only tag=["tender"] (D-02); drop award/planning/untagged-with-awards
| - map fields -> Tender (title, buyer, cpvCodes, region/plz, deadlineAt, estimatedValue, procedureType, sourceUrl)
| - dedupKey: ocid (from paired OCDS fetch) -> fallback sourcePortal:noticeId
| - contentHash: sha256(title+deadline+value+status)
v
[TenderIngestionService.upsert()] -- prisma.tender.upsert({ where: { dedupKey } })
| advance dayCursor by 1 day; loop while dayCursor still < today (catch-up on missed days)
v (only newly-created OR contentHash-changed rows this run)
[TenderMatchingService.matchDelta()] -- Phase 11+ (saved searches don't exist yet in Phase 10)
Recommended Project Structure
(Extends ARCHITECTURE.md's apps/api/src/tenders/ layout — no changes needed, confirmed still correct after live verification.)
apps/api/src/tenders/
├── tenders.module.ts
├── tenders.controller.ts # GET /tenders, GET /tenders/:id, admin source-config
├── tenders.seed.ts # seeds 'tender-radar' into ModuleRegistry (CONFIG-01)
├── tender-ingestion.service.ts # poll -> normalize -> upsert -> change-detect
├── tender-normalizer.service.ts # eForms-XML+OCDS -> Tender fields, dedupKey, contentHash
├── tender-scheduler.service.ts # SchedulerRegistry cron: 1 global job, day-cursor gated
├── tender.types.ts # RawTenderRecord, NormalizedTenderFields, SourceType
├── dto/
│ ├── source-config.dto.ts # admin: pollIntervalMin, isActive
│ └── tender-query.dto.ts # pagination + filter query params (list/detail read, no saved-search yet)
└── adapters/
├── tender-source-adapter.interface.ts
└── doe-opendata.adapter.ts # THIS PHASE's only adapter
Pattern 1: DÖE API — Day-Batch, Not Paginated Feed (live-verified this session)
What: GET https://oeffentlichevergabe.de/api/notice-exports — the entire real API surface for OpenData bulk retrieval (confirmed: the live OpenAPI 3.1 spec at /documentation/api/opendata lists exactly one path). No auth (no security scheme in the spec; confirmed 200 with a plain unauthenticated curl).
Parameters (from the real spec, cross-verified against live requests):
pubDay(string,YYYY-MM-DD) — returns all notice versions processed on that calendar day. Mutually exclusive withpubMonth.pubMonth(string,YYYY-MM) — same, for a whole month (useful only for a manual/admin-triggered historical import — not used in this phase per D-01).format(query param, or viaAcceptheader):eforms.zip(default),ocds.zip,csv.zip.
Live-verified boundary rules (confirmed via actual 400 responses, not just spec text):
pubDay= today → HTTP 400, message:"The specified pubDay exceeds the allowed range. It must lie in the past."pubDay= any future date → same 400.- Earliest allowed:
pubDay >= 2022-12-01,pubMonth >= 2022-12(per spec text — the Bekanntmachungsservice's operational start). - Response headers on a real 200:
etag: "version-N",cache-control: max-age=120,content-disposition: attachment; filename=notices_YYYY-MM-DD_OCDS.zip. No rate-limit headers of any kind (X-RateLimit-*absent) — theetag/short cache-control instead suggest a caching layer in front of the origin; conditionalIf-None-Matchrequests are a viable politeness optimization but not required for correctness at day-granularity polling.
When to use: This is the only way to get DÖE data — there is no "list notices since X" or paged-results endpoint. Design the adapter around "fetch one day's full batch," not "fetch new items since last check."
Trade-offs: A day-cursor model means: (a) the very first poll after module activation on deployment day will return zero results until the next calendar day rolls over (today's data isn't retrievable yet) — this is expected API behavior, not a bug, and should be documented in the phase's UAT expectations; (b) catching up after downtime (e.g., scheduler was down for 3 days) is naturally supported — just loop pubDay from lastIngestedDay + 1 up to today - 1, each as a separate request.
Pattern 2: OCDS Release Tag as the D-02 "Open Tender" Filter (live-verified)
What: Live sample (105 real notices, 2026-07-20) breaks down as:
releases[].tag |
Count | Meaning |
|---|---|---|
["tender"] |
74 (70%) | Open/active procurement notice — the only kind SCHEMA-01/D-02 wants |
["award"] |
17 (16%) | Award/result notice — explicitly out of scope (D-02) |
["planning"] |
3 (3%) | Prior-information/planning notice — not yet a bookable tender — exclude |
(no tag field at all) |
11 (11%) | Live-verified edge case — direct-award justification notices ("Vergabevermerke") with populated awards/contracts arrays but no tag. These are closed/awarded, not open. |
Filter logic (recommended, not yet in ARCHITECTURE.md):
function isOpenTenderNotice(release: OcdsRelease): boolean {
const tags = release.tag ?? [];
if (tags.includes('tender')) return true;
// Untagged notices with populated awards/contracts are NOT open — exclude.
// Untagged notices with neither (rare, if any) should also be excluded conservatively
// rather than defaulting to "open" — a false "open tender" shown to a bidder is worse
// than a missed one (which the coverage-transparency UI in Phase 11 already communicates).
return false;
}
Why this matters: A naive filter of "absence of award tag = open" would have wrongly included the 11 untagged-but-actually-closed notices found in this live sample (11% of one real day — not a negligible edge case).
Pattern 3: eForms-DE XML as Primary Structured-Field Source (live cross-check, corrects ARCHITECTURE.md)
What: ARCHITECTURE.md recommended "prefer OCDS... for direct field alignment." Live cross-check of the same real notice (25605686-1) in both formats shows:
- OCDS JSON:
tender.tenderPeriodis absent (no deadline field at all);tender.valueis absent; the deadline ("Angebotsfrist: Mittwoch, 26.08.2026, 10:00 Uhr") exists only as unstructured German prose insidetender.description. - eForms-DE XML (same notice, same day, same export): contains a proper structured element —
<TenderSubmissionDeadlinePeriod><ns3:EndDate>2026-08-26+02:00</ns3:EndDate></TenderSubmissionDeadlinePeriod>— and the document'sCustomizationID(eforms-sdk-0.1) confirms this notice uses an old eForms SDK version (directly matching PITFALLS.md's Pitfall 17 — SDK version drift — now observed live, not just theorized).
Recommendation: Fetch both formats per poll cycle (eforms.zip for structured deadline/value/procedure fields; ocds.zip for the ocid dedup key and resolved buyer/party names) rather than relying on OCDS alone. This is two HTTP requests per day-cursor tick instead of one — negligible cost at day-granularity polling. If phase scope favors a single-format MVP, prefer eforms.zip as primary and treat ocid absence as acceptable for v1 (fall back to sourcePortal:noticeId dedup key, already the documented fallback tier in ARCHITECTURE.md's Pattern 2) — but fetching both is the more complete and only modestly more expensive option, and is the recommended default.
Trade-off: Two structurally different parsers now run per poll (fast-xml-parser for eForms-DE, plain JSON.parse for OCDS) — acceptable complexity, both already selected in STACK.md; no new parser library needed for this reversal, only a change in which one is authoritative for which fields.
Pattern 4: Nullable Deadline/Value as the Common Case, Not the Edge Case
What: Of the 12 "tender"-tagged notices spot-checked in this session's live sample, all 12 had tenderPeriod.endDate = null and value = null in the OCDS export. This is a stronger and more concrete version of PITFALLS.md's Pitfall 18 ("OCDS optional/conditional fields treated as always present") — live data suggests this is the majority case for Unterschwelle notices, not a rare edge case.
Recommendation for SCHEMA-01/D-05:
deadlineAtandestimatedValuemust be nullable in theTenderPrisma model (already planned in ARCHITECTURE.md's schema sketch — confirmed necessary, not optional hardening).- D-05's "90 days after deadline, then delete" retention rule needs an explicit fallback for
deadlineAt IS NULLrows: do not auto-expire them on the deadline-based path (there is no deadline to expire from); either (a) leave them permanently active/searchable (simplest, matches "keine Vor-Eingrenzung" spirit — a human should judge relevance, not the retention job), or (b) apply a secondary fallback expiry based onpublishedAt + N daysfor deadline-less rows only. Recommend (a) for the MVP phase — introduce (b) later if the table grows unmanageably, since retrofitting a stricter policy is easy but a wrongly-deleted no-deadline tender is not recoverable. - The "nur offene" (still-open) view/filter (Phase 11, FILTER-04) will only be meaningful for the subset with a populated
deadlineAt— this is a coverage-transparency concern (PITFALLS.md Pitfall 20) that now has a concrete, live-measured magnitude (100% of a 12-notice spot sample) to inform the Phase 11 UI copy, not just an abstract "some tenders lack this."
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| ZIP extraction | A manual DEFLATE/central-directory parser | adm-zip |
Well-understood, tiny surface, sync API fits the modest archive sizes involved |
| Multi-tenant scheduling | A second scheduling mechanism | SchedulerRegistry.addCronJob() (same as DkvSchedulerService) |
Already proven, dynamically updatable at runtime — reuse the mechanics, just replace the findFirst()-single-tenant pattern, not the underlying cron library |
| Module registry / marketplace activation | A new activation table or gating mechanism | ModuleRegistryService (unmodified) + tenders.seed.ts (new, mirrors cert-manager.seed.ts) |
Already the load-bearing platform mechanism for CONFIG-01; do not special-case Tenders |
| OCID/dedup key computation | A custom hashing scheme from scratch | Priority-ordered key per ARCHITECTURE.md Pattern 2 (ocid → sourcePortal:noticeId → fuzzy fingerprint) |
Already researched and live-confirmed: ocid is present and stable across notice versions (ocds-mnwr74-{processId}, distinct from the per-version notice/release id) |
Key insight: This phase's genuinely new risk is not "how do we build a scheduler" (solved, mechanics reused from DKV) — it's correctly interpreting the DÖE API's actual batch/day-cursor shape, which this session's live verification now settles definitively instead of leaving as an assumption.
Runtime State Inventory
Not applicable — this is a greenfield module addition (new Prisma models, new NestJS module, new module-registry row), not a rename/refactor/migration phase. No existing runtime state references the "Ausschreibungs-Radar"/"tender-radar" name that would need updating.
Common Pitfalls
Pitfall A: Treating the DÖE poll interval as "fetch frequency" instead of "check frequency"
What goes wrong: A naive implementation calls the DÖE endpoint every tick of the admin-configured interval (e.g., hourly per D-04), re-requesting the same day's ZIP 23 times before the day actually advances — wasted load, and worse, a developer "optimizing" this by shortening the interval further, or "fixing" the apparent no-op by re-implementing since-timestamp logic that the API doesn't actually support.
Why it happens: D-04 says "poll interval, admin-configurable, default hourly" — this reads like a typical incremental-feed poll interval, but the DÖE API's actual granularity is daily.
How to avoid: Decouple "cron tick frequency" (can stay hourly, satisfies D-04/INGEST-06 literally) from "actual upstream fetch" (gated by dayCursor < today(Europe/Berlin)). Document this decoupling explicitly in the adapter/scheduler code, not just in this research doc.
Warning signs: Scheduler logs show identical pubDay values fetched repeatedly within the same 24h window with unchanged results — expected, not a bug, but worth a debug-level (not warn/error) log line so it doesn't look broken during on-call review.
Pitfall B: Assuming OCDS alone is sufficient for SCHEMA-01's deadline/value fields
What goes wrong: Building the normalizer against OCDS only (per ARCHITECTURE.md's original guidance) ships a Tender catalog where deadline/value are null for the majority of below-threshold notices, even though the source data (eForms-DE XML) actually had it.
How to avoid: Parse eForms-DE XML as the primary field source per Pattern 3 above; treat OCDS as the ocid/party-resolution source, not the deadline/value source.
Warning signs: QA/UAT notices most "tender"-tagged records showing no deadline in the results list — check whether the eForms-XML export was even fetched/parsed before assuming this is unavoidable API limitation (it partly is — not every eForms document will have a machine-readable deadline either, since it can be embedded in a portal-specific free-text description — but the eForms path recovers meaningfully more than OCDS alone, confirmed on at least one directly cross-checked notice this session).
Pitfall C: Filtering "open tenders" (D-02) by tag absence instead of tag presence
What goes wrong: Assuming "no award tag = open tender" wrongly includes untagged-but-closed notices (11% of the live sample).
How to avoid: Filter positively on tag.includes('tender'); treat missing/other tags as excluded by default (Pattern 2 above).
Warning signs: Award-justification/"Vergabevermerk" notices (identifiable by populated awards/contracts arrays with no submission deadline) appearing in the "open, biddable" result list.
Pitfall D: Copying DkvSchedulerService's findFirst() single-tenant pattern (carried over from PITFALLS.md Pitfall 24, restated for this phase specifically)
What goes wrong: Already extensively documented in PITFALLS.md — restated here because this phase's scheduler is the first place it must be avoided, and because the DÖE source is global (not per-tenant, unlike DKV), the "multi-tenant" framing is slightly different: there IS only one DÖE poll config (platform-wide, per Pattern 3 in ARCHITECTURE.md), so findFirst() on TenderSourcePollConfig for the DÖE row specifically is not the anti-pattern here (there's genuinely only one DÖE config row to find) — the anti-pattern would be if a future per-tenant source (e.g., email-alert in Phase 14) reused this same findFirst() call path. For Phase 10 specifically: it's safe and correct to load the single DÖE TenderSourcePollConfig row via findFirst() (or better, findUnique on a fixed known slug like sourceType: 'doe-opendata') since it is architecturally a singleton config — this is the one legitimate findFirst() in this phase; the acceptance-criterion "activating a 2nd tenant must not duplicate ingestion" is satisfied by the config being tenant-agnostic in the first place (Pattern 3), not by scheduler code that iterates tenants.
Verification for this phase's two-tenant acceptance criterion (Success Criteria 4 & 5): the test is NOT "does the scheduler correctly loop over 2 tenant configs" (there are none for DÖE) — it is "does activating the module for a 2nd tenant (TenantModuleActivation row) trigger zero additional DÖE HTTP calls, zero additional cron jobs, and zero additional Tender rows" — i.e., prove the absence of tenant-count-scaled behavior, not the presence of correct per-tenant iteration.
Code Examples
DÖE fetch — real, verified request shape
// Source: live-verified this session against https://oeffentlichevergabe.de
// GET https://oeffentlichevergabe.de/api/notice-exports?pubDay=2026-07-20&format=eforms.zip
// -> 200, Content-Disposition: attachment; filename=notices_2026-07-20_eForms.zip
// -> binary ZIP body, ~105 files for a typical day (~160KB compressed for the OCDS variant)
async function fetchDoeDay(pubDay: string, format: 'eforms.zip' | 'ocds.zip' | 'csv.zip') {
const url = `https://oeffentlichevergabe.de/api/notice-exports?pubDay=${pubDay}&format=${format}`;
const res = await fetch(url);
if (res.status === 400) {
// "must lie in the past" — pubDay is today or future; nothing to fetch yet this cycle
return null;
}
if (!res.ok) throw new Error(`DÖE fetch failed: ${res.status}`);
return Buffer.from(await res.arrayBuffer());
}
Day-cursor gate (replaces a since: Date timestamp cursor)
// Source: this session's live finding — DÖE has no incremental/since parameter.
function nextDayToFetch(lastIngestedDay: Date | null): string | null {
const berlinToday = new Date().toLocaleDateString('en-CA', { timeZone: 'Europe/Berlin' }); // YYYY-MM-DD
const next = lastIngestedDay
? new Date(lastIngestedDay.getTime() + 86_400_000).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10); // D-01: "from now" — first eligible day is tomorrow relative to activation
return next < berlinToday ? next : null; // null = nothing new yet, no-op this tick
}
D-02 open-tender filter (OCDS release tag)
// Source: live-verified against a real 105-notice sample, 2026-07-20
function isOpenTenderNotice(release: { tag?: string[]; awards?: unknown[]; contracts?: unknown[] }): boolean {
if (release.tag?.includes('tender')) return true;
return false; // conservative: missing tag + award/planning tags are all excluded
}
State of the Art
| Old Approach (assumed pre-verification) | Current Approach (live-verified this session) | When Changed | Impact |
|---|---|---|---|
DÖE = paginated REST feed with a since/cursor param (implicit assumption in ARCHITECTURE.md's adapter signature) |
DÖE = day/month-batch ZIP export, no pagination param exists, current/future days rejected (400) | Confirmed 2026-07-21, this session | Scheduler must be day-cursor gated, not since-timestamp gated; "pagination handling" (a phase-focus research question) resolves to "there is no pagination — there is a per-day archive" |
| "Prefer OCDS over eForms-DE" (ARCHITECTURE.md Pattern 2, based on the OCDS spec's cleaner structure) | eForms-DE XML has structured deadline data that OCDS conversion drops, at least for some notices | Confirmed 2026-07-21, this session, via direct cross-check of one real notice in both formats | Normalizer should parse eForms-DE as primary for deadline/value; OCDS retained for ocid/party names |
Deprecated/outdated: None — this is a young API (operational since 2023-02-06 per prior research); no deprecated version churn observed in the live spec.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | adm-zip is the right ZIP library choice (vs. unzipper/jszip/yauzl) |
Standard Stack | Low — all are viable; adm-zip's sync API is a convenience choice, not a hard technical requirement. If archives grow much larger than observed (161KB/105 files for one day), a streaming approach (unzipper) may be preferable — reassess if daily archive size grows past a few MB. |
| A2 | Fetching both eforms.zip and ocds.zip per poll cycle (two HTTP calls) is acceptable overhead vs. a single-format MVP |
Pattern 3 | Low — at day-granularity polling (at most 1-2 real fetches/day per the day-cursor gate), two requests instead of one is negligible; if the planner descopes to single-format for MVP speed, document that deadline/value coverage will be measurably worse (per Pattern 4's 100%-null spot check) as an explicit MVP trade-off, not a silent gap |
| A3 | The 105-notice, 12-notice, and 11-notice samples (all from 2026-07-20) are representative of typical daily volume/tag distribution, not an anomalous day | Pattern 2, Pattern 4 | Medium — a single day's sample is directional, not statistically proven; if actual production volume/tag ratios differ significantly, the D-02 filter logic itself is still correct (tag-based), only the magnitude estimates (70% tender / 16% award / etc.) may shift |
Open Questions
-
Exact rate-limit tolerance of the DÖE API (undocumented)
- What we know: no
X-RateLimit-*headers observed on a real response; acache-control: max-age=120+etagsuggest a caching/CDN layer that can absorb repeated identical requests cheaply. - What's unclear: whether rapid-fire requests (e.g., a catch-up loop fetching 30 missed days in quick succession after extended downtime) would trigger throttling — not tested this session to avoid abusing a public-good API mid-research.
- Recommendation: implement the catch-up loop (Pattern 1) with a small delay between requests (e.g., 1-2s, matching the "politeDelay" helper already recommended in STACK.md for the scraping adapters) even though DÖE itself carries "null" ToS risk — cheap insurance, no downside.
- What we know: no
-
Whether eForms-DE deadline coverage is meaningfully better than OCDS across a larger sample
- What we know: confirmed better on 1 directly cross-checked notice (deadline present in XML, absent in OCDS JSON and OCDS proper structured field).
- What's unclear: the aggregate improvement rate across hundreds/thousands of notices — this session's live check was necessarily a small spot-check, not a full-corpus audit.
- Recommendation: the phase's implementation should log a metric (e.g.,
% of ingested tender-tagged notices with non-null deadlineAt) from day one, so this can be measured empirically in production rather than re-guessed.
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
Outbound internet access from the deployment host to oeffentlichevergabe.de |
INGEST-01 (DÖE polling) | ✓ (verified from this dev/research environment — production host connectivity not independently verified this session) | — | If the production host lacks outbound HTTPS to this domain (e.g., restrictive egress firewall), the module cannot function at all — confirm with infra before phase execution if the API host is on the same network segment as the dev machine used for this research |
Node.js fetch (native, Node 18+) |
DÖE HTTP client | ✓ | Node 22-class per @types/node@^22 (existing project baseline) |
— |
adm-zip / equivalent ZIP library |
ZIP extraction | ✗ (not yet installed) | — | pnpm add adm-zip — no fallback needed, this is a build-time dependency, not an external service |
Missing dependencies with no fallback: none beyond the standard "install the new npm package" step. Missing dependencies with fallback: none applicable.
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 3.x (existing, per v1.0 STACK.md) |
| Config file | apps/api/vitest.config.ts (existing — confirm path during planning; not modified by this phase) |
| Quick run command | pnpm --filter @tessera/api test -- tenders (scoped to new test files) |
| Full suite command | pnpm --filter @tessera/api test |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| CONFIG-01 | Module self-seeds into registry on boot; activatable per tenant | unit + integration | pnpm --filter @tessera/api test -- tenders.seed |
❌ Wave 0 |
| INGEST-01 | DoeOpenDataAdapter parses a real (or fixture-captured) eForms-DE/OCDS ZIP into RawTenderRecord[] |
unit | pnpm --filter @tessera/api test -- doe-opendata.adapter |
❌ Wave 0 — fixture ZIP should be captured from this session's live download (doe-2026-07-20-eforms.zip/doe-2026-07-20-ocds.zip) rather than synthetic data, per this repo's stated preference for real fixtures over synthetic ones (matches the DKV PDF-parser precedent) |
| INGEST-06 | Two-tenant scheduler test: activating module for a 2nd tenant produces zero additional DÖE HTTP calls / cron jobs / Tender rows |
integration | pnpm --filter @tessera/api test -- tender-scheduler.service |
❌ Wave 0 |
| SCHEMA-01 | Normalizer maps a real eForms-DE + OCDS notice pair into the expected Tender fields, including nullable deadline/value |
unit | pnpm --filter @tessera/api test -- tender-normalizer.service |
❌ Wave 0 |
| SCHEMA-02 | Re-ingesting an updated notice (same ocid, changed contentHash) updates the existing row instead of duplicating |
integration | pnpm --filter @tessera/api test -- tender-ingestion.service |
❌ Wave 0 |
| D-02 filter | tag=["tender"] included; award/planning/untagged-with-awards excluded |
unit | pnpm --filter @tessera/api test -- doe-opendata.adapter (same fixture, asserts filtered-out count) |
❌ Wave 0 |
Sampling Rate
- Per task commit:
pnpm --filter @tessera/api test -- tenders(scoped) - Per wave merge:
pnpm --filter @tessera/api test(full suite) - Phase gate: Full suite green before
/gsd-verify-work
Wave 0 Gaps
apps/api/src/tenders/__fixtures__/doe-2026-07-20-eforms.zipanddoe-2026-07-20-ocds.zip— real captured fixtures from this session's live download (recommend committing a trimmed version, e.g., 5-10 representative notices covering tender/award/planning/untagged tags, rather than the full 105-file archive, to keep the repo lean)apps/api/src/tenders/tenders.seed.spec.ts,doe-opendata.adapter.spec.ts,tender-normalizer.service.spec.ts,tender-ingestion.service.spec.ts,tender-scheduler.service.spec.ts— none exist yet, this is a greenfield module
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | No | DÖE endpoint is auth-free by design (confirmed live); no credentials to protect for this specific integration |
| V3 Session Management | No | No session/cookie state involved in the DÖE adapter (unlike the later scraping adapters) |
| V4 Access Control | Yes | GET /tenders list/detail must be gated by TenantModuleActivation (module licensing) but NOT by tenantId row-filtering — this is the one controller where "tenant-gated" and "tenant-scoped" genuinely differ (per ARCHITECTURE.md's explicit callout); admin source-config routes (POST /tenders/source-config) must be @Roles(Role.ADMIN, Role.SUPER_ADMIN)-guarded like DkvController |
| V5 Input Validation | Yes | All parsed eForms-XML/OCDS-JSON fields are external, untrusted input — sanitize/escape before any future HTML rendering (Phase 11 UI); validate pubDay/pubMonth cursor values server-side before constructing the outbound DÖE URL (defense against cursor-corruption, low risk since these are internally computed, not user-supplied, but validate anyway) |
| V6 Cryptography | No | No credentials/secrets are stored for this phase (DÖE is auth-free) — CalendarCryptoService/AES-256-GCM pattern is reserved for later phases (email-alert inbox credentials, Phase 14) |
Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Untrusted external content (buyer names, tender descriptions, CPV descriptions from eForms/OCDS) rendered unescaped in a future UI | Tampering / Injection | Sanitize/escape all ingested text fields before HTML interpolation — applies to Phase 11's UI, but the normalizer in this phase should not assume the data is "safe" just because it came from a government API; store raw, escape at render time |
| Zip-bomb / decompression-bomb via a maliciously large or deeply-nested ZIP response | Denial of Service | Low realistic risk given DÖE is a trusted government-operated source over HTTPS, but as defense-in-depth: adm-zip's getEntries() reads the central directory without fully decompressing — validate total uncompressed size against a sane ceiling (e.g., reject if entries.reduce(sum of entry.header.size) > 50MB) before extracting each entry's buffer |
| Cursor/date-parameter injection into the outbound DÖE URL | Tampering | The pubDay/pubMonth cursor is entirely internally computed from lastIngestedDay (never user-supplied) — no admin-facing input flows directly into this URL, so injection risk is structurally absent as designed; keep it that way (don't add an admin "manually trigger for date X" feature without validating the date format server-side first) |
Sources
Primary (HIGH confidence)
- Live API verification, this session (2026-07-21):
https://oeffentlichevergabe.de/documentation/swagger-ui/opendata/swagger-initializer.js(reveals real spec URL, bypassing the JS-rendered Swagger UI),https://oeffentlichevergabe.de/documentation/api/opendata(real OpenAPI 3.1 spec, fetched and parsed),https://oeffentlichevergabe.de/api/notice-exports?pubDay=2026-07-20&format=ocds.zipand...&format=eforms.zip(real 200 responses, 105 real notices downloaded and inspected), boundary tests forpubDay=todayandpubDay=future(both confirmed 400) npm view adm-zip version/npm view unzipper version/gsd-tools query package-legitimacy check— direct registry + legitimacy-seam verification, this session- Existing codebase (direct read, this session):
apps/api/src/dkv/dkv-scheduler.service.ts,apps/api/src/dkv/dkv.service.ts,apps/api/src/dkv/dkv.module.ts,apps/api/src/dkv/dkv.controller.ts,apps/api/src/dkv/dkv.seed.ts,apps/api/src/cert-manager/cert-manager.seed.ts,apps/api/src/module-registry/module-registry.service.ts,apps/api/src/prisma/prisma-tenant.extension.ts,apps/api/prisma/schema.prisma(DkvModuleConfigmodel),apps/web/src/lib/module-loader.ts,apps/web/src/app/(portal)/modules/dkv-fleet/settings/components/InboxConfigForm.tsx,apps/api/src/app.module.ts,apps/api/package.json
Secondary (MEDIUM confidence)
.planning/research/SUMMARY.md,ARCHITECTURE.md,STACK.md,PITFALLS.md,ausschreibungs-portale-feasibility.md(milestone-level research, 2026-07-16/17) — used as the binding baseline this document extends/corrects where live verification diverged
Tertiary (LOW confidence)
- None — this session prioritized live verification over inference specifically because the milestone research had flagged the DÖE pagination/rate-limit question as an explicit open point.
Metadata
Confidence breakdown:
- DÖE API mechanics (endpoint, params, day-batch model, boundary rules): HIGH — directly observed via live requests this session, not inferred from documentation
- OCDS/eForms field completeness (deadline/value nullability, tag distribution): MEDIUM-HIGH — directly observed on a real sample, but the sample is one day (105 notices) and may not capture the full range of buyer/portal submission quality variance
- Platform integration (module registry, scheduler mechanics, Prisma conventions): HIGH — verified against existing, working codebase
- ZIP library choice (
adm-zip): MEDIUM — sound technical choice, but package-name provenance is[ASSUMED](training knowledge) per the package-legitimacy protocol; flagged forcheckpoint:human-verify
Research date: 2026-07-21 Valid until: DÖE API mechanics — treat as stable for 90 days (young, actively-operated government API, no deprecation signals observed; re-verify if a phase re-plan happens much later). npm package versions — 30 days (fast-moving ecosystem norm).