From 31865cb3f043ddb0bc7f15bf07e83355386780ff Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 8 Sep 2026 02:53:37 +0200 Subject: [PATCH 01/10] feat: define and prove the municipal snapshot contract --- .github/workflows/tests.yml | 18 ++ CONTEXT.md | 14 ++ app/Support/SnapshotContract.php | 140 +++++++++++ composer.json | 1 + composer.lock | 192 ++++++++++++++- docs/development/data-foundation-delivery.md | 233 ++++++++++++++++++ docs/development/data-foundation-stack.md | 73 ++++++ docs/development/data-import-contract.md | 192 +++++++++++++++ docs/development/data-import-pilot.md | 91 +++++++ docs/development/quality-checks.md | 4 + docs/product/data-foundation.md | 192 +++++++++++++++ docs/product/feature-inventory.md | 4 +- docs/product/trust-and-provenance.md | 2 + .../schemas/import/v1/manifest.schema.json | 225 +++++++++++++++++ .../import/v1/municipal-record.schema.json | 159 ++++++++++++ .../v1/artifact-declared-limit/context.json | 1 + .../v1/artifact-declared-limit/manifest.json | 1 + .../v1/artifact-declared-limit/records.jsonl | 1 + .../import/v1/backwards-time/context.json | 1 + .../import/v1/backwards-time/manifest.json | 1 + .../import/v1/backwards-time/records.jsonl | 1 + .../import/v1/blank-line/context.json | 1 + .../import/v1/blank-line/manifest.json | 1 + .../import/v1/blank-line/records.jsonl | 1 + .../v1/byte-count-mismatch/context.json | 1 + .../v1/byte-count-mismatch/manifest.json | 1 + .../v1/byte-count-mismatch/records.jsonl | 1 + tests/Fixtures/import/v1/cases.json | 214 ++++++++++++++++ .../import/v1/checksum-mismatch/context.json | 1 + .../import/v1/checksum-mismatch/manifest.json | 1 + .../import/v1/checksum-mismatch/records.jsonl | 1 + .../import/v1/complete-empty/context.json | 1 + .../import/v1/complete-empty/manifest.json | 1 + .../import/v1/complete-empty/records.jsonl | 0 .../import/v1/conflicting-batch/context.json | 1 + .../import/v1/conflicting-batch/manifest.json | 1 + .../import/v1/conflicting-batch/records.jsonl | 1 + .../import/v1/cross-source-path/context.json | 1 + .../import/v1/cross-source-path/manifest.json | 1 + .../import/v1/cross-source-path/records.jsonl | 1 + .../import/v1/duplicate-batch/context.json | 1 + .../import/v1/duplicate-batch/manifest.json | 1 + .../import/v1/duplicate-batch/records.jsonl | 1 + .../import/v1/duplicate-records/context.json | 1 + .../import/v1/duplicate-records/manifest.json | 1 + .../import/v1/duplicate-records/records.jsonl | 2 + .../import/v1/duplicate-sequence/context.json | 1 + .../v1/duplicate-sequence/manifest.json | 1 + .../v1/duplicate-sequence/records.jsonl | 1 + .../v1/expected-count-mismatch/context.json | 1 + .../v1/expected-count-mismatch/manifest.json | 1 + .../v1/expected-count-mismatch/records.jsonl | 1 + .../import/v1/fetch-deadline/context.json | 1 + .../import/v1/fetch-deadline/manifest.json | 1 + .../import/v1/fetch-deadline/records.jsonl | 1 + .../v1/filtered-count-mismatch/context.json | 1 + .../v1/filtered-count-mismatch/manifest.json | 1 + .../v1/filtered-count-mismatch/records.jsonl | 1 + .../import/v1/incomplete/context.json | 1 + .../import/v1/incomplete/manifest.json | 1 + .../import/v1/incomplete/records.jsonl | 1 + .../import/v1/invalid-json/context.json | 1 + .../import/v1/invalid-json/manifest.json | 1 + .../import/v1/invalid-json/records.jsonl | 1 + .../import/v1/invalid-latitude/context.json | 1 + .../import/v1/invalid-latitude/manifest.json | 1 + .../import/v1/invalid-latitude/records.jsonl | 1 + .../import/v1/invalid-longitude/context.json | 1 + .../import/v1/invalid-longitude/manifest.json | 1 + .../import/v1/invalid-longitude/records.jsonl | 1 + .../v1/invalid-manifest-date/context.json | 1 + .../v1/invalid-manifest-date/manifest.json | 1 + .../v1/invalid-manifest-date/records.jsonl | 1 + .../import/v1/late-batch/context.json | 1 + .../import/v1/late-batch/manifest.json | 1 + .../import/v1/late-batch/records.jsonl | 1 + .../v1/leading-zero-identities/context.json | 1 + .../v1/leading-zero-identities/manifest.json | 1 + .../v1/leading-zero-identities/records.jsonl | 2 + .../import/v1/line-limit/context.json | 1 + .../import/v1/line-limit/manifest.json | 1 + .../import/v1/line-limit/records.jsonl | 1 + .../import/v1/manifest-limit/context.json | 1 + .../import/v1/manifest-limit/manifest.json | 1 + .../import/v1/manifest-limit/records.jsonl | 1 + .../import/v1/missing-capacity/context.json | 1 + .../import/v1/missing-capacity/manifest.json | 1 + .../import/v1/missing-capacity/records.jsonl | 1 + .../import/v1/missing-version/context.json | 1 + .../import/v1/missing-version/manifest.json | 1 + .../import/v1/missing-version/records.jsonl | 1 + .../import/v1/new-config/context.json | 1 + .../import/v1/new-config/manifest.json | 1 + .../import/v1/new-config/records.jsonl | 1 + .../Fixtures/import/v1/new-scope/context.json | 1 + .../import/v1/new-scope/manifest.json | 1 + .../import/v1/new-scope/records.jsonl | 1 + .../import/v1/non-json-number/context.json | 1 + .../import/v1/non-json-number/manifest.json | 1 + .../import/v1/non-json-number/records.jsonl | 1 + .../v1/null-object-version/context.json | 1 + .../v1/null-object-version/manifest.json | 1 + .../v1/null-object-version/records.jsonl | 1 + .../v1/outside-source-bounds/context.json | 1 + .../v1/outside-source-bounds/manifest.json | 1 + .../v1/outside-source-bounds/records.jsonl | 1 + .../v1/pagination-not-exhausted/context.json | 1 + .../v1/pagination-not-exhausted/manifest.json | 1 + .../v1/pagination-not-exhausted/records.jsonl | 1 + .../import/v1/parse-failures/context.json | 1 + .../import/v1/parse-failures/manifest.json | 1 + .../import/v1/parse-failures/records.jsonl | 1 + .../v1/personal-needs-review/context.json | 1 + .../v1/personal-needs-review/manifest.json | 1 + .../v1/personal-needs-review/records.jsonl | 1 + .../import/v1/pilot-normalized/context.json | 1 + .../import/v1/pilot-normalized/manifest.json | 1 + .../import/v1/pilot-normalized/records.jsonl | 1 + tests/Fixtures/import/v1/pilot-source.json | 26 ++ .../context.json | 1 + .../manifest.json | 1 + .../records.jsonl | 1 + .../context.json | 1 + .../manifest.json | 1 + .../records.jsonl | 1 + .../context.json | 1 + .../manifest.json | 1 + .../records.jsonl | 1 + .../v1/record-count-mismatch/context.json | 1 + .../v1/record-count-mismatch/manifest.json | 1 + .../v1/record-count-mismatch/records.jsonl | 1 + .../v1/record-country_code-DE/context.json | 1 + .../v1/record-country_code-DE/manifest.json | 1 + .../v1/record-country_code-DE/records.jsonl | 1 + .../v1/record-declared-limit/context.json | 1 + .../v1/record-declared-limit/manifest.json | 1 + .../v1/record-declared-limit/records.jsonl | 1 + .../v1/record-external_id-/context.json | 1 + .../v1/record-external_id-/manifest.json | 1 + .../v1/record-external_id-/records.jsonl | 1 + .../v1/record-external_id-123/context.json | 1 + .../v1/record-external_id-123/manifest.json | 1 + .../v1/record-external_id-123/records.jsonl | 1 + .../context.json | 1 + .../manifest.json | 1 + .../records.jsonl | 1 + .../context.json | 1 + .../manifest.json | 1 + .../records.jsonl | 1 + .../v1/restriction-needs-review/context.json | 1 + .../v1/restriction-needs-review/manifest.json | 1 + .../v1/restriction-needs-review/records.jsonl | 1 + .../import/v1/sequence-gap/context.json | 1 + .../import/v1/sequence-gap/manifest.json | 1 + .../import/v1/sequence-gap/records.jsonl | 1 + .../import/v1/truncated-line/context.json | 1 + .../import/v1/truncated-line/manifest.json | 1 + .../import/v1/truncated-line/records.jsonl | 1 + .../import/v1/unadmitted-source/context.json | 1 + .../import/v1/unadmitted-source/manifest.json | 1 + .../import/v1/unadmitted-source/records.jsonl | 1 + .../import/v1/unknown-capacity/context.json | 1 + .../import/v1/unknown-capacity/manifest.json | 1 + .../import/v1/unknown-capacity/records.jsonl | 1 + .../import/v1/unknown-contract/context.json | 1 + .../import/v1/unknown-contract/manifest.json | 1 + .../import/v1/unknown-contract/records.jsonl | 1 + .../v1/unknown-record-field/context.json | 1 + .../v1/unknown-record-field/manifest.json | 1 + .../v1/unknown-record-field/records.jsonl | 1 + .../v1/unmapped-needs-review/context.json | 1 + .../v1/unmapped-needs-review/manifest.json | 1 + .../v1/unmapped-needs-review/records.jsonl | 1 + .../import/v1/zero-capacity/context.json | 1 + .../import/v1/zero-capacity/manifest.json | 1 + .../import/v1/zero-capacity/records.jsonl | 1 + tests/Support/import_contract/.gitignore | 2 + tests/Support/import_contract/contract.py | 102 ++++++++ tests/Support/import_contract/poetry.lock | 197 +++++++++++++++ tests/Support/import_contract/pyproject.toml | 10 + .../Support/import_contract/test_contract.py | 25 ++ tests/Unit/Support/SnapshotContractTest.php | 27 ++ 182 files changed, 2301 insertions(+), 2 deletions(-) create mode 100644 app/Support/SnapshotContract.php create mode 100644 docs/development/data-foundation-delivery.md create mode 100644 docs/development/data-foundation-stack.md create mode 100644 docs/development/data-import-contract.md create mode 100644 docs/development/data-import-pilot.md create mode 100644 docs/product/data-foundation.md create mode 100644 resources/schemas/import/v1/manifest.schema.json create mode 100644 resources/schemas/import/v1/municipal-record.schema.json create mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/context.json create mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/manifest.json create mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/records.jsonl create mode 100644 tests/Fixtures/import/v1/backwards-time/context.json create mode 100644 tests/Fixtures/import/v1/backwards-time/manifest.json create mode 100644 tests/Fixtures/import/v1/backwards-time/records.jsonl create mode 100644 tests/Fixtures/import/v1/blank-line/context.json create mode 100644 tests/Fixtures/import/v1/blank-line/manifest.json create mode 100644 tests/Fixtures/import/v1/blank-line/records.jsonl create mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/context.json create mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/manifest.json create mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/records.jsonl create mode 100644 tests/Fixtures/import/v1/cases.json create mode 100644 tests/Fixtures/import/v1/checksum-mismatch/context.json create mode 100644 tests/Fixtures/import/v1/checksum-mismatch/manifest.json create mode 100644 tests/Fixtures/import/v1/checksum-mismatch/records.jsonl create mode 100644 tests/Fixtures/import/v1/complete-empty/context.json create mode 100644 tests/Fixtures/import/v1/complete-empty/manifest.json create mode 100644 tests/Fixtures/import/v1/complete-empty/records.jsonl create mode 100644 tests/Fixtures/import/v1/conflicting-batch/context.json create mode 100644 tests/Fixtures/import/v1/conflicting-batch/manifest.json create mode 100644 tests/Fixtures/import/v1/conflicting-batch/records.jsonl create mode 100644 tests/Fixtures/import/v1/cross-source-path/context.json create mode 100644 tests/Fixtures/import/v1/cross-source-path/manifest.json create mode 100644 tests/Fixtures/import/v1/cross-source-path/records.jsonl create mode 100644 tests/Fixtures/import/v1/duplicate-batch/context.json create mode 100644 tests/Fixtures/import/v1/duplicate-batch/manifest.json create mode 100644 tests/Fixtures/import/v1/duplicate-batch/records.jsonl create mode 100644 tests/Fixtures/import/v1/duplicate-records/context.json create mode 100644 tests/Fixtures/import/v1/duplicate-records/manifest.json create mode 100644 tests/Fixtures/import/v1/duplicate-records/records.jsonl create mode 100644 tests/Fixtures/import/v1/duplicate-sequence/context.json create mode 100644 tests/Fixtures/import/v1/duplicate-sequence/manifest.json create mode 100644 tests/Fixtures/import/v1/duplicate-sequence/records.jsonl create mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/context.json create mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/manifest.json create mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/records.jsonl create mode 100644 tests/Fixtures/import/v1/fetch-deadline/context.json create mode 100644 tests/Fixtures/import/v1/fetch-deadline/manifest.json create mode 100644 tests/Fixtures/import/v1/fetch-deadline/records.jsonl create mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/context.json create mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/manifest.json create mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/records.jsonl create mode 100644 tests/Fixtures/import/v1/incomplete/context.json create mode 100644 tests/Fixtures/import/v1/incomplete/manifest.json create mode 100644 tests/Fixtures/import/v1/incomplete/records.jsonl create mode 100644 tests/Fixtures/import/v1/invalid-json/context.json create mode 100644 tests/Fixtures/import/v1/invalid-json/manifest.json create mode 100644 tests/Fixtures/import/v1/invalid-json/records.jsonl create mode 100644 tests/Fixtures/import/v1/invalid-latitude/context.json create mode 100644 tests/Fixtures/import/v1/invalid-latitude/manifest.json create mode 100644 tests/Fixtures/import/v1/invalid-latitude/records.jsonl create mode 100644 tests/Fixtures/import/v1/invalid-longitude/context.json create mode 100644 tests/Fixtures/import/v1/invalid-longitude/manifest.json create mode 100644 tests/Fixtures/import/v1/invalid-longitude/records.jsonl create mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/context.json create mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/manifest.json create mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/records.jsonl create mode 100644 tests/Fixtures/import/v1/late-batch/context.json create mode 100644 tests/Fixtures/import/v1/late-batch/manifest.json create mode 100644 tests/Fixtures/import/v1/late-batch/records.jsonl create mode 100644 tests/Fixtures/import/v1/leading-zero-identities/context.json create mode 100644 tests/Fixtures/import/v1/leading-zero-identities/manifest.json create mode 100644 tests/Fixtures/import/v1/leading-zero-identities/records.jsonl create mode 100644 tests/Fixtures/import/v1/line-limit/context.json create mode 100644 tests/Fixtures/import/v1/line-limit/manifest.json create mode 100644 tests/Fixtures/import/v1/line-limit/records.jsonl create mode 100644 tests/Fixtures/import/v1/manifest-limit/context.json create mode 100644 tests/Fixtures/import/v1/manifest-limit/manifest.json create mode 100644 tests/Fixtures/import/v1/manifest-limit/records.jsonl create mode 100644 tests/Fixtures/import/v1/missing-capacity/context.json create mode 100644 tests/Fixtures/import/v1/missing-capacity/manifest.json create mode 100644 tests/Fixtures/import/v1/missing-capacity/records.jsonl create mode 100644 tests/Fixtures/import/v1/missing-version/context.json create mode 100644 tests/Fixtures/import/v1/missing-version/manifest.json create mode 100644 tests/Fixtures/import/v1/missing-version/records.jsonl create mode 100644 tests/Fixtures/import/v1/new-config/context.json create mode 100644 tests/Fixtures/import/v1/new-config/manifest.json create mode 100644 tests/Fixtures/import/v1/new-config/records.jsonl create mode 100644 tests/Fixtures/import/v1/new-scope/context.json create mode 100644 tests/Fixtures/import/v1/new-scope/manifest.json create mode 100644 tests/Fixtures/import/v1/new-scope/records.jsonl create mode 100644 tests/Fixtures/import/v1/non-json-number/context.json create mode 100644 tests/Fixtures/import/v1/non-json-number/manifest.json create mode 100644 tests/Fixtures/import/v1/non-json-number/records.jsonl create mode 100644 tests/Fixtures/import/v1/null-object-version/context.json create mode 100644 tests/Fixtures/import/v1/null-object-version/manifest.json create mode 100644 tests/Fixtures/import/v1/null-object-version/records.jsonl create mode 100644 tests/Fixtures/import/v1/outside-source-bounds/context.json create mode 100644 tests/Fixtures/import/v1/outside-source-bounds/manifest.json create mode 100644 tests/Fixtures/import/v1/outside-source-bounds/records.jsonl create mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/context.json create mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/manifest.json create mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/records.jsonl create mode 100644 tests/Fixtures/import/v1/parse-failures/context.json create mode 100644 tests/Fixtures/import/v1/parse-failures/manifest.json create mode 100644 tests/Fixtures/import/v1/parse-failures/records.jsonl create mode 100644 tests/Fixtures/import/v1/personal-needs-review/context.json create mode 100644 tests/Fixtures/import/v1/personal-needs-review/manifest.json create mode 100644 tests/Fixtures/import/v1/personal-needs-review/records.jsonl create mode 100644 tests/Fixtures/import/v1/pilot-normalized/context.json create mode 100644 tests/Fixtures/import/v1/pilot-normalized/manifest.json create mode 100644 tests/Fixtures/import/v1/pilot-normalized/records.jsonl create mode 100644 tests/Fixtures/import/v1/pilot-source.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/context.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/manifest.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/context.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/manifest.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/context.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/manifest.json create mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-count-mismatch/context.json create mode 100644 tests/Fixtures/import/v1/record-count-mismatch/manifest.json create mode 100644 tests/Fixtures/import/v1/record-count-mismatch/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-country_code-DE/context.json create mode 100644 tests/Fixtures/import/v1/record-country_code-DE/manifest.json create mode 100644 tests/Fixtures/import/v1/record-country_code-DE/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-declared-limit/context.json create mode 100644 tests/Fixtures/import/v1/record-declared-limit/manifest.json create mode 100644 tests/Fixtures/import/v1/record-declared-limit/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-external_id-/context.json create mode 100644 tests/Fixtures/import/v1/record-external_id-/manifest.json create mode 100644 tests/Fixtures/import/v1/record-external_id-/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-external_id-123/context.json create mode 100644 tests/Fixtures/import/v1/record-external_id-123/manifest.json create mode 100644 tests/Fixtures/import/v1/record-external_id-123/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/context.json create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/manifest.json create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/records.jsonl create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/context.json create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/manifest.json create mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/records.jsonl create mode 100644 tests/Fixtures/import/v1/restriction-needs-review/context.json create mode 100644 tests/Fixtures/import/v1/restriction-needs-review/manifest.json create mode 100644 tests/Fixtures/import/v1/restriction-needs-review/records.jsonl create mode 100644 tests/Fixtures/import/v1/sequence-gap/context.json create mode 100644 tests/Fixtures/import/v1/sequence-gap/manifest.json create mode 100644 tests/Fixtures/import/v1/sequence-gap/records.jsonl create mode 100644 tests/Fixtures/import/v1/truncated-line/context.json create mode 100644 tests/Fixtures/import/v1/truncated-line/manifest.json create mode 100644 tests/Fixtures/import/v1/truncated-line/records.jsonl create mode 100644 tests/Fixtures/import/v1/unadmitted-source/context.json create mode 100644 tests/Fixtures/import/v1/unadmitted-source/manifest.json create mode 100644 tests/Fixtures/import/v1/unadmitted-source/records.jsonl create mode 100644 tests/Fixtures/import/v1/unknown-capacity/context.json create mode 100644 tests/Fixtures/import/v1/unknown-capacity/manifest.json create mode 100644 tests/Fixtures/import/v1/unknown-capacity/records.jsonl create mode 100644 tests/Fixtures/import/v1/unknown-contract/context.json create mode 100644 tests/Fixtures/import/v1/unknown-contract/manifest.json create mode 100644 tests/Fixtures/import/v1/unknown-contract/records.jsonl create mode 100644 tests/Fixtures/import/v1/unknown-record-field/context.json create mode 100644 tests/Fixtures/import/v1/unknown-record-field/manifest.json create mode 100644 tests/Fixtures/import/v1/unknown-record-field/records.jsonl create mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/context.json create mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/manifest.json create mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/records.jsonl create mode 100644 tests/Fixtures/import/v1/zero-capacity/context.json create mode 100644 tests/Fixtures/import/v1/zero-capacity/manifest.json create mode 100644 tests/Fixtures/import/v1/zero-capacity/records.jsonl create mode 100644 tests/Support/import_contract/.gitignore create mode 100644 tests/Support/import_contract/contract.py create mode 100644 tests/Support/import_contract/poetry.lock create mode 100644 tests/Support/import_contract/pyproject.toml create mode 100644 tests/Support/import_contract/test_contract.py create mode 100644 tests/Unit/Support/SnapshotContractTest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90c4cd09..adf5cc3e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,3 +89,21 @@ jobs: - name: Build client assets and run tests run: composer ci:test + + import-contract-python: + name: Python 3.14 import contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 + with: + python-version: '3.14.2' + - name: Install locked contract dependencies + run: | + pipx install poetry==2.4.3 + poetry -C tests/Support/import_contract install --no-interaction + - name: Check shared fixtures without source network + run: poetry -C tests/Support/import_contract run python -m unittest -v diff --git a/CONTEXT.md b/CONTEXT.md index 5ba537e3..ea4a5a69 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -17,3 +17,17 @@ _Avoid_: ParkingSpotConfirmation **ParkingRule**: A reference to parking rules for a municipality or an entire country. **Favorite**: A user's saved reference to a parking option from any of the three sources. + +**DatasetSource**: A dataset selected for connection to NIPKaart, with an identified publisher, scope and provenance. It is not a research lead or a contact-management record. + +**SourceRecord**: What one dataset says about a parking place or facility, identified within that dataset. Multiple source records can describe the same physical place without becoming the same source. + +**ParkingObservation**: A dated statement about particular properties of a parking place, with its method and supporting provenance. Its registration date does not establish when the place was observed. + +**CorrectionProposal**: A proposed change to information about an existing community or imported parking record, with a reason and supporting observation. + +**LocalCorrection**: An accepted correction whose value and justification remain distinct from the source's current statement. + +**PublicationDecision**: An attributable decision about which information NIPKaart presents, including the reason and evidence on which it is based. + +**ParkingRecordLink**: An assessed relationship between parking records, distinguishing records that describe the same place from a space located within a facility. diff --git a/app/Support/SnapshotContract.php b/app/Support/SnapshotContract.php new file mode 100644 index 00000000..8568f88d --- /dev/null +++ b/app/Support/SnapshotContract.php @@ -0,0 +1,140 @@ +clock = $clock ?? static fn (): float => hrtime(true) / 1e9; + $this->validator = new Validator; + $directory = dirname(__DIR__, 2).'/resources/schemas/import/v1/'; + $this->manifestSchema = json_decode(file_get_contents($directory.'manifest.schema.json'), false, 32, JSON_THROW_ON_ERROR); + $this->recordSchema = json_decode(file_get_contents($directory.'municipal-record.schema.json'), false, 32, JSON_THROW_ON_ERROR); + } + + /** + * Context is trusted admission/ledger state supplied by the consumer, never by the manifest. + * + * @param array{source_id: string, scope_version: int, source_config_version: int, country_code: string, bounds: array{float, float, float, float}, highest_sequence: int, batches: array, sequences: array} $context + */ + public function check(string $manifestJson, string $recordsPath, array $context): string + { + $started = ($this->clock)(); + if (strlen($manifestJson) > self::MAX_MANIFEST_BYTES) { + return 'invalid'; + } + + try { + $manifest = json_decode($manifestJson, false, 32, JSON_THROW_ON_ERROR); + if (! $this->validator->validate($manifest, $this->manifestSchema)->isValid()) { + return 'invalid'; + } + + $expectedKey = "sources/{$manifest->source_id}/batches/{$manifest->batch_id}/records.jsonl"; + $completeness = $manifest->completeness; + if ($manifest->source_id !== $context['source_id'] + || $manifest->artifact->key !== $expectedKey + || $manifest->fetched_finished_at < $manifest->fetched_started_at + || strtotime($manifest->fetched_finished_at) - strtotime($manifest->fetched_started_at) > 1800 + || $completeness->records_seen != $completeness->records_emitted + $completeness->records_filtered_out + || $completeness->records_emitted != $manifest->artifact->record_count + || ($completeness->expected_records !== null && $completeness->expected_records != $completeness->records_seen)) { + return 'invalid'; + } + + if (! is_file($recordsPath) || ! is_readable($recordsPath)) { + return 'invalid'; + } + $stream = fopen($recordsPath, 'rb'); + if ($stream === false) { + return 'invalid'; + } + $hash = hash_init('sha256'); + $bytes = 0; + $ids = []; + $review = false; + try { + while (($line = fgets($stream, self::MAX_LINE_BYTES + 2)) !== false) { + $bytes += strlen($line); + if ($bytes > self::MAX_ARTIFACT_BYTES || strlen($line) > self::MAX_LINE_BYTES + || ! str_ends_with($line, "\n") || str_contains($line, "\r") + || (($this->clock)() - $started) > self::MAX_VALIDATION_SECONDS) { + return 'invalid'; + } + hash_update($hash, $line); + $record = json_decode($line, false, 32, JSON_THROW_ON_ERROR); + if (! $this->validator->validate($record, $this->recordSchema)->isValid()) { + return 'invalid'; + } + $id = 'id:'.$record->external_id; + if (isset($ids[$id]) || count($ids) >= self::MAX_RECORDS) { + return 'invalid'; + } + $ids[$id] = true; + [$west, $south, $east, $north] = $context['bounds']; + if ($record->country_code !== $context['country_code'] + || $record->position->longitude < $west || $record->position->longitude > $east + || $record->position->latitude < $south || $record->position->latitude > $north) { + return 'invalid'; + } + $review = $review || $record->access_category !== 'designated_accessible' + || $record->unmapped_fields !== [] || $record->restrictions !== []; + } + if (! feof($stream)) { + return 'invalid'; + } + } finally { + fclose($stream); + } + if ($bytes != $manifest->artifact->bytes || count($ids) != $manifest->artifact->record_count + || hash_final($hash) !== $manifest->artifact->sha256 + || (($this->clock)() - $started) > self::MAX_VALIDATION_SECONDS) { + return 'invalid'; + } + + $knownHash = $context['batches'][$manifest->batch_id] ?? null; + if ($knownHash !== null) { + return hash_equals($knownHash, hash('sha256', $manifestJson)) ? 'duplicate' : 'conflict'; + } + if (isset($context['sequences'][$manifest->source_sequence])) { + return 'conflict'; + } + if ($manifest->source_sequence <= $context['highest_sequence']) { + return 'superseded'; + } + if ($review || $ids === [] || $manifest->scope_version != $context['scope_version'] + || $manifest->source_config_version != $context['source_config_version']) { + return 'review'; + } + + return 'valid'; + } catch (JsonException) { + return 'invalid'; + } + } +} diff --git a/composer.json b/composer.json index 0fe5d629..9b381405 100644 --- a/composer.json +++ b/composer.json @@ -21,6 +21,7 @@ "laravel/wayfinder": "^0.1.10", "league/flysystem-aws-s3-v3": "^3.0", "opcodesio/log-viewer": "^3.15", + "opis/json-schema": "^2", "spatie/laravel-backup": "^10.0", "spatie/laravel-permission": "^8.0" }, diff --git a/composer.lock b/composer.lock index a6cdd0eb..d0fadf83 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a8fb57045fe9c9ec14a3eb9b4f58aedf", + "content-hash": "800b88f54d20308f01182e3643483fa3", "packages": [ { "name": "aws/aws-crt-php", @@ -3757,6 +3757,196 @@ }, "time": "2026-02-28T09:04:57+00:00" }, + { + "name": "opis/json-schema", + "version": "2.6.0", + "source": { + "type": "git", + "url": "https://github.com/opis/json-schema.git", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", + "shasum": "" + }, + "require": { + "ext-json": "*", + "opis/string": "^2.1", + "opis/uri": "^1.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "ext-bcmath": "*", + "ext-intl": "*", + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\JsonSchema\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + }, + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + } + ], + "description": "Json Schema Validator for PHP", + "homepage": "https://opis.io/json-schema", + "keywords": [ + "json", + "json-schema", + "schema", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/opis/json-schema/issues", + "source": "https://github.com/opis/json-schema/tree/2.6.0" + }, + "time": "2025-10-17T12:46:48+00:00" + }, + { + "name": "opis/string", + "version": "2.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/string.git", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", + "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "ext-json": "*", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\String\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Multibyte strings as objects", + "homepage": "https://opis.io/string", + "keywords": [ + "multi-byte", + "opis", + "string", + "string manipulation", + "utf-8" + ], + "support": { + "issues": "https://github.com/opis/string/issues", + "source": "https://github.com/opis/string/tree/2.1.0" + }, + "time": "2025-10-17T12:38:41+00:00" + }, + { + "name": "opis/uri", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/opis/uri.git", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", + "shasum": "" + }, + "require": { + "opis/string": "^2.0", + "php": "^7.4 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Opis\\Uri\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Marius Sarca", + "email": "marius.sarca@gmail.com" + }, + { + "name": "Sorin Sarca", + "email": "sarca_sorin@hotmail.com" + } + ], + "description": "Build, parse and validate URIs and URI-templates", + "homepage": "https://opis.io", + "keywords": [ + "URI Template", + "parse url", + "punycode", + "uri", + "uri components", + "url", + "validate uri" + ], + "support": { + "issues": "https://github.com/opis/uri/issues", + "source": "https://github.com/opis/uri/tree/1.1.0" + }, + "time": "2021-05-22T15:57:08+00:00" + }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", diff --git a/docs/development/data-foundation-delivery.md b/docs/development/data-foundation-delivery.md new file mode 100644 index 00000000..744d6c0c --- /dev/null +++ b/docs/development/data-foundation-delivery.md @@ -0,0 +1,233 @@ +# Batchimports: uitvoering en beheer + +Status: uitvoeringsvoorstel, 2026-09-08. De eigenaar heeft de zelfstandige batchaanpak gekozen. Dit document vervangt de werkpakketten voor door core aangestuurde workers. De uitvoering is op 2026-09-08 in GitHub gepland met 13 nieuwe subissues, bijgewerkte epics en native parent-/blockingrelaties. De [contractproef voor #1214](data-import-pilot.md) voegt goedgekeurde validators, schemas en fixtures toe. Er zijn geen importjobs gestart of productiegegevens geïmporteerd. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [techstack](data-foundation-stack.md). + +## Eenvoud als uitgangspunt voor uitvoering + +De eerste versie heeft één dataset, één adapter rond een bestaand universeel package en één volledige snapshot. De adapter produceert een bestand; core beoordeelt en verwerkt het. Automatisering verandert alleen hoe dat bestand verschijnt. Brononderzoek/CRM, generieke adapterbouwers, karma en een mobiele app blijven buiten deze eerste keten. + +Drie kleine opleveringen: + +1. **Bestand naar correcte parkeerdata:** fixture/package naar JSONL en manifest, dezelfde validatie in Python en core, handmatig gestart importproces en beoordeelde publicatie. +2. **Bestanden automatisch leveren en ontdekken:** geplande batchuitvoering in de importomgeving, records uploaden en als laatste gereedmelden; core ontdekt en verwerkt zonder runner-API. +3. **Correctie behouden:** een beoordeelde veldcorrectie op een geïmporteerde plek blijft na herimport bestaan en kan expliciet worden ingetrokken. + +De eerste stap werkt lokaal met een fixture; uiteindelijke pilotacceptatie vereist een echte toegestane bron en stagingtransport. Integreer alleen de opslag en UI die deze stappen vereisen. Het beheer begint met datasetinstellingen en een importoverzicht met verschillen, publiceren en afwijzen. + +| Direct nodig | Later wanneer aangetoond nodig | +| --- | --- | +| Volledige snapshots en stabiele bron-ID | Deltafeeds/cursors | +| Klaar-manifest pas na volledige upload | Opslagnotificaties of extra delivery-index | +| Unieke batchregistratie en sourcevolgorde | Meerdere productiehosts met coördinatie | +| Eén begrensde publicatietransactie na staging | Grote versioned publicaties met atomair omschakelen | +| Veldcorrectie apart van bronwaarde | Uitgebreid bewijs-/vertrouwensmodel | +| Ongewijzigde records niet herschrijven | Bronafhankelijke conditional requests | +| Bestaande Poetry-tooling | Zelfstandige toolingmigratie indien nuttig | + +Correctheid blijft verplicht: beperkte opslagrechten, volledige levering controleren, oudere batches weigeren, idempotente verwerking, stabiele verwijzingen, behoud van correcties en onbekend niet als nul of vrij presenteren. + +## 1. Roadmapkoppeling + +De bestaande productissues zijn als anker hergebruikt. De concrete planning hieronder is op 2026-09-08 aangemaakt; alle acceptatiecriteria blijven open totdat de uitvoering bewijs oplevert. + +| Issue | Bijdrage | +| --- | --- | +| [#1168](https://github.com/NIPKaart/core/issues/1168) | Productervaring; noodzakelijke databasis eerder dan oude Horizon 2-indeling | +| [#1176](https://github.com/NIPKaart/core/issues/1176) | Operationeel datasetregister, batchimporthistorie, identiteit en dekking | +| [#1174](https://github.com/NIPKaart/core/issues/1174) | Provenance, freshness en begrijpelijke status | +| [#1175](https://github.com/NIPKaart/core/issues/1175) / [#276](https://github.com/NIPKaart/core/issues/276) | Correcties/besluiten/retentie; correcties op geïmporteerde plekken expliciet meenemen | +| [#1177](https://github.com/NIPKaart/core/issues/1177) | Voorzieningen en aparte bezettingsstroom | +| [#1170](https://github.com/NIPKaart/core/issues/1170) / [#1172](https://github.com/NIPKaart/core/issues/1172) | Effectieve gegevens en bronkoppelingen in discovery/details | +| [#1171](https://github.com/NIPKaart/core/issues/1171) | Toegankelijke lijst/detail en correctieflow | +| [#455](https://github.com/NIPKaart/core/issues/455) | Bulkbeheer behoudt import-/correctiehistorie | + +Maak kleine reviewbare PR's per verticale stap. Sluit issues pas na hun werkelijke acceptatie, niet op basis van deze ontwerptekst. + +## Concrete GitHub-planning + +De [data-epic #1176](https://github.com/NIPKaart/core/issues/1176) hangt onder [productepic #1168](https://github.com/NIPKaart/core/issues/1168). De [municipal producer-epic #690](https://github.com/NIPKaart/disabled-parking/issues/690) is hergebruikt en herschreven rond bestanden in plaats van directe databasewrites. Correcties, freshness en offstreet blijven onder hun bestaande productepics. Afhankelijkheden zijn echte GitHub-blockingrelaties; dit zijn oplevergroepen zonder verzonnen kalenderdeadlines. + +| Oplevering | Issue | Resultaat | +| --- | --- | --- | +| M1 | [core#1214](https://github.com/NIPKaart/core/issues/1214) | Pilotbron en bestandcontract | +| M1 | [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774) | Eerste packageadapter | +| M1 | [core#1215](https://github.com/NIPKaart/core/issues/1215) | Beoordeelde bestandimport | +| M2 | [disabled-parking#775](https://github.com/NIPKaart/disabled-parking/issues/775) | Zelfstandige batchproducer | +| M2 | [core#1216](https://github.com/NIPKaart/core/issues/1216) | Manifestdiscovery en herstel | +| M2 | [core#1217](https://github.com/NIPKaart/core/issues/1217) | Staging, opslagrechten en retentie | +| M3 | [core#1218](https://github.com/NIPKaart/core/issues/1218) | Correctiebehoud bij herimport | +| M3 | [core#1219](https://github.com/NIPKaart/core/issues/1219) | Herkomst en overdue | +| M3 | [core#1220](https://github.com/NIPKaart/core/issues/1220) | Volledige ketenacceptatie | +| Vervolg | [disabled-parking#776](https://github.com/NIPKaart/disabled-parking/issues/776) | Tweede Europese bron | +| Vervolg | [offstreet-parking#656](https://github.com/NIPKaart/offstreet-parking/issues/656) | Offstreetcatalogus | +| Vervolg | [core#1221](https://github.com/NIPKaart/core/issues/1221) | Bezettingscontract en coreconsumer | +| Vervolg | [offstreet-parking#657](https://github.com/NIPKaart/offstreet-parking/issues/657) | Liveproducer en integratie | + +Start met de contractproef in core#1214. Daarna kunnen adapter en core-bestandintake afzonderlijk worden uitgewerkt. Stagingbeslissingen blokkeren de lokale bestandproef niet. De gemeentelijke ketenacceptatie blokkeert verdere bronuitbreiding; de offstreetconsumer wordt eerst op fixtures bewezen voordat de liveproducer integreert. + +## 2. Werkpakketten + +### A — Package naar bestandcontract + +Repos: core voor schemas/fixtures, disabled-parking voor adapter. Geen infrastructuur vereist. + +- [ ] Selecteer één echte bron op stabiele ID, begrijpelijke scope en voorwaarden. Amsterdam is kandidaat, niet vooraf geaccepteerd. +- [ ] Controleer packageversie, doelruntime, paginering, nullwaarden, brondata en geografische mapping. +- [ ] Leg toegestane gesaniteerde fixtures vast en maak manifest-/recordschemas volgens het batchcontract. +- [ ] Bouw expliciete adaptermapping en bestanduitvoer; het universele package krijgt geen NIPKaart-afhankelijkheid. +- [ ] Laat Python en PHP dezelfde positieve/negatieve voorbeelden accepteren of afwijzen. +- [ ] Bepaal verplichte grenzen voor bytes, records, regels en uitvoeringsduur op basis van de pilot. + +Klaar wanneer een lokaal gemaakt bestand reproduceerbaar geldig is. Een API bereikbaar krijgen bewijst geen volledige of semantisch juiste dataset. + +### B — Bestand ontvangen, vergelijken en publiceren + +Repo: core. Afhankelijk van A; voltooit de eerste kleine oplevering. + +- [ ] Registreer een toegelaten dataset met source-ID, scope, herkomst, voorwaarden, geografische mapping en publicatiebeleid; geen onderzoeksworkflow. +- [ ] Bouw één toepassingsservice voor intake vanaf een lokaal/uploadbestand en later objectopslag. Geen directe spreadsheetwrites naar parkeertabellen. +- [ ] Leg batchidentiteit, volgorde, artifactidentiteit en status vast met unieke constraints en herhaalbare verwerking. +- [ ] Valideer in staging en toon een diff met aantallen, fouten, verdwijningen en kaart/lijststeekproef. +- [ ] Beoordeel eerste publicatie; blokkeer incomplete, onverwacht lege en ongeldige datasets. +- [ ] Behoud bestaande bron-ID's, visibility en verwijzingen; maak alleen inhoudelijke bronwijzigingen tot revisies. +- [ ] Publiceer de begrensde pilot in één korte transactie; bewijs rollback en hervatten bij uitval. +- [ ] Hercontroleer basis-/correctieversies vlak vóór publicatie; missing-from-source is beoordeling, geen delete. + +Klaar wanneer lokale eerste import/herimport werkt en mislukte batches bestaande publieke data niet wijzigen. + +### C — Zelfstandige aanlevering automatiseren + +Repo: disabled-parking en deploymentconfiguratie. Afhankelijk van A; bruikbaar naast B. + +- [ ] Leg sourceconfiguratie, ophaalritme en expliciete adapterregistry vast in de importrepo; secrets buiten versiebeheer. +- [ ] Bouw een eindige batchrunner met deadlines, retries/backoff, bronlimieten en durable source-sequence/uploadstatus. +- [ ] Maak een gelockt containerimage en één geplande hostuitvoering per repo; geen container/cron per gemeente. +- [ ] Upload naar private source-prefix met beperkte producentrechten en recordsversion-ID; schrijf manifest pas na complete upload. +- [ ] Test half bestand, manifestuploadfout, herhaalde aanlevering, oudere batch, bron-429, bronfout en stateherstel. +- [ ] Produceer foutlogs en een operationele melding; geen fout als succesmanifest publiceren. +- [ ] Bewijs beperkte bronhouderrequests, ook wanneer packagepaginering intern gebeurt. + +Klaar wanneer de importomgeving zonder core beschikbaarheid een complete versieerbare levering kan publiceren. De batchrunner heeft geen coretoken of DB-credentials. + +### D — Core ontdekt leveringen en bewaakt actualiteit + +Repo: core. Afhankelijk van B/C; voltooit automatisering. + +- [ ] Leg toegelaten opslagprefix en verwachte maximale leveringsleeftijd per dataset vast. +- [ ] Ontdek ready-manifesten met volledige paginering en lees exacte recordsversies via read-only opslagrechten. +- [ ] Gebruik dezelfde intake/verwerking als B en herplan niet-afgeronde imports na een crash vóór queuedispatch. +- [ ] Bewijs unieke intake, conflicterende batch-ID, late oudere sequence en twee gelijktijdige coreprocessors. +- [ ] Test core-uitval, ontdekking na herstel en de afgesproken opslagretentie als leveringsgrens. +- [ ] Toon laatste ontvangst, validatie, publicatie, brondatum en overdue afzonderlijk; een oud succes blijft niet gezond. +- [ ] Maak verschil tussen ophalen pauzeren buiten core en verwerking/publicatie pauzeren in core duidelijk. + +Klaar wanneer de volledige stagingketen autonoom werkt en een ontbrekende levering zichtbaar wordt zonder dat core het ophaalproces bestuurt. + +### E — Correctiebehoud en productacceptatie + +Repo: core. Afhankelijk van B; integreer met D voor ketenacceptatie. + +- [ ] Laat een gebruiker een veldcorrectie op een geïmporteerde plek voorstellen met reden en waarnemingsdatum. +- [ ] Laat een bevoegde beheerder accepteren/afwijzen met actor, basisversie en duurzame historie. +- [ ] Bewaar bronwaarde en lokale correctie afzonderlijk; details lezen effectieve gegevens. +- [ ] Herimport overschrijft de correctie niet; afwijkende bronwaarden worden als conflict zichtbaar. +- [ ] Intrekken van een correctie gebruikt de actuele bronbasis, niet een toevallige oude kopie. +- [ ] Favorieten en detailverwijzingen blijven stabiel bij herimport, correctie en terugkeer. +- [ ] Test rechten, gelijktijdige wijzigingen, publieke velden en keyboard-/telefoongebruik. + +Klaar wanneer de hele keten een correctie en herimport doorstaat. Bijdragerechten/retentie moeten vóór publieke activering geregeld zijn. Foto's en karma kunnen later. + +### F — Tweede bron en offstreet + +Repos: beide importrepos en core. Na de eerste ketenacceptatie. + +- [ ] Sluit een inhoudelijk andere Belgische/Duitse bron aan en toets geografische/semantische variatie. +- [ ] Trek pas werkelijk gedeelde uitvoeringslogica los; geen generiek workerframework vooraf. +- [ ] Sluit een offstreetcatalogus aan op hetzelfde snapshotcontract. +- [ ] Voeg aparte versieerbare bezettingsbatches toe met waarnemingstijd, stale-status, eigen ritme en retentie; geen catalogusverwijdering. +- [ ] Voeg eventueel handmatige CSV/GeoJSON-vertaling toe naar dezelfde intake. +- [ ] Meet runtime, listingvolume, transactieduur en beheerwerk voordat bronnen/hosts worden opgeschaald. +- [ ] Bronkoppelingen en ontdubbeling krijgen eigen review met behoud van bestaande type/ID-verwijzingen. + +Klaar per bron; geen claim van volledige Europese dekking. + +## 3. Hosting en planning + +| Proces | Taak | +| --- | --- | +| Importhost systemd-timer per repository | Start periodiek de eindige due-runner | +| Python-container | Leest sourceconfig, haalt due bronnen op, maakt bestanden en manifesten | +| Persistent runnervolume | Lokale SQLite-staat voor planning, sequences en onafgeronde uploads; tijdelijke bestanden begrensd | +| Private versioned S3-opslag | Uitwisselpunt; source-prefixen en onafhankelijke rechten | +| Core scheduler | Ontdekt manifests, herplant verwerking en controleert overdue | +| Core databasequeueprocessor | Valideert/staget/vergelijkt/publiceert | +| PostgreSQL/PostGIS | Bronidentiteit, batchstatus, correcties en gepubliceerde gegevens | + +Start met één Linux-host en één gemeentelijke runner. Docker Compose bevat runtimeconfiguratie; systemd start de batchservice. De due-check kan iedere minuut draaien terwijl sources hun eigen ritme hebben. Hostlocks voorkomen overlap; maximaal één actieve fetch per bronhouder in de pilot met interne pacing. Dezelfde bronhouder in beide repos gebruikt een gedeelde hostlock. Ophaalplanning is geen onderdeel van core. + +De runner eindigt na het werk. Crash, timeout of SIGTERM mag geen half gereedmanifest nalaten. Bewaar geproduceerd bestand en uploadstatus waar veilig hervatten mogelijk is; een nieuwe fetch krijgt een nieuwe sequence. Gemiste snapshotmomenten leiden tot de eerstvolgende actuele fetch, niet onbeperkt inhalen. + +Livebezetting kan een aparte continue offstreetservice worden met dezelfde bestandgrens. Bij groei sources exclusief verdelen over hosts of eerst coördinatie ontwerpen; zomaar replicas starten kan bronlimieten of sequence-eigenaarschap schenden. + +## 4. Beveiliging en bescherming + +| Grens | Vereiste | +| --- | --- | +| Producent | Alleen eigen source-prefix schrijven en noodzakelijke upload/herstelacties; geen core- of DB-toegang | +| Coreconsumer | Alleen toegelaten prefixen lezen/listen en exacte versies lezen; geen ophaalcredentials | +| Cleanup | Afzonderlijke beperkte delete/lifecyclebevoegdheid; geen automatisch verwijderen van benodigde herstelversies | +| Secrets | Gescheiden roteerbare rollen/credentials; nooit in image, repo, URL-log of manifest | +| Bestanden | Private opslag, unieke batchkeys, exacte versions, checksum en harde inhoud-/omvangsgrenzen | +| Adapter/netwerk | Expliciete coderegistry; geen code uit configuratie; bronhosts/redirects en egress begrenzen inclusief private/metadataadressen | +| Beheer/community | Bestaande policies en rollen voor datasetbeheer, beoordeling en correcties | +| Publieke dienst | Begrensde queries en rate limits, expliciete publieke velden en aparte bulkkeuze; basiszoeken zonder account | + +Een toegelaten producent kan onjuiste gegevens aanleveren. Opslagrechten/checksums bewijzen geen inhoudelijke betrouwbaarheid: validatie, broncontrole en beoordeling blijven nodig. Open images/code geven geen toegang tot de private bucket of core. Datarechten/attributie blijven per bron gelden; de [productbasis](../product/data-foundation.md#9-open-code-en-beschermde-dienstverlening) beschrijft de grenzen. + +## 5. Monitoring en retentie + +De importomgeving logt source/batch/sequence/adapter/image, fase, duur en veilige foutcode. Meld herhaalde fetch-/uploadfouten via operationele monitoring en bundel herhalingen. Core toont ontvangst, validatie, publicatie en brondatum; uitblijvende levering wordt overdue. Core kent de fetchfoutoorzaak niet zonder aanvullende statusfeed, die niet bij de pilot hoort. + +Stel vóór staging vaste upload-/record-/uitvoeringslimieten in. Stel vóór productie ook bewaartermijnen, maximaal te overbruggen core-uitval, RPO/RTO en kostenplafond vast. Recordsobjectversies en manifests moeten samen beschikbaar blijven binnen die uitvalgrens. Weesbestanden mogen eerder worden opgeruimd; benodigde publicatie-/herstelreferenties blijven bewaard. Onbeperkte bezettingshistorie is geen default. + +Databasebackups dekken de bucket en lokale runnerstaat niet. Test gezamenlijk herstel van core, benodigde objectversies en sequenceplanning. Bronvoorwaarden bepalen of ruwe payloads bewaard mogen worden. Genormaliseerde batches hebben eveneens expliciet bewaarbeleid. Bestaande user-/plekdeletecascades veranderen niet door dit document; nieuwe bewijsretentie hoort bij #1175/#276. + +## 6. Herstel en release + +1. Bronfout: inspecteer runnerlogs, pas package/config aan en maak een nieuwe batch; de vorige corepublicatie blijft staan. +2. Uploadfout: hervat de levering van hetzelfde geproduceerde artifact; schrijf ready pas na volledige opslag. +3. Core-uitval: hervat manifestdiscovery en niet-afgeronde imports; behoud sequence- en batchcontrole. +4. Verdachte batch: blokkeer/verwerp publicatie. Een identieke herontdekking omzeilt die beslissing niet. +5. Producentcompromis: trek opslagcredentials in, stop runner en pauzeer nieuwe intake/publicatie voor betrokken sources; beoordeel artifacts/publicaties in die periode. +6. Foute publicatie: nieuw herstelbesluit met geldige bewaarde bronrevisie en actuele correcties. Geen oude DB over nieuwe communitybijdragen terugzetten. +7. Stateherstel: herstel sequences uit lokale staat en reeds gepubliceerde manifesten; hergebruik geen oude sequence. Bij onzekere continuïteit aansluiting pauzeren en expliciet reconciliëren. + +Rol nieuwe contractondersteuning eerst in core uit; daarna nieuwe producerimages. Oude ondersteuning verdwijnt pas nadat opgeslagen relevante batches en producers uitgefaseerd zijn. Pin images op digest en bewaar versie-/commitinformatie bij batches. Ophalen blijft uit tot bronvoorwaarden, scope, rechten en limieten klaar zijn. Eerste echte publicatie wordt beoordeeld; automatische normale verwerking volgt na pilotacceptatie. + +De [fresh-start-afspraak](postgresql.md#fresh-start-decision) blijft gelden: geen verplichte historische MySQL-transfer. Eventuele oude communityplekken krijgen een aparte herleidbare legacy-import zonder fictieve verificatiedatum. Oude productiejobs uitschakelen is een expliciete deploymentactie, geen gevolg van deze documentatie. Laat oude en nieuwe importsystemen niet tegelijk dezelfde doelgegevens schrijven. + +## 7. Tests en startbeslissingen + +Volg [quality-checks.md](quality-checks.md) en de toepasselijke skills tijdens implementatie. Deze documentatiewijziging vereist geen applicatie-/databasetest. + +| Niveau | Bewijs | +| --- | --- | +| Adapter/schema | Gesaniteerde echte fixtures, semantiek, pagina's, ID's, nullwaarden en fouten | +| Opslagtransport | Upload-before-ready, versioned reads, prefixrechten, half bestand en herhaalde aanlevering | +| Core-integratie | PostgreSQL-transacties, idempotentie, volgorde, revisies en correctiebehoud | +| Stagingketen | Geplande echte producer → private opslag → corediscovery → publicatie → correctie → herimport | +| Operationeel | Overdue, bronlimieten, credentials intrekken, log/retentiegrenzen en herstel | +| Product | Attributie/freshness, juiste locatie en toegankelijke capaciteit, keyboard-/telefoonflow | + +Normale CI gebruikt geen gemeentelijke netwerken. Bronprobes zijn apart, read-only en begrensd. Bewaar core-SHA, producerimage-digest, adapter/package/contractversie, sourceconfigversie en batch-ID bij acceptatie. CI is niet hetzelfde als productie- of fysieke datakwaliteit. + +| Beslissing | Nodig vóór | +| --- | --- | +| Pilotbron/packageversie en voorwaarden | A afronden | +| Definitief bestandcontract, sourcevolgorde en limieten | B/C | +| Buitenlandse geografische mapping | Betreffende bron aansluiten | +| Host, opslagregio, rechten en budget | Stagingdeploy | +| Retentie, uitvalgrens, herstel en contributierechten | Productie/publieke bijdragen | +| Historische import, foto's, karma, mobiel, bulktoegang | Afzonderlijke vervolgfunctie | + +De eerstvolgende ontwikkelopdracht is A met de minimale bestandintake van B. Zo bewijzen we het gegevenscontract en de verwerking voordat automatische aanlevering wordt toegevoegd. diff --git a/docs/development/data-foundation-stack.md b/docs/development/data-foundation-stack.md new file mode 100644 index 00000000..52b697cd --- /dev/null +++ b/docs/development/data-foundation-stack.md @@ -0,0 +1,73 @@ +# Concrete techstack voor batchimports + +Status: aanbevolen implementatiestack, 2026-09-08. De gekozen batchaanpak vervangt de eerdere aanbeveling voor een interne worker-API en machine-authenticatie via Sanctum. Voor #1214 zijn opis/json-schema 2.6.0 en de geïsoleerde Python-testdependency jsonschema 4.26.0 met expliciete toestemming toegevoegd en gelockt. Er zijn geen diensten geprovisioned. Overige nieuwe dependencies worden per implementatie beoordeeld conform de repositoryregels. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [uitvoering](data-foundation-delivery.md). + +## 1. Eerste versie + +| Onderdeel | Concrete keuze | Status en reden | +| --- | --- | --- | +| Core | Laravel 13, PHP 8.4 baseline, PHP 8.5 extra CI-doel | Bestaand | +| Beheer/web | React 19, Inertia 3, TypeScript, bestaande componenten en Wayfinder | Bestaand; alleen dataset-/importbeheer uitbreiden | +| Database/geo | PostgreSQL 18 + PostGIS 3.6 | Bestaand; identiteit, importhistorie, correcties en publicatie | +| Core discoveryplanning | Laravel Scheduler | Controleert ready-manifesten en achterstand; plant geen bronfetches | +| Core verwerking | Laravel Queue met bestaande database-driver | Valideren, staging, diff en publicatie op achtergrond | +| Python-uitvoering | CPython 3.14 met asyncio | Doelruntime; bronpackagecompatibiliteit eerst toetsen | +| Dependencies | Bestaande Poetry-tooling en poetry.lock | Behouden; geen verplichte uv-migratie | +| Bron-HTTP | Bestaande packageclients, aiohttp voor directe async adapterverzoeken | Universele packages behouden hun clientkeuze | +| Recordmodel | dataclasses en expliciete adaptermapping | Kleine getypeerde vertaallaag | +| Contract | JSON Schema Draft 2020-12; Python jsonschema 4.x en PHP opis/json-schema 2.x | Dezelfde normatieve schemas en fixtures | +| Bestanden | UTF-8 JSONL + ready-manifest + SHA-256 | Streaming verwerking en onafhankelijke herverwerking | +| Objectopslag | Private Amazon S3-bucket in gekozen EU-regio, versioning aan | Concrete referentieprovider; geen bucket aangemaakt | +| Opslagclients | Python boto3; bestaande Laravel Flysystem S3-adapter / AWS SDK voor versiegebonden reads | Python uploadt; core leest exacte versies via SDK waar de abstractie dat niet ondersteunt | +| Toegangsbeheer | Afzonderlijke scoped IAM-rollen/credentials per producent en coreconsumer | Geen coretoken of publieke import-API nodig | +| Containers | Linux, Docker Engine en Compose v2; gepind Python slim-image | Eén host voor de pilot | +| Ophaalplanning | Eén systemd-timer per importrepository die een eindige containeruitvoering start | Python kiest lokaal welke geconfigureerde sources aan de beurt zijn | +| Runnerstaat | Kleine lokale SQLite-state via Python-stdlib, op persistent volume | Due-tijden, gereserveerde source-sequences en herstelbare uploadstatus; geen gedeelde broker | +| Images/CI | GitHub Actions + GHCR; Poetry-install, pytest en Ruff | Pipeline per importrepo; core behoudt bestaande checks | +| Monitoring | JSON-logs, hostlogrotatie, runnerfoutmelding; core importhistorie en overdue-status | Geen apart observabilityplatform als startvoorwaarde | + +Patchversies worden in lockfiles en image-digests vastgelegd. Python 3.14 is een onderhouden versie volgens de [Python-status](https://devguide.python.org/versions/). De huidige repos declareren Python ^3.11; dat bewijst geen compatibiliteit met 3.14. De [Eindhoven-contractproef](data-import-pilot.md) heeft package 5.1.0 op Python 3.14.2 uitgevoerd; bronpackages zijn geen core-dependencies. Een tijdelijke geteste 3.13-baseline is mogelijk als dat nodig blijkt, met expliciete upgradeactie. Universele packages houden hun eigen supportbeleid en tooling. + +## 2. Twee onafhankelijke planningen + +De importhost start bijvoorbeeld iedere minuut een due-check per repository via een systemd-timer. De container voert alleen sources uit die volgens de repositoryconfiguratie aan de beurt zijn en eindigt daarna. De daadwerkelijke bronfrequentie staat bij de source; de timerfrequentie is niet de fetchfrequentie. Een overlappende start van dezelfde service wordt voorkomen; bronhouderlocks beschermen waar beide repos dezelfde API aanspreken. + +De runner bewaart planning, sequences en status van geproduceerde maar nog niet gereedgemelde bestanden op een persistent volume. De SQLite-staat is lokaal operationeel herstelmateriaal, geen tweede domeindatabase en geen gedistribueerde taakqueue. Eén producer is eigenaar van een source. Meerdere hosts komen pas met expliciete bronverdeling of een afzonderlijk coördinatieontwerp. + +Core draait zijn eigen Laravel-scheduler: ready-manifesten ontdekken, vastgelopen verwerkingen herplannen en uitblijvende leveringen signaleren. De databasequeue verwerkt de ontvangen gegevens. Batches blijven beschikbaar wanneer core tijdelijk uitstaat. Voor bron- en corepauzes zijn afzonderlijke handelingen nodig, zoals beschreven in het contract. + +Frequente bezetting kan later een apart continu offstreetproces krijgen. Het publiceert hetzelfde type versieerbare meetbatch met een eigen stream/source-ID en limieten. Catalogussnapshots en liveobservaties krijgen verschillende ritmes en retentie. Dit verandert niets aan de zelfstandigheid van core. + +## 3. Python en bestanden + +De adapter roept het bestaande bronpackage aan en schrijft records regel voor regel naar een tijdelijk bestand. De runner valideert, berekent checksum/grootte/aantallen en uploadt met boto3. Na geslaagde upload publiceert hij het ready-manifest. Synchrone SDK-/CPU-stappen worden zo uitgevoerd dat benodigde async bronverzoeken niet blokkeren; de eerste eindige batchrunner heeft geen heartbeat-taak. + +Gebruik standaard dataclasses, json, hashlib, logging, datetime, zoneinfo en sqlite3 waar dat volstaat. Boto3 beheert opslagtransport; aiohttp is beschikbaar waar de bronadapter zelf async HTTP nodig heeft. De bronpackages krijgen geen verplichte NIPKaart-client of dependency. + +Poetry installeert gelockte dependencies tijdens imagebuild. Start productie zonder downloads bij containerstart. Zie [Poetry's lockfilegedrag](https://python-poetry.org/docs/basic-usage/). Bestaande pymysql-writes en databasecredentials vervallen bij vervanging van de legacy-importuitvoering; de universele bronclients veranderen daarvoor niet. + +## 4. Contract en coreverwerking + +De schemas in een core-contractrelease zijn normatief. Dataclasses valideren geen JSON. Python gebruikt [jsonschema](https://python-jsonschema.readthedocs.io/en/stable/), PHP [Opis](https://opis.io/json-schema/2.x/); A bewijst dezelfde Draft 2020-12-subset met positieve en negatieve fixtures. Datum-/URI-formatchecks zijn expliciet en externe netwerkresolutie van schema-referenties staat uit. Er is voor bestanduitwisseling geen OpenAPI-specificatie nodig. + +Core gebruikt unieke batchregistratie, sequencecontrole en transacties voor idempotente verwerking. Jobs worden na commit beschikbaar gemaakt en een herstelcontrole herplant duurzaam geregistreerde niet-afgeronde imports. Alleen after-commit dispatch is geen garantie tegen een crash vóór dispatch; zie de [queue-transactiedocumentatie](https://laravel.com/docs/13.x/queues#jobs-and-database-transactions). + +Valkey is beschikbaar in [DDEV](valkey.md), maar wordt geen nieuwe productieafhankelijkheid voor de batchketen. De core-databasequeue blijft de startkeuze. Meet belasting voordat een queuedriver of opslagindex wordt gewijzigd. + +## 5. Opslag en bevoegdheden + +S3 is de referentieprovider. De bucket is private, versioning staat aan, core leest de exacte recordsversie uit het manifest en legt de manifestidentiteit vast. Verwerk niet blind de actuele inhoud van een overschrijfbare key. De [S3-versioningdocumentatie](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) beschrijft afzonderlijke objectversies; de integratietest moet dit gedrag aantonen. + +De producent heeft alleen toegang tot eigen source-prefixen en noodzakelijke upload-/herstelhandelingen; geen database- of corebeheerrechten. Core heeft read/list/versionread voor toegelaten prefixen. Cleanup/delete loopt via afzonderlijke beperkte bevoegdheden. Gebruik waar mogelijk tijdelijke rolcredentials, anders afzonderlijke roteerbare secrets. Credentials staan buiten code en images. Een algemene publieke API-key of workerregistratie in core is niet nodig. + +Een andere S3-compatible provider kan pas worden gekozen na toetsing van versioning, create-only writes, paginering, retentie en exacte versie-reads. Er is geen provider gecontracteerd. Ruwe brondata wordt alleen opgeslagen als dat nodig en toegestaan is; een recordsartifact is de genormaliseerde aanlevering. + +## 6. Hosting, CI en schaalpad + +Core behoudt zijn bestaande hosting. Op de importhost beheert [Docker Compose](https://docs.docker.com/compose/how-tos/production/) de containerdefinities; systemd start de eindige batchservices. Images worden op digest gebruikt. Voorgestelde namen zijn ghcr.io/nipkaart/disabled-parking en ghcr.io/nipkaart/offstreet-parking; de documentatie bewijst niet dat ze al bestaan. [GHCR](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) bewaart de OCI-images. + +De host heeft geen publieke importpoort nodig. Runtime krijgt alleen benodigde sourceconfig, opslagrechten, persistent statevolume, tijdelijke werkruimte en resourcegrenzen. Geen Docker-socket in de container. systemd/Docker beheren deadlines, exits en logs. Beheerde hostkeuze, machinegrootte en budget worden bij staging bepaald; er is geen gemeten capaciteit geclaimd. + +Lokaal kan dezelfde adapter naar een bestand schrijven zonder opslagaccount. Core verwerkt het bestand via dezelfde validator en services. DDEV blijft de coreomgeving. Staging test de echte opslagoverdracht, versioning, discovery, permissions en herstel. Python test met pytest, pytest-asyncio waar async gedrag nodig is en Ruff; core gebruikt Pest en [bestaande qualitychecks](quality-checks.md). Gewone CI haalt geen gemeentelijke datasets op. + +Bij groei eerst meten: runtime, gemiste leveringen, bronlimieten, bytes, manifestlistingkosten en coreverwerkingstijd. Daarna sources verdelen over exclusieve producenten, discovery versnellen of kernverwerking opschalen. Een gedeeld Python-framework ontstaat pas als de tweede repository werkelijk dezelfde uitvoeringslogica nodig heeft. diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md new file mode 100644 index 00000000..fbf953be --- /dev/null +++ b/docs/development/data-import-contract.md @@ -0,0 +1,192 @@ +# Batchimportcontract + +Status: snapshotcontract 1.0, 2026-09-08; schemas en offline contractvalidatie zijn uitgewerkt in [de Eindhoven-contractproef](data-import-pilot.md). De eigenaar heeft gekozen voor geplande batchleveringen. Dit vervangt het eerdere workerprotocol; core deelt geen opdrachten uit en registreert geen workers. Zie de [productbasis](../product/data-foundation.md), [techstack](data-foundation-stack.md) en het [uitvoeringsplan](data-foundation-delivery.md). Opslaglevering, scheduling, review en publicatie zijn vervolgstappen en nog niet geïmplementeerd. + +## 1. Eigenaarschap + +De repositories `disabled-parking` en `offstreet-parking` beheren adapters, bronconfiguratie en ophaalplanning. Een geplande Python-uitvoering gebruikt universele bronpackages en produceert een genormaliseerde levering. Core ontdekt gereedgemelde leveringen en beheert validatie, beoordeling, correcties en publicatie. + +Het bestandcontract is de grens. Python heeft geen coretoken of databaseverbinding nodig; core heeft geen gemeentelijke API-credentials nodig. Private objectopslag heeft afzonderlijke toegangsrechten voor producent en consumer. Universele packages kennen dit NIPKaart-contract niet. + +De eerste versie ondersteunt volledige snapshots. Delta's/cursors volgen alleen wanneer een concrete bron ze nodig maakt. Livebezetting is een aparte leveringssoort en verandert geen catalogusidentiteit. + +## 2. Configuratie zonder twee planners + +| Gegeven | Plaats | +| --- | --- | +| Endpoint, adapter, broncredentials, filters en ophaalritme | Importrepository/configuratie en secrets van de importomgeving | +| Stabiele source-ID, scopeversie en contractversie | Afspraak tussen importconfiguratie en toegelaten dataset in core | +| Toegelaten opslagprefix, recordsoort, geografische mapping, voorwaarden | Core-datasetregistratie | +| Verwachte maximale leveringsleeftijd | Core als signaleringsgrens; geen bronplanning | +| Publicatiebeleid, zichtbaarheid en correcties | Core | + +Een nieuwe source of scope wordt eerst in core toegelaten. Een manifest kan dat niet zelf autoriseren. Scopewijzigingen krijgen een versie en een beoordeelde baseline. Ophalen pauzeren gebeurt in de importomgeving; verwerken/publiceren pauzeren gebeurt in core. Het beheer maakt dit onderscheid zichtbaar. Er is aanvankelijk geen coreknop om een nieuwe fetch te starten. + +## 3. Een levering gereedmelden + +Voorgestelde opslagstructuur: + +```text +sources//batches//records.jsonl +sources//ready/.json +``` + +Een batch heeft een unieke batch-ID, een binnen de source oplopende `source_sequence`, scopeversie en één recordsbestand. Het bestand is UTF-8 JSONL, in de pilot ongecomprimeerd en begrensd. Het ready-bestand is het manifest en wordt als laatste geschreven. + +1. De runner reserveert een sequence vóór ophalen en bewaart batchidentiteit en uitvoeringsmoment duurzaam. Eén producer is eigenaar van een source; een hostlock voorkomt overlap tijdens de eerste single-hostopzet. +2. De adapter haalt volledig op, vertaalt en valideert. Ontbrekende pagina's of parsefouten maken de levering onvolledig. +3. De runner schrijft een tijdelijk bestand, berekent recordaantal, bytes en SHA-256 en uploadt naar de unieke batchkey. +4. De referentieopslag heeft versioning; de runner bewaart de exacte recordsobjectversion-ID. +5. Alleen na succesvolle volledige bestandsupload schrijft de runner het ready-manifest met key, version-ID en checksum. Gebruik create-only voor manifestkeys waar ondersteund; dezelfde batch-ID krijgt nooit andere inhoud. +6. Core legt ook de manifestversie/checksum vast en leest de exacte recordsversie. Een latere key-overwrite verandert een al geaccepteerde levering niet. + +Bij een onzekere uploadrespons controleert de runner de bestaande versie en checksum. Hetzelfde geproduceerde bestand kan opnieuw worden gereedgemeld. Opnieuw ophalen van mogelijk veranderde data is een nieuwe batch met een nieuwe sequence. Gaten door mislukte runs zijn toegestaan. Na restart/restore wordt de sequence niet teruggezet: herstel aan de hand van duurzaam bewaarde staat en de hoogste reeds gereedgemelde sequence. De lokale gereserveerde high-watermark blijft leidend voor nog niet gereedgemelde runs. Als staat verloren is, retentie oude manifesten heeft verwijderd of oude producers nog kunnen schrijven: pauzeer en reconcileer met core plus opslag, trek oude schrijfrechten in en kies pas daarna een sequence boven alle bekende reserveringen/leveringen. Een lege listing is geen toestemming om bij 1 te beginnen. + +Een batch-ID bepaalt geen volgorde. Sequence bepaalt de catalogusvolgorde binnen de source, niet directoryvolgorde, UUID of eindtijd. Beschikbare brondatum/versie wordt daarnaast gecontroleerd; ook een nieuwe fetch kan een oude bronversie teruggeven. Dat vereist beoordeling. + +Er is in de pilot geen mutable `latest.json` als enige verwijzing. Het ready-prefix bevat de ontdekbare reeks, zodat late uploads geen nieuwere levering onzichtbaar maken. + +## 4. Ontdekken en idempotent verwerken + +Core inspecteert periodiek de toegelaten ready-prefixen via de opslag-SDK met volledige paginering. De pilot scant de bewaarde manifestreeks per bron en registreert `(source_id, batch_id)` uniek. Listingvolgorde is geen uitvoeringsvolgorde; core sorteert op source-sequence. + +Retentie van manifesten én recordsversies is langer dan de afgesproken maximale core-uitval. Python krijgt geen ontvangstbevestiging, dus opslagretentie is onderdeel van de leveringsgarantie. Uitval voorbij die termijn vereist een nieuwe volledige snapshot en een expliciete herstelactie. Een index of opslagnotificatie kan later discovery versnellen; periodieke reconciliatie blijft nodig. Een gepagineerde volledige scan is alleen de pilotkeuze bij gemeten beheersbaar volume. + +Core accepteert uitsluitend geconfigureerde source/prefix/scope/recordsoort/contractversie. Het volgt geen willekeurige URL uit het manifest. Cross-sourcekeys, ongeldige paden, onbekende versies, te grote bestanden en ongeldige inhoud worden afgewezen. + +Dezelfde batch met dezelfde manifest-/artifactidentiteit wordt overgeslagen of hervat. Dezelfde batch-ID met andere inhoud is een integriteitsconflict. Een dubbele sequence met een andere batch vereist eveneens beoordeling. Oudere sequences worden eventueel `superseded`, maar draaien geen nieuwere publicatie terug. + +Na een crash tussen ontvangstregistratie en queuedispatch herplant een periodieke herstelcontrole niet-afgeronde imports. De Laravel-taken blijven idempotent. Er is geen HTTP-completionprotocol nodig. + +## 5. Illustratief ready-manifest + +Dit is een syntactisch JSON-voorbeeld; IDs, versies en checksum zijn illustratief. De normatieve [schema's en fixtures](data-import-pilot.md#normatief-formaat-en-validatiegrens) leggen verplichte velden en enums vast. Dit voorbeeld is geen gemeten of uploadbaar artifact. + +```json +{ + "contract_version": "1.0", + "batch_id": "batch-example-001", + "source_id": "source-example-amsterdam", + "source_sequence": 42, + "source_config_version": 1, + "scope_version": 1, + "mode": "snapshot", + "record_kind": "municipal", + "adapter": {"key": "amsterdam.accessible_parking", "version": "0.1.0"}, + "packages": [{"name": "example-source-package", "version": "1.0.0"}], + "fetched_started_at": "2026-09-08T08:00:00Z", + "fetched_finished_at": "2026-09-08T08:00:20Z", + "source_updated_at": null, + "source_version": null, + "completeness": { + "status": "complete", + "pages_fetched": 2, + "pagination_exhausted": true, + "expected_records": null, + "records_seen": 125, + "records_filtered_out": 2, + "records_emitted": 123, + "records_failed": 0 + }, + "artifact": { + "key": "sources/source-example-amsterdam/batches/batch-example-001/records.jsonl", + "version_id": "example-object-version", + "format": "jsonl", + "bytes": 45678, + "record_count": 123, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + } +} +``` + +De runner verklaart volledigheid; core toetst aantallen, scope, bronkennis en inhoud. Uitgeputte paginering bewijst geen consistente momentopname als de bron tijdens ophalen verandert. Die beperking wordt in adapter-/bronbeleid vastgelegd. Filterredenen zijn expliciet en aantallen sluiten aan. Een volledige maar onverwacht lege snapshot kan gereedgemeld worden en wordt in core tegengehouden; een technisch mislukte run krijgt geen ready-manifest. + +## 6. Illustratief record + +Dit voorbeeld is uitgeschreven voor leesbaarheid; in JSONL staat ieder record op één regel. + +```json +{ + "external_id": "00123", + "source_updated_at": null, + "position": {"latitude": 52.3702, "longitude": 4.8952}, + "geometry_method": "source_point", + "country_code": "NL", + "administrative_codes": [], + "name": null, + "street": "Voorbeeldstraat", + "accessible_capacity": 2, + "access_category": "designated_accessible", + "orientation": null, + "restrictions": [], + "unmapped_fields": [], + "source_record_url": null +} +``` + +Minimale regels: + +- Externe ID's zijn begrensde niet-lege strings, uniek binnen source en batch. Behoud voorloopnullen; coördinaten zijn geen generieke identiteit. +- Posities zijn eindige WGS84-getallen met expliciete lat/lon, geldige grenzen en passende scope. Bron-CRS en conversie blijven herleidbaar. Een polygonenmiddelpunt is niet automatisch een navigatie-ingang. +- Aantallen zijn niet-negatieve gehele getallen of `null`; onbekend is niet nul. Onderscheid algemene toegankelijke versus persoonsgebonden plekken. Neem kentekens niet mee voor publieke classificatie. +- Records zijn volledige weergaven. Verplichte velden mogen niet ontbreken; `null` maakt een oude bronwaarde onbekend. Onbegrepen veiligheidsrelevante beperkingen blokkeren automatische publicatie. +- Begrens tekst, regellengte, bestandsgrootte en recordaantal. Geen uitvoerbare HTML, credentials of lokale paden. Schemas volgen geen externe netwerkreferenties. +- Buitenlandse administratieve niveaus vragen een expliciete mapping naar de huidige geografische relaties; adapters hardcoden geen interne land-/provincie-ID's. + +Offstreetcatalogi krijgen afzonderlijke velden voor voorzieningstype, algemene/toegankelijke capaciteit, ingang en URL. Live `observations` bevatten bronrecord-ID, ophaaltijd, bekende/onbekende waarnemingstijd en afzonderlijke algemene/toegankelijke vrije aantallen. Oudere brontijd overschrijft geen nieuwere meting. Zonder brontijd blijft actualiteit een als zodanig benoemde schatting op basis van ophalen. Een meting voor een onbekende voorziening wacht op een cataloguskoppeling. Deze varianten worden met echte package-output beproefd vóór aansluiting. + +## 7. Coreopslag en publicatie + +| Verantwoordelijkheid | Invariant | +| --- | --- | +| Datasetregistratie | Toegelaten source/prefix/scope, herkomst, voorwaarden, verwachte leveringsleeftijd en publicatiebeleid | +| Importhistorie | Unieke batch en sequencecontrole; immutable artifactreferentie, verwerkingstijden, status en fout | +| Bronrecordidentiteit | Uniek `(source_id, external_id)` met stabiele verwijzing naar bestaande doeltype/ID | +| Bronrevisie | Alleen inhoudelijke wijzigingen maken een nieuwe revisie; noodzakelijke historie blijft herstelbaar | +| Lokale correctie/besluit | Bronwaarde en geaccepteerde correctie apart, met actor, reden, basisversie en waarnemingsdatum | + +Dit zijn opslagverantwoordelijkheden, geen verplichte aparte frameworks. Importstatus: `discovered → validating → awaiting_review | ready → publishing → published`; andere resultaten zijn `failed`, `rejected`, `superseded` en `unchanged`. Deze states beschrijven coreverwerking, niet het externe ophaalproces. + +1. Download de exacte recordsversie en verifieer bytes/checksum. De checksum bewijst integriteit, niet bronjuistheid. +2. Valideer in staging en bereken de diff tegenover een expliciete actuele bron-/publicatieversie. +3. Beoordeel eerste import, scopewijzigingen, onverwachte aantallen, verdwijningen en conflicten. Latere normale updates volgen pas na pilotacceptatie een automatisch beleid. +4. Controleer vlak vóór publicatie sequence, basisversie en correctieversie opnieuw. Verouderde diffs worden herberekend/beoordeeld. +5. Publiceer de begrensde pilot in één korte transactie met importstatus en actuele waarden. Taken zijn na commit beschikbaar of worden vanuit duurzame status hersteld. + +Geen publieke tussentoestand door chunks alvast te publiceren. Grotere datasets vereisen een loadproef en eventueel versieerbare publicaties met atomair omschakelen. Bestaande bronmodellen, detail- en favorietidentiteit blijven behouden. + +Hash genormaliseerde inhoud zonder ophaaltijd en irrelevante volgorde om ongewijzigde records te herkennen. Ongewijzigde snapshots bevestigen controle en aanwezigheid binnen die scope, maar vernieuwen geen brondatum en veroorzaken geen nieuwe publicatie. Veranderde lokale correcties worden onafhankelijk daarvan verwerkt. + +Alleen afwezigheid binnen dezelfde complete snapshotscope betekent `missing_from_source`; dit wordt in de pilot beoordeeld en nooit vooraf verwijderd. Een terugkeer behoudt identiteit. Livebezetting en mislukte runs verwijderen geen voorzieningen. Een goedgekeurde lokale veldcorrectie blijft gelden bij herimport; een afwijkende nieuwe bronwaarde registreert een conflict zonder de correctie te wissen. + +## 8. Uitval en herstel + +| Scenario | Gedrag | +| --- | --- | +| Fetch/parsing mislukt | Geen ready-manifest; foutmelding in importomgeving, core signaleert uitblijvende levering | +| Upload half voltooid | Geen ready-manifest; weesobject na retentie opruimen | +| Manifestupload mislukt na geslaagde bestandsupload | Dezelfde artifactversie opnieuw gereedmelden zonder opnieuw ophalen | +| Records ontbreken/checksum fout | Afwijzen of begrensd opnieuw lezen; niet publiceren | +| Core tijdelijk uitgevallen | Gereedgemelde batches blijven liggen; volledige discovery vindt ze na herstel | +| Batch opnieuw ontdekt | Overslaan of eerdere verwerking hervatten | +| Oude levering arriveert later | Sequence/basisversie voorkomt terugdraaien | +| Twee processors verwerken dezelfde batch | Unieke registratie en transactionele publicatie voorkomen dubbelen | +| Lege/sterk kleinere snapshot | Review; bestaande publicatie blijft staan | +| Crash tijdens publiceren | Transactierollback en herstel vanuit duurzame status | +| Communitycorrectie tijdens import | Versiecontrole en correctiebehoud | + +De importomgeving beheert retries/backoff, HTTP-timeouts, bron-429 en totale deadlines. Pilot: maximaal drie pogingen binnen een harde catalogusdeadline van 30 minuten. De [contractproef](data-import-pilot.md#verplichte-pilotlimieten) legt byte-, regel-, record- en validatielimieten vast. Verhogingen vereisen een nieuwe capaciteitsbeslissing. Snapshots halen gemiste tijdsloten niet onbeperkt in; de volgende actuele fetch heeft voorrang. Bezettingsprocessen bouwen evenmin een reeks verouderde meetopdrachten op. + +Bronhouderlimieten gelden inclusief package-interne requests. De pilot voert één bronhouder tegelijk uit met pacing en een gedeelde hostlock als beide repos dezelfde aanbieder aanspreken. Meerdere hosts vereisen expliciete coördinatie of exclusieve bronverdeling; extra containers alleen garanderen dit niet. + +## 9. Gezondheid zonder aansturing vanuit core + +Core toont laatste ontvangen, gevalideerde en gepubliceerde batch apart, met brondatum en verwachte maximale leveringsleeftijd. Uitblijven wordt `overdue`; een oud succes blijft niet onbeperkt gezond. + +Directe fetchfouten staan in de logs/meldingen van de importomgeving. Core kan uit afwezigheid niet bepalen of bron, host, credentials of opslag faalt. Geen manifest schrijven om een fout als succes te maskeren. Een statusfeed is alleen een latere uitbreiding als beheer die nodig heeft. + +## 10. Contractbewijs + +Schemas en fixtures zijn versieerbaar in core en worden door beide importrepos getest. Normale tests gebruiken gesaniteerde fixtures zonder bronnetwerk. Aparte read-only bronprobes bevestigen actuele bereikbaarheid en veldbetekenis. + +Minimale gevallen: volledige/lege/incomplete snapshot, nul versus onbekend, voorloopnullen, dubbele ID, ongeldige geo, scopewijziging, late oudere sequence, gewijzigde inhoud onder dezelfde batch-ID, half bestand zonder manifest, ontbrekende objectversie, herontdekking na uitval en herimport na lokale correctie. Handmatig bestand en objectopslag gebruiken dezelfde verwerking. Release pas na gelijke contractinterpretatie in PHP en Python. diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md new file mode 100644 index 00000000..3a374c0e --- /dev/null +++ b/docs/development/data-import-pilot.md @@ -0,0 +1,91 @@ +# Contractproef 1.0: Eindhoven + +Status: implementatie van [#1214](https://github.com/NIPKaart/core/issues/1214), 2026-09-08. Dit bevriest het gemeentelijke snapshotformaat en levert offline PHP/Python-contractchecks. Eindhoven is de gekozen **contractpilot**; publicatie blijft geblokkeerd door onderstaande bronvoorwaarden. Er is geen producer, opslagintegratie, database-import of productieaansluiting gebouwd. + +## Bron en bewijs + +De bron is [Gemeente Eindhoven — Parkeerplaatsen](https://data.eindhoven.nl/explore/dataset/parkeerplaatsen/information/), met filter `type_en_merk = 'Parkeerplaats Gehandicapten'`. De [datasetmetadata](https://data.eindhoven.nl/api/explore/v2.1/catalog/datasets/parkeerplaatsen) noemt Gemeente Eindhoven als uitgever, “Publiek domein” als licentie en geen aanvullende licentie-URL. De geselecteerde bronrij bevat uitsluitend parkeerlocatiegegevens; `tests/Fixtures/import/v1/pilot-source.json` behoudt de relevante velden en verwijdert portalwrapper, portal-ID en timestamp. Herkomst blijft hier vastgelegd. De overige randgevallen zijn door ons gemaakte fictieve gegevens; ze zijn geen meldingen over echte parkeerplaatsen. + +De bestaande universele package [eindhoven 5.1.0](https://github.com/klaasnicolaas/python-eindhoven/releases/tag/v5.1.0) is op CPython 3.14.2 uitgevoerd. De packagecode is MIT; dat is een afzonderlijk gegeven van de datasetlicentie. Deze bronproef is uitgevoerd in een tijdelijke, geïsoleerde omgeving; core heeft geen dependency op de Eindhoven-package. De importrepository gebruikt nog een oudere major; #774 moet de nieuwe geneste datamodellen expliciet verwerken. + +Read-only meting op 2026-09-08, met `ODPEindhoven.locations(limit=1000, parking_type=ParkingType.DISABLED_PARKING)`: + +| Meting | Uitkomst | +| --- | --- | +| HTTP-verzoeken | 1 | +| `nhits` / ontvangen / unieke portal-ID's / unieke `objectid` | 180 / 180 / 180 / 180 | +| Responsebytes / grootste losse bronrij | 88.805 / 506 bytes | +| Totale fetch inclusief packageparsing | 0,317 seconden | +| Response SHA-256 | `9100542741d1d37f69eb82101f89729b545afb5bdb4c01a44c6692e5dd7d99ef` | +| Recordtimestamps | Allemaal `2026-08-10T21:45:03.036Z` | +| Portal `modified` | `2026-08-10T21:45:24Z` | +| Geografische uitersten, west/zuid/oost/noord | `5.450612694939047, 51.426665509086355, 5.4905604066468765, 51.47076467693479` | + +Dit is één actuele netwerkproef, geen beschikbaarheids-SLA en geen bewijs van volledige gemeentelijke dekking. De metadata noemt ook `temporal: t/m juli 2018`. Portalverwerking in 2026 bewijst daarom geen recente veldcontrole. + +## Mapping en concrete aansluitvoorwaarden voor #774 + +| Onderwerp | Afspraak en beperking | +| --- | --- | +| Source/scope | `nl-eindhoven-accessible`, scope 1 = genoemde dataset en exact filter. Geen dynamisch afwijkend geografisch filter onder dezelfde scopeversie. | +| Identiteit | Gebruik bronveld `objectid` als string, gekoppeld aan deze source; nooit coördinaten. In deze response zijn alle 180 waarden aanwezig en uniek. Langdurige stabiliteit/hernummering moet de aansluiting nog vaststellen. De package geeft alleen portal-`recordid` door en laat `objectid` weg: **blokker voor de adapter**, op te lossen door een generieke package-uitbreiding. Geen stilzwijgende fallback naar portalhash. | +| Volledigheid | De package doet één request, exposeert `nhits` niet en heeft geen offset/paginering. In deze meting past alles in één response en is ontvangen = `nhits`; `limit=1000` alleen is geen bewijs. Voor gereedmelding moet de package generiek het totaal en volledige iteratie beschikbaar maken, of aantoonbaar één complete response met totaal teruggeven. Bij meer resultaten, afgekapt antwoord, gewijzigde datasetversie tijdens ophalen of onbekende volledigheid: geen ready-manifest. | +| Bronplatform | De package gebruikt [Search API v1](https://help.opendatasoft.com/apis/ods-search-v1/), die de aanbieder deprecated noemt. Kies de ondersteunde API bij de generieke pagination/identity-uitbreiding; kopieer de oude client niet naar core. | +| Positie | Bron-GeoJSON is Point in WGS84; `[longitude, latitude]` wordt expliciet `position.longitude/latitude`. Geen centroid nodig. Het is een parkeerlocatie, geen bewezen navigatie-ingang. | +| Geografie | `country_code=NL`, `administrative_codes=[{"scheme":"nl:cbs:municipality","code":"0772"}]`. Core koppelt dit aan zijn bestaande relaties; geen interne database-ID's in de adapter. De testbounds `[5.3,51.3,5.6,51.6]` zijn een grove foutcontrole, geen gemeentegrens. | +| Capaciteit | `aantal` is een brongetal (double in metadata); alleen integrale, niet-negatieve waarden overnemen. `null` blijft onbekend en is geen nul. Package 5.1.0 typeert dit als int; de adapter mag een ontbrekend of fractioneel brongetal niet onopgemerkt laten coerceren. | +| Toegangsbetekenis | Het bronlabel zegt gehandicaptenparkeerplaats, maar bevat geen aantoonbaar onderscheid tussen algemene en persoonsgebonden reservering of tijdsbeperkingen. Pilotfixture gebruikt daarom `access_category=unknown` en `unmapped_fields=["reservation_status"]`, wat review oplevert. Publicatie vereist bronverduidelijking of een concrete beoordeelde waarneming. | +| Datums | `record_timestamp` en portal `modified` zijn verwerkingsmetadata; niet presenteren als veldwaarneming. In de genormaliseerde pilot is `source_updated_at=null`. `fetched_*` beschrijft alleen onze fetch. | +| Hergebruik | De datasetmetadata is de basis voor het opgenomen minimale fixture. Hercontroleer voorwaarden en bronbetekenis vóór aansluiting; licentietekst of gebruiksvoorwaarden veranderen niet ongemerkt mee met een manifest. | + +Deze acties horen bij de eerste adapter in [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774). Het contract kan onafhankelijk worden geïmplementeerd door #1215. Brononderzoek blijft buiten core; bovenstaande punten zijn concrete toelatingsvoorwaarden voor deze ene aansluiting. + +## Normatief formaat en validatiegrens + +[Manifestschema](../../resources/schemas/import/v1/manifest.schema.json) en [gemeentelijk recordschema](../../resources/schemas/import/v1/municipal-record.schema.json) zijn JSON Schema Draft 2020-12, contract `1.0`. Er zijn geen externe `$ref`-verwijzingen of schema's uit leveringen. De validator laadt uitsluitend deze lokale vertrouwde schema's; Python heeft bovendien een resolver die netwerkreferenties weigert. Een `$id` is een identifier, geen te downloaden bestand. + +Records zijn UTF-8 zonder BOM, één JSON-object per regel, uitsluitend LF en altijd een afsluitende LF. Geen compressie, lege regels of impliciete ontbrekende velden. Producers schrijven JSON via een encoder, geen handmatige stringconcatenatie. Tijdstippen gebruiken UTC-seconden `YYYY-MM-DDTHH:mm:ssZ`, echte kalenderdatums, geen leap seconds. Nullwaarden staan expliciet in het bestand. Onbekende velden worden afgewezen; uitbreidingen krijgen een afgesproken contractversie. V1 omvat alleen gemeentelijke snapshots, geen offstreet- of livevelden. + +`SnapshotContract::check()` en de onafhankelijke Python-referentie controleren een lokaal recordsbestand en de **exacte manifestbytes**. Het pad en de context komen van de vertrouwde consumer, niet uit het manifest. Context bevat bron/geografische toelating, actuele config/scope en de volledige bewaarde batch-/sequencehistorie voor deze source. Een manifest kan zijn eigen bron of scope niet toelaten. + +| Resultaat | Betekenis | +| --- | --- | +| `invalid` | Vorm, integriteit, volledigheid, geografie of limiet faalt; niet verwerken. | +| `conflict` | Bekende batch-ID met andere exacte manifestbytes, of reeds gebruikte sequence; niet verwerken zonder onderzoek. | +| `duplicate` | Dezelfde batch en exacte manifesthash na bestandsvalidatie; consumer bepaalt hervatten of overslaan. | +| `superseded` | Sequence ouder dan/gelijk aan de vertrouwde high-watermark; nooit nieuwere data terugdraaien. | +| `review` | Technisch geldig maar leeg, gewijzigde config/scope, onbekende/persoonlijke toegang of onbegrepen beperkingen. Geen automatische publicatie. | +| `valid` | De begrensde contractcheck slaagt. **Geen publicatiebesluit**: eerste import, aantalsdaling, brondatumregressie, verschillen en correctiebehoud worden pas in #1215/#1218 beoordeeld. | + +Een identieke levering moet ook dezelfde manifestserialisatie behouden. Hashes omvatten exacte bytes, inclusief LF. De checker raadpleegt geen objectopslag of database en bewijst geen bestaande objectversie, locking, queueherstel of transactionele publicatie. De consumer moet state onder lock opnieuw controleren vóór publicatie. De Python-code is een kleine testreferentie; de producerimplementatie blijft in disabled-parking. + +## Verplichte pilotlimieten + +| Limiet | Waarde | +| --- | --- | +| Ready-manifest | 16.384 bytes | +| JSONL-regel inclusief LF | 16.384 bytes | +| Recordsbestand | 33.554.432 bytes (32 MiB), ongecomprimeerd | +| Records per snapshot | 10.000 | +| Fetch inclusief requests, retries en parsing | 1.800 seconden | +| Lokale contractvalidatie | 30 seconden monotone verstreken tijd | +| Probe | 30 seconden totaal, 15 seconden request-timeout, één fetch, geen automatische retries | + +Deze zijn harde pilotplafonds, geen schaalbelofte. Producer en consumer moeten ze afdwingen. De lokale validator telt werkelijke bytes en regels tijdens streaming; een manifest mag niet met kleine metadata een groter bestand toestaan. Deadlinecontrole gebeurt tussen regels en bij afronding. De opslagdownload moet in #1215/#1216 zelfstandig een byte- en wall-clockgrens krijgen; een geblokkeerde storage-read wordt niet door deze lokale checker onderbroken. Een nieuwe bron die niet past krijgt een bewuste limiet-/capaciteitsbeslissing, geen stilzwijgende verhoging. + +De 180 werkelijk gemeten bronrecords normaliseren in de proef naar 79.566 bytes, maximaal 457 bytes per regel. Een synthetische 10.000-recordproef gebruikt 4.409.201 bytes. De ceilings bieden ruimte boven deze bron, met begrensd geheugengebruik voor de set externe ID's. Gemeten validatietijden voor 180 / 10.000 records: PHP 0,024 / 0,600 seconden, Python 0,051 / 1,900 seconden. Dit is contractvalidatie op de ontwikkelmachine; databasepublicatie en stagingcapaciteit moeten afzonderlijk worden gemeten. + +## Reproduceren + +Vanaf de repositoryroot: + +```sh +vendor/bin/pest tests/Unit/Support/SnapshotContractTest.php --no-tia --compact +poetry -C tests/Support/import_contract env use python3.14 +poetry -C tests/Support/import_contract install --no-interaction +poetry -C tests/Support/import_contract run python -m unittest -v +``` + +PHP en Python gebruiken dezelfde bestanden uit [de fixturelijst](../../tests/Fixtures/import/v1/cases.json). De Python-testreference is uitsluitend contractcompatibiliteit, zonder bronpackages, netwerkfetches of producerlogica. Een aparte deadlineproef gebruikt een gecontroleerde monotone klok. Normale CI doet geen bronrequests en gebruikt de vastgelegde dependencies. Een nieuwe read-only bronproef hoort in de importrepository of een tijdelijke onderzoeksomgeving, met `eindhoven==5.1.0` en CPython 3.14.2. Gebruik de bovenstaande package-aanroep en controleer op dezelfde response `nhits`, rijenaantal, unieke IDs en bytes. De publieke [packagevoorbeeldcode](https://github.com/klaasnicolaas/python-eindhoven/tree/v5.1.0/examples) toont het ophalen. Voor de meting is via een aiohttp trace callback de ongewijzigde response geteld; de package zelf exposeert de completenessmetadata nog niet. + +Herhaal die proef bij bron-/packagewijzigingen; voeg live netwerkafhankelijkheid of een bronadapter niet toe aan core. De normale tests blijven offline. diff --git a/docs/development/quality-checks.md b/docs/development/quality-checks.md index 67c420ca..4931a14a 100644 --- a/docs/development/quality-checks.md +++ b/docs/development/quality-checks.md @@ -69,3 +69,7 @@ Additional plugins were evaluated individually: agent output is optional and the Permanent spatial coverage lives in `ParkingLocationTest`, `ParkingDiscoveryTest`, `GeoPointTest` and `GeoBoundsTest`: all source models, generated SRID/coordinate order and bulk updates, metre distances, deterministic ties, inclusive bounds, antimeridian queries, public filtering, and invalid inputs. There is no application coverage-polygon/intersection API yet; no speculative polygon model or tests of bare PostGIS functions are introduced. Query-plan assertions are intentionally omitted: tiny transactional fixtures do not meaningfully predict planner choices, while exact query results are stable regression contracts. See [Pest TIA documentation](https://pestphp.com/docs/tia) for baseline storage, invalidation and replay behavior. + +## Snapshot contract parity + +The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`. The **Python 3.14 import contract** job validates the same byte fixtures with the isolated Poetry environment under `tests/Support/import_contract`. Run `poetry -C tests/Support/import_contract install --no-interaction` and `poetry -C tests/Support/import_contract run python -m unittest -v` after selecting Python 3.14 for that environment. It has no source packages or network fetches. See the [contract proof](data-import-pilot.md) for source evidence and mandatory limits; passing these checks does not validate deployment or publication. diff --git a/docs/product/data-foundation.md b/docs/product/data-foundation.md new file mode 100644 index 00000000..b2fb7e3d --- /dev/null +++ b/docs/product/data-foundation.md @@ -0,0 +1,192 @@ +# Europese databasis voor NIPKaart + +Status: overeengekomen richting met een voorgesteld technisch ontwerp; nog niet geïmplementeerd. Vastgelegd op 2026-09-08 naar aanleiding van de product- en architectuurgesprekken met de eigenaar. Technische defaults en open beslissingen zijn hieronder expliciet gemarkeerd. Dit document is geen bewijs van werkende imports, Europese dekking of productieacceptatie. + +De gekozen uitvoering is een zelfstandige batchaanpak: importrepositories plannen het ophalen en publiceren complete bestanden; core ontdekt en verwerkt die leveringen. Dit vervangt het eerdere voorstel waarin core opdrachten aan workers uitdeelde. + +## Leeswijzer + +- Dit document beschrijft doel, verantwoordelijkheden, aangesloten datasets, hybride gegevens en productgrenzen. +- [Batchimportcontract](../development/data-import-contract.md) beschrijft versieerbare bestanden, gereedmelding, verwerking en herstel. +- [Uitvoering en beheer](../development/data-foundation-delivery.md) bevat werkpakketten, acceptatiecriteria, hosting, beveiliging, migratie en open beslissingen. +- [Concrete techstack](../development/data-foundation-stack.md) koppelt het ontwerp aan runtimes, libraries, opslag, authenticatie en deployment. +- [CONTEXT.md](../../CONTEXT.md) bevat de gedeelde begrippen. Bestaande modelnamen blijven gelden; nieuwe begrippen betekenen niet dat er al overeenkomstige modellen of tabellen bestaan. + +## 1. Doel en aanleiding + +NIPKaart helpt mensen bruikbare toegankelijke parkeerplekken vinden. Europese dekking is het ontwerpuitgangspunt. Werkelijke dekking wordt per gebied en dataset aangetoond; een Europese architectuur is geen belofte dat ieder land volledig wordt gedekt. + +Kant-en-klare datasets zijn schaars, verschillen sterk en bevatten vaak onvoldoende informatie. De eigenaar heeft daarom naast het bouwen van Python-packages veel handmatig brononderzoek en kaartonderzoek gedaan en zelf plekken toegevoegd. NIPKaart moet de resulterende parkeerinformatie duurzaam vastleggen en door anderen laten aanvullen en onderhouden. Het onderzoeksproces zelf blijft buiten het platform. + +De basis ondersteunt drie manieren van vergaren: + +1. Gemeentelijke en andere datasets via herbruikbare bronpackages, API's en bestanden. +2. Handmatige aanlevering van gevonden plekken of verkregen bestanden, met hun relevante herkomst. Het voorafgaande onderzoek wordt buiten NIPKaart gedaan. +3. Communitybijdragen: nieuwe plekken, aanvullingen, bevestigingen, correcties en meldingen dat een plek verdwenen of tijdelijk onbruikbaar is. + +Imports en communitywaarnemingen dragen samen bij aan wat NIPKaart toont. Een herimport mag lokale beoordelingen en correcties niet wissen. Herkomst, onzekerheid en actualiteit moeten voor gebruikers begrijpelijk blijven. + +## 2. Bestaande situatie en grenzen + +De volgende bevindingen zijn gebaseerd op de in deze sessie gelezen code en documentatie; de draaiende oude productieomgeving is niet onderzocht. + +| Onderdeel | Bestaande situatie | Richting | +| --- | --- | --- | +| [Oude NIPKaart](https://github.com/klaasnicolaas/nipkaart) | Spotterbijdragen met beoordeling; afzonderlijke gemeentelijke en offstreetdata | Bron van productervaring, geen runtimebasis voor nieuwe imports | +| [Core](https://github.com/NIPKaart/core) | Laravel 13, PostgreSQL/PostGIS, aparte bronmodellen, communitybevestigingen, favorieten en gedeelde discoveryservice | Regie, beoordeling, publicatie en gebruikerservaring | +| [disabled-parking](https://github.com/NIPKaart/disabled-parking) | 15 stadsadapters; Python-packages en enkele directe downloads; oude MySQL-tabellen; eerst verwijderen en daarna uploaden | Gemeentelijke adapters en zelfstandig geplande batchuitvoering | +| [offstreet-parking](https://github.com/NIPKaart/offstreet-parking) | Amsterdam en Hamburg; packagegebruik, periodieke lus, directe MySQL-writes | Adapters en geplande levering voor voorzieningen en bezetting | +| Universele Python-packages | Gemeentespecifieke bronclients, bijvoorbeeld ODPAmsterdam en UDPHamburg | Zelfstandig bruikbaar houden, zonder NIPKaart-afhankelijkheid | + +Bekende aandachtspunten uit de importcode: sommige identiteit is afgeleid van coördinaten, Den Haag gebruikt een limiet van 300 zonder paginering in de adapter, de gemeentelijke runner verwijdert bestaande stadsrecords vóór publicatie van vervangers en de Amsterdamse offstreetadapter zet ontbrekende aantallen om naar nul. Deze bevindingen rechtvaardigen gerichte tests; ze bewijzen geen huidige productiedatalekken of dataverlies. + +De [bestaande PostgreSQL-afspraak](../development/postgresql.md#fresh-start-decision) is een nieuwe database. Er is geen opdracht om de gehele oude MySQL-database over te zetten. Nieuw opgebouwde bronidentiteit, communitygegevens en favorieten moeten vanaf de eerste nieuwe publicatie wel stabiel blijven. + +## 3. Besluiten uit het gesprek + +| Besluit | Consequentie | +| --- | --- | +| Europese dekking als uitgangspunt | Geen verplicht Nederlands CBS-nummer, postcodeformaat, provinciepatroon of telefooncode als identiteit | +| Universele packages blijven een zelfstandig open-dataproduct | Geen NIPKaart-publicatiestatus, database-ID's, importcredentials of businessregels in die packages | +| Adapters en batchuitvoering horen in de twee importrepositories | NIPKaart-vertaling en ophaalplanning gebeuren buiten de universele packages | +| Het bestandcontract scheidt verzamelen en verwerken | Importomgeving plant fetches; core ontdekt complete leveringen en beheert publicatie | +| Brononderzoek blijft buiten het platform | Alleen operationeel datasetbeheer; geen CRM, leads, contacthistorie of onderzoeksworkflow | +| Python levert bestanden, core publiceert parkeerinformatie | Beperkte opslagrechten voor producenten; geen coretoken of databaseverbinding | +| Datasets en communitykennis zijn beide nodig | Correcties op geïmporteerde plekken horen bij de basis | +| Code mag open zijn, de operatie is gecontroleerd | Private opslag, gescheiden producent-/consumerrechten en beheerde bulktoegang | +| Mobiel is een toekomstige productrichting | Verwerking centraal en herbruikbaar; geen mobiele app bouwen in deze eerste oplevering | +| Vertrouwen kan later verwerking versnellen | Nu onderbouwde bijdrage- en beslisgeschiedenis verzamelen; nog geen automatische karmadrempels | + +De batchgrens is gekozen. Exacte manifestvelden, termijnen en infrastructuurdetails blijven ontwerpvoorstellen. Werkpakket A valideert en bevriest het eerste bestandcontract voordat productiecode erop vertrouwt. + +## 4. Verantwoordelijkheden + +```mermaid +flowchart TD + U[Universele Python-packages] --> A[Adapters in de twee importrepositories] + T[Planning in importomgeving] --> A + A --> B[Recordsbestand uploaden] + B --> M[Ready-manifest als laatste in private opslag] + M --> C[Core ontdekt complete leveringen] + C --> V[Core: validatie en vergelijking] + R[Communitywaarnemingen en correcties] --> D[Beoordeling en publicatiebesluiten] + V --> D + D --> P[Gepubliceerde gegevens per bronmodel] + P --> Q[Gedeelde discovery: kaart en lijst] +``` + +| Laag | Verantwoordelijk voor | Niet verantwoordelijk voor | +| --- | --- | --- | +| Universeel package | Bronprotocol, pagina's ophalen, bronobjecten en bronfouten | NIPKaart-identiteit, moderatie of distributiebeleid | +| NIPKaart-adapter | Veldbetekenis vertalen, bron-ID behouden, filterscope en volledigheid rapporteren | Gemeentelijke bronwaarden stilzwijgend vervangen door lokale correcties | +| Python-batchrunner | Eigen sourceplanning, begrensd ophalen, bestanden uploaden, als laatste gereedmelden en fetchfouten melden | Core aanroepen om werk te claimen of parkeerinformatie publiceren | +| Core | Toegelaten datasets, manifestdiscovery, importhistorie, validatie, beoordeling, publicatie en uitblijvende leveringen signaleren | Bronfetches plannen, broncredentials beheren of onderzoeksworkflow aanbieden | +| Beheerder | Bron toelaten, uitzonderingen beoordelen, voorwaarden en kwaliteit vastleggen | Iedere normale herimport handmatig overtypen | + +Er zijn twee verschillende soorten achtergrondwerk: Python verzamelt brondata; Laravel verwerkt aanleveringen en besluiten. Python leest geen Laravel-queuetabellen en krijgt geen databasecredentials. + +Core beheert het versieerbare bestandcontract en voorbeelden; beide importrepositories testen tegen dezelfde release. Gedeelde Python-uitvoeringslogica wordt pas losgetrokken wanneer een tweede repository die werkelijk nodig heeft. De universele bronclients blijven daarvan onafhankelijk. + +## 5. Aangesloten datasets beheren + +Onderzoek naar mogelijke bronnen, contact met gemeenten en onderzoeksnotities blijven buiten NIPKaart. De eigenaar heeft expliciet aangegeven geen CRM of leadbeheer te willen bouwen. Deze documentatie schrijft daarvoor ook geen ander hulpmiddel voor. + +Een dataset wordt in core geregistreerd zodra we deze aansluiten. Het beheer bevat aanbieder/titel, herkomst-URL, gebied, datacategorie, toegelaten opslagprefix/scope, voorwaarden/attributie, verwachte leveringsleeftijd en import-/publicatiestatus. Adapterinstellingen, broncredentials en fetchplanning staan in de importomgeving. Coretoestanden `configured`, `active`, `paused` en `retired` betreffen verwerking, niet het stopzetten van de externe runner. Een eerste proefimport hoort bij aansluiting. + +Beantwoord bij het voorbereiden van een aansluiting buiten het platform de volgende vragen en neem de operationeel relevante uitkomst in de datasetconfiguratie over: + +- inhoud: algemene toegankelijke parkeerplekken, persoonsgebonden plekken, garages/P+R of livebezetting; +- werkelijk gedekt gebied en uitsluitingen, niet alleen de naam van de gemeente; +- stabiele bron-ID's, wijzigingen, verwijderingen, paginering en brondatums; +- geometrie, coördinatenstelsel, aantallen, toegang en tijdsbeperkingen; +- actualiteit en verantwoordelijke bronhouder; +- licentie, attributie, opslag en hergebruik, inclusief eventuele bronvoorwaarden; +- technische toegang, snelheidslimieten, onderhoudskosten en bestaand universeel package. + +Een bronhouder kan meerdere datasets leveren; een dataset kan meerdere gebieden bedienen; hetzelfde gebied kan meerdere datasets hebben. Landen/regio's zijn interne referenties met lokale benamingen en externe codes als optionele mappings. Een adapter hardcodet geen interne country/province-ID's. Werkpakket B definieert hoe onbekende buitenlandse administratieve niveaus naar de huidige verplichte geografische relaties worden vertaald; maak geen fictieve provincies zonder expliciete mappingbeslissing. + +De bestaande Nederlandse, Belgische en Duitse adapters zijn kandidaten voor een gevarieerde pilot. Geen daarvan is automatisch geselecteerd of productieklaar. Landelijke registers zoals [RDW/NPR](https://www.nationaalparkeerregister.nl/open-parkeerdata), gemeentelijke portalen en eventueel [OpenStreetMap](https://www.openstreetmap.org/copyright) worden buiten het platform inhoudelijk en qua voorwaarden beoordeeld. Dekking in NIPKaart beschrijft aangesloten datasets en communitygegevens, niet een onderzoeksinventaris van alle Europese gemeenten. + +## 6. Hybride gegevens en identiteit + +### 6.1 Bronrecords en fysieke plekken + +`ParkingSpace`, `ParkingMunicipal` en `ParkingOffstreet` blijven afzonderlijke modellen en tabellen. Een bronrecord zegt wat één bron over een plek of voorziening meldt; het is niet vanzelf het volledige beeld van de fysieke plek. + +De basis voegt herkomst en koppelingen toe zonder deze modellen samen te voegen. Een koppeling verwijst naar een expliciet type en een bestaande ID. Een gemeentelijk punt en een communityplek kunnen na beoordeling dezelfde fysieke plek beschrijven. Een garage en een gehandicaptenvak binnen die garage hebben eerder een containmentrelatie dan een identiteitsovereenkomst. + +Elke bronrecordidentiteit is uniek binnen `(dataset, external_id)`. Externe ID's zijn ondoorzichtige strings; behoud voorloopnullen en de volledige waarde. Een coördinatenwijziging verandert de interne ID niet. Ontbrekende stabiele bron-ID's vereisen een expliciete, geteste strategie voor die adapter en beoordeling van onzekere matches; een afgeronde coördinatenhash is geen universele oplossing. + +De eerste pilot maakt nieuwe bronrecords aan of koppelt ze aan een bestaande bronidentiteit. Mogelijke matches met andere bronnen/community worden voorgesteld, niet automatisch samengevoegd. Beoordeelde koppelingen hebben een actor, datum, reden en ontkoppelgeschiedenis. Discovery mag pas dubbelen onderdrukken zodra broninformatie en bestaande favoriet/detailverwijzingen behouden blijven. Die UI-wijziging heeft eigen acceptatie onder #1170/#1172. + +### 6.2 Waarnemingen en correcties + +Een communitywaarneming beschrijft welke eigenschap iemand heeft vastgesteld, hoe en wanneer. Onderscheid veldbezoek, toegestane beeldbron, document of andere bron. Vandaag ingevoerd is niet hetzelfde als vandaag waargenomen. Bij ouder beeldmateriaal blijft de beelddatum apart van de registratiedatum, met onbekende datum expliciet toegestaan. + +Een correctievoorstel bevat doeltype/ID, gewijzigde velden, eerdere waarden of de versie waarop het voorstel is gebaseerd, voorgestelde waarden, reden, waarnemingsdatum en onderbouwing. Correcties mogen op geïmporteerde plekken worden aangebracht zonder een nieuwe dubbele communityplek te creëren. + +Een beoordeling accepteert, verwerpt of vraagt meer informatie en bewaart wie wat op welke onderbouwing besliste. De huidige publicatie-/bevestigingsstatussen worden hergebruikt waar hun betekenis overeenkomt; een voorstelstatus wordt geen tweede concurrerende status van een parkeerplek. + +Voor de eerste versie geldt een expliciete veldkeuze: een actieve, goedgekeurde lokale correctie gaat voor de actuele bronwaarde van dat veld. Core bewaart beide. Een nieuwe afwijkende bronwaarde heropent een conflict voor beoordeling maar wist de correctie niet. Wanneer een correctie wordt ingetrokken, wordt de laatste geldige bronwaarde weer de basis. Een verlopen of betwiste correctie vraagt herbeoordeling; automatisch terugvallen vereist later een expliciet beleid. + +Voorbeeld: een bron meldt twee vakken, een beoordeelde waarneming corrigeert dat naar één. Een nieuwe import met twee vakken houdt bronwaarde twee en gepubliceerde waarde één vast en registreert het conflict. Als de bron later ook één meldt, kan het conflict vervallen; de correctiegeschiedenis blijft bestaan. + +### 6.3 Actualiteit en verdwenen plekken + +Bewaar afzonderlijk: wanneer opgehaald, welke datum/versie de bron vermeldt, wanneer iets is waargenomen, wanneer beoordeeld en wanneer gepubliceerd. Een geslaagde import vernieuwt niet automatisch de waarnemingsdatum van ieder record. + +Een ontbrekend record uit een aantoonbaar volledige snapshot wordt `missing_from_source`, met datum en importreferentie. In de pilot leidt dit tot beoordeling. Het verdwijnen uit een delta, mislukte import of livebezettingsbatch heeft die betekenis niet. Een afgeronde beoordeling kan de plek als niet beschikbaar publiceren zonder de identiteit en favorieten te vernietigen. Herintroductie behoudt de identiteit en volgt opnieuw het publicatiebeleid. + +Actualiteit wordt per informatietype beoordeeld. Een verouderde bezettingsmeting maakt alleen de livebeschikbaarheid onbekend; zij verwijdert geen garage. Algemene vrije capaciteit, toegankelijke capaciteit en toegankelijke vrije plaatsen zijn afzonderlijke gegevens. Onbekend is nooit nul. + +## 7. Beheer- en gebruikersflows + +### Bron aansluiten + +1. Selecteer buiten het platform een geschikte dataset en registreer de aansluiting met inhoud, herkomst en voorwaarden. +2. Configureer package/adapter, scope en ophaalritme in de importrepo; leg in core de toegelaten levering en verwachte maximale leeftijd vast. +3. Produceer een proefbestand en laat core dat valideren zonder publicatie. +4. Bekijk aantallen, kaartsteekproef, afwijkingen, voorwaarden en mogelijke dubbelen. +5. Beoordeel de eerste publicatie; stel daarna een automatisch beleid binnen vastgelegde controles in. + +### Terugkerende import + +De importomgeving haalt volgens eigen planning op en schrijft pas na volledige bestandsupload een ready-manifest. Core ontdekt dit, valideert en vergelijkt. Normale wijzigingen mogen na pilotacceptatie automatisch publiceren; afwijkingen komen in de beoordelingslijst. Core toont uitblijvende leveringen als achterstand en houdt ontvangst, validatie en publicatie apart. De oorzaak van een fetchfout staat in de importomgeving. Ophalen pauzeren en corepublicatie pauzeren zijn afzonderlijke handelingen. + +### Bijdragen en onderhouden + +Een gebruiker kan een plek toevoegen of op een bestaande plek een concrete correctie indienen. Het formulier vraagt alleen relevante informatie en maakt onbekende waarden mogelijk. Bevestigen moet duidelijk maken wat wordt bevestigd. Beheerders krijgen een vergelijkbare oude/nieuwe weergave, herkomst en reden, met een toegankelijk alternatief voor kaartinteractie. + +Gerichte onderhoudstaken richten zich op oude waarnemingen, conflicten en ontbrekende dekking. Start met een beheerfilter en vrijwillige bevestigingsflow. Locatiegerichte pushberichten of tracking van gebruikers horen niet bij deze basis. + +Publieke resultaten tonen bruikbare herkomst, relevante datum en tekstuele onzekerheid. Interne configuratie, credentials en onbewerkte payloads worden niet doorgegeven. Basiszoeken blijft zonder account beschikbaar en alle essentiële taken hebben een toetsenbordbruikbare lijst/detailflow. + +## 8. Vertrouwen en mobiele toekomst + +Het door de eigenaar genoemde Flitsmeister-voorbeeld is inspiratie; de precieze werking daarvan is hier niet onderzocht of als specificatie overgenomen. + +De basis registreert bijdragen, onafhankelijke bevestigingen, beoordelingen en teruggedraaide besluiten. Later kan aantoonbaar betrouwbare bijdragegeschiedenis helpen bij het prioriteren of automatisch verwerken van laag-risicowijzigingen. Veel indienen, zelfbevestiging of eerder automatisch accepteren is geen onafhankelijk bewijs van juistheid. Voorkom een feedbacklus waarin automatische acceptatie direct nieuw vertrouwen oplevert. + +Vertrouwen in een persoon verschilt van zekerheid over een specifieke eigenschap. Een melding dat een plek verdwenen is vraagt een ander beleid dan een kleine beschrijvingscorrectie. Drempels, misbruikdetectie, bezwaar en verval worden pas ingevoerd nadat de handmatige flow voldoende beoordeelde voorbeelden heeft. Geen publieke ranglijst of karmascore in de eerste oplevering. + +Een toekomstige mobiele app gebruikt dezelfde toepassingsregels voor bijdragen, beoordeling en lezen. Het batchcontract is geen mobiele API. Mobiele authenticatie, offlinebijdragen, GPS, camera en notificaties worden afzonderlijk ontworpen; vandaag bouwen we een telefoonbruikbare webflow en herbruikbare toepassingslogica. + +## 9. Open code en beschermde dienstverlening + +De universele bronpackages blijven onafhankelijk bruikbaar. Ook adapter- en corecode mogen open blijven volgens hun gekozen licenties. Open code geeft geen toegang tot de beheerde installatie, workers, opgeslagen bronbestanden of private operationele gegevens. Onderzoeksgegevens worden buiten het platform beheerd. + +De waarde van NIPKaart ontstaat uit doorlopend onderzoek, beoordeelde koppelingen, actuele correcties, operationele betrouwbaarheid en community. Toegang tot samengestelde bulkdata is een afzonderlijke productkeuze. Rate limits en querygrenzen beperken misbruik maar garanderen niet dat zichtbare kaartinformatie niet verzameld wordt. + +Voorwaarden verschillen per bron. [OpenStreetMap](https://www.openstreetmap.org/copyright) gebruikt ODbL met attributie- en share-alikevoorwaarden; veronderstel daarom niet dat iedere samengestelde dataset exclusief kan blijven. De [Google Maps-voorwaarden](https://www.google.com/intl/en-US/help/terms_maps/) bevatten beperkingen op het hergebruiken van kaartinhoud en het opbouwen van andere kaartdatasets. De geschiktheid van Street View als structurele gegevensbron moet vóór integratie specifiek worden beoordeeld. Deze architectuur geeft daarvoor geen hergebruiktoestemming. De geraadpleegde voorwaarden zijn gecontroleerd op 2026-09-08; bewaar per bron de toepasselijke versie en toegestane handelingen. + +Leg voor iedere bron apart vast of ophalen, ruwe opslag, normalisatie, publieke weergave en bulkherdistributie zijn toegestaan, met vereiste attributie. Voor communitybijdragen moeten gebruiksrechten en bewaarbeleid nog worden vastgesteld. Dat zijn lanceerbeslissingen, geen impliciete gevolgen van een code-licentie. + +## 10. Eerste resultaat en volgorde + +Eenvoud is leidend: [de startscope](../development/data-foundation-delivery.md#eenvoud-als-uitgangspunt-voor-uitvoering) splitst de eerste keten in bestandverwerking, automatisering en correctiebehoud. Begin met één bron en expliciete adaptermapping; bouw gedeelde abstracties pas wanneer een tweede bron daar aanleiding toe geeft. De technische uitwerking beschrijft ook latere mogelijkheden en is geen opdracht om alle onderdelen vooraf te bouwen. + +Het eerste resultaat is een volledige keten met één bestaand Python-package: bron toelaten, zelfstandig ophalen, bestand gereedmelden, ontdekken, beoordelen en publiceren, een communitycorrectie accepteren en veilig herimporteren. Onderbroken uploads, core-uitval, dubbele leveringen en laat binnenkomende oudere batches moeten diezelfde keten doorstaan. + +Daarna volgen een inhoudelijk afwijkende gemeentelijke bron uit een ander land, een offstreetvoorzieningenbron en een aparte bezettingsstroom. Generieke CSV/GeoJSON-upload sluit vervolgens aan op dezelfde verwerking. Een generieke adapterbouwer, continentbrede bronselectie, publieke bulk-API, automatische karma en mobiele app zijn geen voorwaarden voor deze eerste keten. + +Deze keuze haalt het noodzakelijke bronregister, importhistorie en correctiebehoud naar de eerste databasis. Zij verfijnt de eerdere Horizon 2-indeling in de [feature-inventaris](feature-inventory.md); bestemmingzoeken en toegankelijkheid blijven productdoelen. Werkpakketten en toetsbare criteria staan in het [uitvoeringsplan](../development/data-foundation-delivery.md). diff --git a/docs/product/feature-inventory.md b/docs/product/feature-inventory.md index 459ec793..bbed9346 100644 --- a/docs/product/feature-inventory.md +++ b/docs/product/feature-inventory.md @@ -99,6 +99,8 @@ Accessibility is a product requirement across every stream. Map-only interaction ## Proposed delivery horizons +The [data foundation plan](data-foundation.md) refines this sequencing as of 2026-09-08: the minimum operational dataset registry, import history and correction preservation are part of the first data delivery, rather than waiting for Horizon 2. The [work packages](../development/data-foundation-delivery.md) define the implementation and acceptance gates. Broader coverage, automated contributor trust and mobile remain later work; source research/CRM is outside platform scope. + ### Horizon 1 — Relaunchable core - destination/address search @@ -152,4 +154,4 @@ Accessibility is a product requirement across every stream. Map-only interaction 7. Map interaction always has an accessible non-map counterpart for essential tasks. 8. Keep source/domain models separate and build shared read models for discovery. 9. Prefer useful, testable increments over a single product rewrite. -10. Avoid collecting sensitive personal information unless a concrete feature truly requires it. \ No newline at end of file +10. Avoid collecting sensitive personal information unless a concrete feature truly requires it. diff --git a/docs/product/trust-and-provenance.md b/docs/product/trust-and-provenance.md index 8a10c02d..b96224fd 100644 --- a/docs/product/trust-and-provenance.md +++ b/docs/product/trust-and-provenance.md @@ -2,6 +2,8 @@ NIPKaart combines information with very different trust characteristics. The UI must make those differences understandable instead of flattening everything into identical markers. +The [European data foundation](data-foundation.md), [batch import contract](../development/data-import-contract.md) and [delivery plan](../development/data-foundation-delivery.md) elaborate this direction as of 2026-09-08. Independent Python producers schedule collection and publish complete versioned files; core discovers, validates and publishes their information while preserving corrections. Core does not coordinate workers. Source research and CRM workflows remain outside the platform. These are design documents, not implemented capabilities. + ## Source classes ### Community (`ParkingSpace`) diff --git a/resources/schemas/import/v1/manifest.schema.json b/resources/schemas/import/v1/manifest.schema.json new file mode 100644 index 00000000..b21bd650 --- /dev/null +++ b/resources/schemas/import/v1/manifest.schema.json @@ -0,0 +1,225 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nipkaart.nl/schemas/import/1.0/manifest.json", + "type": "object", + "additionalProperties": false, + "required": [ + "contract_version", + "source_id", + "batch_id", + "source_sequence", + "source_config_version", + "scope_version", + "mode", + "record_kind", + "adapter", + "packages", + "fetched_started_at", + "fetched_finished_at", + "source_updated_at", + "source_version", + "completeness", + "artifact" + ], + "properties": { + "contract_version": { + "const": "1.0" + }, + "source_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,79}$" + }, + "batch_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,79}$" + }, + "source_sequence": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "source_config_version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "scope_version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "mode": { + "const": "snapshot" + }, + "record_kind": { + "const": "municipal" + }, + "adapter": { + "type": "object", + "additionalProperties": false, + "required": ["key", "version"], + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + }, + "packages": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "version"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "version": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + }, + "maxItems": 16, + "minItems": 1 + }, + "fetched_started_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]Z$" + }, + "fetched_finished_at": { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]Z$" + }, + "source_updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]Z$" + }, + { + "type": "null" + } + ] + }, + "source_version": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "completeness": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "pages_fetched", + "pagination_exhausted", + "expected_records", + "records_seen", + "records_filtered_out", + "records_emitted", + "records_failed" + ], + "properties": { + "status": { + "const": "complete" + }, + "pages_fetched": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "pagination_exhausted": { + "const": true + }, + "expected_records": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + { + "type": "null" + } + ] + }, + "records_seen": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "records_filtered_out": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "records_emitted": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "records_failed": { + "const": 0 + } + } + }, + "artifact": { + "type": "object", + "additionalProperties": false, + "required": ["key", "version_id", "format", "bytes", "record_count", "sha256"], + "properties": { + "key": { + "type": "string", + "minLength": 1, + "maxLength": 300 + }, + "version_id": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "not": { + "const": "null" + } + }, + "format": { + "const": "jsonl" + }, + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 33554432 + }, + "record_count": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + } + } +} diff --git a/resources/schemas/import/v1/municipal-record.schema.json b/resources/schemas/import/v1/municipal-record.schema.json new file mode 100644 index 00000000..ad19664f --- /dev/null +++ b/resources/schemas/import/v1/municipal-record.schema.json @@ -0,0 +1,159 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://nipkaart.nl/schemas/import/1.0/municipal-record.json", + "type": "object", + "additionalProperties": false, + "required": [ + "external_id", + "source_updated_at", + "position", + "geometry_method", + "country_code", + "administrative_codes", + "name", + "street", + "accessible_capacity", + "access_category", + "orientation", + "restrictions", + "unmapped_fields", + "source_record_url" + ], + "properties": { + "external_id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "pattern": "^[^\\s]+$" + }, + "source_updated_at": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-5][0-9]Z$" + }, + { + "type": "null" + } + ] + }, + "position": { + "type": "object", + "additionalProperties": false, + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "number", + "minimum": -90, + "maximum": 90 + }, + "longitude": { + "type": "number", + "minimum": -180, + "maximum": 180 + } + } + }, + "geometry_method": { + "enum": ["source_point", "derived_point"] + }, + "country_code": { + "type": "string", + "pattern": "^[A-Z]{2}$" + }, + "administrative_codes": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["scheme", "code"], + "properties": { + "scheme": { + "type": "string", + "minLength": 1, + "maxLength": 40 + }, + "code": { + "type": "string", + "minLength": 1, + "maxLength": 80 + } + } + }, + "maxItems": 8 + }, + "name": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "street": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + { + "type": "null" + } + ] + }, + "accessible_capacity": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + { + "type": "null" + } + ] + }, + "access_category": { + "enum": ["designated_accessible", "personal", "unknown"] + }, + "orientation": { + "enum": [null, "parallel", "perpendicular", "angled", "unknown"] + }, + "restrictions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 500 + }, + "maxItems": 32 + }, + "unmapped_fields": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "maxItems": 32 + }, + "source_record_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "pattern": "^https://[^/@]+/" + }, + { + "type": "null" + } + ] + } + } +} diff --git a/tests/Fixtures/import/v1/artifact-declared-limit/context.json b/tests/Fixtures/import/v1/artifact-declared-limit/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/artifact-declared-limit/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/artifact-declared-limit/manifest.json b/tests/Fixtures/import/v1/artifact-declared-limit/manifest.json new file mode 100644 index 00000000..2aa743f7 --- /dev/null +++ b/tests/Fixtures/import/v1/artifact-declared-limit/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":33554433,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/artifact-declared-limit/records.jsonl b/tests/Fixtures/import/v1/artifact-declared-limit/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/artifact-declared-limit/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/backwards-time/context.json b/tests/Fixtures/import/v1/backwards-time/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/backwards-time/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/backwards-time/manifest.json b/tests/Fixtures/import/v1/backwards-time/manifest.json new file mode 100644 index 00000000..597aa1c2 --- /dev/null +++ b/tests/Fixtures/import/v1/backwards-time/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T07:00:00Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/backwards-time/records.jsonl b/tests/Fixtures/import/v1/backwards-time/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/backwards-time/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/blank-line/context.json b/tests/Fixtures/import/v1/blank-line/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/blank-line/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/blank-line/manifest.json b/tests/Fixtures/import/v1/blank-line/manifest.json new file mode 100644 index 00000000..dc8e94f5 --- /dev/null +++ b/tests/Fixtures/import/v1/blank-line/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":1,"record_count":1,"sha256":"01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"}} diff --git a/tests/Fixtures/import/v1/blank-line/records.jsonl b/tests/Fixtures/import/v1/blank-line/records.jsonl new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/Fixtures/import/v1/blank-line/records.jsonl @@ -0,0 +1 @@ + diff --git a/tests/Fixtures/import/v1/byte-count-mismatch/context.json b/tests/Fixtures/import/v1/byte-count-mismatch/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/byte-count-mismatch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/byte-count-mismatch/manifest.json b/tests/Fixtures/import/v1/byte-count-mismatch/manifest.json new file mode 100644 index 00000000..dba7a6a8 --- /dev/null +++ b/tests/Fixtures/import/v1/byte-count-mismatch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":1,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/byte-count-mismatch/records.jsonl b/tests/Fixtures/import/v1/byte-count-mismatch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/byte-count-mismatch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/cases.json b/tests/Fixtures/import/v1/cases.json new file mode 100644 index 00000000..12206c6f --- /dev/null +++ b/tests/Fixtures/import/v1/cases.json @@ -0,0 +1,214 @@ +[ + { + "name": "unknown-capacity", + "expected": "valid" + }, + { + "name": "zero-capacity", + "expected": "valid" + }, + { + "name": "leading-zero-identities", + "expected": "valid" + }, + { + "name": "complete-empty", + "expected": "review" + }, + { + "name": "record-external_id-123", + "expected": "invalid" + }, + { + "name": "record-external_id-", + "expected": "invalid" + }, + { + "name": "record-accessible_capacity--1", + "expected": "invalid" + }, + { + "name": "record-accessible_capacity-1.5", + "expected": "invalid" + }, + { + "name": "record-accessible_capacity-True", + "expected": "invalid" + }, + { + "name": "record-country_code-DE", + "expected": "invalid" + }, + { + "name": "record-source_updated_at-2026-02-30T080000Z", + "expected": "invalid" + }, + { + "name": "record-source_updated_at-2026-09-08T080060Z", + "expected": "invalid" + }, + { + "name": "missing-capacity", + "expected": "invalid" + }, + { + "name": "unknown-record-field", + "expected": "invalid" + }, + { + "name": "invalid-latitude", + "expected": "invalid" + }, + { + "name": "invalid-longitude", + "expected": "invalid" + }, + { + "name": "outside-source-bounds", + "expected": "invalid" + }, + { + "name": "duplicate-records", + "expected": "invalid" + }, + { + "name": "restriction-needs-review", + "expected": "review" + }, + { + "name": "unmapped-needs-review", + "expected": "review" + }, + { + "name": "personal-needs-review", + "expected": "review" + }, + { + "name": "new-scope", + "expected": "review" + }, + { + "name": "new-config", + "expected": "review" + }, + { + "name": "incomplete", + "expected": "invalid" + }, + { + "name": "pagination-not-exhausted", + "expected": "invalid" + }, + { + "name": "parse-failures", + "expected": "invalid" + }, + { + "name": "expected-count-mismatch", + "expected": "invalid" + }, + { + "name": "filtered-count-mismatch", + "expected": "invalid" + }, + { + "name": "record-count-mismatch", + "expected": "invalid" + }, + { + "name": "checksum-mismatch", + "expected": "invalid" + }, + { + "name": "byte-count-mismatch", + "expected": "invalid" + }, + { + "name": "cross-source-path", + "expected": "invalid" + }, + { + "name": "unadmitted-source", + "expected": "invalid" + }, + { + "name": "missing-version", + "expected": "invalid" + }, + { + "name": "null-object-version", + "expected": "invalid" + }, + { + "name": "unknown-contract", + "expected": "invalid" + }, + { + "name": "invalid-manifest-date", + "expected": "invalid" + }, + { + "name": "backwards-time", + "expected": "invalid" + }, + { + "name": "fetch-deadline", + "expected": "invalid" + }, + { + "name": "sequence-gap", + "expected": "valid" + }, + { + "name": "late-batch", + "expected": "superseded" + }, + { + "name": "duplicate-sequence", + "expected": "conflict" + }, + { + "name": "duplicate-batch", + "expected": "duplicate" + }, + { + "name": "conflicting-batch", + "expected": "conflict" + }, + { + "name": "truncated-line", + "expected": "invalid" + }, + { + "name": "invalid-json", + "expected": "invalid" + }, + { + "name": "blank-line", + "expected": "invalid" + }, + { + "name": "line-limit", + "expected": "invalid" + }, + { + "name": "manifest-limit", + "expected": "invalid" + }, + { + "name": "artifact-declared-limit", + "expected": "invalid" + }, + { + "name": "record-declared-limit", + "expected": "invalid" + }, + { + "name": "non-json-number", + "expected": "invalid" + }, + { + "name": "pilot-normalized", + "expected": "review" + } +] diff --git a/tests/Fixtures/import/v1/checksum-mismatch/context.json b/tests/Fixtures/import/v1/checksum-mismatch/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/checksum-mismatch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/checksum-mismatch/manifest.json b/tests/Fixtures/import/v1/checksum-mismatch/manifest.json new file mode 100644 index 00000000..560e6ff9 --- /dev/null +++ b/tests/Fixtures/import/v1/checksum-mismatch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"0000000000000000000000000000000000000000000000000000000000000000"}} diff --git a/tests/Fixtures/import/v1/checksum-mismatch/records.jsonl b/tests/Fixtures/import/v1/checksum-mismatch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/checksum-mismatch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/complete-empty/context.json b/tests/Fixtures/import/v1/complete-empty/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/complete-empty/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/complete-empty/manifest.json b/tests/Fixtures/import/v1/complete-empty/manifest.json new file mode 100644 index 00000000..a8a2a3b7 --- /dev/null +++ b/tests/Fixtures/import/v1/complete-empty/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":0,"records_seen":0,"records_filtered_out":0,"records_emitted":0,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":0,"record_count":0,"sha256":"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}} diff --git a/tests/Fixtures/import/v1/complete-empty/records.jsonl b/tests/Fixtures/import/v1/complete-empty/records.jsonl new file mode 100644 index 00000000..e69de29b diff --git a/tests/Fixtures/import/v1/conflicting-batch/context.json b/tests/Fixtures/import/v1/conflicting-batch/context.json new file mode 100644 index 00000000..40485cca --- /dev/null +++ b/tests/Fixtures/import/v1/conflicting-batch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{"batch-001":"0000000000000000000000000000000000000000000000000000000000000000"},"sequences":{}} diff --git a/tests/Fixtures/import/v1/conflicting-batch/manifest.json b/tests/Fixtures/import/v1/conflicting-batch/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/conflicting-batch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/conflicting-batch/records.jsonl b/tests/Fixtures/import/v1/conflicting-batch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/conflicting-batch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/cross-source-path/context.json b/tests/Fixtures/import/v1/cross-source-path/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/cross-source-path/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/cross-source-path/manifest.json b/tests/Fixtures/import/v1/cross-source-path/manifest.json new file mode 100644 index 00000000..8191e787 --- /dev/null +++ b/tests/Fixtures/import/v1/cross-source-path/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/other/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/cross-source-path/records.jsonl b/tests/Fixtures/import/v1/cross-source-path/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/cross-source-path/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/duplicate-batch/context.json b/tests/Fixtures/import/v1/duplicate-batch/context.json new file mode 100644 index 00000000..398cf9a3 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-batch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{"batch-001":"d3ec609e18c7b910e59f85b8d1ba13a0e5e0ecc452e1548ba3ace6f3bc54890d"},"sequences":{}} diff --git a/tests/Fixtures/import/v1/duplicate-batch/manifest.json b/tests/Fixtures/import/v1/duplicate-batch/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-batch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/duplicate-batch/records.jsonl b/tests/Fixtures/import/v1/duplicate-batch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-batch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/duplicate-records/context.json b/tests/Fixtures/import/v1/duplicate-records/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-records/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/duplicate-records/manifest.json b/tests/Fixtures/import/v1/duplicate-records/manifest.json new file mode 100644 index 00000000..1313e7ca --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-records/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":2,"records_seen":2,"records_filtered_out":0,"records_emitted":2,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":824,"record_count":2,"sha256":"87e095a3ac5a65ed1b67c97f5314a64ca9a8224ac36120da964e6e51eff981ab"}} diff --git a/tests/Fixtures/import/v1/duplicate-records/records.jsonl b/tests/Fixtures/import/v1/duplicate-records/records.jsonl new file mode 100644 index 00000000..da634b15 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-records/records.jsonl @@ -0,0 +1,2 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/duplicate-sequence/context.json b/tests/Fixtures/import/v1/duplicate-sequence/context.json new file mode 100644 index 00000000..c8623b83 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-sequence/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{"42":"another-batch"}} diff --git a/tests/Fixtures/import/v1/duplicate-sequence/manifest.json b/tests/Fixtures/import/v1/duplicate-sequence/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-sequence/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/duplicate-sequence/records.jsonl b/tests/Fixtures/import/v1/duplicate-sequence/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/duplicate-sequence/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/expected-count-mismatch/context.json b/tests/Fixtures/import/v1/expected-count-mismatch/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/expected-count-mismatch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/expected-count-mismatch/manifest.json b/tests/Fixtures/import/v1/expected-count-mismatch/manifest.json new file mode 100644 index 00000000..d0eafc0f --- /dev/null +++ b/tests/Fixtures/import/v1/expected-count-mismatch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":2,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/expected-count-mismatch/records.jsonl b/tests/Fixtures/import/v1/expected-count-mismatch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/expected-count-mismatch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/fetch-deadline/context.json b/tests/Fixtures/import/v1/fetch-deadline/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/fetch-deadline/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/fetch-deadline/manifest.json b/tests/Fixtures/import/v1/fetch-deadline/manifest.json new file mode 100644 index 00000000..1f7766a7 --- /dev/null +++ b/tests/Fixtures/import/v1/fetch-deadline/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:30:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/fetch-deadline/records.jsonl b/tests/Fixtures/import/v1/fetch-deadline/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/fetch-deadline/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/filtered-count-mismatch/context.json b/tests/Fixtures/import/v1/filtered-count-mismatch/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/filtered-count-mismatch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/filtered-count-mismatch/manifest.json b/tests/Fixtures/import/v1/filtered-count-mismatch/manifest.json new file mode 100644 index 00000000..68c2ad90 --- /dev/null +++ b/tests/Fixtures/import/v1/filtered-count-mismatch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":1,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/filtered-count-mismatch/records.jsonl b/tests/Fixtures/import/v1/filtered-count-mismatch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/filtered-count-mismatch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/incomplete/context.json b/tests/Fixtures/import/v1/incomplete/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/incomplete/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/incomplete/manifest.json b/tests/Fixtures/import/v1/incomplete/manifest.json new file mode 100644 index 00000000..2bf2c832 --- /dev/null +++ b/tests/Fixtures/import/v1/incomplete/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"incomplete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/incomplete/records.jsonl b/tests/Fixtures/import/v1/incomplete/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/incomplete/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/invalid-json/context.json b/tests/Fixtures/import/v1/invalid-json/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-json/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/invalid-json/manifest.json b/tests/Fixtures/import/v1/invalid-json/manifest.json new file mode 100644 index 00000000..cb772a68 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-json/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":2,"record_count":1,"sha256":"a6fb08fda1acb957b6116bd37811a1fe41a01611c0631edbf786d6889a27a55c"}} diff --git a/tests/Fixtures/import/v1/invalid-json/records.jsonl b/tests/Fixtures/import/v1/invalid-json/records.jsonl new file mode 100644 index 00000000..98232c64 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-json/records.jsonl @@ -0,0 +1 @@ +{ diff --git a/tests/Fixtures/import/v1/invalid-latitude/context.json b/tests/Fixtures/import/v1/invalid-latitude/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-latitude/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/invalid-latitude/manifest.json b/tests/Fixtures/import/v1/invalid-latitude/manifest.json new file mode 100644 index 00000000..4a56b7a2 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-latitude/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":408,"record_count":1,"sha256":"adce3ee9235987ad12f53c5cbb84f156d42ea5aaf2ae5607906bb6671e5512a8"}} diff --git a/tests/Fixtures/import/v1/invalid-latitude/records.jsonl b/tests/Fixtures/import/v1/invalid-latitude/records.jsonl new file mode 100644 index 00000000..e5f8a22f --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-latitude/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":91,"longitude":5.4},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/invalid-longitude/context.json b/tests/Fixtures/import/v1/invalid-longitude/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-longitude/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/invalid-longitude/manifest.json b/tests/Fixtures/import/v1/invalid-longitude/manifest.json new file mode 100644 index 00000000..2982d02f --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-longitude/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":410,"record_count":1,"sha256":"ea91450bed34eb788dc28443c284ddc64c6b335fceec9e9001e19850a07dea86"}} diff --git a/tests/Fixtures/import/v1/invalid-longitude/records.jsonl b/tests/Fixtures/import/v1/invalid-longitude/records.jsonl new file mode 100644 index 00000000..9924ec17 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-longitude/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.4,"longitude":181},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/invalid-manifest-date/context.json b/tests/Fixtures/import/v1/invalid-manifest-date/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-manifest-date/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/invalid-manifest-date/manifest.json b/tests/Fixtures/import/v1/invalid-manifest-date/manifest.json new file mode 100644 index 00000000..b1815950 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-manifest-date/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-02-30T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/invalid-manifest-date/records.jsonl b/tests/Fixtures/import/v1/invalid-manifest-date/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/invalid-manifest-date/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/late-batch/context.json b/tests/Fixtures/import/v1/late-batch/context.json new file mode 100644 index 00000000..bc36d8b8 --- /dev/null +++ b/tests/Fixtures/import/v1/late-batch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":43,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/late-batch/manifest.json b/tests/Fixtures/import/v1/late-batch/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/late-batch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/late-batch/records.jsonl b/tests/Fixtures/import/v1/late-batch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/late-batch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/leading-zero-identities/context.json b/tests/Fixtures/import/v1/leading-zero-identities/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/leading-zero-identities/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/leading-zero-identities/manifest.json b/tests/Fixtures/import/v1/leading-zero-identities/manifest.json new file mode 100644 index 00000000..2867eed4 --- /dev/null +++ b/tests/Fixtures/import/v1/leading-zero-identities/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":2,"records_seen":2,"records_filtered_out":0,"records_emitted":2,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":822,"record_count":2,"sha256":"f8c06e9759b72df2143d4fa298f2f86c8f3cb12869e93f0f638f3b57eaa8a661"}} diff --git a/tests/Fixtures/import/v1/leading-zero-identities/records.jsonl b/tests/Fixtures/import/v1/leading-zero-identities/records.jsonl new file mode 100644 index 00000000..e35c6fec --- /dev/null +++ b/tests/Fixtures/import/v1/leading-zero-identities/records.jsonl @@ -0,0 +1,2 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} +{"external_id":"123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/line-limit/context.json b/tests/Fixtures/import/v1/line-limit/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/line-limit/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/line-limit/manifest.json b/tests/Fixtures/import/v1/line-limit/manifest.json new file mode 100644 index 00000000..31f34594 --- /dev/null +++ b/tests/Fixtures/import/v1/line-limit/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":16796,"record_count":1,"sha256":"f437466004271b95236f43f93926994780c3a67dbf9ef55ef26e1c30ec14d156"}} diff --git a/tests/Fixtures/import/v1/line-limit/records.jsonl b/tests/Fixtures/import/v1/line-limit/records.jsonl new file mode 100644 index 00000000..47c428a3 --- /dev/null +++ b/tests/Fixtures/import/v1/line-limit/records.jsonl @@ -0,0 +1 @@ + {"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/manifest-limit/context.json b/tests/Fixtures/import/v1/manifest-limit/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/manifest-limit/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/manifest-limit/manifest.json b/tests/Fixtures/import/v1/manifest-limit/manifest.json new file mode 100644 index 00000000..1022c58c --- /dev/null +++ b/tests/Fixtures/import/v1/manifest-limit/manifest.json @@ -0,0 +1 @@ + {"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/manifest-limit/records.jsonl b/tests/Fixtures/import/v1/manifest-limit/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/manifest-limit/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/missing-capacity/context.json b/tests/Fixtures/import/v1/missing-capacity/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/missing-capacity/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/missing-capacity/manifest.json b/tests/Fixtures/import/v1/missing-capacity/manifest.json new file mode 100644 index 00000000..b2a4bb97 --- /dev/null +++ b/tests/Fixtures/import/v1/missing-capacity/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":385,"record_count":1,"sha256":"2a884365e9e382a496d0379ed8ac71f9b4bc71c61a1d400cc49eead2254b6772"}} diff --git a/tests/Fixtures/import/v1/missing-capacity/records.jsonl b/tests/Fixtures/import/v1/missing-capacity/records.jsonl new file mode 100644 index 00000000..f1df94f3 --- /dev/null +++ b/tests/Fixtures/import/v1/missing-capacity/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/missing-version/context.json b/tests/Fixtures/import/v1/missing-version/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/missing-version/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/missing-version/manifest.json b/tests/Fixtures/import/v1/missing-version/manifest.json new file mode 100644 index 00000000..09872cf0 --- /dev/null +++ b/tests/Fixtures/import/v1/missing-version/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/missing-version/records.jsonl b/tests/Fixtures/import/v1/missing-version/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/missing-version/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/new-config/context.json b/tests/Fixtures/import/v1/new-config/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/new-config/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/new-config/manifest.json b/tests/Fixtures/import/v1/new-config/manifest.json new file mode 100644 index 00000000..2312371b --- /dev/null +++ b/tests/Fixtures/import/v1/new-config/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":2,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/new-config/records.jsonl b/tests/Fixtures/import/v1/new-config/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/new-config/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/new-scope/context.json b/tests/Fixtures/import/v1/new-scope/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/new-scope/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/new-scope/manifest.json b/tests/Fixtures/import/v1/new-scope/manifest.json new file mode 100644 index 00000000..14db0358 --- /dev/null +++ b/tests/Fixtures/import/v1/new-scope/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":2,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/new-scope/records.jsonl b/tests/Fixtures/import/v1/new-scope/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/new-scope/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/non-json-number/context.json b/tests/Fixtures/import/v1/non-json-number/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/non-json-number/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/non-json-number/manifest.json b/tests/Fixtures/import/v1/non-json-number/manifest.json new file mode 100644 index 00000000..26d8ebfc --- /dev/null +++ b/tests/Fixtures/import/v1/non-json-number/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":410,"record_count":1,"sha256":"1aa0d87d8d78549a72f2ec93383766c6fbaa9593290e21e8264163d3986c15fe"}} diff --git a/tests/Fixtures/import/v1/non-json-number/records.jsonl b/tests/Fixtures/import/v1/non-json-number/records.jsonl new file mode 100644 index 00000000..2f0608f1 --- /dev/null +++ b/tests/Fixtures/import/v1/non-json-number/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":NaN,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/null-object-version/context.json b/tests/Fixtures/import/v1/null-object-version/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/null-object-version/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/null-object-version/manifest.json b/tests/Fixtures/import/v1/null-object-version/manifest.json new file mode 100644 index 00000000..80dc75af --- /dev/null +++ b/tests/Fixtures/import/v1/null-object-version/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"null","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/null-object-version/records.jsonl b/tests/Fixtures/import/v1/null-object-version/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/null-object-version/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/outside-source-bounds/context.json b/tests/Fixtures/import/v1/outside-source-bounds/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/outside-source-bounds/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/outside-source-bounds/manifest.json b/tests/Fixtures/import/v1/outside-source-bounds/manifest.json new file mode 100644 index 00000000..f6bf5b81 --- /dev/null +++ b/tests/Fixtures/import/v1/outside-source-bounds/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":410,"record_count":1,"sha256":"43cd803df20ca8d999a0eb7eee7fdada1c7f9839e32d316b6654d23cb25a99db"}} diff --git a/tests/Fixtures/import/v1/outside-source-bounds/records.jsonl b/tests/Fixtures/import/v1/outside-source-bounds/records.jsonl new file mode 100644 index 00000000..47d7a701 --- /dev/null +++ b/tests/Fixtures/import/v1/outside-source-bounds/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":52.4,"longitude":5.4},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/pagination-not-exhausted/context.json b/tests/Fixtures/import/v1/pagination-not-exhausted/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/pagination-not-exhausted/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/pagination-not-exhausted/manifest.json b/tests/Fixtures/import/v1/pagination-not-exhausted/manifest.json new file mode 100644 index 00000000..881e559a --- /dev/null +++ b/tests/Fixtures/import/v1/pagination-not-exhausted/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":false,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/pagination-not-exhausted/records.jsonl b/tests/Fixtures/import/v1/pagination-not-exhausted/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/pagination-not-exhausted/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/parse-failures/context.json b/tests/Fixtures/import/v1/parse-failures/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/parse-failures/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/parse-failures/manifest.json b/tests/Fixtures/import/v1/parse-failures/manifest.json new file mode 100644 index 00000000..3abb031c --- /dev/null +++ b/tests/Fixtures/import/v1/parse-failures/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":1},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/parse-failures/records.jsonl b/tests/Fixtures/import/v1/parse-failures/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/parse-failures/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/personal-needs-review/context.json b/tests/Fixtures/import/v1/personal-needs-review/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/personal-needs-review/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/personal-needs-review/manifest.json b/tests/Fixtures/import/v1/personal-needs-review/manifest.json new file mode 100644 index 00000000..4c6ee210 --- /dev/null +++ b/tests/Fixtures/import/v1/personal-needs-review/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":399,"record_count":1,"sha256":"cee1e615793458036571de95f44c0c9e48c3d07d9659ce50e166f85eea3de6c7"}} diff --git a/tests/Fixtures/import/v1/personal-needs-review/records.jsonl b/tests/Fixtures/import/v1/personal-needs-review/records.jsonl new file mode 100644 index 00000000..7ad11d23 --- /dev/null +++ b/tests/Fixtures/import/v1/personal-needs-review/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"personal","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/pilot-normalized/context.json b/tests/Fixtures/import/v1/pilot-normalized/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/pilot-normalized/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/pilot-normalized/manifest.json b/tests/Fixtures/import/v1/pilot-normalized/manifest.json new file mode 100644 index 00000000..290c3159 --- /dev/null +++ b/tests/Fixtures/import/v1/pilot-normalized/manifest.json @@ -0,0 +1 @@ +{"contract_version": "1.0", "source_id": "nl-eindhoven-accessible", "batch_id": "batch-001", "source_sequence": 42, "source_config_version": 1, "scope_version": 1, "mode": "snapshot", "record_kind": "municipal", "adapter": {"key": "eindhoven.accessible", "version": "0.1.0"}, "packages": [{"name": "eindhoven", "version": "5.1.0"}], "fetched_started_at": "2026-09-08T08:00:00Z", "fetched_finished_at": "2026-09-08T08:00:01Z", "source_updated_at": null, "source_version": null, "completeness": {"status": "complete", "pages_fetched": 1, "pagination_exhausted": true, "expected_records": 1, "records_seen": 1, "records_filtered_out": 0, "records_emitted": 1, "records_failed": 0}, "artifact": {"key": "sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl", "version_id": "fixture-version-001", "format": "jsonl", "bytes": 447, "record_count": 1, "sha256": "55a01599139e95eccd213bf6a1f4e7a5c2df9b86967fc95a9e392d1953761ef9"}} diff --git a/tests/Fixtures/import/v1/pilot-normalized/records.jsonl b/tests/Fixtures/import/v1/pilot-normalized/records.jsonl new file mode 100644 index 00000000..c3225eb1 --- /dev/null +++ b/tests/Fixtures/import/v1/pilot-normalized/records.jsonl @@ -0,0 +1 @@ +{"external_id":"12338","source_updated_at":null,"position":{"latitude":51.469735439493796,"longitude":5.478167744280201},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Winkelcentrum Woensel","accessible_capacity":1,"access_category":"unknown","orientation":null,"restrictions":[],"unmapped_fields":["reservation_status"],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/pilot-source.json b/tests/Fixtures/import/v1/pilot-source.json new file mode 100644 index 00000000..262b7991 --- /dev/null +++ b/tests/Fixtures/import/v1/pilot-source.json @@ -0,0 +1,26 @@ +{ + "fields": { + "straat": "Winkelcentrum Woensel", + "type_en_merk": "Parkeerplaats Gehandicapten", + "geo_point_2d": [ + 51.469735439493796, + 5.478167744280201 + ], + "geo_shape": { + "coordinates": [ + 5.478167744280201, + 51.469735439493796 + ], + "type": "Point" + }, + "aantal": 1.0, + "objectid": 12338 + }, + "geometry": { + "type": "Point", + "coordinates": [ + 5.478167744280201, + 51.469735439493796 + ] + } +} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity--1/context.json b/tests/Fixtures/import/v1/record-accessible_capacity--1/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity--1/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity--1/manifest.json b/tests/Fixtures/import/v1/record-accessible_capacity--1/manifest.json new file mode 100644 index 00000000..840c41cd --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity--1/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":410,"record_count":1,"sha256":"4ed9e97812a12c150d2fa48badf680a952c51a5bd630d24f7a19653c4e392ffe"}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity--1/records.jsonl b/tests/Fixtures/import/v1/record-accessible_capacity--1/records.jsonl new file mode 100644 index 00000000..df06564c --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity--1/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":-1,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-1.5/context.json b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-1.5/manifest.json b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/manifest.json new file mode 100644 index 00000000..770a9979 --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":411,"record_count":1,"sha256":"0c38e58a08f3d9663ee5720d65bb29d46ebee3c5e7134a7abbb3832186f6172c"}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-1.5/records.jsonl b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/records.jsonl new file mode 100644 index 00000000..31307251 --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-1.5/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":1.5,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-True/context.json b/tests/Fixtures/import/v1/record-accessible_capacity-True/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-True/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-True/manifest.json b/tests/Fixtures/import/v1/record-accessible_capacity-True/manifest.json new file mode 100644 index 00000000..74b858c5 --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-True/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"8f3116522cf7b24e7520b6a8f3b8075c38636cc7b5475973b6b9aa6575b7a337"}} diff --git a/tests/Fixtures/import/v1/record-accessible_capacity-True/records.jsonl b/tests/Fixtures/import/v1/record-accessible_capacity-True/records.jsonl new file mode 100644 index 00000000..378ddc4b --- /dev/null +++ b/tests/Fixtures/import/v1/record-accessible_capacity-True/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":true,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-count-mismatch/context.json b/tests/Fixtures/import/v1/record-count-mismatch/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-count-mismatch/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-count-mismatch/manifest.json b/tests/Fixtures/import/v1/record-count-mismatch/manifest.json new file mode 100644 index 00000000..b7b7597b --- /dev/null +++ b/tests/Fixtures/import/v1/record-count-mismatch/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":2,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/record-count-mismatch/records.jsonl b/tests/Fixtures/import/v1/record-count-mismatch/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/record-count-mismatch/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-country_code-DE/context.json b/tests/Fixtures/import/v1/record-country_code-DE/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-country_code-DE/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-country_code-DE/manifest.json b/tests/Fixtures/import/v1/record-country_code-DE/manifest.json new file mode 100644 index 00000000..a576e458 --- /dev/null +++ b/tests/Fixtures/import/v1/record-country_code-DE/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"ca546b717b0562c68483aaa49220be29f9d18f3fd0e11af55c6f5985b4c6945b"}} diff --git a/tests/Fixtures/import/v1/record-country_code-DE/records.jsonl b/tests/Fixtures/import/v1/record-country_code-DE/records.jsonl new file mode 100644 index 00000000..1c4d7180 --- /dev/null +++ b/tests/Fixtures/import/v1/record-country_code-DE/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"DE","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-declared-limit/context.json b/tests/Fixtures/import/v1/record-declared-limit/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-declared-limit/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-declared-limit/manifest.json b/tests/Fixtures/import/v1/record-declared-limit/manifest.json new file mode 100644 index 00000000..7d81d05c --- /dev/null +++ b/tests/Fixtures/import/v1/record-declared-limit/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":10001,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/record-declared-limit/records.jsonl b/tests/Fixtures/import/v1/record-declared-limit/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/record-declared-limit/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-external_id-/context.json b/tests/Fixtures/import/v1/record-external_id-/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-external_id-/manifest.json b/tests/Fixtures/import/v1/record-external_id-/manifest.json new file mode 100644 index 00000000..34c1f7b3 --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":407,"record_count":1,"sha256":"7a33851c5b6963dcb6e080399c834fa9ca230352cb694b972fe28674e095495c"}} diff --git a/tests/Fixtures/import/v1/record-external_id-/records.jsonl b/tests/Fixtures/import/v1/record-external_id-/records.jsonl new file mode 100644 index 00000000..8d3fd57e --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-/records.jsonl @@ -0,0 +1 @@ +{"external_id":"","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-external_id-123/context.json b/tests/Fixtures/import/v1/record-external_id-123/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-123/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-external_id-123/manifest.json b/tests/Fixtures/import/v1/record-external_id-123/manifest.json new file mode 100644 index 00000000..cbddda52 --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-123/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":408,"record_count":1,"sha256":"4c07239f0b677563f62bb2a2d3ebc2c3de1496be9c919aed5ff436d2357c18de"}} diff --git a/tests/Fixtures/import/v1/record-external_id-123/records.jsonl b/tests/Fixtures/import/v1/record-external_id-123/records.jsonl new file mode 100644 index 00000000..1dc7e3c5 --- /dev/null +++ b/tests/Fixtures/import/v1/record-external_id-123/records.jsonl @@ -0,0 +1 @@ +{"external_id":123,"source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/context.json b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/manifest.json b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/manifest.json new file mode 100644 index 00000000..5a9e80a9 --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":430,"record_count":1,"sha256":"b23ee0f88dd31e6395fa90c276077cc28fdc1fea78c8e5a87d83de0d504632d9"}} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/records.jsonl b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/records.jsonl new file mode 100644 index 00000000..c0ba7c33 --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":"2026-02-30T08:00:00Z","position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/context.json b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/manifest.json b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/manifest.json new file mode 100644 index 00000000..0119b4b9 --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":430,"record_count":1,"sha256":"d10ba8a6574fe2272ebd8b4bb466e11e365404146a9710eef23f32123eedbbad"}} diff --git a/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/records.jsonl b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/records.jsonl new file mode 100644 index 00000000..4ca147e9 --- /dev/null +++ b/tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":"2026-09-08T08:00:60Z","position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/restriction-needs-review/context.json b/tests/Fixtures/import/v1/restriction-needs-review/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/restriction-needs-review/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/restriction-needs-review/manifest.json b/tests/Fixtures/import/v1/restriction-needs-review/manifest.json new file mode 100644 index 00000000..b978ea6f --- /dev/null +++ b/tests/Fixtures/import/v1/restriction-needs-review/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":434,"record_count":1,"sha256":"eac43be15b1450c8eed039b5580129fbe389dbb3a2db42ed6483ae2378c4be88"}} diff --git a/tests/Fixtures/import/v1/restriction-needs-review/records.jsonl b/tests/Fixtures/import/v1/restriction-needs-review/records.jsonl new file mode 100644 index 00000000..20c9f9c2 --- /dev/null +++ b/tests/Fixtures/import/v1/restriction-needs-review/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":["Unmapped time window"],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/sequence-gap/context.json b/tests/Fixtures/import/v1/sequence-gap/context.json new file mode 100644 index 00000000..bf369847 --- /dev/null +++ b/tests/Fixtures/import/v1/sequence-gap/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":40,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/sequence-gap/manifest.json b/tests/Fixtures/import/v1/sequence-gap/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/sequence-gap/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/sequence-gap/records.jsonl b/tests/Fixtures/import/v1/sequence-gap/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/sequence-gap/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/truncated-line/context.json b/tests/Fixtures/import/v1/truncated-line/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/truncated-line/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/truncated-line/manifest.json b/tests/Fixtures/import/v1/truncated-line/manifest.json new file mode 100644 index 00000000..74bd7abf --- /dev/null +++ b/tests/Fixtures/import/v1/truncated-line/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":411,"record_count":1,"sha256":"b88195e80fbbb274be5cccc4394430e38aaa921359e53a031b768307a9bcc8ab"}} diff --git a/tests/Fixtures/import/v1/truncated-line/records.jsonl b/tests/Fixtures/import/v1/truncated-line/records.jsonl new file mode 100644 index 00000000..1f7d4a2c --- /dev/null +++ b/tests/Fixtures/import/v1/truncated-line/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} \ No newline at end of file diff --git a/tests/Fixtures/import/v1/unadmitted-source/context.json b/tests/Fixtures/import/v1/unadmitted-source/context.json new file mode 100644 index 00000000..71af4df3 --- /dev/null +++ b/tests/Fixtures/import/v1/unadmitted-source/context.json @@ -0,0 +1 @@ +{"source_id":"other","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/unadmitted-source/manifest.json b/tests/Fixtures/import/v1/unadmitted-source/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/unadmitted-source/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/unadmitted-source/records.jsonl b/tests/Fixtures/import/v1/unadmitted-source/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/unadmitted-source/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/unknown-capacity/context.json b/tests/Fixtures/import/v1/unknown-capacity/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-capacity/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/unknown-capacity/manifest.json b/tests/Fixtures/import/v1/unknown-capacity/manifest.json new file mode 100644 index 00000000..fcd67265 --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-capacity/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/unknown-capacity/records.jsonl b/tests/Fixtures/import/v1/unknown-capacity/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-capacity/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/unknown-contract/context.json b/tests/Fixtures/import/v1/unknown-contract/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-contract/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/unknown-contract/manifest.json b/tests/Fixtures/import/v1/unknown-contract/manifest.json new file mode 100644 index 00000000..89013e55 --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-contract/manifest.json @@ -0,0 +1 @@ +{"contract_version":"2.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":412,"record_count":1,"sha256":"d2e5fcae894a2bafb34333264531312702f8c34a33360160130f29b40bb018c5"}} diff --git a/tests/Fixtures/import/v1/unknown-contract/records.jsonl b/tests/Fixtures/import/v1/unknown-contract/records.jsonl new file mode 100644 index 00000000..35819ed7 --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-contract/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/unknown-record-field/context.json b/tests/Fixtures/import/v1/unknown-record-field/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-record-field/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/unknown-record-field/manifest.json b/tests/Fixtures/import/v1/unknown-record-field/manifest.json new file mode 100644 index 00000000..48d9d27f --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-record-field/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":440,"record_count":1,"sha256":"3140f3cc667ee3b462fd584084c5272dd8d1954d765a57ede8984dfecb8fbd9d"}} diff --git a/tests/Fixtures/import/v1/unknown-record-field/records.jsonl b/tests/Fixtures/import/v1/unknown-record-field/records.jsonl new file mode 100644 index 00000000..7e444c14 --- /dev/null +++ b/tests/Fixtures/import/v1/unknown-record-field/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null,"license_plate":"synthetic"} diff --git a/tests/Fixtures/import/v1/unmapped-needs-review/context.json b/tests/Fixtures/import/v1/unmapped-needs-review/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/unmapped-needs-review/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/unmapped-needs-review/manifest.json b/tests/Fixtures/import/v1/unmapped-needs-review/manifest.json new file mode 100644 index 00000000..1ae81aaf --- /dev/null +++ b/tests/Fixtures/import/v1/unmapped-needs-review/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":418,"record_count":1,"sha256":"97e9487022c462d291f8afb7a8a17279f2b45463183bb475d547db2a1b094d93"}} diff --git a/tests/Fixtures/import/v1/unmapped-needs-review/records.jsonl b/tests/Fixtures/import/v1/unmapped-needs-review/records.jsonl new file mode 100644 index 00000000..57f2a2fb --- /dev/null +++ b/tests/Fixtures/import/v1/unmapped-needs-review/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":null,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":["sign"],"source_record_url":null} diff --git a/tests/Fixtures/import/v1/zero-capacity/context.json b/tests/Fixtures/import/v1/zero-capacity/context.json new file mode 100644 index 00000000..5f36d75e --- /dev/null +++ b/tests/Fixtures/import/v1/zero-capacity/context.json @@ -0,0 +1 @@ +{"source_id":"nl-eindhoven-accessible","scope_version":1,"source_config_version":1,"country_code":"NL","bounds":[5.3,51.3,5.6,51.6],"highest_sequence":0,"batches":{},"sequences":{}} diff --git a/tests/Fixtures/import/v1/zero-capacity/manifest.json b/tests/Fixtures/import/v1/zero-capacity/manifest.json new file mode 100644 index 00000000..732dd805 --- /dev/null +++ b/tests/Fixtures/import/v1/zero-capacity/manifest.json @@ -0,0 +1 @@ +{"contract_version":"1.0","source_id":"nl-eindhoven-accessible","batch_id":"batch-001","source_sequence":42,"source_config_version":1,"scope_version":1,"mode":"snapshot","record_kind":"municipal","adapter":{"key":"eindhoven.accessible","version":"0.1.0"},"packages":[{"name":"eindhoven","version":"5.1.0"}],"fetched_started_at":"2026-09-08T08:00:00Z","fetched_finished_at":"2026-09-08T08:00:01Z","source_updated_at":null,"source_version":null,"completeness":{"status":"complete","pages_fetched":1,"pagination_exhausted":true,"expected_records":1,"records_seen":1,"records_filtered_out":0,"records_emitted":1,"records_failed":0},"artifact":{"key":"sources/nl-eindhoven-accessible/batches/batch-001/records.jsonl","version_id":"fixture-version-001","format":"jsonl","bytes":409,"record_count":1,"sha256":"fcc4721185191e090ee19c3fff38cbf1f643ae1cdca6234ee9e01df62223c901"}} diff --git a/tests/Fixtures/import/v1/zero-capacity/records.jsonl b/tests/Fixtures/import/v1/zero-capacity/records.jsonl new file mode 100644 index 00000000..6a9ee014 --- /dev/null +++ b/tests/Fixtures/import/v1/zero-capacity/records.jsonl @@ -0,0 +1 @@ +{"external_id":"00123","source_updated_at":null,"position":{"latitude":51.45,"longitude":5.47},"geometry_method":"source_point","country_code":"NL","administrative_codes":[{"scheme":"nl:cbs:municipality","code":"0772"}],"name":null,"street":"Voorbeeldstraat","accessible_capacity":0,"access_category":"designated_accessible","orientation":null,"restrictions":[],"unmapped_fields":[],"source_record_url":null} diff --git a/tests/Support/import_contract/.gitignore b/tests/Support/import_contract/.gitignore new file mode 100644 index 00000000..670a9362 --- /dev/null +++ b/tests/Support/import_contract/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +.venv/ diff --git a/tests/Support/import_contract/contract.py b/tests/Support/import_contract/contract.py new file mode 100644 index 00000000..90458007 --- /dev/null +++ b/tests/Support/import_contract/contract.py @@ -0,0 +1,102 @@ +"""Independent offline Python interpretation of the v1 snapshot contract. + +This is a parity reference, not a scheduler, importer or source adapter. +""" +import hashlib +import json +import time +from datetime import datetime +from pathlib import Path + +from jsonschema import Draft202012Validator, FormatChecker +from referencing import Registry + +ROOT = Path(__file__).resolve().parents[3] +MAX_MANIFEST_BYTES = 16384 +MAX_ARTIFACT_BYTES = 33554432 +MAX_LINE_BYTES = 16384 +MAX_RECORDS = 10000 +MAX_VALIDATION_SECONDS = 30 +FORMATS = FormatChecker() + + +@FORMATS.checks("date-time", raises=ValueError) +def utc_timestamp(value): + if not isinstance(value, str): + return True + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + return True + + +def no_remote_reference(uri): + raise ValueError("Remote schema references are forbidden") + + +def validator(name): + schema = json.loads((ROOT / f"resources/schemas/import/v1/{name}.schema.json").read_text()) + Draft202012Validator.check_schema(schema) + return Draft202012Validator(schema, format_checker=FORMATS, registry=Registry(retrieve=no_remote_reference)) + + +MANIFEST = validator("manifest") +RECORD = validator("municipal-record") + + +def reject_constant(value): + raise ValueError(f"Non-JSON number: {value}") + + +def check(manifest_bytes, records_path, context, clock=time.monotonic): + started = clock() + if len(manifest_bytes) > MAX_MANIFEST_BYTES: + return "invalid" + try: + manifest = json.loads(manifest_bytes.decode("utf-8"), parse_constant=reject_constant) + if not MANIFEST.is_valid(manifest): + return "invalid" + completeness, artifact = manifest["completeness"], manifest["artifact"] + duration = (datetime.strptime(manifest["fetched_finished_at"], "%Y-%m-%dT%H:%M:%SZ") - datetime.strptime(manifest["fetched_started_at"], "%Y-%m-%dT%H:%M:%SZ")).total_seconds() + if (manifest["source_id"] != context["source_id"] + or artifact["key"] != f'sources/{manifest["source_id"]}/batches/{manifest["batch_id"]}/records.jsonl' + or not 0 <= duration <= 1800 + or completeness["records_seen"] != completeness["records_emitted"] + completeness["records_filtered_out"] + or completeness["records_emitted"] != artifact["record_count"] + or (completeness["expected_records"] is not None and completeness["expected_records"] != completeness["records_seen"])): + return "invalid" + digest, byte_count, ids, review = hashlib.sha256(), 0, set(), False + with Path(records_path).open("rb") as stream: + while line := stream.readline(MAX_LINE_BYTES + 1): + byte_count += len(line) + if (byte_count > MAX_ARTIFACT_BYTES or len(line) > MAX_LINE_BYTES + or not line.endswith(b"\n") or b"\r" in line + or clock() - started > MAX_VALIDATION_SECONDS): + return "invalid" + digest.update(line) + record = json.loads(line.decode("utf-8"), parse_constant=reject_constant) + if not RECORD.is_valid(record) or record["external_id"] in ids or len(ids) >= MAX_RECORDS: + return "invalid" + ids.add(record["external_id"]) + west, south, east, north = context["bounds"] + position = record["position"] + if (record["country_code"] != context["country_code"] + or not west <= position["longitude"] <= east + or not south <= position["latitude"] <= north): + return "invalid" + review |= record["access_category"] != "designated_accessible" or bool(record["unmapped_fields"] or record["restrictions"]) + if (byte_count != artifact["bytes"] or len(ids) != artifact["record_count"] + or digest.hexdigest() != artifact["sha256"] + or clock() - started > MAX_VALIDATION_SECONDS): + return "invalid" + known_hash = context["batches"].get(manifest["batch_id"]) + if known_hash is not None: + return "duplicate" if known_hash == hashlib.sha256(manifest_bytes).hexdigest() else "conflict" + if str(manifest["source_sequence"]) in context["sequences"]: + return "conflict" + if manifest["source_sequence"] <= context["highest_sequence"]: + return "superseded" + if (review or not ids or manifest["scope_version"] != context["scope_version"] + or manifest["source_config_version"] != context["source_config_version"]): + return "review" + return "valid" + except (ValueError, OSError, RecursionError): + return "invalid" diff --git a/tests/Support/import_contract/poetry.lock b/tests/Support/import_contract/poetry.lock new file mode 100644 index 00000000..23ae018c --- /dev/null +++ b/tests/Support/import_contract/poetry.lock @@ -0,0 +1,197 @@ +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. + +[[package]] +name = "attrs" +version = "26.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, + {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, + {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.3.6" +referencing = ">=0.28.4" +rpds-py = ">=0.25.0" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, + {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + +[[package]] +name = "referencing" +version = "0.37.0" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, + {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" + +[[package]] +name = "rpds-py" +version = "2026.6.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.11" +groups = ["main"] +files = [ + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, + {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, + {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, + {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, + {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, + {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, + {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, + {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, + {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, + {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, + {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, + {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, + {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, + {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, + {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, + {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, + {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, + {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, + {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, + {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, + {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, + {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, + {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, + {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, +] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14,<3.15" +content-hash = "75cc01be3aa01b1cadf7f3140c02e23c3a5ff0dcdca82d47813a3e4230b7ec2d" diff --git a/tests/Support/import_contract/pyproject.toml b/tests/Support/import_contract/pyproject.toml new file mode 100644 index 00000000..b23e1c10 --- /dev/null +++ b/tests/Support/import_contract/pyproject.toml @@ -0,0 +1,10 @@ +[tool.poetry] +name = "nipkaart-import-contract-proof" +version = "1.0.0" +description = "Offline cross-language contract proof, not a producer" +package-mode = false + +[tool.poetry.dependencies] +python = ">=3.14,<3.15" +jsonschema = "^4.26" + diff --git a/tests/Support/import_contract/test_contract.py b/tests/Support/import_contract/test_contract.py new file mode 100644 index 00000000..61540c99 --- /dev/null +++ b/tests/Support/import_contract/test_contract.py @@ -0,0 +1,25 @@ +"""Run the exact files also consumed by Pest, without contacting a source.""" +import json +import unittest + +from contract import ROOT, check + +FIXTURES = ROOT / "tests/Fixtures/import/v1" + + +class SnapshotContractTest(unittest.TestCase): + def test_shared_contract_decisions(self): + for case in json.loads((FIXTURES / "cases.json").read_text()): + with self.subTest(case=case["name"]): + directory = FIXTURES / case["name"] + context = json.loads((directory / "context.json").read_text()) + self.assertEqual(check((directory / "manifest.json").read_bytes(), directory / "records.jsonl", context), case["expected"]) + + def test_monotonic_deadline(self): + directory = FIXTURES / "unknown-capacity" + ticks = iter([0, 31]) + self.assertEqual(check((directory / "manifest.json").read_bytes(), directory / "records.jsonl", json.loads((directory / "context.json").read_text()), clock=lambda: next(ticks)), "invalid") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/Unit/Support/SnapshotContractTest.php b/tests/Unit/Support/SnapshotContractTest.php new file mode 100644 index 00000000..b5c01a37 --- /dev/null +++ b/tests/Unit/Support/SnapshotContractTest.php @@ -0,0 +1,27 @@ +check(file_get_contents($directory.'manifest.json'), $directory.'records.jsonl', $context); + + expect($result)->toBe($expected); +})->with(array_combine(array_column($cases, 'name'), array_map(fn (array $case): array => [$case['name'], $case['expected']], $cases))); + +test('validation stops after its monotonic deadline', function () use ($fixtureDirectory) { + $directory = $fixtureDirectory.'unknown-capacity/'; + $ticks = [0.0, 31.0]; + $contract = new SnapshotContract(static function () use (&$ticks): float { + return array_shift($ticks); + }); + + $result = $contract->check(file_get_contents($directory.'manifest.json'), $directory.'records.jsonl', json_decode(file_get_contents($directory.'context.json'), true)); + + expect($result)->toBe('invalid'); +}); From 4e128cd784dd2ba3a6f4afbf9e3a61aaa7e2d58c Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 8 Sep 2026 02:55:38 +0200 Subject: [PATCH 02/10] style: remove trailing blank line from contract project --- tests/Support/import_contract/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/Support/import_contract/pyproject.toml b/tests/Support/import_contract/pyproject.toml index b23e1c10..989e3d33 100644 --- a/tests/Support/import_contract/pyproject.toml +++ b/tests/Support/import_contract/pyproject.toml @@ -7,4 +7,3 @@ package-mode = false [tool.poetry.dependencies] python = ">=3.14,<3.15" jsonschema = "^4.26" - From 333fbeaaa77d913f42be595d57737fd58d6d5ec6 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 8 Sep 2026 03:03:17 +0200 Subject: [PATCH 03/10] refactor: move Python contract proof to disabled-parking --- .github/workflows/tests.yml | 18 -- docs/development/data-foundation-stack.md | 2 +- docs/development/data-import-pilot.md | 22 +- docs/development/quality-checks.md | 2 +- tests/Support/import_contract/.gitignore | 2 - tests/Support/import_contract/contract.py | 102 --------- tests/Support/import_contract/poetry.lock | 197 ------------------ tests/Support/import_contract/pyproject.toml | 9 - .../Support/import_contract/test_contract.py | 25 --- 9 files changed, 17 insertions(+), 362 deletions(-) delete mode 100644 tests/Support/import_contract/.gitignore delete mode 100644 tests/Support/import_contract/contract.py delete mode 100644 tests/Support/import_contract/poetry.lock delete mode 100644 tests/Support/import_contract/pyproject.toml delete mode 100644 tests/Support/import_contract/test_contract.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index adf5cc3e..90c4cd09 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -89,21 +89,3 @@ jobs: - name: Build client assets and run tests run: composer ci:test - - import-contract-python: - name: Python 3.14 import contract - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6 - with: - python-version: '3.14.2' - - name: Install locked contract dependencies - run: | - pipx install poetry==2.4.3 - poetry -C tests/Support/import_contract install --no-interaction - - name: Check shared fixtures without source network - run: poetry -C tests/Support/import_contract run python -m unittest -v diff --git a/docs/development/data-foundation-stack.md b/docs/development/data-foundation-stack.md index 52b697cd..33b6d965 100644 --- a/docs/development/data-foundation-stack.md +++ b/docs/development/data-foundation-stack.md @@ -1,6 +1,6 @@ # Concrete techstack voor batchimports -Status: aanbevolen implementatiestack, 2026-09-08. De gekozen batchaanpak vervangt de eerdere aanbeveling voor een interne worker-API en machine-authenticatie via Sanctum. Voor #1214 zijn opis/json-schema 2.6.0 en de geïsoleerde Python-testdependency jsonschema 4.26.0 met expliciete toestemming toegevoegd en gelockt. Er zijn geen diensten geprovisioned. Overige nieuwe dependencies worden per implementatie beoordeeld conform de repositoryregels. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [uitvoering](data-foundation-delivery.md). +Status: aanbevolen implementatiestack, 2026-09-08. De gekozen batchaanpak vervangt de eerdere aanbeveling voor een interne worker-API en machine-authenticatie via Sanctum. Voor #1214 zijn opis/json-schema 2.6.0 in core en jsonschema 4.26.0 in disabled-parking met expliciete toestemming toegevoegd en gelockt. Er zijn geen diensten geprovisioned. Overige nieuwe dependencies worden per implementatie beoordeeld conform de repositoryregels. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [uitvoering](data-foundation-delivery.md). ## 1. Eerste versie diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md index 3a374c0e..9bd55aa6 100644 --- a/docs/development/data-import-pilot.md +++ b/docs/development/data-import-pilot.md @@ -46,7 +46,7 @@ Deze acties horen bij de eerste adapter in [disabled-parking#774](https://github Records zijn UTF-8 zonder BOM, één JSON-object per regel, uitsluitend LF en altijd een afsluitende LF. Geen compressie, lege regels of impliciete ontbrekende velden. Producers schrijven JSON via een encoder, geen handmatige stringconcatenatie. Tijdstippen gebruiken UTC-seconden `YYYY-MM-DDTHH:mm:ssZ`, echte kalenderdatums, geen leap seconds. Nullwaarden staan expliciet in het bestand. Onbekende velden worden afgewezen; uitbreidingen krijgen een afgesproken contractversie. V1 omvat alleen gemeentelijke snapshots, geen offstreet- of livevelden. -`SnapshotContract::check()` en de onafhankelijke Python-referentie controleren een lokaal recordsbestand en de **exacte manifestbytes**. Het pad en de context komen van de vertrouwde consumer, niet uit het manifest. Context bevat bron/geografische toelating, actuele config/scope en de volledige bewaarde batch-/sequencehistorie voor deze source. Een manifest kan zijn eigen bron of scope niet toelaten. +`SnapshotContract::check()` in core en de onafhankelijke Python-referentie in disabled-parking controleren een lokaal recordsbestand en de **exacte manifestbytes**. Het pad en de context komen van de vertrouwde consumer, niet uit het manifest. Context bevat bron/geografische toelating, actuele config/scope en de volledige bewaarde batch-/sequencehistorie voor deze source. Een manifest kan zijn eigen bron of scope niet toelaten. | Resultaat | Betekenis | | --- | --- | @@ -57,7 +57,7 @@ Records zijn UTF-8 zonder BOM, één JSON-object per regel, uitsluitend LF en al | `review` | Technisch geldig maar leeg, gewijzigde config/scope, onbekende/persoonlijke toegang of onbegrepen beperkingen. Geen automatische publicatie. | | `valid` | De begrensde contractcheck slaagt. **Geen publicatiebesluit**: eerste import, aantalsdaling, brondatumregressie, verschillen en correctiebehoud worden pas in #1215/#1218 beoordeeld. | -Een identieke levering moet ook dezelfde manifestserialisatie behouden. Hashes omvatten exacte bytes, inclusief LF. De checker raadpleegt geen objectopslag of database en bewijst geen bestaande objectversie, locking, queueherstel of transactionele publicatie. De consumer moet state onder lock opnieuw controleren vóór publicatie. De Python-code is een kleine testreferentie; de producerimplementatie blijft in disabled-parking. +Een identieke levering moet ook dezelfde manifestserialisatie behouden. Hashes omvatten exacte bytes, inclusief LF. De checker raadpleegt geen objectopslag of database en bewijst geen bestaande objectversie, locking, queueherstel of transactionele publicatie. De consumer moet state onder lock opnieuw controleren vóór publicatie. Zowel de Python-contractproef als de latere producerimplementatie staan in disabled-parking. Core bevat alleen schemas, fixtures en PHP. Offstreet krijgt een eigen aansluiting wanneer zijn werkpakket start. ## Verplichte pilotlimieten @@ -77,15 +77,23 @@ De 180 werkelijk gemeten bronrecords normaliseren in de proef naar 79.566 bytes, ## Reproduceren -Vanaf de repositoryroot: +In core: ```sh vendor/bin/pest tests/Unit/Support/SnapshotContractTest.php --no-tia --compact -poetry -C tests/Support/import_contract env use python3.14 -poetry -C tests/Support/import_contract install --no-interaction -poetry -C tests/Support/import_contract run python -m unittest -v ``` -PHP en Python gebruiken dezelfde bestanden uit [de fixturelijst](../../tests/Fixtures/import/v1/cases.json). De Python-testreference is uitsluitend contractcompatibiliteit, zonder bronpackages, netwerkfetches of producerlogica. Een aparte deadlineproef gebruikt een gecontroleerde monotone klok. Normale CI doet geen bronrequests en gebruikt de vastgelegde dependencies. Een nieuwe read-only bronproef hoort in de importrepository of een tijdelijke onderzoeksomgeving, met `eindhoven==5.1.0` en CPython 3.14.2. Gebruik de bovenstaande package-aanroep en controleer op dezelfde response `nhits`, rijenaantal, unieke IDs en bytes. De publieke [packagevoorbeeldcode](https://github.com/klaasnicolaas/python-eindhoven/tree/v5.1.0/examples) toont het ophalen. Voor de meting is via een aiohttp trace callback de ongewijzigde response geteld; de package zelf exposeert de completenessmetadata nog niet. +In de lokale checkout van disabled-parking, met een pad naar de core-checkout waarvan je het contract wilt testen: + +```sh +poetry install --only contract --no-interaction +NIPKAART_CORE_PATH=../core poetry run python -m unittest discover -s tests/contracts -v +``` + +PHP en Python gebruiken dezelfde bestanden uit [de fixturelijst](../../tests/Fixtures/import/v1/cases.json); kopieer de schemas of fixtures niet naar een tweede repository. Disabled-parking CI pint de core-commit, zodat een wijziging in main niet ongemerkt zijn contract verandert. Bij een nieuwe contractversie worden eerst de core-schemas/fixtures beoordeeld en daarna expliciet de producerpin en mapping bijgewerkt. Beide PR's moeten slagen voordat #1214 volledig is afgerond. + +De Python-test is uitsluitend contractcompatibiliteit en voert geen bronfetches of uploads uit. Een aparte deadlineproef gebruikt een gecontroleerde monotone klok. Normale CI doet geen bronrequests en gebruikt de vastgelegde dependencies. + +Een nieuwe read-only bronproef hoort in de importrepository of een tijdelijke onderzoeksomgeving, met `eindhoven==5.1.0` en CPython 3.14.2. Gebruik de bovenstaande package-aanroep en controleer op dezelfde response `nhits`, rijenaantal, unieke IDs en bytes. De publieke [packagevoorbeeldcode](https://github.com/klaasnicolaas/python-eindhoven/tree/v5.1.0/examples) toont het ophalen. Voor de meting is via een aiohttp trace callback de ongewijzigde response geteld; de package zelf exposeert de completenessmetadata nog niet. Herhaal die proef bij bron-/packagewijzigingen; voeg live netwerkafhankelijkheid of een bronadapter niet toe aan core. De normale tests blijven offline. diff --git a/docs/development/quality-checks.md b/docs/development/quality-checks.md index 4931a14a..c840a7af 100644 --- a/docs/development/quality-checks.md +++ b/docs/development/quality-checks.md @@ -72,4 +72,4 @@ See [Pest TIA documentation](https://pestphp.com/docs/tia) for baseline storage, ## Snapshot contract parity -The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`. The **Python 3.14 import contract** job validates the same byte fixtures with the isolated Poetry environment under `tests/Support/import_contract`. Run `poetry -C tests/Support/import_contract install --no-interaction` and `poetry -C tests/Support/import_contract run python -m unittest -v` after selecting Python 3.14 for that environment. It has no source packages or network fetches. See the [contract proof](data-import-pilot.md) for source evidence and mandatory limits; passing these checks does not validate deployment or publication. +The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`. Core owns the versioned schemas and byte fixtures. The Python interpretation, dependency and CI job live in [disabled-parking](https://github.com/NIPKaart/disabled-parking), where CI checks out a pinned core revision and validates those same files without source requests. Core does not contain Python code or a Poetry environment. See the [contract proof](data-import-pilot.md) for the cross-repository commands, source evidence and mandatory limits. diff --git a/tests/Support/import_contract/.gitignore b/tests/Support/import_contract/.gitignore deleted file mode 100644 index 670a9362..00000000 --- a/tests/Support/import_contract/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -__pycache__/ -.venv/ diff --git a/tests/Support/import_contract/contract.py b/tests/Support/import_contract/contract.py deleted file mode 100644 index 90458007..00000000 --- a/tests/Support/import_contract/contract.py +++ /dev/null @@ -1,102 +0,0 @@ -"""Independent offline Python interpretation of the v1 snapshot contract. - -This is a parity reference, not a scheduler, importer or source adapter. -""" -import hashlib -import json -import time -from datetime import datetime -from pathlib import Path - -from jsonschema import Draft202012Validator, FormatChecker -from referencing import Registry - -ROOT = Path(__file__).resolve().parents[3] -MAX_MANIFEST_BYTES = 16384 -MAX_ARTIFACT_BYTES = 33554432 -MAX_LINE_BYTES = 16384 -MAX_RECORDS = 10000 -MAX_VALIDATION_SECONDS = 30 -FORMATS = FormatChecker() - - -@FORMATS.checks("date-time", raises=ValueError) -def utc_timestamp(value): - if not isinstance(value, str): - return True - datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") - return True - - -def no_remote_reference(uri): - raise ValueError("Remote schema references are forbidden") - - -def validator(name): - schema = json.loads((ROOT / f"resources/schemas/import/v1/{name}.schema.json").read_text()) - Draft202012Validator.check_schema(schema) - return Draft202012Validator(schema, format_checker=FORMATS, registry=Registry(retrieve=no_remote_reference)) - - -MANIFEST = validator("manifest") -RECORD = validator("municipal-record") - - -def reject_constant(value): - raise ValueError(f"Non-JSON number: {value}") - - -def check(manifest_bytes, records_path, context, clock=time.monotonic): - started = clock() - if len(manifest_bytes) > MAX_MANIFEST_BYTES: - return "invalid" - try: - manifest = json.loads(manifest_bytes.decode("utf-8"), parse_constant=reject_constant) - if not MANIFEST.is_valid(manifest): - return "invalid" - completeness, artifact = manifest["completeness"], manifest["artifact"] - duration = (datetime.strptime(manifest["fetched_finished_at"], "%Y-%m-%dT%H:%M:%SZ") - datetime.strptime(manifest["fetched_started_at"], "%Y-%m-%dT%H:%M:%SZ")).total_seconds() - if (manifest["source_id"] != context["source_id"] - or artifact["key"] != f'sources/{manifest["source_id"]}/batches/{manifest["batch_id"]}/records.jsonl' - or not 0 <= duration <= 1800 - or completeness["records_seen"] != completeness["records_emitted"] + completeness["records_filtered_out"] - or completeness["records_emitted"] != artifact["record_count"] - or (completeness["expected_records"] is not None and completeness["expected_records"] != completeness["records_seen"])): - return "invalid" - digest, byte_count, ids, review = hashlib.sha256(), 0, set(), False - with Path(records_path).open("rb") as stream: - while line := stream.readline(MAX_LINE_BYTES + 1): - byte_count += len(line) - if (byte_count > MAX_ARTIFACT_BYTES or len(line) > MAX_LINE_BYTES - or not line.endswith(b"\n") or b"\r" in line - or clock() - started > MAX_VALIDATION_SECONDS): - return "invalid" - digest.update(line) - record = json.loads(line.decode("utf-8"), parse_constant=reject_constant) - if not RECORD.is_valid(record) or record["external_id"] in ids or len(ids) >= MAX_RECORDS: - return "invalid" - ids.add(record["external_id"]) - west, south, east, north = context["bounds"] - position = record["position"] - if (record["country_code"] != context["country_code"] - or not west <= position["longitude"] <= east - or not south <= position["latitude"] <= north): - return "invalid" - review |= record["access_category"] != "designated_accessible" or bool(record["unmapped_fields"] or record["restrictions"]) - if (byte_count != artifact["bytes"] or len(ids) != artifact["record_count"] - or digest.hexdigest() != artifact["sha256"] - or clock() - started > MAX_VALIDATION_SECONDS): - return "invalid" - known_hash = context["batches"].get(manifest["batch_id"]) - if known_hash is not None: - return "duplicate" if known_hash == hashlib.sha256(manifest_bytes).hexdigest() else "conflict" - if str(manifest["source_sequence"]) in context["sequences"]: - return "conflict" - if manifest["source_sequence"] <= context["highest_sequence"]: - return "superseded" - if (review or not ids or manifest["scope_version"] != context["scope_version"] - or manifest["source_config_version"] != context["source_config_version"]): - return "review" - return "valid" - except (ValueError, OSError, RecursionError): - return "invalid" diff --git a/tests/Support/import_contract/poetry.lock b/tests/Support/import_contract/poetry.lock deleted file mode 100644 index 23ae018c..00000000 --- a/tests/Support/import_contract/poetry.lock +++ /dev/null @@ -1,197 +0,0 @@ -# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. - -[[package]] -name = "attrs" -version = "26.1.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"}, - {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"}, -] - -[[package]] -name = "jsonschema" -version = "4.26.0" -description = "An implementation of JSON Schema validation for Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"}, - {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -jsonschema-specifications = ">=2023.3.6" -referencing = ">=0.28.4" -rpds-py = ">=0.25.0" - -[package.extras] -format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] -format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"] - -[[package]] -name = "jsonschema-specifications" -version = "2025.9.1" -description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" -optional = false -python-versions = ">=3.9" -groups = ["main"] -files = [ - {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"}, - {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"}, -] - -[package.dependencies] -referencing = ">=0.31.0" - -[[package]] -name = "referencing" -version = "0.37.0" -description = "JSON Referencing + Python" -optional = false -python-versions = ">=3.10" -groups = ["main"] -files = [ - {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"}, - {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"}, -] - -[package.dependencies] -attrs = ">=22.2.0" -rpds-py = ">=0.7.0" - -[[package]] -name = "rpds-py" -version = "2026.6.3" -description = "Python bindings to Rust's persistent data structures (rpds)" -optional = false -python-versions = ">=3.11" -groups = ["main"] -files = [ - {file = "rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7"}, - {file = "rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3"}, - {file = "rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6"}, - {file = "rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127"}, - {file = "rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804"}, - {file = "rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0"}, - {file = "rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a"}, - {file = "rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc"}, - {file = "rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f"}, - {file = "rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96"}, - {file = "rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223"}, - {file = "rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885"}, - {file = "rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d"}, - {file = "rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80"}, - {file = "rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb"}, - {file = "rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e"}, - {file = "rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc"}, - {file = "rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd"}, - {file = "rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5"}, - {file = "rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e"}, - {file = "rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2"}, - {file = "rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13"}, - {file = "rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a"}, - {file = "rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107"}, - {file = "rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146"}, - {file = "rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577"}, - {file = "rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76"}, - {file = "rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826"}, - {file = "rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4"}, -] - -[metadata] -lock-version = "2.1" -python-versions = ">=3.14,<3.15" -content-hash = "75cc01be3aa01b1cadf7f3140c02e23c3a5ff0dcdca82d47813a3e4230b7ec2d" diff --git a/tests/Support/import_contract/pyproject.toml b/tests/Support/import_contract/pyproject.toml deleted file mode 100644 index 989e3d33..00000000 --- a/tests/Support/import_contract/pyproject.toml +++ /dev/null @@ -1,9 +0,0 @@ -[tool.poetry] -name = "nipkaart-import-contract-proof" -version = "1.0.0" -description = "Offline cross-language contract proof, not a producer" -package-mode = false - -[tool.poetry.dependencies] -python = ">=3.14,<3.15" -jsonschema = "^4.26" diff --git a/tests/Support/import_contract/test_contract.py b/tests/Support/import_contract/test_contract.py deleted file mode 100644 index 61540c99..00000000 --- a/tests/Support/import_contract/test_contract.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Run the exact files also consumed by Pest, without contacting a source.""" -import json -import unittest - -from contract import ROOT, check - -FIXTURES = ROOT / "tests/Fixtures/import/v1" - - -class SnapshotContractTest(unittest.TestCase): - def test_shared_contract_decisions(self): - for case in json.loads((FIXTURES / "cases.json").read_text()): - with self.subTest(case=case["name"]): - directory = FIXTURES / case["name"] - context = json.loads((directory / "context.json").read_text()) - self.assertEqual(check((directory / "manifest.json").read_bytes(), directory / "records.jsonl", context), case["expected"]) - - def test_monotonic_deadline(self): - directory = FIXTURES / "unknown-capacity" - ticks = iter([0, 31]) - self.assertEqual(check((directory / "manifest.json").read_bytes(), directory / "records.jsonl", json.loads((directory / "context.json").read_text()), clock=lambda: next(ticks)), "invalid") - - -if __name__ == "__main__": - unittest.main() From edc05e87fe79512bda0ba4a93e50bde1fb7806f8 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Tue, 8 Sep 2026 09:19:52 +0200 Subject: [PATCH 04/10] docs: narrow pilot scope to a provisional local delivery --- docs/development/data-foundation-delivery.md | 59 ++--- docs/development/data-foundation-stack.md | 6 +- docs/development/data-import-contract.md | 222 ++++--------------- docs/development/data-import-pilot.md | 66 +----- docs/development/quality-checks.md | 4 +- docs/product/data-foundation.md | 2 + 6 files changed, 97 insertions(+), 262 deletions(-) diff --git a/docs/development/data-foundation-delivery.md b/docs/development/data-foundation-delivery.md index 744d6c0c..24fcd157 100644 --- a/docs/development/data-foundation-delivery.md +++ b/docs/development/data-foundation-delivery.md @@ -1,30 +1,27 @@ # Batchimports: uitvoering en beheer -Status: uitvoeringsvoorstel, 2026-09-08. De eigenaar heeft de zelfstandige batchaanpak gekozen. Dit document vervangt de werkpakketten voor door core aangestuurde workers. De uitvoering is op 2026-09-08 in GitHub gepland met 13 nieuwe subissues, bijgewerkte epics en native parent-/blockingrelaties. De [contractproef voor #1214](data-import-pilot.md) voegt goedgekeurde validators, schemas en fixtures toe. Er zijn geen importjobs gestart of productiegegevens geïmporteerd. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [techstack](data-foundation-stack.md). +Status: gefaseerd uitvoeringsvoorstel, 2026-09-08. De actuele eerste stap is [#1214](https://github.com/NIPKaart/core/issues/1214): één bruikbare bron en één voorlopige levering beschrijven. [PR #1222](https://github.com/NIPKaart/core/pull/1222) blijft een onvoltooid prototype. Er zijn geen importjobs gestart of productiegegevens geïmporteerd. Zie de [productbasis](../product/data-foundation.md), [voorlopige gegevenslevering](data-import-contract.md) en [bronbevindingen](data-import-pilot.md). ## Eenvoud als uitgangspunt voor uitvoering -De eerste versie heeft één dataset, één adapter rond een bestaand universeel package en één volledige snapshot. De adapter produceert een bestand; core beoordeelt en verwerkt het. Automatisering verandert alleen hoe dat bestand verschijnt. Brononderzoek/CRM, generieke adapterbouwers, karma en een mobiele app blijven buiten deze eerste keten. +De eerste werkende keten is: universele bronpackage → lokaal bestand vanuit disabled-parking → beoordeling en verwerking in core. De huidige planning omvat meerdere issues; #1214 hoeft niet de hele keten te implementeren. -Drie kleine opleveringen: +1. **Bron en betekenis, #1214:** kies een bruikbare bron, onderbouw de veldbetekenis en beschrijf één voorlopige voorbeeldlevering. Leg verantwoordelijkheden en foutgevallen vast. +2. **Lokale export, disabled-parking #774:** gebruik de echte package om dat bestand te produceren. Controleer selectie, identiteit, onbekende waarden en volledigheid. +3. **Beoordeelde verwerking, core #1215:** lees het bestand, toon verschillen en verwerk na beoordeling. Herimport behoudt dezelfde plekken; ontbrekende records worden niet automatisch verwijderd. +4. **Vervolgwerk:** correctiebehoud verder uitwerken in #1218 en ophalen/overdracht/discovery automatiseren nadat de lokale keten werkt. Een geaccepteerde correctie mag vanaf haar introductie nooit door import worden gewist. -1. **Bestand naar correcte parkeerdata:** fixture/package naar JSONL en manifest, dezelfde validatie in Python en core, handmatig gestart importproces en beoordeelde publicatie. -2. **Bestanden automatisch leveren en ontdekken:** geplande batchuitvoering in de importomgeving, records uploaden en als laatste gereedmelden; core ontdekt en verwerkt zonder runner-API. -3. **Correctie behouden:** een beoordeelde veldcorrectie op een geïmporteerde plek blijft na herimport bestaan en kan expliciet worden ingetrokken. +Brononderzoek/CRM, generieke adapterbouwers, karma en een mobiele app blijven buiten deze eerste keten. Het bestandsformaat wordt pas vastgezet na de praktijkproef. Een losse validator of bereikbare API bewijst geen werkende import. -De eerste stap werkt lokaal met een fixture; uiteindelijke pilotacceptatie vereist een echte toegestane bron en stagingtransport. Integreer alleen de opslag en UI die deze stappen vereisen. Het beheer begint met datasetinstellingen en een importoverzicht met verschillen, publiceren en afwijzen. - -| Direct nodig | Later wanneer aangetoond nodig | +| Nu vastleggen | Later uitwerken | | --- | --- | -| Volledige snapshots en stabiele bron-ID | Deltafeeds/cursors | -| Klaar-manifest pas na volledige upload | Opslagnotificaties of extra delivery-index | -| Unieke batchregistratie en sourcevolgorde | Meerdere productiehosts met coördinatie | -| Eén begrensde publicatietransactie na staging | Grote versioned publicaties met atomair omschakelen | -| Veldcorrectie apart van bronwaarde | Uitgebreid bewijs-/vertrouwensmodel | -| Ongewijzigde records niet herschrijven | Bronafhankelijke conditional requests | -| Bestaande Poetry-tooling | Zelfstandige toolingmigratie indien nuttig | +| Bron-ID en betekenis van gegevens | Cloudobjectkeys en version-ID's | +| Afbakening en aantoonbare volledigheid | Ready-manifest-last en opslagdiscovery | +| Bron, levering, ophaaltijd en onbekende waarden | Producersequence, planning en herstel over hosts | +| Verwacht gedrag bij herhaling, verdwijning en correctie | Precieze transport- en schaalmechanismen | +| Verantwoordelijkheden per repository | Hosting, bewaartermijnen en operationele limieten | -Correctheid blijft verplicht: beperkte opslagrechten, volledige levering controleren, oudere batches weigeren, idempotente verwerking, stabiele verwijzingen, behoud van correcties en onbekend niet als nul of vrij presenteren. +Integriteit, begrensde invoer, veilige herverwerking en correctiebehoud blijven nodig. De precieze mechanismen volgen uit de eerste werkende keten. De hieronder bewaarde latere werkpakketten en operationele voorstellen voegen geen extra acceptatiecriteria toe aan #1214. ## 1. Roadmapkoppeling @@ -63,26 +60,32 @@ De [data-epic #1176](https://github.com/NIPKaart/core/issues/1176) hangt onder [ | Vervolg | [core#1221](https://github.com/NIPKaart/core/issues/1221) | Bezettingscontract en coreconsumer | | Vervolg | [offstreet-parking#657](https://github.com/NIPKaart/offstreet-parking/issues/657) | Liveproducer en integratie | -Start met de contractproef in core#1214. Daarna kunnen adapter en core-bestandintake afzonderlijk worden uitgewerkt. Stagingbeslissingen blokkeren de lokale bestandproef niet. De gemeentelijke ketenacceptatie blokkeert verdere bronuitbreiding; de offstreetconsumer wordt eerst op fixtures bewezen voordat de liveproducer integreert. +Start met de bronkeuze en voorlopige voorbeeldlevering in core#1214. Daarna volgen de echte adapterexport en de core-bestandintake. Stagingbeslissingen blokkeren de lokale bestandproef niet. De gemeentelijke ketenacceptatie blokkeert verdere bronuitbreiding; de offstreetconsumer wordt eerst op fixtures bewezen voordat de liveproducer integreert. ## 2. Werkpakketten -### A — Package naar bestandcontract +### A — Bruikbare bron en voorlopige levering (#1214) + +Core bewaart de leveringsafspraak en voorbeelden; brongebonden Python-onderzoek hoort in disabled-parking of een tijdelijke onderzoeksomgeving. Er is geen infrastructuur vereist. + +- [ ] Selecteer één bruikbare bron met onderbouwde bronidentiteit, scope, veldbetekenis en hergebruikvoorwaarden. Eindhoven is een onderzochte kandidaat, nog geen geaccepteerde keuze. +- [ ] Controleer package-output, volledige ophaling, onbekende waarden, brondata en geografische mapping. Los blokkers op of kies een andere bron. +- [ ] Leg één toegestane bronrij en de voorgestelde NIPKaart-weergave vast, met uitleg per veld. +- [ ] Beschrijf leveringidentiteit, dataset, selectie, ophaaltijd, volledigheid en de voorlopige formaatkeuze zonder verplichte opslagvelden. +- [ ] Leg herhaling, wijziging, verdwijning, fetchfout en correctiebehoud uit. +- [ ] Leg de overdracht naar disabled-parking #774 en core #1215 vast. + +Klaar wanneer de bron bruikbaar is en één onderbouwde voorlopige levering beschreven is. Het formaat is dan nog geen vrijgegeven 1.0-contract. -Repos: core voor schemas/fixtures, disabled-parking voor adapter. Geen infrastructuur vereist. +### A2 — Daadwerkelijke lokale export (disabled-parking #774) -- [ ] Selecteer één echte bron op stabiele ID, begrijpelijke scope en voorwaarden. Amsterdam is kandidaat, niet vooraf geaccepteerd. -- [ ] Controleer packageversie, doelruntime, paginering, nullwaarden, brondata en geografische mapping. -- [ ] Leg toegestane gesaniteerde fixtures vast en maak manifest-/recordschemas volgens het batchcontract. -- [ ] Bouw expliciete adaptermapping en bestanduitvoer; het universele package krijgt geen NIPKaart-afhankelijkheid. -- [ ] Laat Python en PHP dezelfde positieve/negatieve voorbeelden accepteren of afwijzen. -- [ ] Bepaal verplichte grenzen voor bytes, records, regels en uitvoeringsduur op basis van de pilot. +De adapter gebruikt de echte universele package en schrijft de afgesproken levering lokaal. Test de mapping en belangrijke foutgevallen zonder bronnetwerk in gewone tests. Een generieke verbetering aan bronophaling hoort in de package; NIPKaart-mapping hoort in disabled-parking. Geen directe databasewrites of verplichte cloudcredentials. -Klaar wanneer een lokaal gemaakt bestand reproduceerbaar geldig is. Een API bereikbaar krijgen bewijst geen volledige of semantisch juiste dataset. +Klaar wanneer hetzelfde bronvoorbeeld reproduceerbaar door de adapter als bestand wordt geleverd. Deze implementatie hoort niet bij het beschrijven van de afspraak in #1214. ### B — Bestand ontvangen, vergelijken en publiceren -Repo: core. Afhankelijk van A; voltooit de eerste kleine oplevering. +Repo: core (#1215). Gebruikt de voorlopige afspraak uit A en wordt uiteindelijk met de echte export uit A2 beproefd. - [ ] Registreer een toegelaten dataset met source-ID, scope, herkomst, voorwaarden, geografische mapping en publicatiebeleid; geen onderzoeksworkflow. - [ ] Bouw één toepassingsservice voor intake vanaf een lokaal/uploadbestand en later objectopslag. Geen directe spreadsheetwrites naar parkeertabellen. diff --git a/docs/development/data-foundation-stack.md b/docs/development/data-foundation-stack.md index 33b6d965..489b4261 100644 --- a/docs/development/data-foundation-stack.md +++ b/docs/development/data-foundation-stack.md @@ -1,8 +1,8 @@ # Concrete techstack voor batchimports -Status: aanbevolen implementatiestack, 2026-09-08. De gekozen batchaanpak vervangt de eerdere aanbeveling voor een interne worker-API en machine-authenticatie via Sanctum. Voor #1214 zijn opis/json-schema 2.6.0 in core en jsonschema 4.26.0 in disabled-parking met expliciete toestemming toegevoegd en gelockt. Er zijn geen diensten geprovisioned. Overige nieuwe dependencies worden per implementatie beoordeeld conform de repositoryregels. Zie [productbasis](../product/data-foundation.md), [batchcontract](data-import-contract.md) en [uitvoering](data-foundation-delivery.md). +Status: opties voor latere implementatie, 2026-09-08. Eerst bewijzen we de [handmatige keten](data-import-contract.md). De onderstaande stack legt geen aanvullende voorwaarden op aan #1214 en is geen infrastructuurbesluit. Opis/json-schema is onderdeel van de onvoltooide core-proef; jsonschema staat in lokale, ongecommitte wijzigingen in disabled-parking. Deze dependencies zijn goedgekeurd, maar bewijzen geen definitief ontwerp. Er zijn geen diensten geprovisioned. De bestaande Laravel/PostgreSQL- en Poetry-basis blijft gelden. -## 1. Eerste versie +## 1. Opties voor implementatie na de bron- en leveringproef | Onderdeel | Concrete keuze | Status en reden | | --- | --- | --- | @@ -48,7 +48,7 @@ Poetry installeert gelockte dependencies tijdens imagebuild. Start productie zon ## 4. Contract en coreverwerking -De schemas in een core-contractrelease zijn normatief. Dataclasses valideren geen JSON. Python gebruikt [jsonschema](https://python-jsonschema.readthedocs.io/en/stable/), PHP [Opis](https://opis.io/json-schema/2.x/); A bewijst dezelfde Draft 2020-12-subset met positieve en negatieve fixtures. Datum-/URI-formatchecks zijn expliciet en externe netwerkresolutie van schema-referenties staat uit. Er is voor bestanduitwisseling geen OpenAPI-specificatie nodig. +De schemas in een core-contractrelease zijn normatief. Dataclasses valideren geen JSON. Python gebruikt [jsonschema](https://python-jsonschema.readthedocs.io/en/stable/), PHP [Opis](https://opis.io/json-schema/2.x/); Als deze schema-aanpak wordt gekozen, toetsen de implementatiestappen dezelfde subset met positieve en negatieve fixtures. #1214 stelt nog geen definitieve schema-release vast. Datum-/URI-formatchecks zijn expliciet en externe netwerkresolutie van schema-referenties staat uit. Er is voor bestanduitwisseling geen OpenAPI-specificatie nodig. Core gebruikt unieke batchregistratie, sequencecontrole en transacties voor idempotente verwerking. Jobs worden na commit beschikbaar gemaakt en een herstelcontrole herplant duurzaam geregistreerde niet-afgeronde imports. Alleen after-commit dispatch is geen garantie tegen een crash vóór dispatch; zie de [queue-transactiedocumentatie](https://laravel.com/docs/13.x/queues#jobs-and-database-transactions). diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md index fbf953be..a1b01764 100644 --- a/docs/development/data-import-contract.md +++ b/docs/development/data-import-contract.md @@ -1,192 +1,66 @@ -# Batchimportcontract +# Voorlopige gegevenslevering -Status: snapshotcontract 1.0, 2026-09-08; schemas en offline contractvalidatie zijn uitgewerkt in [de Eindhoven-contractproef](data-import-pilot.md). De eigenaar heeft gekozen voor geplande batchleveringen. Dit vervangt het eerdere workerprotocol; core deelt geen opdrachten uit en registreert geen workers. Zie de [productbasis](../product/data-foundation.md), [techstack](data-foundation-stack.md) en het [uitvoeringsplan](data-foundation-delivery.md). Opslaglevering, scheduling, review en publicatie zijn vervolgstappen en nog niet geïmplementeerd. +Status: werkafspraak voor de eerste handmatige keten, 2026-09-08. Er is nog geen vrijgegeven contract 1.0. De schemas en validator in [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn een onvoltooid prototype; hun opslagvelden en uitvoeringsmechanismen zijn geen vastgestelde eisen. Dit document vervangt de eerdere verplichting om eerst een volledig automatisch afleverprotocol te bouwen. -## 1. Eigenaarschap +Het doel is één bruikbare bron via één lokaal bestand beoordeeld in core verwerken. Zie [#1214](https://github.com/NIPKaart/core/issues/1214), de [bronbevindingen](data-import-pilot.md) en de [uitvoeringsvolgorde](data-foundation-delivery.md). -De repositories `disabled-parking` en `offstreet-parking` beheren adapters, bronconfiguratie en ophaalplanning. Een geplande Python-uitvoering gebruikt universele bronpackages en produceert een genormaliseerde levering. Core ontdekt gereedgemelde leveringen en beheert validatie, beoordeling, correcties en publicatie. +## Verantwoordelijkheden -Het bestandcontract is de grens. Python heeft geen coretoken of databaseverbinding nodig; core heeft geen gemeentelijke API-credentials nodig. Private objectopslag heeft afzonderlijke toegangsrechten voor producent en consumer. Universele packages kennen dit NIPKaart-contract niet. - -De eerste versie ondersteunt volledige snapshots. Delta's/cursors volgen alleen wanneer een concrete bron ze nodig maakt. Livebezetting is een aparte leveringssoort en verandert geen catalogusidentiteit. - -## 2. Configuratie zonder twee planners - -| Gegeven | Plaats | -| --- | --- | -| Endpoint, adapter, broncredentials, filters en ophaalritme | Importrepository/configuratie en secrets van de importomgeving | -| Stabiele source-ID, scopeversie en contractversie | Afspraak tussen importconfiguratie en toegelaten dataset in core | -| Toegelaten opslagprefix, recordsoort, geografische mapping, voorwaarden | Core-datasetregistratie | -| Verwachte maximale leveringsleeftijd | Core als signaleringsgrens; geen bronplanning | -| Publicatiebeleid, zichtbaarheid en correcties | Core | - -Een nieuwe source of scope wordt eerst in core toegelaten. Een manifest kan dat niet zelf autoriseren. Scopewijzigingen krijgen een versie en een beoordeelde baseline. Ophalen pauzeren gebeurt in de importomgeving; verwerken/publiceren pauzeren gebeurt in core. Het beheer maakt dit onderscheid zichtbaar. Er is aanvankelijk geen coreknop om een nieuwe fetch te starten. - -## 3. Een levering gereedmelden - -Voorgestelde opslagstructuur: - -```text -sources//batches//records.jsonl -sources//ready/.json -``` - -Een batch heeft een unieke batch-ID, een binnen de source oplopende `source_sequence`, scopeversie en één recordsbestand. Het bestand is UTF-8 JSONL, in de pilot ongecomprimeerd en begrensd. Het ready-bestand is het manifest en wordt als laatste geschreven. - -1. De runner reserveert een sequence vóór ophalen en bewaart batchidentiteit en uitvoeringsmoment duurzaam. Eén producer is eigenaar van een source; een hostlock voorkomt overlap tijdens de eerste single-hostopzet. -2. De adapter haalt volledig op, vertaalt en valideert. Ontbrekende pagina's of parsefouten maken de levering onvolledig. -3. De runner schrijft een tijdelijk bestand, berekent recordaantal, bytes en SHA-256 en uploadt naar de unieke batchkey. -4. De referentieopslag heeft versioning; de runner bewaart de exacte recordsobjectversion-ID. -5. Alleen na succesvolle volledige bestandsupload schrijft de runner het ready-manifest met key, version-ID en checksum. Gebruik create-only voor manifestkeys waar ondersteund; dezelfde batch-ID krijgt nooit andere inhoud. -6. Core legt ook de manifestversie/checksum vast en leest de exacte recordsversie. Een latere key-overwrite verandert een al geaccepteerde levering niet. - -Bij een onzekere uploadrespons controleert de runner de bestaande versie en checksum. Hetzelfde geproduceerde bestand kan opnieuw worden gereedgemeld. Opnieuw ophalen van mogelijk veranderde data is een nieuwe batch met een nieuwe sequence. Gaten door mislukte runs zijn toegestaan. Na restart/restore wordt de sequence niet teruggezet: herstel aan de hand van duurzaam bewaarde staat en de hoogste reeds gereedgemelde sequence. De lokale gereserveerde high-watermark blijft leidend voor nog niet gereedgemelde runs. Als staat verloren is, retentie oude manifesten heeft verwijderd of oude producers nog kunnen schrijven: pauzeer en reconcileer met core plus opslag, trek oude schrijfrechten in en kies pas daarna een sequence boven alle bekende reserveringen/leveringen. Een lege listing is geen toestemming om bij 1 te beginnen. - -Een batch-ID bepaalt geen volgorde. Sequence bepaalt de catalogusvolgorde binnen de source, niet directoryvolgorde, UUID of eindtijd. Beschikbare brondatum/versie wordt daarnaast gecontroleerd; ook een nieuwe fetch kan een oude bronversie teruggeven. Dat vereist beoordeling. - -Er is in de pilot geen mutable `latest.json` als enige verwijzing. Het ready-prefix bevat de ontdekbare reeks, zodat late uploads geen nieuwere levering onzichtbaar maken. - -## 4. Ontdekken en idempotent verwerken - -Core inspecteert periodiek de toegelaten ready-prefixen via de opslag-SDK met volledige paginering. De pilot scant de bewaarde manifestreeks per bron en registreert `(source_id, batch_id)` uniek. Listingvolgorde is geen uitvoeringsvolgorde; core sorteert op source-sequence. - -Retentie van manifesten én recordsversies is langer dan de afgesproken maximale core-uitval. Python krijgt geen ontvangstbevestiging, dus opslagretentie is onderdeel van de leveringsgarantie. Uitval voorbij die termijn vereist een nieuwe volledige snapshot en een expliciete herstelactie. Een index of opslagnotificatie kan later discovery versnellen; periodieke reconciliatie blijft nodig. Een gepagineerde volledige scan is alleen de pilotkeuze bij gemeten beheersbaar volume. - -Core accepteert uitsluitend geconfigureerde source/prefix/scope/recordsoort/contractversie. Het volgt geen willekeurige URL uit het manifest. Cross-sourcekeys, ongeldige paden, onbekende versies, te grote bestanden en ongeldige inhoud worden afgewezen. - -Dezelfde batch met dezelfde manifest-/artifactidentiteit wordt overgeslagen of hervat. Dezelfde batch-ID met andere inhoud is een integriteitsconflict. Een dubbele sequence met een andere batch vereist eveneens beoordeling. Oudere sequences worden eventueel `superseded`, maar draaien geen nieuwere publicatie terug. - -Na een crash tussen ontvangstregistratie en queuedispatch herplant een periodieke herstelcontrole niet-afgeronde imports. De Laravel-taken blijven idempotent. Er is geen HTTP-completionprotocol nodig. - -## 5. Illustratief ready-manifest - -Dit is een syntactisch JSON-voorbeeld; IDs, versies en checksum zijn illustratief. De normatieve [schema's en fixtures](data-import-pilot.md#normatief-formaat-en-validatiegrens) leggen verplichte velden en enums vast. Dit voorbeeld is geen gemeten of uploadbaar artifact. - -```json -{ - "contract_version": "1.0", - "batch_id": "batch-example-001", - "source_id": "source-example-amsterdam", - "source_sequence": 42, - "source_config_version": 1, - "scope_version": 1, - "mode": "snapshot", - "record_kind": "municipal", - "adapter": {"key": "amsterdam.accessible_parking", "version": "0.1.0"}, - "packages": [{"name": "example-source-package", "version": "1.0.0"}], - "fetched_started_at": "2026-09-08T08:00:00Z", - "fetched_finished_at": "2026-09-08T08:00:20Z", - "source_updated_at": null, - "source_version": null, - "completeness": { - "status": "complete", - "pages_fetched": 2, - "pagination_exhausted": true, - "expected_records": null, - "records_seen": 125, - "records_filtered_out": 2, - "records_emitted": 123, - "records_failed": 0 - }, - "artifact": { - "key": "sources/source-example-amsterdam/batches/batch-example-001/records.jsonl", - "version_id": "example-object-version", - "format": "jsonl", - "bytes": 45678, - "record_count": 123, - "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - } -} -``` - -De runner verklaart volledigheid; core toetst aantallen, scope, bronkennis en inhoud. Uitgeputte paginering bewijst geen consistente momentopname als de bron tijdens ophalen verandert. Die beperking wordt in adapter-/bronbeleid vastgelegd. Filterredenen zijn expliciet en aantallen sluiten aan. Een volledige maar onverwacht lege snapshot kan gereedgemeld worden en wordt in core tegengehouden; een technisch mislukte run krijgt geen ready-manifest. - -## 6. Illustratief record - -Dit voorbeeld is uitgeschreven voor leesbaarheid; in JSONL staat ieder record op één regel. - -```json -{ - "external_id": "00123", - "source_updated_at": null, - "position": {"latitude": 52.3702, "longitude": 4.8952}, - "geometry_method": "source_point", - "country_code": "NL", - "administrative_codes": [], - "name": null, - "street": "Voorbeeldstraat", - "accessible_capacity": 2, - "access_category": "designated_accessible", - "orientation": null, - "restrictions": [], - "unmapped_fields": [], - "source_record_url": null -} -``` - -Minimale regels: - -- Externe ID's zijn begrensde niet-lege strings, uniek binnen source en batch. Behoud voorloopnullen; coördinaten zijn geen generieke identiteit. -- Posities zijn eindige WGS84-getallen met expliciete lat/lon, geldige grenzen en passende scope. Bron-CRS en conversie blijven herleidbaar. Een polygonenmiddelpunt is niet automatisch een navigatie-ingang. -- Aantallen zijn niet-negatieve gehele getallen of `null`; onbekend is niet nul. Onderscheid algemene toegankelijke versus persoonsgebonden plekken. Neem kentekens niet mee voor publieke classificatie. -- Records zijn volledige weergaven. Verplichte velden mogen niet ontbreken; `null` maakt een oude bronwaarde onbekend. Onbegrepen veiligheidsrelevante beperkingen blokkeren automatische publicatie. -- Begrens tekst, regellengte, bestandsgrootte en recordaantal. Geen uitvoerbare HTML, credentials of lokale paden. Schemas volgen geen externe netwerkreferenties. -- Buitenlandse administratieve niveaus vragen een expliciete mapping naar de huidige geografische relaties; adapters hardcoden geen interne land-/provincie-ID's. - -Offstreetcatalogi krijgen afzonderlijke velden voor voorzieningstype, algemene/toegankelijke capaciteit, ingang en URL. Live `observations` bevatten bronrecord-ID, ophaaltijd, bekende/onbekende waarnemingstijd en afzonderlijke algemene/toegankelijke vrije aantallen. Oudere brontijd overschrijft geen nieuwere meting. Zonder brontijd blijft actualiteit een als zodanig benoemde schatting op basis van ophalen. Een meting voor een onbekende voorziening wacht op een cataloguskoppeling. Deze varianten worden met echte package-output beproefd vóór aansluiting. - -## 7. Coreopslag en publicatie - -| Verantwoordelijkheid | Invariant | +| Onderdeel | Verantwoordelijkheid | | --- | --- | -| Datasetregistratie | Toegelaten source/prefix/scope, herkomst, voorwaarden, verwachte leveringsleeftijd en publicatiebeleid | -| Importhistorie | Unieke batch en sequencecontrole; immutable artifactreferentie, verwerkingstijden, status en fout | -| Bronrecordidentiteit | Uniek `(source_id, external_id)` met stabiele verwijzing naar bestaande doeltype/ID | -| Bronrevisie | Alleen inhoudelijke wijzigingen maken een nieuwe revisie; noodzakelijke historie blijft herstelbaar | -| Lokale correctie/besluit | Bronwaarde en geaccepteerde correctie apart, met actor, reden, basisversie en waarnemingsdatum | +| Universele bronpackage | Begrijpt de gemeentelijke API, haalt de afgesproken selectie volledig op en exposeert bron-ID's, oorspronkelijke waarden en informatie over volledigheid. Kent NIPKaart niet. | +| disabled-parking | Kiest dataset/filter, vertaalt de betekenis naar NIPKaart en maakt de lokale levering. Hier staan Python-code, bronpackages en Python-tests. Schrijft niet rechtstreeks in de core-database. | +| core | Beschrijft welke gegevens het kan verwerken, valideert die in PHP, toont verschillen en beheert beoordeling, publicatie en communitycorrecties. Bevat geen Python-omgeving of gemeentelijke API-clients. | +| offstreet-parking | Vervult dezelfde producerrol voor voorzieningen zodra dat werkpakket begint. We ontwerpen nu geen gedeeld producerframework. | -Dit zijn opslagverantwoordelijkheden, geen verplichte aparte frameworks. Importstatus: `discovered → validating → awaiting_review | ready → publishing → published`; andere resultaten zijn `failed`, `rejected`, `superseded` en `unchanged`. Deze states beschrijven coreverwerking, niet het externe ophaalproces. +Een bronpackage mag generieke verbeteringen nodig hebben voor paginering of bron-ID's. Dat is geen reden om het ophalen naar core te verplaatsen. Onderzoek naar mogelijke bronnen blijft buiten het platform. -1. Download de exacte recordsversie en verifieer bytes/checksum. De checksum bewijst integriteit, niet bronjuistheid. -2. Valideer in staging en bereken de diff tegenover een expliciete actuele bron-/publicatieversie. -3. Beoordeel eerste import, scopewijzigingen, onverwachte aantallen, verdwijningen en conflicten. Latere normale updates volgen pas na pilotacceptatie een automatisch beleid. -4. Controleer vlak vóór publicatie sequence, basisversie en correctieversie opnieuw. Verouderde diffs worden herberekend/beoordeeld. -5. Publiceer de begrensde pilot in één korte transactie met importstatus en actuele waarden. Taken zijn na commit beschikbaar of worden vanuit duurzame status hersteld. +## Eén voorbeeld -Geen publieke tussentoestand door chunks alvast te publiceren. Grotere datasets vereisen een loadproef en eventueel versieerbare publicaties met atomair omschakelen. Bestaande bronmodellen, detail- en favorietidentiteit blijven behouden. +Een fictieve gemeente biedt parkeerplek `000123` aan, aan de Voorbeeldstraat, op een bekende WGS84-locatie. Het bronveld betekent expliciet twee algemene gehandicaptenparkeerplaatsen. Dit voorbeeld beschrijft een gewenste gegevensstroom, niet een bevestigde interpretatie van de Eindhoven-dataset. -Hash genormaliseerde inhoud zonder ophaaltijd en irrelevante volgorde om ongewijzigde records te herkennen. Ongewijzigde snapshots bevestigen controle en aanwezigheid binnen die scope, maar vernieuwen geen brondatum en veroorzaken geen nieuwe publicatie. Veranderde lokale correcties worden onafhankelijk daarvan verwerkt. +De package geeft de oorspronkelijke gegevens terug. De adapter in disabled-parking vertaalt deze naar onderstaande betekenis. Core herkent de plek aan de combinatie van dataset en bron-ID. -Alleen afwezigheid binnen dezelfde complete snapshotscope betekent `missing_from_source`; dit wordt in de pilot beoordeeld en nooit vooraf verwijderd. Een terugkeer behoudt identiteit. Livebezetting en mislukte runs verwijderen geen voorzieningen. Een goedgekeurde lokale veldcorrectie blijft gelden bij herimport; een afwijkende nieuwe bronwaarde registreert een conflict zonder de correctie te wissen. +| Gegeven | Voorbeeld | Betekenis | +| --- | --- | --- | +| Dataset | `voorbeeldgemeente-toegankelijk` | Aangesloten dataset met bekende herkomst en geografische mapping | +| Unieke levering | `levering-001` | Herkennen dat hetzelfde bestand opnieuw wordt aangeboden; geen voorgeschreven UUID- of opslagmechanisme | +| Opgehaald op | `2026-09-08T08:00:00Z` | Tijdstip van onze fetch, geen bewijs van veldcontrole | +| Selectie | Alle algemene gehandicaptenparkeerplaatsen in deze dataset | Expliciete afbakening om volledigheid en ontbrekende records te kunnen beoordelen | +| Volledigheid | Volledig, met onderbouwing uit de bronresponse | Alleen verklaren wanneer alle resultaten van de selectie zijn opgehaald | +| Formaatrevisie | Voorlopig voorbeeld A | Nog geen stabiele release of compatibiliteitsbelofte | +| Bron-ID | `000123` | String; behoud voorloopnullen, gebruik geen coördinaten als identiteit | +| Positie | Latitude `52.3702`, longitude `4.8952` | Benoemde WGS84-coördinaten, geen impliciete volgorde | +| Toegankelijk aantal | `2` | Niet-negatief geheel aantal; onbekend blijft `null` | +| Toegang | Algemeen toegankelijk met gehandicaptenparkeerkaart | Onderscheiden van persoonsgebonden en onbekende toegang | +| Beperkingen | Onbekend tenzij de bron ze expliciet beschrijft | Geen ontbrekend veld vertalen naar “geen beperkingen” | +| Brondatum | Onbekend | Alleen invullen wanneer de betekenis van de brondatum bekend is | -## 8. Uitval en herstel +De gekozen echte bron moet een toegestane bronrij en een veld-voor-veldvertaling opleveren. Dit fictieve voorbeeld is daarvoor geen vervanging. Het is nog niet besloten of leveringsinformatie en records in één bestand of twee bestanden komen; een bestaande JSONL/manifestproef mag een mogelijkheid aantonen, maar legt de keuze niet vast. -| Scenario | Gedrag | -| --- | --- | -| Fetch/parsing mislukt | Geen ready-manifest; foutmelding in importomgeving, core signaleert uitblijvende levering | -| Upload half voltooid | Geen ready-manifest; weesobject na retentie opruimen | -| Manifestupload mislukt na geslaagde bestandsupload | Dezelfde artifactversie opnieuw gereedmelden zonder opnieuw ophalen | -| Records ontbreken/checksum fout | Afwijzen of begrensd opnieuw lezen; niet publiceren | -| Core tijdelijk uitgevallen | Gereedgemelde batches blijven liggen; volledige discovery vindt ze na herstel | -| Batch opnieuw ontdekt | Overslaan of eerdere verwerking hervatten | -| Oude levering arriveert later | Sequence/basisversie voorkomt terugdraaien | -| Twee processors verwerken dezelfde batch | Unieke registratie en transactionele publicatie voorkomen dubbelen | -| Lege/sterk kleinere snapshot | Review; bestaande publicatie blijft staan | -| Crash tijdens publiceren | Transactierollback en herstel vanuit duurzame status | -| Communitycorrectie tijdens import | Versiecontrole en correctiebehoud | - -De importomgeving beheert retries/backoff, HTTP-timeouts, bron-429 en totale deadlines. Pilot: maximaal drie pogingen binnen een harde catalogusdeadline van 30 minuten. De [contractproef](data-import-pilot.md#verplichte-pilotlimieten) legt byte-, regel-, record- en validatielimieten vast. Verhogingen vereisen een nieuwe capaciteitsbeslissing. Snapshots halen gemiste tijdsloten niet onbeperkt in; de volgende actuele fetch heeft voorrang. Bezettingsprocessen bouwen evenmin een reeks verouderde meetopdrachten op. +## Gedrag bij vervolggevallen -Bronhouderlimieten gelden inclusief package-interne requests. De pilot voert één bronhouder tegelijk uit met pacing en een gedeelde hostlock als beide repos dezelfde aanbieder aanspreken. Meerdere hosts vereisen expliciete coördinatie of exclusieve bronverdeling; extra containers alleen garanderen dit niet. - -## 9. Gezondheid zonder aansturing vanuit core +| Gebeurtenis | Verwacht gedrag | +| --- | --- | +| De bron wijzigt het aantal van dezelfde plek | Nieuwe bronwaarde bij dezelfde dataset + bron-ID; core toont het verschil voor beoordeling. | +| Hetzelfde bestand wordt opnieuw aangeboden | Geen dubbele parkeerplek of dubbele publicatie. De consumer herkent de levering. | +| Een plek ontbreekt bij de volgende volledige selectie | Toon als mogelijk verdwenen; niet automatisch verwijderen. | +| Fetch of paginering mislukt | Geen complete levering presenteren. De bestaande publicatie blijft staan. | +| Capaciteit ontbreekt | Bewaar onbekend; maak er geen nul van. | +| Toegangsbetekenis is onduidelijk | Niet automatisch als algemene gehandicaptenparkeerplek publiceren. | +| Een gebruiker meldt een correctie | Core beoordeelt deze. Een geaccepteerde correctie blijft afzonderlijk van de bronwaarde bestaan. | +| Een volgende bronwaarde wijkt af van een geaccepteerde correctie | Toon het conflict; overschrijf de correctie niet stilzwijgend. | +| De selectie verandert | Vergelijk afwezigheid niet ongemerkt met de oude selectie; beoordeel eerst de gewijzigde afbakening. | -Core toont laatste ontvangen, gevalideerde en gepubliceerde batch apart, met brondatum en verwachte maximale leveringsleeftijd. Uitblijven wordt `overdue`; een oud succes blijft niet onbeperkt gezond. +Bestaande bronmodellen en detail-/favorietverwijzingen blijven behouden. Het bestandsformaat bepaalt geen publicatiebeleid. -Directe fetchfouten staan in de logs/meldingen van de importomgeving. Core kan uit afwezigheid niet bepalen of bron, host, credentials of opslag faalt. Geen manifest schrijven om een fout als succes te maskeren. Een statusfeed is alleen een latere uitbreiding als beheer die nodig heeft. +## Eerst bewijzen, daarna automatiseren -## 10. Contractbewijs +1. **#1214:** bruikbare bron selecteren, betekenis controleren, één voorlopige voorbeeldlevering vastleggen en de verantwoordelijkheden afbakenen. +2. **disabled-parking #774:** vanuit de echte package een lokaal bestand maken met expliciete mapping en volledigheidscontrole. +3. **core #1215:** dat bestand lezen, fouten en verschillen tonen en na beoordeling verwerken. Herimport behoudt identiteit en verwijdert ontbrekende records niet automatisch. Correctiebehoud krijgt een expliciete aansluiting op #1218. +4. **Vervolgwerk:** automatisch ophalen, overdragen en ontdekken van bestanden nadat de lokale keten werkt. -Schemas en fixtures zijn versieerbaar in core en worden door beide importrepos getest. Normale tests gebruiken gesaniteerde fixtures zonder bronnetwerk. Aparte read-only bronprobes bevestigen actuele bereikbaarheid en veldbetekenis. +Integriteit, begrensde invoer en veilige herverwerking blijven nodig. De bestaande prototypekeuzes voor hashes, JSONL, exacte tijdsyntax en byte-/recordlimieten moeten bij de echte levering opnieuw worden beoordeeld. Opslagkeys, objectversies, ready-manifest-last, duurzame producersequences en herstel over meerdere hosts worden pas verplicht als het ontwerp voor automatische overdracht dat rechtvaardigt. -Minimale gevallen: volledige/lege/incomplete snapshot, nul versus onbekend, voorloopnullen, dubbele ID, ongeldige geo, scopewijziging, late oudere sequence, gewijzigde inhoud onder dezelfde batch-ID, half bestand zonder manifest, ontbrekende objectversie, herontdekking na uitval en herimport na lokale correctie. Handmatig bestand en objectopslag gebruiken dezelfde verwerking. Release pas na gelijke contractinterpretatie in PHP en Python. +Het contract wordt pas vastgezet nadat de eerste keten is beproefd. Testresultaten van een losstaande validator bewijzen die keten niet. diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md index 9bd55aa6..8e339723 100644 --- a/docs/development/data-import-pilot.md +++ b/docs/development/data-import-pilot.md @@ -1,6 +1,6 @@ -# Contractproef 1.0: Eindhoven +# Brononderzoek: kandidaat Eindhoven -Status: implementatie van [#1214](https://github.com/NIPKaart/core/issues/1214), 2026-09-08. Dit bevriest het gemeentelijke snapshotformaat en levert offline PHP/Python-contractchecks. Eindhoven is de gekozen **contractpilot**; publicatie blijft geblokkeerd door onderstaande bronvoorwaarden. Er is geen producer, opslagintegratie, database-import of productieaansluiting gebouwd. +Status: onderzochte kandidaat voor [#1214](https://github.com/NIPKaart/core/issues/1214), 2026-09-08. Eindhoven is nog geen geaccepteerde aansluiting of bewezen bruikbare pilotbron. Onderstaande bevindingen blijven behouden; de selectie staat open. Er is geen definitief contract 1.0 en geen producer, database-import of productieaansluiting gebouwd. ## Bron en bewijs @@ -23,7 +23,7 @@ Read-only meting op 2026-09-08, met `ODPEindhoven.locations(limit=1000, parking_ Dit is één actuele netwerkproef, geen beschikbaarheids-SLA en geen bewijs van volledige gemeentelijke dekking. De metadata noemt ook `temporal: t/m juli 2018`. Portalverwerking in 2026 bewijst daarom geen recente veldcontrole. -## Mapping en concrete aansluitvoorwaarden voor #774 +## Voorlopige mapping en open bronvragen | Onderwerp | Afspraak en beperking | | --- | --- | @@ -38,62 +38,18 @@ Dit is één actuele netwerkproef, geen beschikbaarheids-SLA en geen bewijs van | Datums | `record_timestamp` en portal `modified` zijn verwerkingsmetadata; niet presenteren als veldwaarneming. In de genormaliseerde pilot is `source_updated_at=null`. `fetched_*` beschrijft alleen onze fetch. | | Hergebruik | De datasetmetadata is de basis voor het opgenomen minimale fixture. Hercontroleer voorwaarden en bronbetekenis vóór aansluiting; licentietekst of gebruiksvoorwaarden veranderen niet ongemerkt mee met een manifest. | -Deze acties horen bij de eerste adapter in [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774). Het contract kan onafhankelijk worden geïmplementeerd door #1215. Brononderzoek blijft buiten core; bovenstaande punten zijn concrete toelatingsvoorwaarden voor deze ene aansluiting. +Deze bronvragen moeten bij #1214 worden opgelost of tot een andere bronkeuze leiden. Daarna bouwt [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774) de export en #1215 de coreverwerking. Brononderzoek blijft buiten core; bovenstaande punten zijn concrete toelatingsvoorwaarden voor deze ene aansluiting. -## Normatief formaat en validatiegrens +## Status van de eerdere contractproef -[Manifestschema](../../resources/schemas/import/v1/manifest.schema.json) en [gemeentelijk recordschema](../../resources/schemas/import/v1/municipal-record.schema.json) zijn JSON Schema Draft 2020-12, contract `1.0`. Er zijn geen externe `$ref`-verwijzingen of schema's uit leveringen. De validator laadt uitsluitend deze lokale vertrouwde schema's; Python heeft bovendien een resolver die netwerkreferenties weigert. Een `$id` is een identifier, geen te downloaden bestand. +De schemas, gesaniteerde voorbeelden en PHP-validator in [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn experimenteel. De paden `resources/schemas/import/v1` en de daarin opgenomen waarde `1.0` zijn namen uit dat prototype, geen vrijgegeven contractversie. De [voorlopige gegevenslevering](data-import-contract.md) is het actuele uitgangspunt. -Records zijn UTF-8 zonder BOM, één JSON-object per regel, uitsluitend LF en altijd een afsluitende LF. Geen compressie, lege regels of impliciete ontbrekende velden. Producers schrijven JSON via een encoder, geen handmatige stringconcatenatie. Tijdstippen gebruiken UTC-seconden `YYYY-MM-DDTHH:mm:ssZ`, echte kalenderdatums, geen leap seconds. Nullwaarden staan expliciet in het bestand. Onbekende velden worden afgewezen; uitbreidingen krijgen een afgesproken contractversie. V1 omvat alleen gemeentelijke snapshots, geen offstreet- of livevelden. +De eerdere offline proef dekte onder meer voorloopnullen, onbekend versus nul, ongeldige coördinaten, incomplete/lege bestanden en conflicterende leveringen. Er is nog geen producer of beoordeelde core-import gebouwd. De Python-proef staat lokaal en ongecommit in disabled-parking; een bijbehorende PR en vastgepinde CI-workflow bestaan nog niet. Core bevat geen Python-code of Poetry-omgeving. -`SnapshotContract::check()` in core en de onafhankelijke Python-referentie in disabled-parking controleren een lokaal recordsbestand en de **exacte manifestbytes**. Het pad en de context komen van de vertrouwde consumer, niet uit het manifest. Context bevat bron/geografische toelating, actuele config/scope en de volledige bewaarde batch-/sequencehistorie voor deze source. Een manifest kan zijn eigen bron of scope niet toelaten. +De metingen uit de eerdere proef blijven onderzoeksgegevens: 180 bronrecords werden 79.566 bytes, met maximaal 457 bytes per regel. Een synthetische 10.000-recordproef gebruikte 4.409.201 bytes; validatietijden waren PHP 0,024 / 0,600 seconden en Python 0,051 / 1,900 seconden voor respectievelijk 180 / 10.000 records. Dit rechtvaardigt geen definitief formaat, verplichte productielimieten of claim over databasepublicatie. -| Resultaat | Betekenis | -| --- | --- | -| `invalid` | Vorm, integriteit, volledigheid, geografie of limiet faalt; niet verwerken. | -| `conflict` | Bekende batch-ID met andere exacte manifestbytes, of reeds gebruikte sequence; niet verwerken zonder onderzoek. | -| `duplicate` | Dezelfde batch en exacte manifesthash na bestandsvalidatie; consumer bepaalt hervatten of overslaan. | -| `superseded` | Sequence ouder dan/gelijk aan de vertrouwde high-watermark; nooit nieuwere data terugdraaien. | -| `review` | Technisch geldig maar leeg, gewijzigde config/scope, onbekende/persoonlijke toegang of onbegrepen beperkingen. Geen automatische publicatie. | -| `valid` | De begrensde contractcheck slaagt. **Geen publicatiebesluit**: eerste import, aantalsdaling, brondatumregressie, verschillen en correctiebehoud worden pas in #1215/#1218 beoordeeld. | - -Een identieke levering moet ook dezelfde manifestserialisatie behouden. Hashes omvatten exacte bytes, inclusief LF. De checker raadpleegt geen objectopslag of database en bewijst geen bestaande objectversie, locking, queueherstel of transactionele publicatie. De consumer moet state onder lock opnieuw controleren vóór publicatie. Zowel de Python-contractproef als de latere producerimplementatie staan in disabled-parking. Core bevat alleen schemas, fixtures en PHP. Offstreet krijgt een eigen aansluiting wanneer zijn werkpakket start. - -## Verplichte pilotlimieten - -| Limiet | Waarde | -| --- | --- | -| Ready-manifest | 16.384 bytes | -| JSONL-regel inclusief LF | 16.384 bytes | -| Recordsbestand | 33.554.432 bytes (32 MiB), ongecomprimeerd | -| Records per snapshot | 10.000 | -| Fetch inclusief requests, retries en parsing | 1.800 seconden | -| Lokale contractvalidatie | 30 seconden monotone verstreken tijd | -| Probe | 30 seconden totaal, 15 seconden request-timeout, één fetch, geen automatische retries | - -Deze zijn harde pilotplafonds, geen schaalbelofte. Producer en consumer moeten ze afdwingen. De lokale validator telt werkelijke bytes en regels tijdens streaming; een manifest mag niet met kleine metadata een groter bestand toestaan. Deadlinecontrole gebeurt tussen regels en bij afronding. De opslagdownload moet in #1215/#1216 zelfstandig een byte- en wall-clockgrens krijgen; een geblokkeerde storage-read wordt niet door deze lokale checker onderbroken. Een nieuwe bron die niet past krijgt een bewuste limiet-/capaciteitsbeslissing, geen stilzwijgende verhoging. - -De 180 werkelijk gemeten bronrecords normaliseren in de proef naar 79.566 bytes, maximaal 457 bytes per regel. Een synthetische 10.000-recordproef gebruikt 4.409.201 bytes. De ceilings bieden ruimte boven deze bron, met begrensd geheugengebruik voor de set externe ID's. Gemeten validatietijden voor 180 / 10.000 records: PHP 0,024 / 0,600 seconden, Python 0,051 / 1,900 seconden. Dit is contractvalidatie op de ontwikkelmachine; databasepublicatie en stagingcapaciteit moeten afzonderlijk worden gemeten. - -## Reproduceren - -In core: - -```sh -vendor/bin/pest tests/Unit/Support/SnapshotContractTest.php --no-tia --compact -``` - -In de lokale checkout van disabled-parking, met een pad naar de core-checkout waarvan je het contract wilt testen: - -```sh -poetry install --only contract --no-interaction -NIPKAART_CORE_PATH=../core poetry run python -m unittest discover -s tests/contracts -v -``` - -PHP en Python gebruiken dezelfde bestanden uit [de fixturelijst](../../tests/Fixtures/import/v1/cases.json); kopieer de schemas of fixtures niet naar een tweede repository. Disabled-parking CI pint de core-commit, zodat een wijziging in main niet ongemerkt zijn contract verandert. Bij een nieuwe contractversie worden eerst de core-schemas/fixtures beoordeeld en daarna expliciet de producerpin en mapping bijgewerkt. Beide PR's moeten slagen voordat #1214 volledig is afgerond. - -De Python-test is uitsluitend contractcompatibiliteit en voert geen bronfetches of uploads uit. Een aparte deadlineproef gebruikt een gecontroleerde monotone klok. Normale CI doet geen bronrequests en gebruikt de vastgelegde dependencies. +## Eerstvolgende bronbeslissing -Een nieuwe read-only bronproef hoort in de importrepository of een tijdelijke onderzoeksomgeving, met `eindhoven==5.1.0` en CPython 3.14.2. Gebruik de bovenstaande package-aanroep en controleer op dezelfde response `nhits`, rijenaantal, unieke IDs en bytes. De publieke [packagevoorbeeldcode](https://github.com/klaasnicolaas/python-eindhoven/tree/v5.1.0/examples) toont het ophalen. Voor de meting is via een aiohttp trace callback de ongewijzigde response geteld; de package zelf exposeert de completenessmetadata nog niet. +Eindhoven kan pas de bruikbare pilotbron worden nadat bronidentiteit, aantoonbare volledigheid en toegangsbetekenis voldoende zijn vastgesteld. Een generieke package-uitbreiding kan daarvoor nodig zijn. Als die gaten niet praktisch kunnen worden opgelost, kiezen we een andere bron voor de eerste keten. We schuiven deze selectie niet als voldongen feit door naar de adapterimplementatie. -Herhaal die proef bij bron-/packagewijzigingen; voeg live netwerkafhankelijkheid of een bronadapter niet toe aan core. De normale tests blijven offline. +De bestaande echte bronrij in `tests/Fixtures/import/v1/pilot-source.json` is bruikbaar onderzoeksmateriaal. De afgeleide fixture met onbekende toegang en benodigde beoordeling is geen bewijs dat de bron als algemeen toegankelijke parkeerdata kan worden gepubliceerd. Voor afronding van #1214 is één bruikbare bron met een onderbouwde voorlopige voorbeeldlevering nodig; dat staat nog open. diff --git a/docs/development/quality-checks.md b/docs/development/quality-checks.md index c840a7af..9b794e13 100644 --- a/docs/development/quality-checks.md +++ b/docs/development/quality-checks.md @@ -70,6 +70,6 @@ Permanent spatial coverage lives in `ParkingLocationTest`, `ParkingDiscoveryTest See [Pest TIA documentation](https://pestphp.com/docs/tia) for baseline storage, invalidation and replay behavior. -## Snapshot contract parity +## Snapshot contract prototype -The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`. Core owns the versioned schemas and byte fixtures. The Python interpretation, dependency and CI job live in [disabled-parking](https://github.com/NIPKaart/disabled-parking), where CI checks out a pinned core revision and validates those same files without source requests. Core does not contain Python code or a Poetry environment. See the [contract proof](data-import-pilot.md) for the cross-repository commands, source evidence and mandatory limits. +The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`, an experimental validator from draft PR #1222. Its passing fixtures do not prove a working adapter or reviewed import. Core contains no Python environment. A Python proof exists locally in disabled-parking; no companion PR or cross-repository CI has been delivered. See the [current delivery scope](data-import-contract.md) and [source findings](data-import-pilot.md) before treating the prototype as a settled contract. diff --git a/docs/product/data-foundation.md b/docs/product/data-foundation.md index b2fb7e3d..81e243cb 100644 --- a/docs/product/data-foundation.md +++ b/docs/product/data-foundation.md @@ -1,5 +1,7 @@ # Europese databasis voor NIPKaart +Actuele uitvoeringsgrens: eerst [één bruikbare bron en een voorlopige levering](../development/data-import-contract.md), daarna een lokale export en beoordeelde core-import. De technische uitwerkingen hieronder zijn richtinggevend voor later; PR #1222 is een onvoltooid prototype, geen definitief contract of bewijs van die keten. + Status: overeengekomen richting met een voorgesteld technisch ontwerp; nog niet geïmplementeerd. Vastgelegd op 2026-09-08 naar aanleiding van de product- en architectuurgesprekken met de eigenaar. Technische defaults en open beslissingen zijn hieronder expliciet gemarkeerd. Dit document is geen bewijs van werkende imports, Europese dekking of productieacceptatie. De gekozen uitvoering is een zelfstandige batchaanpak: importrepositories plannen het ophalen en publiceren complete bestanden; core ontdekt en verwerkt die leveringen. Dit vervangt het eerdere voorstel waarin core opdrachten aan workers uitdeelde. From 54a5b20c606f01033084573a96b98b41d9145a5b Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 14 Sep 2026 00:00:37 +0200 Subject: [PATCH 05/10] docs: select Amsterdam pilot and simplify import handoff --- docs/development/data-foundation-delivery.md | 256 ++++--------------- docs/development/data-foundation-stack.md | 86 ++----- docs/development/data-import-contract.md | 105 ++++---- docs/development/data-import-pilot.md | 118 ++++++--- docs/product/data-foundation.md | 14 +- 5 files changed, 211 insertions(+), 368 deletions(-) diff --git a/docs/development/data-foundation-delivery.md b/docs/development/data-foundation-delivery.md index 24fcd157..c5927918 100644 --- a/docs/development/data-foundation-delivery.md +++ b/docs/development/data-foundation-delivery.md @@ -1,236 +1,70 @@ # Batchimports: uitvoering en beheer -Status: gefaseerd uitvoeringsvoorstel, 2026-09-08. De actuele eerste stap is [#1214](https://github.com/NIPKaart/core/issues/1214): één bruikbare bron en één voorlopige levering beschrijven. [PR #1222](https://github.com/NIPKaart/core/pull/1222) blijft een onvoltooid prototype. Er zijn geen importjobs gestart of productiegegevens geïmporteerd. Zie de [productbasis](../product/data-foundation.md), [voorlopige gegevenslevering](data-import-contract.md) en [bronbevindingen](data-import-pilot.md). +Status: uitvoering van het KISS-plan uit [core #1176](https://github.com/NIPKaart/core/issues/1176). De [pilotbeschrijving](data-import-pilot.md), [leveringsafspraak](data-import-contract.md) en [techstack](data-foundation-stack.md) geven de concrete eerste stap. Geen productie-import of infrastructuur is door deze documentatie geactiveerd. -## Eenvoud als uitgangspunt voor uitvoering +## Werkvolgorde -De eerste werkende keten is: universele bronpackage → lokaal bestand vanuit disabled-parking → beoordeling en verwerking in core. De huidige planning omvat meerdere issues; #1214 hoeft niet de hele keten te implementeren. - -1. **Bron en betekenis, #1214:** kies een bruikbare bron, onderbouw de veldbetekenis en beschrijf één voorlopige voorbeeldlevering. Leg verantwoordelijkheden en foutgevallen vast. -2. **Lokale export, disabled-parking #774:** gebruik de echte package om dat bestand te produceren. Controleer selectie, identiteit, onbekende waarden en volledigheid. -3. **Beoordeelde verwerking, core #1215:** lees het bestand, toon verschillen en verwerk na beoordeling. Herimport behoudt dezelfde plekken; ontbrekende records worden niet automatisch verwijderd. -4. **Vervolgwerk:** correctiebehoud verder uitwerken in #1218 en ophalen/overdracht/discovery automatiseren nadat de lokale keten werkt. Een geaccepteerde correctie mag vanaf haar introductie nooit door import worden gewist. - -Brononderzoek/CRM, generieke adapterbouwers, karma en een mobiele app blijven buiten deze eerste keten. Het bestandsformaat wordt pas vastgezet na de praktijkproef. Een losse validator of bereikbare API bewijst geen werkende import. - -| Nu vastleggen | Later uitwerken | -| --- | --- | -| Bron-ID en betekenis van gegevens | Cloudobjectkeys en version-ID's | -| Afbakening en aantoonbare volledigheid | Ready-manifest-last en opslagdiscovery | -| Bron, levering, ophaaltijd en onbekende waarden | Producersequence, planning en herstel over hosts | -| Verwacht gedrag bij herhaling, verdwijning en correctie | Precieze transport- en schaalmechanismen | -| Verantwoordelijkheden per repository | Hosting, bewaartermijnen en operationele limieten | - -Integriteit, begrensde invoer, veilige herverwerking en correctiebehoud blijven nodig. De precieze mechanismen volgen uit de eerste werkende keten. De hieronder bewaarde latere werkpakketten en operationele voorstellen voegen geen extra acceptatiecriteria toe aan #1214. - -## 1. Roadmapkoppeling - -De bestaande productissues zijn als anker hergebruikt. De concrete planning hieronder is op 2026-09-08 aangemaakt; alle acceptatiecriteria blijven open totdat de uitvoering bewijs oplevert. - -| Issue | Bijdrage | -| --- | --- | -| [#1168](https://github.com/NIPKaart/core/issues/1168) | Productervaring; noodzakelijke databasis eerder dan oude Horizon 2-indeling | -| [#1176](https://github.com/NIPKaart/core/issues/1176) | Operationeel datasetregister, batchimporthistorie, identiteit en dekking | -| [#1174](https://github.com/NIPKaart/core/issues/1174) | Provenance, freshness en begrijpelijke status | -| [#1175](https://github.com/NIPKaart/core/issues/1175) / [#276](https://github.com/NIPKaart/core/issues/276) | Correcties/besluiten/retentie; correcties op geïmporteerde plekken expliciet meenemen | -| [#1177](https://github.com/NIPKaart/core/issues/1177) | Voorzieningen en aparte bezettingsstroom | -| [#1170](https://github.com/NIPKaart/core/issues/1170) / [#1172](https://github.com/NIPKaart/core/issues/1172) | Effectieve gegevens en bronkoppelingen in discovery/details | -| [#1171](https://github.com/NIPKaart/core/issues/1171) | Toegankelijke lijst/detail en correctieflow | -| [#455](https://github.com/NIPKaart/core/issues/455) | Bulkbeheer behoudt import-/correctiehistorie | - -Maak kleine reviewbare PR's per verticale stap. Sluit issues pas na hun werkelijke acceptatie, niet op basis van deze ontwerptekst. - -## Concrete GitHub-planning - -De [data-epic #1176](https://github.com/NIPKaart/core/issues/1176) hangt onder [productepic #1168](https://github.com/NIPKaart/core/issues/1168). De [municipal producer-epic #690](https://github.com/NIPKaart/disabled-parking/issues/690) is hergebruikt en herschreven rond bestanden in plaats van directe databasewrites. Correcties, freshness en offstreet blijven onder hun bestaande productepics. Afhankelijkheden zijn echte GitHub-blockingrelaties; dit zijn oplevergroepen zonder verzonnen kalenderdeadlines. - -| Oplevering | Issue | Resultaat | +| Stap | Issue | Klaar wanneer | | --- | --- | --- | -| M1 | [core#1214](https://github.com/NIPKaart/core/issues/1214) | Pilotbron en bestandcontract | -| M1 | [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774) | Eerste packageadapter | -| M1 | [core#1215](https://github.com/NIPKaart/core/issues/1215) | Beoordeelde bestandimport | -| M2 | [disabled-parking#775](https://github.com/NIPKaart/disabled-parking/issues/775) | Zelfstandige batchproducer | -| M2 | [core#1216](https://github.com/NIPKaart/core/issues/1216) | Manifestdiscovery en herstel | -| M2 | [core#1217](https://github.com/NIPKaart/core/issues/1217) | Staging, opslagrechten en retentie | -| M3 | [core#1218](https://github.com/NIPKaart/core/issues/1218) | Correctiebehoud bij herimport | -| M3 | [core#1219](https://github.com/NIPKaart/core/issues/1219) | Herkomst en overdue | -| M3 | [core#1220](https://github.com/NIPKaart/core/issues/1220) | Volledige ketenacceptatie | -| Vervolg | [disabled-parking#776](https://github.com/NIPKaart/disabled-parking/issues/776) | Tweede Europese bron | -| Vervolg | [offstreet-parking#656](https://github.com/NIPKaart/offstreet-parking/issues/656) | Offstreetcatalogus | -| Vervolg | [core#1221](https://github.com/NIPKaart/core/issues/1221) | Bezettingscontract en coreconsumer | -| Vervolg | [offstreet-parking#657](https://github.com/NIPKaart/offstreet-parking/issues/657) | Liveproducer en integratie | - -Start met de bronkeuze en voorlopige voorbeeldlevering in core#1214. Daarna volgen de echte adapterexport en de core-bestandintake. Stagingbeslissingen blokkeren de lokale bestandproef niet. De gemeentelijke ketenacceptatie blokkeert verdere bronuitbreiding; de offstreetconsumer wordt eerst op fixtures bewezen voordat de liveproducer integreert. - -## 2. Werkpakketten - -### A — Bruikbare bron en voorlopige levering (#1214) - -Core bewaart de leveringsafspraak en voorbeelden; brongebonden Python-onderzoek hoort in disabled-parking of een tijdelijke onderzoeksomgeving. Er is geen infrastructuur vereist. - -- [ ] Selecteer één bruikbare bron met onderbouwde bronidentiteit, scope, veldbetekenis en hergebruikvoorwaarden. Eindhoven is een onderzochte kandidaat, nog geen geaccepteerde keuze. -- [ ] Controleer package-output, volledige ophaling, onbekende waarden, brondata en geografische mapping. Los blokkers op of kies een andere bron. -- [ ] Leg één toegestane bronrij en de voorgestelde NIPKaart-weergave vast, met uitleg per veld. -- [ ] Beschrijf leveringidentiteit, dataset, selectie, ophaaltijd, volledigheid en de voorlopige formaatkeuze zonder verplichte opslagvelden. -- [ ] Leg herhaling, wijziging, verdwijning, fetchfout en correctiebehoud uit. -- [ ] Leg de overdracht naar disabled-parking #774 en core #1215 vast. - -Klaar wanneer de bron bruikbaar is en één onderbouwde voorlopige levering beschreven is. Het formaat is dan nog geen vrijgegeven 1.0-contract. - -### A2 — Daadwerkelijke lokale export (disabled-parking #774) - -De adapter gebruikt de echte universele package en schrijft de afgesproken levering lokaal. Test de mapping en belangrijke foutgevallen zonder bronnetwerk in gewone tests. Een generieke verbetering aan bronophaling hoort in de package; NIPKaart-mapping hoort in disabled-parking. Geen directe databasewrites of verplichte cloudcredentials. - -Klaar wanneer hetzelfde bronvoorbeeld reproduceerbaar door de adapter als bestand wordt geleverd. Deze implementatie hoort niet bij het beschrijven van de afspraak in #1214. - -### B — Bestand ontvangen, vergelijken en publiceren - -Repo: core (#1215). Gebruikt de voorlopige afspraak uit A en wordt uiteindelijk met de echte export uit A2 beproefd. +| Voorbereiding | disabled-parking #778 | Afgerond met gemergede #779 (uv), #780 (SQL-verwijdering) en #781 (klein voorbeeld). Dit bewijst nog geen live aansluiting. | +| Bron en bestand | core #1214 | Eén bruikbare bron, hergebruikbewijs, betekenis, beperkingen, voorbeeldrij en voorlopige levering zijn beschreven. | +| Live export | disabled-parking #774 | Eén commando haalt die bron volledig via de package op en schrijft het afgesproken bestand; failures behouden de vorige geldige export. | +| Handmatige keten | core #1215 | Werkelijke eerste en herhaalde levering beoordeeld verwerkt, met veilige wijzigingen, ontbrekende records en correctiebehoud. | +| Opslagbesluit | core #1217 | Provider, regio, kosten, rechten, retentie en veilige voltooiing van een upload zijn gekozen en getest vóór inzet. R2 blijft kandidaat. | +| Automatisch leveren | disabled-parking #775 | Een geplande eindige uitvoering uploadt hetzelfde bestandsformaat naar de private bucket. | +| Automatisch ontvangen | core #1216 | Core ontdekt complete bestanden en gebruikt hetzelfde intakepad als bij de lokale proef. | +| Correcties uitbreiden | core #1218 | Bijdragen, beoordeling en intrekken van correcties zijn uitgewerkt. Basisbehoud van bestaande correcties hoort al bij #1215. | +| Actualiteit | core #1219 | Ontvangst, publicatie, brondatum en uitblijvende leveringen zijn afzonderlijk zichtbaar. | +| Ketenacceptatie | core #1220 | De automatische stagingketen, herstel en correctie/herimport werken samen. | +| Tweede bron | disabled-parking #776 | Een tweede Europese bron bewijst hergebruik na de eerste ketenacceptatie. | +| Offstreet | offstreet-parking #656/#657, core #1221 | Eerst catalogus, daarna afzonderlijke bezettingsbetekenis en passend ritme. | -- [ ] Registreer een toegelaten dataset met source-ID, scope, herkomst, voorwaarden, geografische mapping en publicatiebeleid; geen onderzoeksworkflow. -- [ ] Bouw één toepassingsservice voor intake vanaf een lokaal/uploadbestand en later objectopslag. Geen directe spreadsheetwrites naar parkeertabellen. -- [ ] Leg batchidentiteit, volgorde, artifactidentiteit en status vast met unieke constraints en herhaalbare verwerking. -- [ ] Valideer in staging en toon een diff met aantallen, fouten, verdwijningen en kaart/lijststeekproef. -- [ ] Beoordeel eerste publicatie; blokkeer incomplete, onverwacht lege en ongeldige datasets. -- [ ] Behoud bestaande bron-ID's, visibility en verwijzingen; maak alleen inhoudelijke bronwijzigingen tot revisies. -- [ ] Publiceer de begrensde pilot in één korte transactie; bewijs rollback en hervatten bij uitval. -- [ ] Hercontroleer basis-/correctieversies vlak vóór publicatie; missing-from-source is beoordeling, geen delete. +Issues behouden hun eigen acceptatiecriteria. Een merge van een voorbeeld, groene CI of bereikbare endpoint bewijst geen volledige dataset of beoordeelde publicatie. -Klaar wanneer lokale eerste import/herimport werkt en mislukte batches bestaande publieke data niet wijzigen. +## Eerste werkende keten -### C — Zelfstandige aanlevering automatiseren +1. Leg in core één toegelaten dataset vast met bronhouder, licentie/attributie, vaste selectie, geografische mapping en publicatiebeleid. Brononderzoek gebeurt buiten het platform; geen CRM bouwen. +2. Laat de universele package de bron begrijpen en volledig ophalen. Generieke endpoint-, parser- en pagineringsfixes horen daar; NIPKaart-velden niet. +3. Laat disabled-parking de bronobjecten vertalen naar één begrensd JSON-bestand met identiteit, ophaaltijd en onderbouwde volledigheid. +4. Laat core het bestand valideren en verschillen tonen vóór publicatie. Eerste publicatie wordt beoordeeld; gewone bestandsintake publiceert niets vanzelf. +5. Bewijs herhaling, wijziging, oudere/incomplete/lege levering, ontbrekend record en geaccepteerde correctie. Identiteit en bestaande verwijzingen blijven behouden. -Repo: disabled-parking en deploymentconfiguratie. Afhankelijk van A; bruikbaar naast B. +#774 vervangt `municipal-records-draft`, onvoorwaardelijke null-metadata en achterhaalde voorbeeldcode. #1215 vervangt of verwijdert de schemas en validator uit #1222 zodra het echte intakepad ze opvolgt. Eén actief formaat, geen parallelle compatibiliteitslaag. Een lokale export en kleine offline tests blijven nuttig. -- [ ] Leg sourceconfiguratie, ophaalritme en expliciete adapterregistry vast in de importrepo; secrets buiten versiebeheer. -- [ ] Bouw een eindige batchrunner met deadlines, retries/backoff, bronlimieten en durable source-sequence/uploadstatus. -- [ ] Maak een gelockt containerimage en één geplande hostuitvoering per repo; geen container/cron per gemeente. -- [ ] Upload naar private source-prefix met beperkte producentrechten en recordsversion-ID; schrijf manifest pas na complete upload. -- [ ] Test half bestand, manifestuploadfout, herhaalde aanlevering, oudere batch, bron-429, bronfout en stateherstel. -- [ ] Produceer foutlogs en een operationele melding; geen fout als succesmanifest publiceren. -- [ ] Bewijs beperkte bronhouderrequests, ook wanneer packagepaginering intern gebeurt. +## Automatisering na de handmatige proef -Klaar wanneer de importomgeving zonder core beschikbaarheid een complete versieerbare levering kan publiceren. De batchrunner heeft geen coretoken of DB-credentials. +`gemeentelijke API → universele package → producer → private bucket → core intake/review → PostgreSQL/PostGIS → publieke discovery` -### D — Core ontdekt leveringen en bewaakt actualiteit +De bucket is de afgesproken automatische overdracht. De producent heeft geen coretoken of databaseverbinding en blijft verantwoordelijk voor ophaalplanning. Core verwerkt leveringen en beheert beoordeling en publicatie. -Repo: core. Afhankelijk van B/C; voltooit automatisering. +Begin met één geplande eindige uitvoering en maximaal één actieve ophaling per dataset. Stel deadlines en beperkte retries in; houd rekening met bronlimieten. De implementatie van host/timer, uploadvoltooiing en herstel hoort bij #775/#1217. Geen verplicht manifest, version-ID, gedistribueerde teller of permanente runnerstaat zonder aantoonbare behoefte. -- [ ] Leg toegelaten opslagprefix en verwachte maximale leveringsleeftijd per dataset vast. -- [ ] Ontdek ready-manifesten met volledige paginering en lees exacte recordsversies via read-only opslagrechten. -- [ ] Gebruik dezelfde intake/verwerking als B en herplan niet-afgeronde imports na een crash vóór queuedispatch. -- [ ] Bewijs unieke intake, conflicterende batch-ID, late oudere sequence en twee gelijktijdige coreprocessors. -- [ ] Test core-uitval, ontdekking na herstel en de afgesproken opslagretentie als leveringsgrens. -- [ ] Toon laatste ontvangst, validatie, publicatie, brondatum en overdue afzonderlijk; een oud succes blijft niet gezond. -- [ ] Maak verschil tussen ophalen pauzeren buiten core en verwerking/publicatie pauzeren in core duidelijk. +Automatische discovery gebruikt dezelfde validatie en importservice als de lokale proef. Een half bestand mag niet als complete levering worden verwerkt. Herontdekking is veilig, een oudere levering overschrijft geen nieuwere publicatie en een conflict omzeilt geen eerder besluit. Automatisch ontvangen betekent niet automatisch alle wijzigingen publiceren. -Klaar wanneer de volledige stagingketen autonoom werkt en een ontbrekende levering zichtbaar wordt zonder dat core het ophaalproces bestuurt. +## Beheer en herstel -### E — Correctiebehoud en productacceptatie - -Repo: core. Afhankelijk van B; integreer met D voor ketenacceptatie. - -- [ ] Laat een gebruiker een veldcorrectie op een geïmporteerde plek voorstellen met reden en waarnemingsdatum. -- [ ] Laat een bevoegde beheerder accepteren/afwijzen met actor, basisversie en duurzame historie. -- [ ] Bewaar bronwaarde en lokale correctie afzonderlijk; details lezen effectieve gegevens. -- [ ] Herimport overschrijft de correctie niet; afwijkende bronwaarden worden als conflict zichtbaar. -- [ ] Intrekken van een correctie gebruikt de actuele bronbasis, niet een toevallige oude kopie. -- [ ] Favorieten en detailverwijzingen blijven stabiel bij herimport, correctie en terugkeer. -- [ ] Test rechten, gelijktijdige wijzigingen, publieke velden en keyboard-/telefoongebruik. - -Klaar wanneer de hele keten een correctie en herimport doorstaat. Bijdragerechten/retentie moeten vóór publieke activering geregeld zijn. Foto's en karma kunnen later. - -### F — Tweede bron en offstreet - -Repos: beide importrepos en core. Na de eerste ketenacceptatie. - -- [ ] Sluit een inhoudelijk andere Belgische/Duitse bron aan en toets geografische/semantische variatie. -- [ ] Trek pas werkelijk gedeelde uitvoeringslogica los; geen generiek workerframework vooraf. -- [ ] Sluit een offstreetcatalogus aan op hetzelfde snapshotcontract. -- [ ] Voeg aparte versieerbare bezettingsbatches toe met waarnemingstijd, stale-status, eigen ritme en retentie; geen catalogusverwijdering. -- [ ] Voeg eventueel handmatige CSV/GeoJSON-vertaling toe naar dezelfde intake. -- [ ] Meet runtime, listingvolume, transactieduur en beheerwerk voordat bronnen/hosts worden opgeschaald. -- [ ] Bronkoppelingen en ontdubbeling krijgen eigen review met behoud van bestaande type/ID-verwijzingen. - -Klaar per bron; geen claim van volledige Europese dekking. - -## 3. Hosting en planning - -| Proces | Taak | +| Gebeurtenis | Actie | | --- | --- | -| Importhost systemd-timer per repository | Start periodiek de eindige due-runner | -| Python-container | Leest sourceconfig, haalt due bronnen op, maakt bestanden en manifesten | -| Persistent runnervolume | Lokale SQLite-staat voor planning, sequences en onafgeronde uploads; tijdelijke bestanden begrensd | -| Private versioned S3-opslag | Uitwisselpunt; source-prefixen en onafhankelijke rechten | -| Core scheduler | Ontdekt manifests, herplant verwerking en controleert overdue | -| Core databasequeueprocessor | Valideert/staget/vergelijkt/publiceert | -| PostgreSQL/PostGIS | Bronidentiteit, batchstatus, correcties en gepubliceerde gegevens | +| Bronfout of onvolledige selectie | Geen nieuwe geldige export afleveren; bestaande publicatie blijft staan. | +| Uploadfout | Hetzelfde geproduceerde bestand met dezelfde delivery-ID opnieuw proberen; geen tweede fetch voorstellen als dezelfde levering. | +| Core-uitval | Complete bestanden later opnieuw ontdekken; eerder ontvangen en afgeronde leveringen herkennen. | +| Onjuiste of verdachte levering | Publicatie blokkeren en beoordelen; niet automatisch bestaande records verwijderen. | +| Bron botst met lokale correctie | Bronwaarde afzonderlijk bewaren; correctie behouden en conflict tonen. | +| Gecompromitteerde producent | Rechten intrekken, ophaling en intake voor die dataset pauzeren en betrokken leveringen beoordelen. | +| Foute publicatie | Een nieuw herstelbesluit met behoud van actuele communitycorrecties; geen oude database over nieuwe bijdragen terugzetten. | -Start met één Linux-host en één gemeentelijke runner. Docker Compose bevat runtimeconfiguratie; systemd start de batchservice. De due-check kan iedere minuut draaien terwijl sources hun eigen ritme hebben. Hostlocks voorkomen overlap; maximaal één actieve fetch per bronhouder in de pilot met interne pacing. Dezelfde bronhouder in beide repos gebruikt een gedeelde hostlock. Ophaalplanning is geen onderdeel van core. +Core toont wanneer iets is ontvangen, gevalideerd en gepubliceerd. Een verwerkingsdatum is geen veldwaarneming. De producent meldt fetch-/uploadfouten; core kan zonder aanvullende status alleen zien dat een levering uitblijft. Een apart monitoringplatform of statusfeed is geen pilotvoorwaarde. -De runner eindigt na het werk. Crash, timeout of SIGTERM mag geen half gereedmanifest nalaten. Bewaar geproduceerd bestand en uploadstatus waar veilig hervatten mogelijk is; een nieuwe fetch krijgt een nieuwe sequence. Gemiste snapshotmomenten leiden tot de eerstvolgende actuele fetch, niet onbeperkt inhalen. +Vóór staging: keuze voor host/bucket, scoped rechten, harde grenzen en budget. Vóór productie: retentie, maximaal te overbruggen uitval, herstelproef, attributie en beheerrechten. Private opslag beschermt de operatie; de voorwaarden van iedere bron blijven gelden. Publieke discovery blijft begrensd en vraagt geen account voor basisgebruik. -Livebezetting kan een aparte continue offstreetservice worden met dezelfde bestandgrens. Bij groei sources exclusief verdelen over hosts of eerst coördinatie ontwerpen; zomaar replicas starten kan bronlimieten of sequence-eigenaarschap schenden. - -## 4. Beveiliging en bescherming - -| Grens | Vereiste | -| --- | --- | -| Producent | Alleen eigen source-prefix schrijven en noodzakelijke upload/herstelacties; geen core- of DB-toegang | -| Coreconsumer | Alleen toegelaten prefixen lezen/listen en exacte versies lezen; geen ophaalcredentials | -| Cleanup | Afzonderlijke beperkte delete/lifecyclebevoegdheid; geen automatisch verwijderen van benodigde herstelversies | -| Secrets | Gescheiden roteerbare rollen/credentials; nooit in image, repo, URL-log of manifest | -| Bestanden | Private opslag, unieke batchkeys, exacte versions, checksum en harde inhoud-/omvangsgrenzen | -| Adapter/netwerk | Expliciete coderegistry; geen code uit configuratie; bronhosts/redirects en egress begrenzen inclusief private/metadataadressen | -| Beheer/community | Bestaande policies en rollen voor datasetbeheer, beoordeling en correcties | -| Publieke dienst | Begrensde queries en rate limits, expliciete publieke velden en aparte bulkkeuze; basiszoeken zonder account | +De [fresh-start-afspraak](postgresql.md#fresh-start-decision) blijft gelden. Geen verplichte historische MySQL-transfer. Productiejobs uitschakelen of een nieuwe dienst activeren is een afzonderlijke deploymentactie. -Een toegelaten producent kan onjuiste gegevens aanleveren. Opslagrechten/checksums bewijzen geen inhoudelijke betrouwbaarheid: validatie, broncontrole en beoordeling blijven nodig. Open images/code geven geen toegang tot de private bucket of core. Datarechten/attributie blijven per bron gelden; de [productbasis](../product/data-foundation.md#9-open-code-en-beschermde-dienstverlening) beschrijft de grenzen. +## Verificatie -## 5. Monitoring en retentie +Gewone CI gebruikt kleine offline voorbeelden. Tests richten zich op mapping en verliesrisico's, integriteit en veilige verwerking. Parser/paginering wordt in de universele package getest; geen fixturecorpus van alle gemeenten in disabled-parking. -De importomgeving logt source/batch/sequence/adapter/image, fase, duur en veilige foutcode. Meld herhaalde fetch-/uploadfouten via operationele monitoring en bundel herhalingen. Core toont ontvangst, validatie, publicatie en brondatum; uitblijvende levering wordt overdue. Core kent de fetchfoutoorzaak niet zonder aanvullende statusfeed, die niet bij de pilot hoort. +Naast offline CI komt in #775 een periodieke begrensde live controle: endpoint, verwacht responsetype/velden, identiteit en volledigheid. Een HTTP 200 alleen is onvoldoende. Een gewijzigde API of semantisch onvolledige package-output mag geen nieuwe geldige levering opleveren. Houd deze brongezondheid apart van package-unit-tests; een upstream storing maakt niet iedere code-PR rood. Frequentie en meldingen worden bij automatisering gekozen. -Stel vóór staging vaste upload-/record-/uitvoeringslimieten in. Stel vóór productie ook bewaartermijnen, maximaal te overbruggen core-uitval, RPO/RTO en kostenplafond vast. Recordsobjectversies en manifests moeten samen beschikbaar blijven binnen die uitvalgrens. Weesbestanden mogen eerder worden opgeruimd; benodigde publicatie-/herstelreferenties blijven bewaard. Onbeperkte bezettingshistorie is geen default. +Een begrensde live bronproef wordt afzonderlijk vastgelegd met packageversie, selectie, aantallen, omvang, tijd en concrete beperkingen. Daarna bewijst #1215 een daadwerkelijke lokale intake. #1220 bewijst later de automatische keten, inclusief uitval/herstel. Geen checkboxes afvinken op basis van uitsluitend een prototype. -Databasebackups dekken de bucket en lokale runnerstaat niet. Test gezamenlijk herstel van core, benodigde objectversies en sequenceplanning. Bronvoorwaarden bepalen of ruwe payloads bewaard mogen worden. Genormaliseerde batches hebben eveneens expliciet bewaarbeleid. Bestaande user-/plekdeletecascades veranderen niet door dit document; nieuwe bewijsretentie hoort bij #1175/#276. - -## 6. Herstel en release - -1. Bronfout: inspecteer runnerlogs, pas package/config aan en maak een nieuwe batch; de vorige corepublicatie blijft staan. -2. Uploadfout: hervat de levering van hetzelfde geproduceerde artifact; schrijf ready pas na volledige opslag. -3. Core-uitval: hervat manifestdiscovery en niet-afgeronde imports; behoud sequence- en batchcontrole. -4. Verdachte batch: blokkeer/verwerp publicatie. Een identieke herontdekking omzeilt die beslissing niet. -5. Producentcompromis: trek opslagcredentials in, stop runner en pauzeer nieuwe intake/publicatie voor betrokken sources; beoordeel artifacts/publicaties in die periode. -6. Foute publicatie: nieuw herstelbesluit met geldige bewaarde bronrevisie en actuele correcties. Geen oude DB over nieuwe communitybijdragen terugzetten. -7. Stateherstel: herstel sequences uit lokale staat en reeds gepubliceerde manifesten; hergebruik geen oude sequence. Bij onzekere continuïteit aansluiting pauzeren en expliciet reconciliëren. - -Rol nieuwe contractondersteuning eerst in core uit; daarna nieuwe producerimages. Oude ondersteuning verdwijnt pas nadat opgeslagen relevante batches en producers uitgefaseerd zijn. Pin images op digest en bewaar versie-/commitinformatie bij batches. Ophalen blijft uit tot bronvoorwaarden, scope, rechten en limieten klaar zijn. Eerste echte publicatie wordt beoordeeld; automatische normale verwerking volgt na pilotacceptatie. - -De [fresh-start-afspraak](postgresql.md#fresh-start-decision) blijft gelden: geen verplichte historische MySQL-transfer. Eventuele oude communityplekken krijgen een aparte herleidbare legacy-import zonder fictieve verificatiedatum. Oude productiejobs uitschakelen is een expliciete deploymentactie, geen gevolg van deze documentatie. Laat oude en nieuwe importsystemen niet tegelijk dezelfde doelgegevens schrijven. - -## 7. Tests en startbeslissingen - -Volg [quality-checks.md](quality-checks.md) en de toepasselijke skills tijdens implementatie. Deze documentatiewijziging vereist geen applicatie-/databasetest. - -| Niveau | Bewijs | -| --- | --- | -| Adapter/schema | Gesaniteerde echte fixtures, semantiek, pagina's, ID's, nullwaarden en fouten | -| Opslagtransport | Upload-before-ready, versioned reads, prefixrechten, half bestand en herhaalde aanlevering | -| Core-integratie | PostgreSQL-transacties, idempotentie, volgorde, revisies en correctiebehoud | -| Stagingketen | Geplande echte producer → private opslag → corediscovery → publicatie → correctie → herimport | -| Operationeel | Overdue, bronlimieten, credentials intrekken, log/retentiegrenzen en herstel | -| Product | Attributie/freshness, juiste locatie en toegankelijke capaciteit, keyboard-/telefoonflow | - -Normale CI gebruikt geen gemeentelijke netwerken. Bronprobes zijn apart, read-only en begrensd. Bewaar core-SHA, producerimage-digest, adapter/package/contractversie, sourceconfigversie en batch-ID bij acceptatie. CI is niet hetzelfde als productie- of fysieke datakwaliteit. - -| Beslissing | Nodig vóór | -| --- | --- | -| Pilotbron/packageversie en voorwaarden | A afronden | -| Definitief bestandcontract, sourcevolgorde en limieten | B/C | -| Buitenlandse geografische mapping | Betreffende bron aansluiten | -| Host, opslagregio, rechten en budget | Stagingdeploy | -| Retentie, uitvalgrens, herstel en contributierechten | Productie/publieke bijdragen | -| Historische import, foto's, karma, mobiel, bulktoegang | Afzonderlijke vervolgfunctie | - -De eerstvolgende ontwikkelopdracht is A met de minimale bestandintake van B. Zo bewijzen we het gegevenscontract en de verwerking voordat automatische aanlevering wordt toegevoegd. +Volg [quality-checks.md](quality-checks.md) tijdens implementatie. Deze documentatie op zichzelf wijzigt geen applicatiegedrag en vereist geen databaseproef. diff --git a/docs/development/data-foundation-stack.md b/docs/development/data-foundation-stack.md index 489b4261..ef19bbb1 100644 --- a/docs/development/data-foundation-stack.md +++ b/docs/development/data-foundation-stack.md @@ -1,73 +1,25 @@ -# Concrete techstack voor batchimports +# Techstack voor de eerste importketen -Status: opties voor latere implementatie, 2026-09-08. Eerst bewijzen we de [handmatige keten](data-import-contract.md). De onderstaande stack legt geen aanvullende voorwaarden op aan #1214 en is geen infrastructuurbesluit. Opis/json-schema is onderdeel van de onvoltooide core-proef; jsonschema staat in lokale, ongecommitte wijzigingen in disabled-parking. Deze dependencies zijn goedgekeurd, maar bewijzen geen definitief ontwerp. Er zijn geen diensten geprovisioned. De bestaande Laravel/PostgreSQL- en Poetry-basis blijft gelden. +Status: eenvoudige uitvoeringskeuzes bij [#1214](https://github.com/NIPKaart/core/issues/1214). Zie de [leveringsafspraak](data-import-contract.md) en [werkpakketten](data-foundation-delivery.md). Er is nog geen live producer/core-keten of bucket ingericht. -## 1. Opties voor implementatie na de bron- en leveringproef +| Onderdeel | Keuze voor de eerste stap | +| --- | --- | +| Core | Bestaande Laravel 13-applicatie, PHP 8.4 baseline en PostgreSQL/PostGIS. | +| Beheer | Bestaande React/Inertia-interface voor vergelijken en beoordelen; geen afzonderlijke importapp. | +| Producer | `disabled-parking`, bestaande Python >=3.11-omgeving en universele bronpackage. Bronpackageversies worden bij de noodzakelijke fix gecontroleerd. | +| Dependencybeheer | uv en `uv.lock`; installatie met `uv sync --locked`, daarna `uv run ...`. Universele packages houden hun eigen tooling. | +| Mapping | Kleine Python-dataclass en expliciete mapping voor één bron. | +| Bestand | Eén begrensd UTF-8 JSON-document, geschreven via tijdelijk bestand en atomische vervanging. | +| Validatie | Expliciete typen en inhoudscontroles aan beide kanten. Opis uit #1222 is beschikbaar als een klein gedeeld schema nuttig blijkt; geen schemarelease-infrastructuur vereist. | +| Tests | Bestaande Python unittest/pre-commit-checks en core Pest. Kleine mappingvoorbeelden; geen gemeentelijke netwerken in gewone CI. | +| Eerste overdracht | Lokaal bestand naar dezelfde core-intakeservice die later bucketbestanden verwerkt. | +| Automatische overdracht | Private bucket; R2 is kandidaat. Provider, regio, kosten, volledigheidsmechanisme en scoped rechten in #1217. | +| Uitvoering later | Eén geplande eindige producentuitvoering en core scheduler/queue. Hosting en ophaalritme pas kiezen bij #775/#1217. | -| Onderdeel | Concrete keuze | Status en reden | -| --- | --- | --- | -| Core | Laravel 13, PHP 8.4 baseline, PHP 8.5 extra CI-doel | Bestaand | -| Beheer/web | React 19, Inertia 3, TypeScript, bestaande componenten en Wayfinder | Bestaand; alleen dataset-/importbeheer uitbreiden | -| Database/geo | PostgreSQL 18 + PostGIS 3.6 | Bestaand; identiteit, importhistorie, correcties en publicatie | -| Core discoveryplanning | Laravel Scheduler | Controleert ready-manifesten en achterstand; plant geen bronfetches | -| Core verwerking | Laravel Queue met bestaande database-driver | Valideren, staging, diff en publicatie op achtergrond | -| Python-uitvoering | CPython 3.14 met asyncio | Doelruntime; bronpackagecompatibiliteit eerst toetsen | -| Dependencies | Bestaande Poetry-tooling en poetry.lock | Behouden; geen verplichte uv-migratie | -| Bron-HTTP | Bestaande packageclients, aiohttp voor directe async adapterverzoeken | Universele packages behouden hun clientkeuze | -| Recordmodel | dataclasses en expliciete adaptermapping | Kleine getypeerde vertaallaag | -| Contract | JSON Schema Draft 2020-12; Python jsonschema 4.x en PHP opis/json-schema 2.x | Dezelfde normatieve schemas en fixtures | -| Bestanden | UTF-8 JSONL + ready-manifest + SHA-256 | Streaming verwerking en onafhankelijke herverwerking | -| Objectopslag | Private Amazon S3-bucket in gekozen EU-regio, versioning aan | Concrete referentieprovider; geen bucket aangemaakt | -| Opslagclients | Python boto3; bestaande Laravel Flysystem S3-adapter / AWS SDK voor versiegebonden reads | Python uploadt; core leest exacte versies via SDK waar de abstractie dat niet ondersteunt | -| Toegangsbeheer | Afzonderlijke scoped IAM-rollen/credentials per producent en coreconsumer | Geen coretoken of publieke import-API nodig | -| Containers | Linux, Docker Engine en Compose v2; gepind Python slim-image | Eén host voor de pilot | -| Ophaalplanning | Eén systemd-timer per importrepository die een eindige containeruitvoering start | Python kiest lokaal welke geconfigureerde sources aan de beurt zijn | -| Runnerstaat | Kleine lokale SQLite-state via Python-stdlib, op persistent volume | Due-tijden, gereserveerde source-sequences en herstelbare uploadstatus; geen gedeelde broker | -| Images/CI | GitHub Actions + GHCR; Poetry-install, pytest en Ruff | Pipeline per importrepo; core behoudt bestaande checks | -| Monitoring | JSON-logs, hostlogrotatie, runnerfoutmelding; core importhistorie en overdue-status | Geen apart observabilityplatform als startvoorwaarde | +De uv-migratie en SQL-runtimeverwijdering zijn gemerged in disabled-parking #779 en #780. #781 bevat één klein Hamburg-voorbeeld; dat is nog geen live aansluiting. De tijdelijke export wordt in #774 vervangen door de afgesproken pilotroute. Core bevat geen Python-code of bronclients. -Patchversies worden in lockfiles en image-digests vastgelegd. Python 3.14 is een onderhouden versie volgens de [Python-status](https://devguide.python.org/versions/). De huidige repos declareren Python ^3.11; dat bewijst geen compatibiliteit met 3.14. De [Eindhoven-contractproef](data-import-pilot.md) heeft package 5.1.0 op Python 3.14.2 uitgevoerd; bronpackages zijn geen core-dependencies. Een tijdelijke geteste 3.13-baseline is mogelijk als dat nodig blijkt, met expliciete upgradeactie. Universele packages houden hun eigen supportbeleid en tooling. +Gebruik bestaande standaardbibliotheken en dependencies waar die voldoen. Geen nieuwe broker, workerframework, lokale SQLite-planningsdatabase, verplichte sequences, JSONL of apart manifest voor de eerste bron. Voeg een component pas toe wanneer de werkende keten die aantoonbaar nodig heeft. -## 2. Twee onafhankelijke planningen +De producent krijgt uitsluitend de noodzakelijke rechten op zijn eigen private bucketlocatie. Core krijgt aparte leesrechten en beslist over publicatie. Geen databasecredentials of coretoken in de producer. Het overdrachtsmechanisme moet complete, unieke leveringen garanderen; daarvoor is niet vooraf één bepaalde S3-versioningimplementatie voorgeschreven. -De importhost start bijvoorbeeld iedere minuut een due-check per repository via een systemd-timer. De container voert alleen sources uit die volgens de repositoryconfiguratie aan de beurt zijn en eindigt daarna. De daadwerkelijke bronfrequentie staat bij de source; de timerfrequentie is niet de fetchfrequentie. Een overlappende start van dezelfde service wordt voorkomen; bronhouderlocks beschermen waar beide repos dezelfde API aanspreken. - -De runner bewaart planning, sequences en status van geproduceerde maar nog niet gereedgemelde bestanden op een persistent volume. De SQLite-staat is lokaal operationeel herstelmateriaal, geen tweede domeindatabase en geen gedistribueerde taakqueue. Eén producer is eigenaar van een source. Meerdere hosts komen pas met expliciete bronverdeling of een afzonderlijk coördinatieontwerp. - -Core draait zijn eigen Laravel-scheduler: ready-manifesten ontdekken, vastgelopen verwerkingen herplannen en uitblijvende leveringen signaleren. De databasequeue verwerkt de ontvangen gegevens. Batches blijven beschikbaar wanneer core tijdelijk uitstaat. Voor bron- en corepauzes zijn afzonderlijke handelingen nodig, zoals beschreven in het contract. - -Frequente bezetting kan later een apart continu offstreetproces krijgen. Het publiceert hetzelfde type versieerbare meetbatch met een eigen stream/source-ID en limieten. Catalogussnapshots en liveobservaties krijgen verschillende ritmes en retentie. Dit verandert niets aan de zelfstandigheid van core. - -## 3. Python en bestanden - -De adapter roept het bestaande bronpackage aan en schrijft records regel voor regel naar een tijdelijk bestand. De runner valideert, berekent checksum/grootte/aantallen en uploadt met boto3. Na geslaagde upload publiceert hij het ready-manifest. Synchrone SDK-/CPU-stappen worden zo uitgevoerd dat benodigde async bronverzoeken niet blokkeren; de eerste eindige batchrunner heeft geen heartbeat-taak. - -Gebruik standaard dataclasses, json, hashlib, logging, datetime, zoneinfo en sqlite3 waar dat volstaat. Boto3 beheert opslagtransport; aiohttp is beschikbaar waar de bronadapter zelf async HTTP nodig heeft. De bronpackages krijgen geen verplichte NIPKaart-client of dependency. - -Poetry installeert gelockte dependencies tijdens imagebuild. Start productie zonder downloads bij containerstart. Zie [Poetry's lockfilegedrag](https://python-poetry.org/docs/basic-usage/). Bestaande pymysql-writes en databasecredentials vervallen bij vervanging van de legacy-importuitvoering; de universele bronclients veranderen daarvoor niet. - -## 4. Contract en coreverwerking - -De schemas in een core-contractrelease zijn normatief. Dataclasses valideren geen JSON. Python gebruikt [jsonschema](https://python-jsonschema.readthedocs.io/en/stable/), PHP [Opis](https://opis.io/json-schema/2.x/); Als deze schema-aanpak wordt gekozen, toetsen de implementatiestappen dezelfde subset met positieve en negatieve fixtures. #1214 stelt nog geen definitieve schema-release vast. Datum-/URI-formatchecks zijn expliciet en externe netwerkresolutie van schema-referenties staat uit. Er is voor bestanduitwisseling geen OpenAPI-specificatie nodig. - -Core gebruikt unieke batchregistratie, sequencecontrole en transacties voor idempotente verwerking. Jobs worden na commit beschikbaar gemaakt en een herstelcontrole herplant duurzaam geregistreerde niet-afgeronde imports. Alleen after-commit dispatch is geen garantie tegen een crash vóór dispatch; zie de [queue-transactiedocumentatie](https://laravel.com/docs/13.x/queues#jobs-and-database-transactions). - -Valkey is beschikbaar in [DDEV](valkey.md), maar wordt geen nieuwe productieafhankelijkheid voor de batchketen. De core-databasequeue blijft de startkeuze. Meet belasting voordat een queuedriver of opslagindex wordt gewijzigd. - -## 5. Opslag en bevoegdheden - -S3 is de referentieprovider. De bucket is private, versioning staat aan, core leest de exacte recordsversie uit het manifest en legt de manifestidentiteit vast. Verwerk niet blind de actuele inhoud van een overschrijfbare key. De [S3-versioningdocumentatie](https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html) beschrijft afzonderlijke objectversies; de integratietest moet dit gedrag aantonen. - -De producent heeft alleen toegang tot eigen source-prefixen en noodzakelijke upload-/herstelhandelingen; geen database- of corebeheerrechten. Core heeft read/list/versionread voor toegelaten prefixen. Cleanup/delete loopt via afzonderlijke beperkte bevoegdheden. Gebruik waar mogelijk tijdelijke rolcredentials, anders afzonderlijke roteerbare secrets. Credentials staan buiten code en images. Een algemene publieke API-key of workerregistratie in core is niet nodig. - -Een andere S3-compatible provider kan pas worden gekozen na toetsing van versioning, create-only writes, paginering, retentie en exacte versie-reads. Er is geen provider gecontracteerd. Ruwe brondata wordt alleen opgeslagen als dat nodig en toegestaan is; een recordsartifact is de genormaliseerde aanlevering. - -## 6. Hosting, CI en schaalpad - -Core behoudt zijn bestaande hosting. Op de importhost beheert [Docker Compose](https://docs.docker.com/compose/how-tos/production/) de containerdefinities; systemd start de eindige batchservices. Images worden op digest gebruikt. Voorgestelde namen zijn ghcr.io/nipkaart/disabled-parking en ghcr.io/nipkaart/offstreet-parking; de documentatie bewijst niet dat ze al bestaan. [GHCR](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) bewaart de OCI-images. - -De host heeft geen publieke importpoort nodig. Runtime krijgt alleen benodigde sourceconfig, opslagrechten, persistent statevolume, tijdelijke werkruimte en resourcegrenzen. Geen Docker-socket in de container. systemd/Docker beheren deadlines, exits en logs. Beheerde hostkeuze, machinegrootte en budget worden bij staging bepaald; er is geen gemeten capaciteit geclaimd. - -Lokaal kan dezelfde adapter naar een bestand schrijven zonder opslagaccount. Core verwerkt het bestand via dezelfde validator en services. DDEV blijft de coreomgeving. Staging test de echte opslagoverdracht, versioning, discovery, permissions en herstel. Python test met pytest, pytest-asyncio waar async gedrag nodig is en Ruff; core gebruikt Pest en [bestaande qualitychecks](quality-checks.md). Gewone CI haalt geen gemeentelijke datasets op. - -Bij groei eerst meten: runtime, gemiste leveringen, bronlimieten, bytes, manifestlistingkosten en coreverwerkingstijd. Daarna sources verdelen over exclusieve producenten, discovery versnellen of kernverwerking opschalen. Een gedeeld Python-framework ontstaat pas als de tweede repository werkelijk dezelfde uitvoeringslogica nodig heeft. +Eerst handmatig de hele keten bewijzen, daarna dezelfde bestanden automatisch overdragen. Bij groei meten we looptijd, bronlimieten, bestandsomvang en beheerwerk voordat we extra processen of gedeelde frameworks toevoegen. Offstreet volgt met eigen inhoudelijke afspraken; algemene garagebezetting bewijst geen beschikbare gehandicaptenparkeerplaats. diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md index a1b01764..1af83568 100644 --- a/docs/development/data-import-contract.md +++ b/docs/development/data-import-contract.md @@ -1,66 +1,77 @@ # Voorlopige gegevenslevering -Status: werkafspraak voor de eerste handmatige keten, 2026-09-08. Er is nog geen vrijgegeven contract 1.0. De schemas en validator in [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn een onvoltooid prototype; hun opslagvelden en uitvoeringsmechanismen zijn geen vastgestelde eisen. Dit document vervangt de eerdere verplichting om eerst een volledig automatisch afleverprotocol te bouwen. +Status: werkafspraak voor [#1214](https://github.com/NIPKaart/core/issues/1214). Eén JSON-bestand voor de eerste handmatige import; het formaat wordt pas vastgezet nadat de producer en core samen zijn beproefd. De schema's en validator uit [PR #1222](https://github.com/NIPKaart/core/pull/1222) blijven een prototype en worden in #1215 vervangen of verwijderd. -Het doel is één bruikbare bron via één lokaal bestand beoordeeld in core verwerken. Zie [#1214](https://github.com/NIPKaart/core/issues/1214), de [bronbevindingen](data-import-pilot.md) en de [uitvoeringsvolgorde](data-foundation-delivery.md). +## Van bron naar gebruiker -## Verantwoordelijkheden +`gemeentelijke API → universele Python-package → disabled-parking → JSON-bestand → core: valideren, vergelijken en beoordelen → gemeentelijke parkeergegevens → publieke discovery` + +De eerste overdracht gebeurt lokaal. Daarna uploadt de producent hetzelfde formaat naar een private bucket en ontdekt core de complete bestanden. De bucket hoort bij de automatische architectuur. R2 is een kandidaat; provider en overdrachtsmechanisme worden in #1217 gekozen. Er komt geen directe producerverbinding met de core-API of database. | Onderdeel | Verantwoordelijkheid | | --- | --- | -| Universele bronpackage | Begrijpt de gemeentelijke API, haalt de afgesproken selectie volledig op en exposeert bron-ID's, oorspronkelijke waarden en informatie over volledigheid. Kent NIPKaart niet. | -| disabled-parking | Kiest dataset/filter, vertaalt de betekenis naar NIPKaart en maakt de lokale levering. Hier staan Python-code, bronpackages en Python-tests. Schrijft niet rechtstreeks in de core-database. | -| core | Beschrijft welke gegevens het kan verwerken, valideert die in PHP, toont verschillen en beheert beoordeling, publicatie en communitycorrecties. Bevat geen Python-omgeving of gemeentelijke API-clients. | -| offstreet-parking | Vervult dezelfde producerrol voor voorzieningen zodra dat werkpakket begint. We ontwerpen nu geen gedeeld producerframework. | - -Een bronpackage mag generieke verbeteringen nodig hebben voor paginering of bron-ID's. Dat is geen reden om het ophalen naar core te verplaatsen. Onderzoek naar mogelijke bronnen blijft buiten het platform. +| Universele package | Bronprotocol, volledige ophaling van een selectie, bron-ID's, oorspronkelijke waarden en volledigheidsinformatie. Zelfstandig bruikbaar zonder NIPKaart. | +| disabled-parking | Datasetselectie, vertaling, leveringsmetadata en bestand schrijven. Python en bronspecifieke tests staan hier of upstream. | +| core | Toegelaten dataset, bestandsvalidatie, verschillen, beoordeling, publicatie en behoud van correcties. Geen Python-omgeving. | +| offstreet-parking | Later een eigen producent voor voorzieningen; pas bij werkelijk gedeelde behoeften uitvoeringscode delen. | -## Eén voorbeeld +## Eén bestand -Een fictieve gemeente biedt parkeerplek `000123` aan, aan de Voorbeeldstraat, op een bekende WGS84-locatie. Het bronveld betekent expliciet twee algemene gehandicaptenparkeerplaatsen. Dit voorbeeld beschrijft een gewenste gegevensstroom, niet een bevestigde interpretatie van de Eindhoven-dataset. +UTF-8 JSON met één object en een `records`-array. Geen apart manifest, JSONL, schemarelease of opslagprovider-ID voor de lokale pilot. De concrete bronrij en mapping staan in de [pilotbeschrijving](data-import-pilot.md). -De package geeft de oorspronkelijke gegevens terug. De adapter in disabled-parking vertaalt deze naar onderstaande betekenis. Core herkent de plek aan de combinatie van dataset en bron-ID. +| Veld | Betekenis | +| --- | --- | +| `format` | `nipkaart-municipal-pilot-1`; één voorlopige revisie voor producer en consumer. Vervangt `municipal-records-draft`, geen compatibiliteitslaag. | +| `dataset` | Vaste, door core toegelaten datasetcode. Bronhouder, licentie en geografische mapping horen bij deze aansluiting. | +| `delivery_id` | UUID, één keer gemaakt per geslaagde ophaling. Een retry van hetzelfde bestand behoudt ID en bytes. | +| `retrieved_at` | UTC-tijdstip waarop de ophaling begon, RFC 3339 met `Z`; sorteert leveringen. Geen waarnemingsdatum. Eén actieve ophaling per dataset. | +| `selection` | Vaste code voor de afgesproken collectie/filter, bijvoorbeeld `all`. Een scopewijziging vereist eerst een nieuwe beoordeelde aansluiting of selectie. | +| `complete` | Moet `true` zijn voor intake. Producent verklaart dit alleen op basis van bronbewijs. | +| `source_count` | Aantal dat de bron voor deze selectie meldt; gelijk aan aantal ontvangen unieke records. Een totaal alleen bewijst geen volledigheid wanneer nog een volgende pagina bestaat. | +| `records` | Alle records van de selectie, zonder stilzwijgend afgekeurde of overgeslagen bronrijen. | -| Gegeven | Voorbeeld | Betekenis | -| --- | --- | --- | -| Dataset | `voorbeeldgemeente-toegankelijk` | Aangesloten dataset met bekende herkomst en geografische mapping | -| Unieke levering | `levering-001` | Herkennen dat hetzelfde bestand opnieuw wordt aangeboden; geen voorgeschreven UUID- of opslagmechanisme | -| Opgehaald op | `2026-09-08T08:00:00Z` | Tijdstip van onze fetch, geen bewijs van veldcontrole | -| Selectie | Alle algemene gehandicaptenparkeerplaatsen in deze dataset | Expliciete afbakening om volledigheid en ontbrekende records te kunnen beoordelen | -| Volledigheid | Volledig, met onderbouwing uit de bronresponse | Alleen verklaren wanneer alle resultaten van de selectie zijn opgehaald | -| Formaatrevisie | Voorlopig voorbeeld A | Nog geen stabiele release of compatibiliteitsbelofte | -| Bron-ID | `000123` | String; behoud voorloopnullen, gebruik geen coördinaten als identiteit | -| Positie | Latitude `52.3702`, longitude `4.8952` | Benoemde WGS84-coördinaten, geen impliciete volgorde | -| Toegankelijk aantal | `2` | Niet-negatief geheel aantal; onbekend blijft `null` | -| Toegang | Algemeen toegankelijk met gehandicaptenparkeerkaart | Onderscheiden van persoonsgebonden en onbekende toegang | -| Beperkingen | Onbekend tenzij de bron ze expliciet beschrijft | Geen ontbrekend veld vertalen naar “geen beperkingen” | -| Brondatum | Onbekend | Alleen invullen wanneer de betekenis van de brondatum bekend is | +De producent controleert bronpagina's, aantallen en unieke ID's voordat het bestand wordt geschreven. Opvangen van parsefouten en doorgaan met de overige records is geen complete levering. Als een bron geen totaal aanbiedt, wordt eerst een andere aantoonbare volledigheidscontrole afgesproken; verzin geen `source_count` uit alleen de ontvangen lijst. -De gekozen echte bron moet een toegestane bronrij en een veld-voor-veldvertaling opleveren. Dit fictieve voorbeeld is daarvoor geen vervanging. Het is nog niet besloten of leveringsinformatie en records in één bestand of twee bestanden komen; een bestaande JSONL/manifestproef mag een mogelijkheid aantonen, maar legt de keuze niet vast. +Core accepteert alleen het bekende formaat en de toegelaten dataset/selectie; valideert typen en inhoud en weigert dubbele JSON-sleutels en bron-ID's. Geen remote schemaresolutie of door het bestand aangeleverde download-URL uitvoeren. Begin met de bestaande grenzen van 10.000 records en 32 MiB; de Amsterdamse bronproef van ongeveer 1,33 MB valt daar ruim binnen. Valideer vóór databasepublicatie. -## Gedrag bij vervolggevallen +## Een record -| Gebeurtenis | Verwacht gedrag | +| Veld | Regel | | --- | --- | -| De bron wijzigt het aantal van dezelfde plek | Nieuwe bronwaarde bij dezelfde dataset + bron-ID; core toont het verschil voor beoordeling. | -| Hetzelfde bestand wordt opnieuw aangeboden | Geen dubbele parkeerplek of dubbele publicatie. De consumer herkent de levering. | -| Een plek ontbreekt bij de volgende volledige selectie | Toon als mogelijk verdwenen; niet automatisch verwijderen. | -| Fetch of paginering mislukt | Geen complete levering presenteren. De bestaande publicatie blijft staan. | -| Capaciteit ontbreekt | Bewaar onbekend; maak er geen nul van. | -| Toegangsbetekenis is onduidelijk | Niet automatisch als algemene gehandicaptenparkeerplek publiceren. | -| Een gebruiker meldt een correctie | Core beoordeelt deze. Een geaccepteerde correctie blijft afzonderlijk van de bronwaarde bestaan. | -| Een volgende bronwaarde wijkt af van een geaccepteerde correctie | Toon het conflict; overschrijf de correctie niet stilzwijgend. | -| De selectie verandert | Vergelijk afwezigheid niet ongemerkt met de oude selectie; beoordeel eerst de gewijzigde afbakening. | +| `external_id` | Niet-lege oorspronkelijke ID als string. Identiteit is `(dataset, external_id)`; behoud volledige ID en voorloopnullen. Geen coördinatenhash of interne core-ID. | +| `geometry` | Oorspronkelijke GeoJSON `Polygon` in WGS84 voor Amsterdam, met `[longitude, latitude]`. Behoud ringen; begrens omvang en valideer bereik en geometrie. Geen verzonnen bronpunt. | +| `number` | Niet-negatief geheel aantal of `null`. Nul en onbekend blijven verschillend; maak van een bronaggregaat geen verzonnen losse bays. | +| `street` | Bronadres of `null`; een nabijheidsadres is geen exact parkeeradres. | +| `access_category` | `general`, `personal` of `unknown`. `general` betekent niet persoonsgebonden gehandicaptenparkeren, geen beschikbaarheid of parkeren zonder vergunning. | +| `source_attributes` | Voor Amsterdam: `regimes`, `orientation` en `version_date`. Alle regimes met hun tijden/dagen/datums/opmerkingen behouden; geen generiek regelsysteem of uitspraak “nu beschikbaar”. | +| `source_updated_at` | Alleen een datum met bekende betekenis als wijziging van het bronrecord; anders `null`. Portaalverwerking is geen veldcontrole. | -Bestaande bronmodellen en detail-/favorietverwijzingen blijven behouden. Het bestandsformaat bepaalt geen publicatiebeleid. +Core bewaart de brongeometrie bij de bronclaim en gebruikt na validatie PostGIS `ST_PointOnSurface` voor het afgeleide kaartpunt (`geometry_method=point_on_surface`). Dit blijft een benadering binnen het parkeervlak, geen ingang of individueel vak. Core schrijft de bestaande latitude/longitude-kolommen; PostgreSQL blijft de bestaande `location` afleiden. Zo zijn geen extra Python-geometriepackage of twee concurrerende afleidingen nodig. Zie [PostGIS](https://postgis.net/docs/ST_PointOnSurface.html) en de [bestaande opslagafspraak](postgresql.md#spatial-representation). -## Eerst bewijzen, daarna automatiseren +Land en administratieve relaties worden door core uit de toegelaten datasetconfiguratie gekoppeld. Geen Nederlandse verplichte codes voor Europese bronnen en geen interne foreign keys in het bestand. Deze pilot ondersteunt alleen de aangetroffen Polygon-geometrie. Puntbronnen of andere geometrieën krijgen pas ondersteuning wanneer ze worden aangesloten. Nuttige broninformatie wordt daarbij nooit stilzwijgend weggegooid. -1. **#1214:** bruikbare bron selecteren, betekenis controleren, één voorlopige voorbeeldlevering vastleggen en de verantwoordelijkheden afbakenen. -2. **disabled-parking #774:** vanuit de echte package een lokaal bestand maken met expliciete mapping en volledigheidscontrole. -3. **core #1215:** dat bestand lezen, fouten en verschillen tonen en na beoordeling verwerken. Herimport behoudt identiteit en verwijdert ontbrekende records niet automatisch. Correctiebehoud krijgt een expliciete aansluiting op #1218. -4. **Vervolgwerk:** automatisch ophalen, overdragen en ontdekken van bestanden nadat de lokale keten werkt. +## Herhaling en wijzigingen -Integriteit, begrensde invoer en veilige herverwerking blijven nodig. De bestaande prototypekeuzes voor hashes, JSONL, exacte tijdsyntax en byte-/recordlimieten moeten bij de echte levering opnieuw worden beoordeeld. Opslagkeys, objectversies, ready-manifest-last, duurzame producersequences en herstel over meerdere hosts worden pas verplicht als het ontwerp voor automatische overdracht dat rechtvaardigt. - -Het contract wordt pas vastgezet nadat de eerste keten is beproefd. Testresultaten van een losstaande validator bewijzen die keten niet. +| Geval | Gedrag | +| --- | --- | +| Eerste complete levering | Valideren, verschillen en kaartsteekproef tonen; publicatie na beoordeling. | +| Zelfde dataset en delivery-ID, dezelfde bytes | Bestaande importstatus teruggeven; geen tweede verwerking of publicatie. Core bewaart de SHA-256 van de ontvangen bytes. | +| Zelfde delivery-ID, andere bytes | Conflict afwijzen; nooit een bestaande levering vervangen. | +| Nieuwe levering, ongewijzigde records | Geen dubbele plekken of inhoudsrevisies; wel nieuwe ontvangst vastleggen. | +| Oudere `retrieved_at` dan de laatst geaccepteerde levering | Geen actuele gegevens overschrijven. Gelijke tijd met verschillende delivery-ID's is een conflict, geen willekeurige winnaar. | +| Een veld wijzigt bij dezelfde bron-ID | Nieuwe bronwaarde tonen voor beoordeling; identiteit, favorieten en detailverwijzingen behouden. | +| Een record ontbreekt in een complete selectie | Markeren als mogelijk verdwenen en beoordelen; geen automatische verwijdering. Terugkeer gebruikt dezelfde identiteit. | +| Lege/incomplete/ongeldige levering of mislukte fetch | Geen publicatie; bestaande gegevens en laatste geldige export blijven behouden. | +| Bronwaarde botst met geaccepteerde correctie | Bronwaarde afzonderlijk bijwerken; correctie behouden en conflict tonen. Ook in de eerste importimplementatie. | +| Onbekende toegang of gewijzigde scope | Geen automatische algemene publicatie of vergelijking van ontbrekende records; eerst beoordelen. | + +Core controleert volgorde en actuele correcties opnieuw bij publicatie, ook als twee beoordeelde imports tegelijk klaarstaan. `retrieved_at` is een eenvoudige volgorderegel voor één producent met correcte UTC-klok; het bewijst geen transactiesnapshot bij de bron. Toekomstige of onlogische tijdstippen vragen beoordeling. Geen gedistribueerde teller bouwen voor de pilot. + +## Concrete overdracht + +1. **#1214:** bronkeuze, toegestane voorbeeldrij, betekenis en deze voorlopige afspraak. +2. **disabled-parking #774:** eventuele generieke bronpackagefix eerst, daarna één live commando dat dit bestand atomair schrijft. Vervang draftformaat, null-volledigheidsmetadata en achterhaalde voorbeeldroute; behoud slechts nuttige kleine tests. +3. **core #1215:** één intakepad met beoordeling en veilige eerste, gewijzigde, herhaalde, oudere, ontbrekende en conflicterende levering. Vervang/verwijder de oude #1222-schema's en validatie in dezelfde implementatie. +4. **#1217, disabled-parking #775 en core #1216:** provider kiezen, dezelfde levering automatisch uploaden en ontdekken. De gekozen opslaggrens moet voorkomen dat core een gedeeltelijk bestand verwerkt. + +Gewone CI werkt offline met kleine voorbeelden van packageobjecten. Eén afzonderlijke begrensde live proef bewijst bronophaling; een daadwerkelijke beoordeelde core-import bewijst de volgende stap. Geen van beide wordt door alleen een fixturetest vervangen. diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md index 8e339723..cf312d78 100644 --- a/docs/development/data-import-pilot.md +++ b/docs/development/data-import-pilot.md @@ -1,55 +1,101 @@ -# Brononderzoek: kandidaat Eindhoven +# Pilot: algemene gehandicaptenparkeerplaatsen Amsterdam -Status: onderzochte kandidaat voor [#1214](https://github.com/NIPKaart/core/issues/1214), 2026-09-08. Eindhoven is nog geen geaccepteerde aansluiting of bewezen bruikbare pilotbron. Onderstaande bevindingen blijven behouden; de selectie staat open. Er is geen definitief contract 1.0 en geen producer, database-import of productieaansluiting gebouwd. +Status: geselecteerd voor implementatie van [#1214](https://github.com/NIPKaart/core/issues/1214), bronproef 2026-09-13. Amsterdam biedt bruikbare broninhoud en een controleerbaar totaal. De bestaande package verliest nog relevante velden; de live aansluiting is daarom nog niet gereed. Eerst de generieke package verbeteren, vervolgens disabled-parking #774 en core #1215. Geen productiepublicatie of automatische levering is uitgevoerd. -## Bron en bewijs +## Waarom deze bron -De bron is [Gemeente Eindhoven — Parkeerplaatsen](https://data.eindhoven.nl/explore/dataset/parkeerplaatsen/information/), met filter `type_en_merk = 'Parkeerplaats Gehandicapten'`. De [datasetmetadata](https://data.eindhoven.nl/api/explore/v2.1/catalog/datasets/parkeerplaatsen) noemt Gemeente Eindhoven als uitgever, “Publiek domein” als licentie en geen aanvullende licentie-URL. De geselecteerde bronrij bevat uitsluitend parkeerlocatiegegevens; `tests/Fixtures/import/v1/pilot-source.json` behoudt de relevante velden en verwijdert portalwrapper, portal-ID en timestamp. Herkomst blijft hier vastgelegd. De overige randgevallen zijn door ons gemaakte fictieve gegevens; ze zijn geen meldingen over echte parkeerplaatsen. +De selectie `eType=E6a` benoemt algemene gehandicaptenparkeerplaatsen expliciet. Dat voorkomt de onbewezen algemene/persoonsgebonden interpretatie bij Eindhoven. Amsterdam levert oorspronkelijke string-ID's, parkeervlakken, aantallen, regimes en versie-informatie. De read-only proef ontving 1.420 unieke records; een afzonderlijke count-query meldde hetzelfde totaal. De [officiële datasetdocumentatie](https://api.data.amsterdam.nl/v1/docs/datasets/parkeervakken.html) beschrijft de velden. `aantal` is een geschatte capaciteit en `versiedatum` de geldigheidsdatum van de dataset; presenteer die niet als telling of veldcontrole. -De bestaande universele package [eindhoven 5.1.0](https://github.com/klaasnicolaas/python-eindhoven/releases/tag/v5.1.0) is op CPython 3.14.2 uitgevoerd. De packagecode is MIT; dat is een afzonderlijk gegeven van de datasetlicentie. Deze bronproef is uitgevoerd in een tijdelijke, geïsoleerde omgeving; core heeft geen dependency op de Eindhoven-package. De importrepository gebruikt nog een oudere major; #774 moet de nieuwe geneste datamodellen expliciet verwerken. +De leverancier is Gemeente Amsterdam. De dataset wordt in de [overheidscatalogus](https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be) als CC0 aangeboden, bevestigd in de [CKAN-metadata](https://data.overheid.nl/data/api/3/action/package_show?id=318a98b8-ef87-4335-9674-f5405f2bc4be). De catalogus linkt oudere ontsluitingen van dezelfde dataset; het licentieveld in de huidige REST-documentatie is leeg. Dit is het traceerbare hergebruikbewijs voor de technische pilot, geen afzonderlijke nieuwe licentieverklaring van de REST-API. Hercontroleer deze koppeling en eventuele voorwaarden vóór publieke ingebruikname. De licentie van de Python-package is daarvan onafhankelijk. Bewaar bronvermelding bij de aansluiting, ook wanneer geen attributie verplicht is. -Read-only meting op 2026-09-08, met `ODPEindhoven.locations(limit=1000, parking_type=ParkingType.DISABLED_PARKING)`: +| Onderdeel | Afbakening | +| --- | --- | +| Datasetcode | `nl-amsterdam-parkeervakken-e6a` | +| Selectiecode | `e6a-all` = alle records uit `parkeervakken/parkeervakken` met exact `eType=E6a`; geen bbox of aanvullende stille filtering. | +| Package | `odp-amsterdam` 6.0.0 is onderzocht; de verbeterde versie moet vóór gebruik expliciet worden vastgelegd. | +| Identiteit | `properties.id`, als volledige string binnen de dataset. De GeoJSON-wrapper `parkeervakken.` wordt niet als tweede identiteit gebruikt. | +| Geografie | Nederland (`NL`), Noord-Holland (`NL-NH`), gemeente Amsterdam (`nl:cbs:municipality`, `0363`). Core koppelt deze codes aan relaties. | +| Betekenis | Algemene gehandicaptenparkeerplaats, mogelijk met tijdsbeperkingen. Geen actuele beschikbaarheid, geen garantie op toegankelijkheid voor ieder voertuig. | +| Granulariteit | Eén bronrecord beschrijft een parkeervlak met aantal; niet omzetten naar verzonnen individuele communityplekken. | + +## Gemeten bewijs en beperkingen | Meting | Uitkomst | | --- | --- | -| HTTP-verzoeken | 1 | -| `nhits` / ontvangen / unieke portal-ID's / unieke `objectid` | 180 / 180 / 180 / 180 | -| Responsebytes / grootste losse bronrij | 88.805 / 506 bytes | -| Totale fetch inclusief packageparsing | 0,317 seconden | -| Response SHA-256 | `9100542741d1d37f69eb82101f89729b545afb5bdb4c01a44c6692e5dd7d99ef` | -| Recordtimestamps | Allemaal `2026-08-10T21:45:03.036Z` | -| Portal `modified` | `2026-08-10T21:45:24Z` | -| Geografische uitersten, west/zuid/oost/noord | `5.450612694939047, 51.426665509086355, 5.4905604066468765, 51.47076467693479` | +| Volledige GeoJSON-response | 1.420 features / 1.420 unieke `properties.id` | +| Onafhankelijke count-query | `X-Total-Count=1420`, `page.totalElements=1420` | +| Omvang | 1.332.469 bytes | +| SHA-256 volledige onderzoeksresponse | `b330ae455f3efbeb8373b419da6fad4f24da351cd0cf90547cb928811e0e6efa` | +| Geometrie | Alle records Polygon in WGS84 | +| Packageparser | Alle 1.420 bronrecords zijn met de geïnstalleerde parser gelezen | +| Regimes | 1.579 regimes; 159 records hebben meerdere regimes, 172 hebben tijdsvakken en 13 een opmerking | +| Toegang | Alle aangetroffen regimes beschrijven algemene gehandicaptenparkeerplaatsen; kentekenvelden zijn leeg | +| Versiedatum | Alle records `2026-09-11`; datasetgeldigheid, geen afzonderlijke recordwijziging of veldcontrole | +| Tijdstip | HTTP Date volledige response `2026-09-13T21:55:18Z`; count-response `2026-09-13T21:55:56Z` | -Dit is één actuele netwerkproef, geen beschikbaarheids-SLA en geen bewijs van volledige gemeentelijke dekking. De metadata noemt ook `temporal: t/m juli 2018`. Portalverwerking in 2026 bewijst daarom geen recente veldcontrole. +Requests: [volledige selectie](https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken?_pageSize=2000&eType=E6a&_format=geojson) met header `Accept-Crs: EPSG:4326`, en [count-query](https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken?_pageSize=1&eType=E6a&_count=true). De GeoJSON-response heeft geen volgende pagina (`_links: []`). -## Voorlopige mapping en open bronvragen +Dit bewijst volledige ontvangst ten opzichte van het toen gerapporteerde totaal, geen volledige werkelijkheid op straat of gegarandeerde transactiesnapshot. Het bronbestand blijft tijdelijk onderzoeksmateriaal; we voegen geen gemeentelijke fixturecorpus toe aan core. -| Onderwerp | Afspraak en beperking | -| --- | --- | -| Source/scope | `nl-eindhoven-accessible`, scope 1 = genoemde dataset en exact filter. Geen dynamisch afwijkend geografisch filter onder dezelfde scopeversie. | -| Identiteit | Gebruik bronveld `objectid` als string, gekoppeld aan deze source; nooit coördinaten. In deze response zijn alle 180 waarden aanwezig en uniek. Langdurige stabiliteit/hernummering moet de aansluiting nog vaststellen. De package geeft alleen portal-`recordid` door en laat `objectid` weg: **blokker voor de adapter**, op te lossen door een generieke package-uitbreiding. Geen stilzwijgende fallback naar portalhash. | -| Volledigheid | De package doet één request, exposeert `nhits` niet en heeft geen offset/paginering. In deze meting past alles in één response en is ontvangen = `nhits`; `limit=1000` alleen is geen bewijs. Voor gereedmelding moet de package generiek het totaal en volledige iteratie beschikbaar maken, of aantoonbaar één complete response met totaal teruggeven. Bij meer resultaten, afgekapt antwoord, gewijzigde datasetversie tijdens ophalen of onbekende volledigheid: geen ready-manifest. | -| Bronplatform | De package gebruikt [Search API v1](https://help.opendatasoft.com/apis/ods-search-v1/), die de aanbieder deprecated noemt. Kies de ondersteunde API bij de generieke pagination/identity-uitbreiding; kopieer de oude client niet naar core. | -| Positie | Bron-GeoJSON is Point in WGS84; `[longitude, latitude]` wordt expliciet `position.longitude/latitude`. Geen centroid nodig. Het is een parkeerlocatie, geen bewezen navigatie-ingang. | -| Geografie | `country_code=NL`, `administrative_codes=[{"scheme":"nl:cbs:municipality","code":"0772"}]`. Core koppelt dit aan zijn bestaande relaties; geen interne database-ID's in de adapter. De testbounds `[5.3,51.3,5.6,51.6]` zijn een grove foutcontrole, geen gemeentegrens. | -| Capaciteit | `aantal` is een brongetal (double in metadata); alleen integrale, niet-negatieve waarden overnemen. `null` blijft onbekend en is geen nul. Package 5.1.0 typeert dit als int; de adapter mag een ontbrekend of fractioneel brongetal niet onopgemerkt laten coerceren. | -| Toegangsbetekenis | Het bronlabel zegt gehandicaptenparkeerplaats, maar bevat geen aantoonbaar onderscheid tussen algemene en persoonsgebonden reservering of tijdsbeperkingen. Pilotfixture gebruikt daarom `access_category=unknown` en `unmapped_fields=["reservation_status"]`, wat review oplevert. Publicatie vereist bronverduidelijking of een concrete beoordeelde waarneming. | -| Datums | `record_timestamp` en portal `modified` zijn verwerkingsmetadata; niet presenteren als veldwaarneming. In de genormaliseerde pilot is `source_updated_at=null`. `fetched_*` beschrijft alleen onze fetch. | -| Hergebruik | De datasetmetadata is de basis voor het opgenomen minimale fixture. Hercontroleer voorwaarden en bronbetekenis vóór aansluiting; licentietekst of gebruiksvoorwaarden veranderen niet ongemerkt mee met een manifest. | +De bestaande package doet één request met een limiet, geeft geen totalen/paginering door en gebruikt slechts delen van het eerste regime. Daardoor verdwijnen tijdsbeperkingen en de versiedatum; `int(aantal)` kan bovendien ongeldige fractionele waarden afronden. Succesvol parsen betekent dus niet dat de levering inhoudelijk volledig is. + +ID's zijn nu uniek en als bron-ID beschikbaar, maar toekomstige hernummering is niet uitgesloten. Grote identiteitswisselingen en verdwenen records vragen beoordeling. Niet terugvallen op coördinatenmatching. De API-documentatie kondigt verplichte API-keys aan; de proef werkte zonder key. Ondersteuning voor eventuele bronauthenticatie hoort in de universele package/producer, nooit in het afleverbestand. De bron biedt geen bewezen mutatieversie over meerdere pagina's: controleer aantallen vóór/na en geef bij verschillen geen complete levering af; gelijke aantallen bewijzen geen snapshotisolatie. + +## Concrete bronrij en vertaling + +Onderstaand voorbeeld is de echte bronrij `114323484886`, met alleen relevante velden. Het lege `kenteken` is weggelaten. Beide regimes blijven behouden; hun onderlinge betekenis wordt niet door de adapter gegokt. + +```json +{ + "id": "114323484886", + "straatnaam": "Pieter Calandlaan", + "eType": "E6a", + "type": "Haaks", + "aantal": 1.0, + "versiedatum": "2026-09-11", + "geometry": {"type": "Polygon", "coordinates": [[[4.790157860968076, 52.35038717769686], [4.790188598734742, 52.3503934444639], [4.79020922008023, 52.3503552603837], [4.790178482337059, 52.35034899362201], [4.790157860968076, 52.35038717769686]]]}, + "regimes": [ + {"soort": "MULDER", "eType": "E6a", "eTypeDescription": "Gehandicaptenparkeerplaats algemeen", "aantal": 1.0, "bord": "", "beginTijd": null, "eindTijd": null, "beginDatum": null, "eindDatum": null, "dagen": [], "opmerking": null, "typeUitzondering": "Venstertijden"}, + {"soort": "MULDER", "eType": "E6a", "eTypeDescription": "Gehandicaptenparkeerplaats algemeen", "aantal": 1.0, "bord": "", "beginTijd": "09:00:00", "eindTijd": "16:00:00", "beginDatum": null, "eindDatum": null, "dagen": ["ma", "di", "wo", "do"], "opmerking": null, "typeUitzondering": "Venstertijden"} + ] +} +``` + +De NIPKaart-representatie gebruikt onderstaande mapping. `geometry` en de twee `regimes` worden uit het voorbeeld ongewijzigd overgenomen; ze worden hier niet nogmaals gekopieerd. + +| Bron | Levering | Uitleg | +| --- | --- | --- | +| `properties.id` | `external_id="114323484886"` | Bronidentiteit; geen numerieke conversie of verkorting. | +| `geometry` | `geometry` | Volledig Polygon behouden. Core berekent pas bij intake een kaartpunt met PostGIS `ST_PointOnSurface`; geen Python-geometrieafhankelijkheid. | +| `aantal=1.0` | `number=1` | Alleen na controle dat het getal eindig, geheel en niet-negatief is. `null` blijft onbekend, nul blijft nul. | +| `straatnaam` | `street="Pieter Calandlaan"` | Bronstraat, geen geocoding of afgeleid huisnummer. | +| `eType` en alle regimebeschrijvingen | `access_category="general"` | Alleen bij consistente algemene betekenis; onbekend of persoonsgebonden wordt niet algemeen verklaard. | +| Alle `regimes` | `source_attributes.regimes` | Tijd, dagen, datums, bord, uitzondering en opmerking blijven zichtbaar voor review. Geen berekende “nu beschikbaar”-status. | +| `type` | `source_attributes.orientation="Haaks"` | Oorspronkelijke plaatsingsaanduiding. | +| `versiedatum` | `source_attributes.version_date="2026-09-11"` | Datasetgeldigheid behouden; `source_updated_at=null` omdat dit geen afzonderlijke recordwijziging is. | +| `kenteken=null` | Niet afleveren | Verwacht leeg voor deze selectie. Een ingevuld kenteken of onverwachte persoonsgebonden betekenis blokkeert de levering; niet wegfilteren en alsnog compleet verklaren. | +| Wrapper-ID, buurtcode en dubbele broncategorie `soort` | Geen afzonderlijk generiek veld | Identiteit, geografie en categorie zijn al expliciet vastgelegd; regimegebonden `soort` blijft behouden. | + +Regimes zijn broninformatie die core moet tonen voordat deze records worden gepubliceerd. Het samengaan van een basisregime en tijdvenster wordt niet vertaald naar een belofte van onbeperkte toegang. Onbegrepen beperkingen blijven in review; een beheerder moet de betekenis kunnen onderbouwen of publicatie achterwege laten. + +De volledige levering volgt [het ene JSON-bestand](data-import-contract.md): `format=nipkaart-municipal-pilot-1`, bovengenoemde dataset/selectie, een werkelijke delivery-UUID en ophaaltijd, `complete=true`, een gecontroleerd `source_count` en alle records. Het voorbeeld hierboven is één record en mag nooit als volledige Amsterdamse levering worden aangeleverd. De toekomstige export mag 1.420 niet hardcoderen. -Deze bronvragen moeten bij #1214 worden opgelost of tot een andere bronkeuze leiden. Daarna bouwt [disabled-parking#774](https://github.com/NIPKaart/disabled-parking/issues/774) de export en #1215 de coreverwerking. Brononderzoek blijft buiten core; bovenstaande punten zijn concrete toelatingsvoorwaarden voor deze ene aansluiting. +## Eerst oplossen in de universele package -## Status van de eerdere contractproef +Dit is de eerste afhankelijkheid van disabled-parking #774, geen Python-werk in core: -De schemas, gesaniteerde voorbeelden en PHP-validator in [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn experimenteel. De paden `resources/schemas/import/v1` en de daarin opgenomen waarde `1.0` zijn namen uit dat prototype, geen vrijgegeven contractversie. De [voorlopige gegevenslevering](data-import-contract.md) is het actuele uitgangspunt. +1. Stel de bron-ID, volledige geometrie, alle regimes en versiedatum beschikbaar. Bewaar oorspronkelijke aantallen zonder verliesgevende conversie; test onbekend, nul, fractioneel en meerdere regimes. +2. Bied een publieke volledige ophaling met totalen/pagina-informatie aan. Controleer de laatste pagina en unieke ID's; één `limit=2000` is geen blijvend volledigheidsbewijs. Behoud de bestaande beperkte ophaalmethode voor andere packagegebruikers indien die onderdeel is van de publieke API. +3. Test generieke paginering en gewijzigde/ontbrekende bronvelden upstream. Maak geen NIPKaart-bestandsformaat of publicatiebeleid onderdeel van de package. +4. Leg de geteste packageversie en één afzonderlijke begrensde live proef vast. Pas daarna de adapter op de nieuwe publieke package-interface aansluiten. -De eerdere offline proef dekte onder meer voorloopnullen, onbekend versus nul, ongeldige coördinaten, incomplete/lege bestanden en conflicterende leveringen. Er is nog geen producer of beoordeelde core-import gebouwd. De Python-proef staat lokaal en ongecommit in disabled-parking; een bijbehorende PR en vastgepinde CI-workflow bestaan nog niet. Core bevat geen Python-code of Poetry-omgeving. +#774 maakt vervolgens één live commando en vervangt de tijdelijke Hamburg-route. #1215 implementeert één intakepad met geometryvalidatie, beoordeling en veilig behoud van identiteit/correcties. De eerste werkende keten blijft handmatig; de private bucket volgt bij automatisering. -De metingen uit de eerdere proef blijven onderzoeksgegevens: 180 bronrecords werden 79.566 bytes, met maximaal 457 bytes per regel. Een synthetische 10.000-recordproef gebruikte 4.409.201 bytes; validatietijden waren PHP 0,024 / 0,600 seconden en Python 0,051 / 1,900 seconden voor respectievelijk 180 / 10.000 records. Dit rechtvaardigt geen definitief formaat, verplichte productielimieten of claim over databasepublicatie. +## Andere onderzochte kandidaten -## Eerstvolgende bronbeslissing +- **Eindhoven:** de [metadata](https://data.eindhoven.nl/api/explore/v2.1/catalog/datasets/parkeerplaatsen) is opnieuw gecontroleerd; de eerdere proef van 2026-09-08 vond 180 records, maar de metadata noemt dekking tot juli 2018; de package verliest `objectid` en volledigheidsinformatie en de toegang is niet aantoonbaar algemeen. Niet geselecteerd. De vroegere uitgebreide proef blijft in de gitgeschiedenis; de #1222-fixtures zijn geen geaccepteerde aansluiting. +- **Hamburg:** de [actuele collectie](https://api.hamburg.de/datasets/v1/behindertenstellplaetze/collections?f=json) en [WFS](https://geodienste.hamburg.de/wfs_behindertenstellplaetze?REQUEST=GetFeature&SERVICE=WFS&VERSION=2.0.0&typename=de.hh.up%3Abehindertenstellplaetze) zijn onderzocht. Het endpoint van package 3.0.0 geeft 404; de nieuwe collectie bestaat maar de itemsrequests liepen bij deze proef vast. WFS leverde 936 unieke features en een afzonderlijke hits-query meldde 936, maar de normale response meldt `numberMatched=unknown` en `numberReturned=0`. Een alternatief protocol en CRS-verwerking zijn extra werk. De oude Hamburg-fixture bewijst geen huidige packagewerking. -Eindhoven kan pas de bruikbare pilotbron worden nadat bronidentiteit, aantoonbare volledigheid en toegangsbetekenis voldoende zijn vastgesteld. Een generieke package-uitbreiding kan daarvoor nodig zijn. Als die gaten niet praktisch kunnen worden opgelost, kiezen we een andere bron voor de eerste keten. We schuiven deze selectie niet als voldongen feit door naar de adapterimplementatie. +## Broncontrole na implementatie -De bestaande echte bronrij in `tests/Fixtures/import/v1/pilot-source.json` is bruikbaar onderzoeksmateriaal. De afgeleide fixture met onbekende toegang en benodigde beoordeling is geen bewijs dat de bron als algemeen toegankelijke parkeerdata kan worden gepubliceerd. Voor afronding van #1214 is één bruikbare bron met een onderbouwde voorlopige voorbeeldlevering nodig; dat staat nog open. +Offline tests bewijzen gedrag tegen bekende voorbeelden. Een periodieke begrensde live controle in #775 controleert daarnaast endpoint, velden, identiteit en volledigheid. Ook een HTTP 200 met gewijzigde betekenis kan een fout zijn. Bij een bronfout geen nieuwe complete levering publiceren; de laatste geaccepteerde gegevens blijven staan en de producent meldt de storing. De broncheck staat los van gewone package-CI. diff --git a/docs/product/data-foundation.md b/docs/product/data-foundation.md index 81e243cb..e7832fdb 100644 --- a/docs/product/data-foundation.md +++ b/docs/product/data-foundation.md @@ -59,7 +59,7 @@ De [bestaande PostgreSQL-afspraak](../development/postgresql.md#fresh-start-deci | Mobiel is een toekomstige productrichting | Verwerking centraal en herbruikbaar; geen mobiele app bouwen in deze eerste oplevering | | Vertrouwen kan later verwerking versnellen | Nu onderbouwde bijdrage- en beslisgeschiedenis verzamelen; nog geen automatische karmadrempels | -De batchgrens is gekozen. Exacte manifestvelden, termijnen en infrastructuurdetails blijven ontwerpvoorstellen. Werkpakket A valideert en bevriest het eerste bestandcontract voordat productiecode erop vertrouwt. +De batchgrens en private bucket voor automatische overdracht zijn gekozen. De eerste proef gebruikt één lokaal JSON-bestand. Werkpakket A beschrijft het voorlopige formaat; producer en core beproeven dit vóór het wordt vastgezet. Opslagprovider en voltooiingsmechanisme horen bij #1217. ## 4. Verantwoordelijkheden @@ -67,8 +67,8 @@ De batchgrens is gekozen. Exacte manifestvelden, termijnen en infrastructuurdeta flowchart TD U[Universele Python-packages] --> A[Adapters in de twee importrepositories] T[Planning in importomgeving] --> A - A --> B[Recordsbestand uploaden] - B --> M[Ready-manifest als laatste in private opslag] + A --> B[Eén JSON-bestand] + B --> M[Private bucket na de lokale proef] M --> C[Core ontdekt complete leveringen] C --> V[Core: validatie en vergelijking] R[Communitywaarnemingen en correcties] --> D[Beoordeling en publicatiebesluiten] @@ -81,13 +81,13 @@ flowchart TD | --- | --- | --- | | Universeel package | Bronprotocol, pagina's ophalen, bronobjecten en bronfouten | NIPKaart-identiteit, moderatie of distributiebeleid | | NIPKaart-adapter | Veldbetekenis vertalen, bron-ID behouden, filterscope en volledigheid rapporteren | Gemeentelijke bronwaarden stilzwijgend vervangen door lokale correcties | -| Python-batchrunner | Eigen sourceplanning, begrensd ophalen, bestanden uploaden, als laatste gereedmelden en fetchfouten melden | Core aanroepen om werk te claimen of parkeerinformatie publiceren | -| Core | Toegelaten datasets, manifestdiscovery, importhistorie, validatie, beoordeling, publicatie en uitblijvende leveringen signaleren | Bronfetches plannen, broncredentials beheren of onderzoeksworkflow aanbieden | +| Python-batchrunner | Eigen sourceplanning, begrensd ophalen, complete bestanden afleveren en fetchfouten melden | Core aanroepen om werk te claimen of parkeerinformatie publiceren | +| Core | Toegelaten datasets, bestandsdiscovery, importhistorie, validatie, beoordeling, publicatie en uitblijvende leveringen signaleren | Bronfetches plannen, broncredentials beheren of onderzoeksworkflow aanbieden | | Beheerder | Bron toelaten, uitzonderingen beoordelen, voorwaarden en kwaliteit vastleggen | Iedere normale herimport handmatig overtypen | Er zijn twee verschillende soorten achtergrondwerk: Python verzamelt brondata; Laravel verwerkt aanleveringen en besluiten. Python leest geen Laravel-queuetabellen en krijgt geen databasecredentials. -Core beheert het versieerbare bestandcontract en voorbeelden; beide importrepositories testen tegen dezelfde release. Gedeelde Python-uitvoeringslogica wordt pas losgetrokken wanneer een tweede repository die werkelijk nodig heeft. De universele bronclients blijven daarvan onafhankelijk. +Core bewaart de leveringsafspraak en voorbeelden; de producent en consumer gebruiken hetzelfde beproefde formaat. Een aparte schemarelease is geen voorwaarde voor de pilot. Gedeelde Python-uitvoeringslogica wordt pas losgetrokken wanneer een tweede repository die werkelijk nodig heeft. De universele bronclients blijven daarvan onafhankelijk. ## 5. Aangesloten datasets beheren @@ -153,7 +153,7 @@ Actualiteit wordt per informatietype beoordeeld. Een verouderde bezettingsmeting ### Terugkerende import -De importomgeving haalt volgens eigen planning op en schrijft pas na volledige bestandsupload een ready-manifest. Core ontdekt dit, valideert en vergelijkt. Normale wijzigingen mogen na pilotacceptatie automatisch publiceren; afwijkingen komen in de beoordelingslijst. Core toont uitblijvende leveringen als achterstand en houdt ontvangst, validatie en publicatie apart. De oorzaak van een fetchfout staat in de importomgeving. Ophalen pauzeren en corepublicatie pauzeren zijn afzonderlijke handelingen. +De importomgeving haalt volgens eigen planning op en levert een compleet bestand af in de private bucket. Het in #1217 gekozen mechanisme voorkomt dat core een gedeeltelijke upload verwerkt. Core ontdekt het bestand, valideert en vergelijkt. Normale wijzigingen mogen na pilotacceptatie automatisch publiceren; afwijkingen komen in de beoordelingslijst. Core toont uitblijvende leveringen als achterstand en houdt ontvangst, validatie en publicatie apart. De oorzaak van een fetchfout staat in de importomgeving. Ophalen pauzeren en corepublicatie pauzeren zijn afzonderlijke handelingen. ### Bijdragen en onderhouden From b068570e13584cc0b53b48529373269bc89f074b Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 14 Sep 2026 00:01:55 +0200 Subject: [PATCH 06/10] docs: link the upstream Amsterdam prerequisite --- docs/development/data-import-contract.md | 2 +- docs/development/data-import-pilot.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md index 1af83568..48316819 100644 --- a/docs/development/data-import-contract.md +++ b/docs/development/data-import-contract.md @@ -40,7 +40,7 @@ Core accepteert alleen het bekende formaat en de toegelaten dataset/selectie; va | --- | --- | | `external_id` | Niet-lege oorspronkelijke ID als string. Identiteit is `(dataset, external_id)`; behoud volledige ID en voorloopnullen. Geen coördinatenhash of interne core-ID. | | `geometry` | Oorspronkelijke GeoJSON `Polygon` in WGS84 voor Amsterdam, met `[longitude, latitude]`. Behoud ringen; begrens omvang en valideer bereik en geometrie. Geen verzonnen bronpunt. | -| `number` | Niet-negatief geheel aantal of `null`. Nul en onbekend blijven verschillend; maak van een bronaggregaat geen verzonnen losse bays. | +| `number` | Niet-negatief geheel aantal of `null`; voor Amsterdam een bronschatting, geen geverifieerde telling. Nul en onbekend blijven verschillend; maak van een bronaggregaat geen verzonnen losse bays. | | `street` | Bronadres of `null`; een nabijheidsadres is geen exact parkeeradres. | | `access_category` | `general`, `personal` of `unknown`. `general` betekent niet persoonsgebonden gehandicaptenparkeren, geen beschikbaarheid of parkeren zonder vergunning. | | `source_attributes` | Voor Amsterdam: `regimes`, `orientation` en `version_date`. Alle regimes met hun tijden/dagen/datums/opmerkingen behouden; geen generiek regelsysteem of uitspraak “nu beschikbaar”. | diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md index cf312d78..0750e6b6 100644 --- a/docs/development/data-import-pilot.md +++ b/docs/development/data-import-pilot.md @@ -67,7 +67,7 @@ De NIPKaart-representatie gebruikt onderstaande mapping. `geometry` en de twee ` | --- | --- | --- | | `properties.id` | `external_id="114323484886"` | Bronidentiteit; geen numerieke conversie of verkorting. | | `geometry` | `geometry` | Volledig Polygon behouden. Core berekent pas bij intake een kaartpunt met PostGIS `ST_PointOnSurface`; geen Python-geometrieafhankelijkheid. | -| `aantal=1.0` | `number=1` | Alleen na controle dat het getal eindig, geheel en niet-negatief is. `null` blijft onbekend, nul blijft nul. | +| `aantal=1.0` | `number=1` | Geschatte capaciteit volgens de bron. Alleen na controle dat het getal eindig, geheel en niet-negatief is. `null` blijft onbekend, nul blijft nul. | | `straatnaam` | `street="Pieter Calandlaan"` | Bronstraat, geen geocoding of afgeleid huisnummer. | | `eType` en alle regimebeschrijvingen | `access_category="general"` | Alleen bij consistente algemene betekenis; onbekend of persoonsgebonden wordt niet algemeen verklaard. | | Alle `regimes` | `source_attributes.regimes` | Tijd, dagen, datums, bord, uitzondering en opmerking blijven zichtbaar voor review. Geen berekende “nu beschikbaar”-status. | @@ -82,7 +82,7 @@ De volledige levering volgt [het ene JSON-bestand](data-import-contract.md): `fo ## Eerst oplossen in de universele package -Dit is de eerste afhankelijkheid van disabled-parking #774, geen Python-werk in core: +Uitvoerbaar package-issue: [python-odp-amsterdam #1291](https://github.com/klaasnicolaas/python-odp-amsterdam/issues/1291). Dit is de eerste afhankelijkheid van disabled-parking #774, geen Python-werk in core: 1. Stel de bron-ID, volledige geometrie, alle regimes en versiedatum beschikbaar. Bewaar oorspronkelijke aantallen zonder verliesgevende conversie; test onbekend, nul, fractioneel en meerdere regimes. 2. Bied een publieke volledige ophaling met totalen/pagina-informatie aan. Controleer de laatste pagina en unieke ID's; één `limit=2000` is geen blijvend volledigheidsbewijs. Behoud de bestaande beperkte ophaalmethode voor andere packagegebruikers indien die onderdeel is van de publieke API. From 1c05f308144a90ee82418e9f30b784bceeda3190 Mon Sep 17 00:00:00 2001 From: Klaas Schoute Date: Mon, 14 Sep 2026 09:30:55 +0200 Subject: [PATCH 07/10] feat: add reviewed municipal file intake --- .../Commands/RegisterAmsterdamDataset.php | 37 +++ .../Admin/MunicipalImportController.php | 71 +++++ .../App/StoreMunicipalImportRequest.php | 20 ++ app/Models/DatasetSource.php | 31 ++ app/Models/MunicipalImport.php | 27 ++ app/Models/ParkingMunicipal.php | 5 + app/Policies/MunicipalImportPolicy.php | 30 ++ app/Services/MunicipalImportService.php | 194 +++++++++++ app/Support/MunicipalSnapshot.php | 197 ++++++++++++ app/Support/SnapshotContract.php | 140 -------- composer.json | 1 - composer.lock | 192 +---------- database/factories/DatasetSourceFactory.php | 26 ++ database/factories/MunicipalImportFactory.php | 21 ++ ..._065958_create_municipal_import_tables.php | 66 ++++ docs/development/data-import-contract.md | 24 +- docs/development/data-import-pilot.md | 28 +- docs/development/quality-checks.md | 6 +- resources/js/components/app-sidebar.tsx | 2 + .../components/map/card-location-marker.tsx | 4 +- .../pages/backend/municipal-imports/index.tsx | 109 +++++++ .../pages/backend/municipal-imports/show.tsx | 173 ++++++++++ .../backend/parking-municipal/columns.tsx | 1 + resources/js/types/index.d.ts | 2 +- .../locales/backend/en/municipal-imports.json | 59 ++++ resources/locales/backend/en/sidebar.json | 3 +- .../locales/backend/nl/municipal-imports.json | 59 ++++ resources/locales/backend/nl/sidebar.json | 3 +- .../schemas/import/v1/manifest.schema.json | 225 ------------- .../import/v1/municipal-record.schema.json | 159 --------- routes/web.php | 3 + tests/Feature/MunicipalImportTest.php | 301 ++++++++++++++++++ .../v1/artifact-declared-limit/context.json | 1 - .../v1/artifact-declared-limit/manifest.json | 1 - .../v1/artifact-declared-limit/records.jsonl | 1 - .../import/v1/backwards-time/context.json | 1 - .../import/v1/backwards-time/manifest.json | 1 - .../import/v1/backwards-time/records.jsonl | 1 - .../import/v1/blank-line/context.json | 1 - .../import/v1/blank-line/manifest.json | 1 - .../import/v1/blank-line/records.jsonl | 1 - .../v1/byte-count-mismatch/context.json | 1 - .../v1/byte-count-mismatch/manifest.json | 1 - .../v1/byte-count-mismatch/records.jsonl | 1 - tests/Fixtures/import/v1/cases.json | 214 ------------- .../import/v1/checksum-mismatch/context.json | 1 - .../import/v1/checksum-mismatch/manifest.json | 1 - .../import/v1/checksum-mismatch/records.jsonl | 1 - .../import/v1/complete-empty/context.json | 1 - .../import/v1/complete-empty/manifest.json | 1 - .../import/v1/complete-empty/records.jsonl | 0 .../import/v1/conflicting-batch/context.json | 1 - .../import/v1/conflicting-batch/manifest.json | 1 - .../import/v1/conflicting-batch/records.jsonl | 1 - .../import/v1/cross-source-path/context.json | 1 - .../import/v1/cross-source-path/manifest.json | 1 - .../import/v1/cross-source-path/records.jsonl | 1 - .../import/v1/duplicate-batch/context.json | 1 - .../import/v1/duplicate-batch/manifest.json | 1 - .../import/v1/duplicate-batch/records.jsonl | 1 - .../import/v1/duplicate-records/context.json | 1 - .../import/v1/duplicate-records/manifest.json | 1 - .../import/v1/duplicate-records/records.jsonl | 2 - .../import/v1/duplicate-sequence/context.json | 1 - .../v1/duplicate-sequence/manifest.json | 1 - .../v1/duplicate-sequence/records.jsonl | 1 - .../v1/expected-count-mismatch/context.json | 1 - .../v1/expected-count-mismatch/manifest.json | 1 - .../v1/expected-count-mismatch/records.jsonl | 1 - .../import/v1/fetch-deadline/context.json | 1 - .../import/v1/fetch-deadline/manifest.json | 1 - .../import/v1/fetch-deadline/records.jsonl | 1 - .../v1/filtered-count-mismatch/context.json | 1 - .../v1/filtered-count-mismatch/manifest.json | 1 - .../v1/filtered-count-mismatch/records.jsonl | 1 - .../import/v1/incomplete/context.json | 1 - .../import/v1/incomplete/manifest.json | 1 - .../import/v1/incomplete/records.jsonl | 1 - .../import/v1/invalid-json/context.json | 1 - .../import/v1/invalid-json/manifest.json | 1 - .../import/v1/invalid-json/records.jsonl | 1 - .../import/v1/invalid-latitude/context.json | 1 - .../import/v1/invalid-latitude/manifest.json | 1 - .../import/v1/invalid-latitude/records.jsonl | 1 - .../import/v1/invalid-longitude/context.json | 1 - .../import/v1/invalid-longitude/manifest.json | 1 - .../import/v1/invalid-longitude/records.jsonl | 1 - .../v1/invalid-manifest-date/context.json | 1 - .../v1/invalid-manifest-date/manifest.json | 1 - .../v1/invalid-manifest-date/records.jsonl | 1 - .../import/v1/late-batch/context.json | 1 - .../import/v1/late-batch/manifest.json | 1 - .../import/v1/late-batch/records.jsonl | 1 - .../v1/leading-zero-identities/context.json | 1 - .../v1/leading-zero-identities/manifest.json | 1 - .../v1/leading-zero-identities/records.jsonl | 2 - .../import/v1/line-limit/context.json | 1 - .../import/v1/line-limit/manifest.json | 1 - .../import/v1/line-limit/records.jsonl | 1 - .../import/v1/manifest-limit/context.json | 1 - .../import/v1/manifest-limit/manifest.json | 1 - .../import/v1/manifest-limit/records.jsonl | 1 - .../import/v1/missing-capacity/context.json | 1 - .../import/v1/missing-capacity/manifest.json | 1 - .../import/v1/missing-capacity/records.jsonl | 1 - .../import/v1/missing-version/context.json | 1 - .../import/v1/missing-version/manifest.json | 1 - .../import/v1/missing-version/records.jsonl | 1 - .../import/v1/new-config/context.json | 1 - .../import/v1/new-config/manifest.json | 1 - .../import/v1/new-config/records.jsonl | 1 - .../Fixtures/import/v1/new-scope/context.json | 1 - .../import/v1/new-scope/manifest.json | 1 - .../import/v1/new-scope/records.jsonl | 1 - .../import/v1/non-json-number/context.json | 1 - .../import/v1/non-json-number/manifest.json | 1 - .../import/v1/non-json-number/records.jsonl | 1 - .../v1/null-object-version/context.json | 1 - .../v1/null-object-version/manifest.json | 1 - .../v1/null-object-version/records.jsonl | 1 - .../v1/outside-source-bounds/context.json | 1 - .../v1/outside-source-bounds/manifest.json | 1 - .../v1/outside-source-bounds/records.jsonl | 1 - .../v1/pagination-not-exhausted/context.json | 1 - .../v1/pagination-not-exhausted/manifest.json | 1 - .../v1/pagination-not-exhausted/records.jsonl | 1 - .../import/v1/parse-failures/context.json | 1 - .../import/v1/parse-failures/manifest.json | 1 - .../import/v1/parse-failures/records.jsonl | 1 - .../v1/personal-needs-review/context.json | 1 - .../v1/personal-needs-review/manifest.json | 1 - .../v1/personal-needs-review/records.jsonl | 1 - .../import/v1/pilot-normalized/context.json | 1 - .../import/v1/pilot-normalized/manifest.json | 1 - .../import/v1/pilot-normalized/records.jsonl | 1 - tests/Fixtures/import/v1/pilot-source.json | 26 -- .../context.json | 1 - .../manifest.json | 1 - .../records.jsonl | 1 - .../context.json | 1 - .../manifest.json | 1 - .../records.jsonl | 1 - .../context.json | 1 - .../manifest.json | 1 - .../records.jsonl | 1 - .../v1/record-count-mismatch/context.json | 1 - .../v1/record-count-mismatch/manifest.json | 1 - .../v1/record-count-mismatch/records.jsonl | 1 - .../v1/record-country_code-DE/context.json | 1 - .../v1/record-country_code-DE/manifest.json | 1 - .../v1/record-country_code-DE/records.jsonl | 1 - .../v1/record-declared-limit/context.json | 1 - .../v1/record-declared-limit/manifest.json | 1 - .../v1/record-declared-limit/records.jsonl | 1 - .../v1/record-external_id-/context.json | 1 - .../v1/record-external_id-/manifest.json | 1 - .../v1/record-external_id-/records.jsonl | 1 - .../v1/record-external_id-123/context.json | 1 - .../v1/record-external_id-123/manifest.json | 1 - .../v1/record-external_id-123/records.jsonl | 1 - .../context.json | 1 - .../manifest.json | 1 - .../records.jsonl | 1 - .../context.json | 1 - .../manifest.json | 1 - .../records.jsonl | 1 - .../v1/restriction-needs-review/context.json | 1 - .../v1/restriction-needs-review/manifest.json | 1 - .../v1/restriction-needs-review/records.jsonl | 1 - .../import/v1/sequence-gap/context.json | 1 - .../import/v1/sequence-gap/manifest.json | 1 - .../import/v1/sequence-gap/records.jsonl | 1 - .../import/v1/truncated-line/context.json | 1 - .../import/v1/truncated-line/manifest.json | 1 - .../import/v1/truncated-line/records.jsonl | 1 - .../import/v1/unadmitted-source/context.json | 1 - .../import/v1/unadmitted-source/manifest.json | 1 - .../import/v1/unadmitted-source/records.jsonl | 1 - .../import/v1/unknown-capacity/context.json | 1 - .../import/v1/unknown-capacity/manifest.json | 1 - .../import/v1/unknown-capacity/records.jsonl | 1 - .../import/v1/unknown-contract/context.json | 1 - .../import/v1/unknown-contract/manifest.json | 1 - .../import/v1/unknown-contract/records.jsonl | 1 - .../v1/unknown-record-field/context.json | 1 - .../v1/unknown-record-field/manifest.json | 1 - .../v1/unknown-record-field/records.jsonl | 1 - .../v1/unmapped-needs-review/context.json | 1 - .../v1/unmapped-needs-review/manifest.json | 1 - .../v1/unmapped-needs-review/records.jsonl | 1 - .../import/v1/zero-capacity/context.json | 1 - .../import/v1/zero-capacity/manifest.json | 1 - .../import/v1/zero-capacity/records.jsonl | 1 - tests/Unit/Support/SnapshotContractTest.php | 27 -- 194 files changed, 1487 insertions(+), 1159 deletions(-) create mode 100644 app/Console/Commands/RegisterAmsterdamDataset.php create mode 100644 app/Http/Controllers/Admin/MunicipalImportController.php create mode 100644 app/Http/Requests/App/StoreMunicipalImportRequest.php create mode 100644 app/Models/DatasetSource.php create mode 100644 app/Models/MunicipalImport.php create mode 100644 app/Policies/MunicipalImportPolicy.php create mode 100644 app/Services/MunicipalImportService.php create mode 100644 app/Support/MunicipalSnapshot.php delete mode 100644 app/Support/SnapshotContract.php create mode 100644 database/factories/DatasetSourceFactory.php create mode 100644 database/factories/MunicipalImportFactory.php create mode 100644 database/migrations/2026_09_14_065958_create_municipal_import_tables.php create mode 100644 resources/js/pages/backend/municipal-imports/index.tsx create mode 100644 resources/js/pages/backend/municipal-imports/show.tsx create mode 100644 resources/locales/backend/en/municipal-imports.json create mode 100644 resources/locales/backend/nl/municipal-imports.json delete mode 100644 resources/schemas/import/v1/manifest.schema.json delete mode 100644 resources/schemas/import/v1/municipal-record.schema.json create mode 100644 tests/Feature/MunicipalImportTest.php delete mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/context.json delete mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/manifest.json delete mode 100644 tests/Fixtures/import/v1/artifact-declared-limit/records.jsonl delete mode 100644 tests/Fixtures/import/v1/backwards-time/context.json delete mode 100644 tests/Fixtures/import/v1/backwards-time/manifest.json delete mode 100644 tests/Fixtures/import/v1/backwards-time/records.jsonl delete mode 100644 tests/Fixtures/import/v1/blank-line/context.json delete mode 100644 tests/Fixtures/import/v1/blank-line/manifest.json delete mode 100644 tests/Fixtures/import/v1/blank-line/records.jsonl delete mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/context.json delete mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/manifest.json delete mode 100644 tests/Fixtures/import/v1/byte-count-mismatch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/cases.json delete mode 100644 tests/Fixtures/import/v1/checksum-mismatch/context.json delete mode 100644 tests/Fixtures/import/v1/checksum-mismatch/manifest.json delete mode 100644 tests/Fixtures/import/v1/checksum-mismatch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/complete-empty/context.json delete mode 100644 tests/Fixtures/import/v1/complete-empty/manifest.json delete mode 100644 tests/Fixtures/import/v1/complete-empty/records.jsonl delete mode 100644 tests/Fixtures/import/v1/conflicting-batch/context.json delete mode 100644 tests/Fixtures/import/v1/conflicting-batch/manifest.json delete mode 100644 tests/Fixtures/import/v1/conflicting-batch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/cross-source-path/context.json delete mode 100644 tests/Fixtures/import/v1/cross-source-path/manifest.json delete mode 100644 tests/Fixtures/import/v1/cross-source-path/records.jsonl delete mode 100644 tests/Fixtures/import/v1/duplicate-batch/context.json delete mode 100644 tests/Fixtures/import/v1/duplicate-batch/manifest.json delete mode 100644 tests/Fixtures/import/v1/duplicate-batch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/duplicate-records/context.json delete mode 100644 tests/Fixtures/import/v1/duplicate-records/manifest.json delete mode 100644 tests/Fixtures/import/v1/duplicate-records/records.jsonl delete mode 100644 tests/Fixtures/import/v1/duplicate-sequence/context.json delete mode 100644 tests/Fixtures/import/v1/duplicate-sequence/manifest.json delete mode 100644 tests/Fixtures/import/v1/duplicate-sequence/records.jsonl delete mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/context.json delete mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/manifest.json delete mode 100644 tests/Fixtures/import/v1/expected-count-mismatch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/fetch-deadline/context.json delete mode 100644 tests/Fixtures/import/v1/fetch-deadline/manifest.json delete mode 100644 tests/Fixtures/import/v1/fetch-deadline/records.jsonl delete mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/context.json delete mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/manifest.json delete mode 100644 tests/Fixtures/import/v1/filtered-count-mismatch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/incomplete/context.json delete mode 100644 tests/Fixtures/import/v1/incomplete/manifest.json delete mode 100644 tests/Fixtures/import/v1/incomplete/records.jsonl delete mode 100644 tests/Fixtures/import/v1/invalid-json/context.json delete mode 100644 tests/Fixtures/import/v1/invalid-json/manifest.json delete mode 100644 tests/Fixtures/import/v1/invalid-json/records.jsonl delete mode 100644 tests/Fixtures/import/v1/invalid-latitude/context.json delete mode 100644 tests/Fixtures/import/v1/invalid-latitude/manifest.json delete mode 100644 tests/Fixtures/import/v1/invalid-latitude/records.jsonl delete mode 100644 tests/Fixtures/import/v1/invalid-longitude/context.json delete mode 100644 tests/Fixtures/import/v1/invalid-longitude/manifest.json delete mode 100644 tests/Fixtures/import/v1/invalid-longitude/records.jsonl delete mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/context.json delete mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/manifest.json delete mode 100644 tests/Fixtures/import/v1/invalid-manifest-date/records.jsonl delete mode 100644 tests/Fixtures/import/v1/late-batch/context.json delete mode 100644 tests/Fixtures/import/v1/late-batch/manifest.json delete mode 100644 tests/Fixtures/import/v1/late-batch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/leading-zero-identities/context.json delete mode 100644 tests/Fixtures/import/v1/leading-zero-identities/manifest.json delete mode 100644 tests/Fixtures/import/v1/leading-zero-identities/records.jsonl delete mode 100644 tests/Fixtures/import/v1/line-limit/context.json delete mode 100644 tests/Fixtures/import/v1/line-limit/manifest.json delete mode 100644 tests/Fixtures/import/v1/line-limit/records.jsonl delete mode 100644 tests/Fixtures/import/v1/manifest-limit/context.json delete mode 100644 tests/Fixtures/import/v1/manifest-limit/manifest.json delete mode 100644 tests/Fixtures/import/v1/manifest-limit/records.jsonl delete mode 100644 tests/Fixtures/import/v1/missing-capacity/context.json delete mode 100644 tests/Fixtures/import/v1/missing-capacity/manifest.json delete mode 100644 tests/Fixtures/import/v1/missing-capacity/records.jsonl delete mode 100644 tests/Fixtures/import/v1/missing-version/context.json delete mode 100644 tests/Fixtures/import/v1/missing-version/manifest.json delete mode 100644 tests/Fixtures/import/v1/missing-version/records.jsonl delete mode 100644 tests/Fixtures/import/v1/new-config/context.json delete mode 100644 tests/Fixtures/import/v1/new-config/manifest.json delete mode 100644 tests/Fixtures/import/v1/new-config/records.jsonl delete mode 100644 tests/Fixtures/import/v1/new-scope/context.json delete mode 100644 tests/Fixtures/import/v1/new-scope/manifest.json delete mode 100644 tests/Fixtures/import/v1/new-scope/records.jsonl delete mode 100644 tests/Fixtures/import/v1/non-json-number/context.json delete mode 100644 tests/Fixtures/import/v1/non-json-number/manifest.json delete mode 100644 tests/Fixtures/import/v1/non-json-number/records.jsonl delete mode 100644 tests/Fixtures/import/v1/null-object-version/context.json delete mode 100644 tests/Fixtures/import/v1/null-object-version/manifest.json delete mode 100644 tests/Fixtures/import/v1/null-object-version/records.jsonl delete mode 100644 tests/Fixtures/import/v1/outside-source-bounds/context.json delete mode 100644 tests/Fixtures/import/v1/outside-source-bounds/manifest.json delete mode 100644 tests/Fixtures/import/v1/outside-source-bounds/records.jsonl delete mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/context.json delete mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/manifest.json delete mode 100644 tests/Fixtures/import/v1/pagination-not-exhausted/records.jsonl delete mode 100644 tests/Fixtures/import/v1/parse-failures/context.json delete mode 100644 tests/Fixtures/import/v1/parse-failures/manifest.json delete mode 100644 tests/Fixtures/import/v1/parse-failures/records.jsonl delete mode 100644 tests/Fixtures/import/v1/personal-needs-review/context.json delete mode 100644 tests/Fixtures/import/v1/personal-needs-review/manifest.json delete mode 100644 tests/Fixtures/import/v1/personal-needs-review/records.jsonl delete mode 100644 tests/Fixtures/import/v1/pilot-normalized/context.json delete mode 100644 tests/Fixtures/import/v1/pilot-normalized/manifest.json delete mode 100644 tests/Fixtures/import/v1/pilot-normalized/records.jsonl delete mode 100644 tests/Fixtures/import/v1/pilot-source.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/context.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity--1/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/context.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-1.5/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/context.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-accessible_capacity-True/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-count-mismatch/context.json delete mode 100644 tests/Fixtures/import/v1/record-count-mismatch/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-count-mismatch/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-country_code-DE/context.json delete mode 100644 tests/Fixtures/import/v1/record-country_code-DE/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-country_code-DE/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-declared-limit/context.json delete mode 100644 tests/Fixtures/import/v1/record-declared-limit/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-declared-limit/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-external_id-/context.json delete mode 100644 tests/Fixtures/import/v1/record-external_id-/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-external_id-/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-external_id-123/context.json delete mode 100644 tests/Fixtures/import/v1/record-external_id-123/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-external_id-123/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/context.json delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-02-30T080000Z/records.jsonl delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/context.json delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/manifest.json delete mode 100644 tests/Fixtures/import/v1/record-source_updated_at-2026-09-08T080060Z/records.jsonl delete mode 100644 tests/Fixtures/import/v1/restriction-needs-review/context.json delete mode 100644 tests/Fixtures/import/v1/restriction-needs-review/manifest.json delete mode 100644 tests/Fixtures/import/v1/restriction-needs-review/records.jsonl delete mode 100644 tests/Fixtures/import/v1/sequence-gap/context.json delete mode 100644 tests/Fixtures/import/v1/sequence-gap/manifest.json delete mode 100644 tests/Fixtures/import/v1/sequence-gap/records.jsonl delete mode 100644 tests/Fixtures/import/v1/truncated-line/context.json delete mode 100644 tests/Fixtures/import/v1/truncated-line/manifest.json delete mode 100644 tests/Fixtures/import/v1/truncated-line/records.jsonl delete mode 100644 tests/Fixtures/import/v1/unadmitted-source/context.json delete mode 100644 tests/Fixtures/import/v1/unadmitted-source/manifest.json delete mode 100644 tests/Fixtures/import/v1/unadmitted-source/records.jsonl delete mode 100644 tests/Fixtures/import/v1/unknown-capacity/context.json delete mode 100644 tests/Fixtures/import/v1/unknown-capacity/manifest.json delete mode 100644 tests/Fixtures/import/v1/unknown-capacity/records.jsonl delete mode 100644 tests/Fixtures/import/v1/unknown-contract/context.json delete mode 100644 tests/Fixtures/import/v1/unknown-contract/manifest.json delete mode 100644 tests/Fixtures/import/v1/unknown-contract/records.jsonl delete mode 100644 tests/Fixtures/import/v1/unknown-record-field/context.json delete mode 100644 tests/Fixtures/import/v1/unknown-record-field/manifest.json delete mode 100644 tests/Fixtures/import/v1/unknown-record-field/records.jsonl delete mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/context.json delete mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/manifest.json delete mode 100644 tests/Fixtures/import/v1/unmapped-needs-review/records.jsonl delete mode 100644 tests/Fixtures/import/v1/zero-capacity/context.json delete mode 100644 tests/Fixtures/import/v1/zero-capacity/manifest.json delete mode 100644 tests/Fixtures/import/v1/zero-capacity/records.jsonl delete mode 100644 tests/Unit/Support/SnapshotContractTest.php diff --git a/app/Console/Commands/RegisterAmsterdamDataset.php b/app/Console/Commands/RegisterAmsterdamDataset.php new file mode 100644 index 00000000..49f09374 --- /dev/null +++ b/app/Console/Commands/RegisterAmsterdamDataset.php @@ -0,0 +1,37 @@ +find($this->argument('municipality')); + if (! $municipality || $municipality->name !== 'Amsterdam' || $municipality->country->code !== 'NL' || $municipality->province->geocode !== 'NL-NH') { + $this->error('Select Amsterdam in Noord-Holland, Netherlands.'); + + return self::FAILURE; + } + DatasetSource::firstOrCreate(['code' => 'nl-amsterdam-parkeervakken-e6a'], [ + 'name' => 'Amsterdam — algemene gehandicaptenparkeerplaatsen', + 'selection' => 'e6a-all', 'target_type' => 'municipal', + 'source_url' => 'https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken/', + 'attribution' => 'Gemeente Amsterdam; parkeervakken E6a; capaciteit is een schatting.', + 'terms_url' => 'https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be', + 'municipality_id' => $municipality->id, + 'bounds' => [4.65, 52.2, 5.15, 52.5], + 'publication_enabled' => false, + ]); + $this->info('Dataset registered. Confirm the source terms in the import screen before publication.'); + + return self::SUCCESS; + } +} diff --git a/app/Http/Controllers/Admin/MunicipalImportController.php b/app/Http/Controllers/Admin/MunicipalImportController.php new file mode 100644 index 00000000..897c5b32 --- /dev/null +++ b/app/Http/Controllers/Admin/MunicipalImportController.php @@ -0,0 +1,71 @@ + DatasetSource::orderBy('name')->get(), + 'imports' => MunicipalImport::with('datasetSource:id,name')->latest('id')->paginate(20), + ]); + } + + public function store(StoreMunicipalImportRequest $request, MunicipalImportService $service): RedirectResponse + { + $import = $service->intake($request->file('file')->getContent(), $request->user()); + + return to_route('app.municipal-imports.show', $import); + } + + public function show(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): Response + { + Gate::authorize('view', $municipalImport); + $review = $service->review($municipalImport); + $page = max(1, min((int) $request->query('page', 1), max(1, (int) ceil(count($review['rows']) / 50)))); + $total = count($review['rows']); + $review['rows'] = array_slice($review['rows'], ($page - 1) * 50, 50); + + return Inertia::render('backend/municipal-imports/show', [ + 'import' => $municipalImport, 'dataset' => $municipalImport->datasetSource, + 'review' => $review, 'page' => $page, 'pages' => max(1, (int) ceil($total / 50)), + ]); + } + + public function update(Request $request, MunicipalImport $municipalImport, MunicipalImportService $service): RedirectResponse + { + Gate::authorize('update', $municipalImport); + $data = $request->validate([ + 'decision' => ['required', 'in:publish,reject'], 'reason' => ['required', 'string', 'max:2000'], + 'review_token' => ['required', 'string', 'size:64'], + ]); + $service->decide($municipalImport, $request->user(), $data['decision'], $data['reason'], $data['review_token']); + + return to_route('app.municipal-imports.show', $municipalImport); + } + + public function enable(Request $request, DatasetSource $datasetSource): RedirectResponse + { + Gate::authorize('create', MunicipalImport::class); + $request->validate(['terms_confirmed' => ['accepted'], 'reason' => ['required', 'string', 'max:2000']]); + $datasetSource->publication_enabled = true; + $datasetSource->terms_review = ['user_id' => $request->user()->id, 'at' => now()->toIso8601String(), 'reason' => $request->string('reason')->toString()]; + $datasetSource->save(); + + return to_route('app.municipal-imports.index'); + } +} diff --git a/app/Http/Requests/App/StoreMunicipalImportRequest.php b/app/Http/Requests/App/StoreMunicipalImportRequest.php new file mode 100644 index 00000000..f28a0858 --- /dev/null +++ b/app/Http/Requests/App/StoreMunicipalImportRequest.php @@ -0,0 +1,20 @@ +user()->can('create', MunicipalImport::class); + } + + /** @return array> */ + public function rules(): array + { + return ['file' => ['required', 'file', 'max:32768']]; + } +} diff --git a/app/Models/DatasetSource.php b/app/Models/DatasetSource.php new file mode 100644 index 00000000..515e3952 --- /dev/null +++ b/app/Models/DatasetSource.php @@ -0,0 +1,31 @@ + */ + use HasFactory; + + protected $dateFormat = 'Y-m-d H:i:s.u'; + + protected $fillable = ['code', 'name', 'selection', 'target_type', 'source_url', 'attribution', 'terms_url', 'municipality_id', 'bounds', 'publication_enabled']; + + protected $casts = ['terms_review' => 'array', 'bounds' => 'array', 'publication_enabled' => 'boolean', 'last_published_retrieved_at' => 'immutable_datetime']; + + public function municipality(): BelongsTo + { + return $this->belongsTo(Municipality::class); + } + + /** @return array */ + public function configuration(): array + { + return [...$this->only($this->fillable), 'country_id' => $this->municipality->country_id, 'province_id' => $this->municipality->province_id]; + } +} diff --git a/app/Models/MunicipalImport.php b/app/Models/MunicipalImport.php new file mode 100644 index 00000000..9474eb84 --- /dev/null +++ b/app/Models/MunicipalImport.php @@ -0,0 +1,27 @@ + */ + use HasFactory; + + protected $dateFormat = 'Y-m-d H:i:s.u'; + + protected $fillable = ['dataset_source_id', 'delivery_id', 'fingerprint', 'retrieved_at', 'dataset_config', 'records', 'submitted_by']; + + protected $casts = ['dataset_config' => 'array', 'records' => 'array', 'before_values' => 'array', 'retrieved_at' => 'immutable_datetime', 'reviewed_at' => 'immutable_datetime']; + + protected $hidden = ['records', 'before_values', 'dataset_config']; + + public function datasetSource(): BelongsTo + { + return $this->belongsTo(DatasetSource::class); + } +} diff --git a/app/Models/ParkingMunicipal.php b/app/Models/ParkingMunicipal.php index df95ce79..b1c23974 100644 --- a/app/Models/ParkingMunicipal.php +++ b/app/Models/ParkingMunicipal.php @@ -17,6 +17,8 @@ class ParkingMunicipal extends Model protected $table = 'parking_municipal_spaces'; + protected $hidden = ['source_record', 'last_imported_values']; + protected $primaryKey = 'id'; protected $keyType = 'string'; @@ -42,6 +44,9 @@ class ParkingMunicipal extends Model * @var array */ protected $casts = [ + 'source_record' => 'array', + 'last_imported_values' => 'array', + 'last_checked_at' => 'immutable_datetime', 'orientation' => ParkingOrientation::class, 'updated_at' => 'datetime', 'created_at' => 'datetime', diff --git a/app/Policies/MunicipalImportPolicy.php b/app/Policies/MunicipalImportPolicy.php new file mode 100644 index 00000000..37f81c97 --- /dev/null +++ b/app/Policies/MunicipalImportPolicy.php @@ -0,0 +1,30 @@ +hasRole(UserRole::ADMIN); + } + + public function view(User $user, MunicipalImport $municipalImport): bool + { + return $this->viewAny($user); + } + + public function create(User $user): bool + { + return $this->viewAny($user); + } + + public function update(User $user, MunicipalImport $municipalImport): bool + { + return $this->viewAny($user); + } +} diff --git a/app/Services/MunicipalImportService.php b/app/Services/MunicipalImportService.php new file mode 100644 index 00000000..6c49434d --- /dev/null +++ b/app/Services/MunicipalImportService.php @@ -0,0 +1,194 @@ +authorize('create', MunicipalImport::class); + $data = $this->snapshot->decode($json); + $source = DatasetSource::where('code', $data['dataset'])->first(); + if (! $source) { + throw ValidationException::withMessages(['dataset' => 'Deze dataset is niet geregistreerd.']); + } + $fingerprint = hash('sha256', $json); + $existing = MunicipalImport::where('dataset_source_id', $source->id)->where('delivery_id', $data['delivery_id'])->first(); + if ($existing) { + return $this->existingDelivery($existing, $fingerprint); + } + $configuration = $source->configuration(); + $records = $this->snapshot->validate($data, $source); + + return DB::transaction(function () use ($source, $configuration, $records, $data, $fingerprint, $actor) { + $source = DatasetSource::whereKey($source->id)->lockForUpdate()->firstOrFail(); + $existing = MunicipalImport::where('dataset_source_id', $source->id)->where('delivery_id', $data['delivery_id'])->first(); + if ($existing) { + return $this->existingDelivery($existing, $fingerprint); + } + if (MunicipalSnapshot::fingerprint($source->configuration()) !== MunicipalSnapshot::fingerprint($configuration)) { + throw ValidationException::withMessages(['dataset' => 'De datasetconfiguratie is gewijzigd. Lees het bestand opnieuw in.']); + } + + return MunicipalImport::create([ + 'dataset_source_id' => $source->id, 'delivery_id' => $data['delivery_id'], + 'fingerprint' => $fingerprint, 'retrieved_at' => $data['retrieved_at'], + 'dataset_config' => $configuration, 'records' => $records, 'submitted_by' => $actor->id, + ]); + }); + } + + private function existingDelivery(MunicipalImport $import, string $fingerprint): MunicipalImport + { + if ($import->fingerprint !== $fingerprint) { + throw ValidationException::withMessages(['delivery_id' => 'Deze leverings-ID is al gebruikt voor andere inhoud.']); + } + + return $import; + } + + /** @return array */ + public function review(MunicipalImport $import, bool $lock = false): array + { + $source = $import->datasetSource; + $query = ParkingMunicipal::where('dataset_source_id', $source->id)->orderBy('id'); + if ($lock) { + $query->lockForUpdate(); + } + $existing = $query->get()->keyBy('external_id'); + $rows = []; + $counts = ['new' => 0, 'changed' => 0, 'unchanged' => 0, 'missing' => 0, 'conflict' => 0]; + foreach ($import->records as $record) { + $id = $record['source']['external_id']; + $space = $existing->get($id); + $fields = []; + $conflicts = []; + if ($space) { + foreach (array_unique([...array_keys($record['source']), ...array_keys($space->source_record ?? [])]) as $field) { + $value = $record['source'][$field] ?? null; + if (MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($space->source_record[$field] ?? null)) { + $fields[] = $field; + } + } + foreach ($record['values'] as $field => $value) { + if ($space->last_imported_values === null || (MunicipalSnapshot::fingerprint($this->value($space, $field)) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field]) && MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field]) && MunicipalSnapshot::fingerprint($value) !== MunicipalSnapshot::fingerprint($this->value($space, $field)))) { + $conflicts[] = $field; + } + } + } + $status = ! $space ? 'new' : ($conflicts ? 'conflict' : ($fields ? 'changed' : 'unchanged')); + $counts[$status]++; + $rows[] = [ + 'external_id' => $id, 'status' => $status, 'fields' => $fields, 'conflicts' => $conflicts, + 'before' => $space?->source_record, 'after' => $record['source'], + 'point' => ['latitude' => $record['values']['latitude'], 'longitude' => $record['values']['longitude']], + 'current' => $space?->only(['id', 'number', 'street', 'orientation', 'latitude', 'longitude', 'visibility']), + ]; + $existing->forget($id); + } + foreach ($existing as $space) { + $counts['missing']++; + $rows[] = ['external_id' => $space->external_id, 'status' => 'missing', 'fields' => [], 'conflicts' => [], 'before' => $space->source_record, 'after' => null, 'current' => $space->only(['id', 'visibility'])]; + } + $blockers = []; + if (! $source->publication_enabled) { + $blockers[] = 'Publicatie is nog niet ingeschakeld voor deze dataset. Bevestig eerst de bronvoorwaarden.'; + } + if (MunicipalSnapshot::fingerprint($source->configuration()) !== MunicipalSnapshot::fingerprint($import->dataset_config)) { + $blockers[] = 'De datasetconfiguratie is gewijzigd sinds ontvangst. Lever een nieuw bestand aan.'; + } + if ($source->last_published_retrieved_at && $import->retrieved_at->lessThanOrEqualTo($source->last_published_retrieved_at)) { + $blockers[] = 'Deze levering is niet nieuwer dan de laatst gepubliceerde levering.'; + } + if ($counts['conflict'] > 0) { + $blockers[] = 'Bronwijzigingen conflicteren met handmatig aangepaste velden. Publicatie is geblokkeerd.'; + } + + return ['rows' => $rows, 'counts' => $counts, 'blockers' => $blockers, 'token' => MunicipalSnapshot::fingerprint([$source->configuration(), $source->last_published_retrieved_at, $rows])]; + } + + public function decide(MunicipalImport $import, User $actor, string $decision, string $reason, string $reviewToken): void + { + Gate::forUser($actor)->authorize('update', $import); + DB::transaction(function () use ($import, $actor, $decision, $reason, $reviewToken) { + $source = DatasetSource::whereKey($import->dataset_source_id)->lockForUpdate()->firstOrFail(); + $import = MunicipalImport::whereKey($import->id)->lockForUpdate()->firstOrFail(); + $import->setRelation('datasetSource', $source); + if ($import->state !== 'pending') { + throw ValidationException::withMessages(['decision' => 'Deze levering is al beoordeeld.']); + } + if ($decision === 'publish') { + $review = $this->review($import, true); + if ($review['blockers'] || ! hash_equals($review['token'], $reviewToken)) { + throw ValidationException::withMessages(['decision' => $review['blockers'] ?: ['De gegevens zijn veranderd. Bekijk de verschillen opnieuw.']]); + } + $this->publish($import, $source); + $source->last_published_retrieved_at = $import->retrieved_at; + $source->save(); + } elseif ($decision !== 'reject') { + throw ValidationException::withMessages(['decision' => 'Ongeldige beslissing.']); + } + $import->forceFill(['state' => $decision === 'publish' ? 'published' : 'rejected', 'reviewed_by' => $actor->id, 'review_reason' => $reason, 'reviewed_at' => now()])->save(); + }); + } + + private function publish(MunicipalImport $import, DatasetSource $source): void + { + $municipality = $source->municipality; + $existing = ParkingMunicipal::where('dataset_source_id', $source->id)->get()->keyBy('external_id'); + $updates = []; + $before = []; + foreach ($import->records as $record) { + $externalId = $record['source']['external_id']; + $space = $existing->get($externalId); + if ($space && MunicipalSnapshot::fingerprint($space->source_record) === MunicipalSnapshot::fingerprint($record['source'])) { + continue; + } + $values = $record['values']; + if ($space) { + foreach ($values as $field => $value) { + if (MunicipalSnapshot::fingerprint($this->value($space, $field)) !== MunicipalSnapshot::fingerprint($space->last_imported_values[$field])) { + $values[$field] = $this->value($space, $field); + } + } + } + $id = $space?->id ?? (string) Str::uuid(); + $before[$id] = $space?->getAttributes(); + unset($before[$id]['location']); + $updates[] = [ + 'id' => $id, 'dataset_source_id' => $source->id, 'external_id' => $externalId, + 'country_id' => $municipality->country_id, 'province_id' => $municipality->province_id, 'municipality_id' => $municipality->id, + ...$values, 'visibility' => $space?->visibility ?? true, + 'source_record' => json_encode($record['source'], JSON_THROW_ON_ERROR), + 'last_imported_values' => json_encode($record['values'], JSON_THROW_ON_ERROR), + 'last_checked_at' => now(), 'created_at' => $space?->created_at ?? now(), 'updated_at' => now(), + ]; + } + foreach (array_chunk($updates, 100) as $chunk) { + ParkingMunicipal::upsert($chunk, ['id'], ['street', 'number', 'orientation', 'latitude', 'longitude', 'source_record', 'last_imported_values', 'last_checked_at', 'updated_at']); + } + ParkingMunicipal::where('dataset_source_id', $source->id) + ->whereIn('external_id', array_column(array_column($import->records, 'source'), 'external_id')) + ->toBase()->update(['last_checked_at' => now()]); + $import->before_values = $before; + } + + private function value(ParkingMunicipal $space, string $field): mixed + { + $value = $space->getAttribute($field); + + return $value instanceof \BackedEnum ? $value->value : $value; + } +} diff --git a/app/Support/MunicipalSnapshot.php b/app/Support/MunicipalSnapshot.php new file mode 100644 index 00000000..158daa6f --- /dev/null +++ b/app/Support/MunicipalSnapshot.php @@ -0,0 +1,197 @@ + */ + public function decode(string $json): array + { + if (strlen($json) > self::MAX_BYTES) { + $this->fail('file', 'Het bestand is groter dan 32 MiB.'); + } + try { + $object = json_decode($json, false, 32, JSON_THROW_ON_ERROR); + if (! $object instanceof stdClass) { + $this->fail('file', 'Verwacht één JSON-object.'); + } + $this->rejectDuplicateKeys($json); + $data = json_decode($json, true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + $this->fail('file', 'Ongeldige JSON of te diep geneste gegevens.'); + } + Validator::make($data, [ + 'format' => ['required', 'in:nipkaart-municipal-pilot-1'], + 'dataset' => ['required', 'string', 'max:255'], + 'delivery_id' => ['required', 'uuid'], + 'retrieved_at' => ['required', 'string', 'regex:/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/', 'date'], + 'selection' => ['required', 'string'], + 'records' => ['required', 'array', 'list', 'min:1', 'max:10000'], + ])->validate(); + if (($data['complete'] ?? null) !== true || ! is_int($data['source_count'] ?? null) || $data['source_count'] !== count($data['records'])) { + $this->fail('file', 'De levering moet volledig zijn en het bronaantal moet overeenkomen.'); + } + if (CarbonImmutable::parse($data['retrieved_at'])->isFuture()) { + $this->fail('retrieved_at', 'Het ophaaltijdstip ligt in de toekomst.'); + } + foreach ($object->records as $index => $record) { + if (! $record instanceof stdClass || ! ($record->geometry ?? null) instanceof stdClass || ! ($record->source_attributes ?? null) instanceof stdClass) { + $this->fail("records.$index", 'Een record, geometrie en bronattributen moeten objecten zijn.'); + } + } + + return $data; + } + + /** @param array $data + * @return list> + */ + public function validate(array $data, DatasetSource $source): array + { + if ($source->code !== 'nl-amsterdam-parkeervakken-e6a' || $source->target_type !== 'municipal' || $source->selection !== $data['selection']) { + $this->fail('dataset', 'Dataset of selectie is niet toegelaten voor deze pilot.'); + } + $seen = []; + foreach ($data['records'] as $index => $record) { + $prefix = "records.$index"; + $errors = Validator::make($record, [ + 'external_id' => ['required', 'string', 'max:255'], + 'number' => ['present', 'nullable', 'integer', 'min:0', 'max:2147483647'], + 'street' => ['present', 'nullable', 'string', 'max:255'], + 'access_category' => ['required', 'in:general'], + 'source_updated_at' => ['present', 'prohibited'], + 'source_attributes' => ['required', 'array:regimes,orientation,version_date'], + 'source_attributes.orientation' => ['present', 'nullable', 'string', 'max:255'], + 'source_attributes.version_date' => ['present', 'nullable', 'date_format:Y-m-d'], + 'source_attributes.regimes' => ['required', 'array', 'list', 'min:1', 'max:100'], + 'geometry.type' => ['required', 'in:Polygon'], + 'geometry.coordinates' => ['required', 'array', 'list', 'min:1', 'max:100'], + ])->errors(); + if ($errors->isNotEmpty()) { + throw ValidationException::withMessages(collect($errors->messages())->mapWithKeys(fn ($messages, $field) => ["$prefix.$field" => $messages])->all()); + } + if ($record['external_id'] !== trim($record['external_id']) || isset($seen[$record['external_id']])) { + $this->fail("$prefix.external_id", 'Bron-ID is ongeldig of dubbel.'); + } + $seen[$record['external_id']] = true; + if ($record['number'] !== null && ! is_int($record['number'])) { + $this->fail("$prefix.number", 'Verwacht een geheel aantal of null.'); + } + foreach ($record['source_attributes']['regimes'] as $regime) { + if (! is_array($regime) || ($regime['eType'] ?? null) !== 'E6a' || ($regime['eTypeDescription'] ?? null) !== 'Gehandicaptenparkeerplaats algemeen' || ! in_array($regime['kenteken'] ?? null, [null, ''], true)) { + $this->fail("$prefix.source_attributes.regimes", 'Onbekende of persoonsgebonden parkeerregeling; selectie opnieuw beoordelen.'); + } + } + $positions = 0; + foreach ($record['geometry']['coordinates'] as $ring) { + if (! is_array($ring) || ! array_is_list($ring) || count($ring) < 4 || $ring[0] !== $ring[array_key_last($ring)]) { + $this->fail("$prefix.geometry", 'Ongeldige of niet gesloten polygoonring.'); + } + $positions += count($ring); + if ($positions > 10000) { + $this->fail("$prefix.geometry", 'Te veel posities in de polygoon.'); + } + foreach ($ring as $point) { + if (! is_array($point) || ! array_is_list($point) || count($point) !== 2 || ! is_numeric($point[0]) || ! is_numeric($point[1]) || is_string($point[0]) || is_string($point[1]) || $point[0] < -180 || $point[0] > 180 || $point[1] < -90 || $point[1] > 90) { + $this->fail("$prefix.geometry", 'Ongeldige WGS84-coördinaten.'); + } + } + } + } + [$west, $south, $east, $north] = $source->bounds; + $rows = DB::select(<<<'SQL' + WITH shapes AS ( + SELECT item->>'external_id' AS external_id, ST_SetSRID(ST_GeomFromGeoJSON((item->'geometry')::text),4326) AS shape + FROM jsonb_array_elements(?::jsonb) AS item + ) SELECT external_id, ST_IsValid(shape) AS valid, ST_IsValidReason(shape) AS validity_reason, + ST_CoveredBy(shape, ST_MakeEnvelope(?, ?, ?, ?, 4326)) AS inside, + ST_X(ST_PointOnSurface(shape)) AS longitude, ST_Y(ST_PointOnSurface(shape)) AS latitude + FROM shapes + SQL, [json_encode($data['records'], JSON_THROW_ON_ERROR), $west, $south, $east, $north]); + $points = []; + $errors = []; + foreach ($rows as $row) { + if (! $row->valid || ! $row->inside) { + $errors["geometry.{$row->external_id}"] = "Bron-ID {$row->external_id}: ".(! $row->valid ? $row->validity_reason : 'buiten het toegelaten gebied.'); + + continue; + } + $points[$row->external_id] = ['longitude' => round($row->longitude, 7), 'latitude' => round($row->latitude, 7)]; + } + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + return array_map(fn ($record) => [ + 'source' => $record, + 'values' => [ + 'street' => $record['street'], 'number' => $record['number'], + 'orientation' => match ($record['source_attributes']['orientation']) { + 'Haaks', 'Dwars' => 'perpendicular', 'Langs' => 'parallel', 'Schuin', 'Visgraat', 'Vissengraat' => 'angle', default => null, + }, + ...$points[$record['external_id']], + ], + ], $data['records']); + } + + /** Canonical comparison ignores object key ordering while preserving array order. */ + public static function fingerprint(mixed $value): string + { + $normalize = function (mixed $item) use (&$normalize): mixed { + if (! is_array($item)) { + return $item; + } + if (! array_is_list($item)) { + ksort($item); + } + + return array_map($normalize, $item); + }; + + return hash('sha256', json_encode($normalize($value), JSON_THROW_ON_ERROR)); + } + + /** JSON decoding is already complete; this pass rejects duplicate object keys. */ + private function rejectDuplicateKeys(string $json): void + { + preg_match_all('/"(?:[^"\\\\]|\\\\.)*"|[{}\[\]:,]/s', $json, $matches); + $stack = []; + foreach ($matches[0] as $token) { + if ($token === '{' || $token === '[') { + $stack[] = ['object' => $token === '{', 'key' => $token === '{', 'seen' => []]; + } elseif ($token === '}' || $token === ']') { + array_pop($stack); + } elseif ($stack !== []) { + $i = array_key_last($stack); + if ($stack[$i]['object']) { + if ($token === ',') { + $stack[$i]['key'] = true; + } elseif ($token === ':') { + $stack[$i]['key'] = false; + } elseif ($token[0] === '"' && $stack[$i]['key']) { + $key = json_decode($token, true, 32, JSON_THROW_ON_ERROR); + if (isset($stack[$i]['seen'][$key])) { + $this->fail('file', 'Dubbele JSON-sleutel: '.$key); + } + $stack[$i]['seen'][$key] = true; + } + } + } + } + } + + private function fail(string $field, string $message): never + { + throw ValidationException::withMessages([$field => $message]); + } +} diff --git a/app/Support/SnapshotContract.php b/app/Support/SnapshotContract.php deleted file mode 100644 index 8568f88d..00000000 --- a/app/Support/SnapshotContract.php +++ /dev/null @@ -1,140 +0,0 @@ -clock = $clock ?? static fn (): float => hrtime(true) / 1e9; - $this->validator = new Validator; - $directory = dirname(__DIR__, 2).'/resources/schemas/import/v1/'; - $this->manifestSchema = json_decode(file_get_contents($directory.'manifest.schema.json'), false, 32, JSON_THROW_ON_ERROR); - $this->recordSchema = json_decode(file_get_contents($directory.'municipal-record.schema.json'), false, 32, JSON_THROW_ON_ERROR); - } - - /** - * Context is trusted admission/ledger state supplied by the consumer, never by the manifest. - * - * @param array{source_id: string, scope_version: int, source_config_version: int, country_code: string, bounds: array{float, float, float, float}, highest_sequence: int, batches: array, sequences: array} $context - */ - public function check(string $manifestJson, string $recordsPath, array $context): string - { - $started = ($this->clock)(); - if (strlen($manifestJson) > self::MAX_MANIFEST_BYTES) { - return 'invalid'; - } - - try { - $manifest = json_decode($manifestJson, false, 32, JSON_THROW_ON_ERROR); - if (! $this->validator->validate($manifest, $this->manifestSchema)->isValid()) { - return 'invalid'; - } - - $expectedKey = "sources/{$manifest->source_id}/batches/{$manifest->batch_id}/records.jsonl"; - $completeness = $manifest->completeness; - if ($manifest->source_id !== $context['source_id'] - || $manifest->artifact->key !== $expectedKey - || $manifest->fetched_finished_at < $manifest->fetched_started_at - || strtotime($manifest->fetched_finished_at) - strtotime($manifest->fetched_started_at) > 1800 - || $completeness->records_seen != $completeness->records_emitted + $completeness->records_filtered_out - || $completeness->records_emitted != $manifest->artifact->record_count - || ($completeness->expected_records !== null && $completeness->expected_records != $completeness->records_seen)) { - return 'invalid'; - } - - if (! is_file($recordsPath) || ! is_readable($recordsPath)) { - return 'invalid'; - } - $stream = fopen($recordsPath, 'rb'); - if ($stream === false) { - return 'invalid'; - } - $hash = hash_init('sha256'); - $bytes = 0; - $ids = []; - $review = false; - try { - while (($line = fgets($stream, self::MAX_LINE_BYTES + 2)) !== false) { - $bytes += strlen($line); - if ($bytes > self::MAX_ARTIFACT_BYTES || strlen($line) > self::MAX_LINE_BYTES - || ! str_ends_with($line, "\n") || str_contains($line, "\r") - || (($this->clock)() - $started) > self::MAX_VALIDATION_SECONDS) { - return 'invalid'; - } - hash_update($hash, $line); - $record = json_decode($line, false, 32, JSON_THROW_ON_ERROR); - if (! $this->validator->validate($record, $this->recordSchema)->isValid()) { - return 'invalid'; - } - $id = 'id:'.$record->external_id; - if (isset($ids[$id]) || count($ids) >= self::MAX_RECORDS) { - return 'invalid'; - } - $ids[$id] = true; - [$west, $south, $east, $north] = $context['bounds']; - if ($record->country_code !== $context['country_code'] - || $record->position->longitude < $west || $record->position->longitude > $east - || $record->position->latitude < $south || $record->position->latitude > $north) { - return 'invalid'; - } - $review = $review || $record->access_category !== 'designated_accessible' - || $record->unmapped_fields !== [] || $record->restrictions !== []; - } - if (! feof($stream)) { - return 'invalid'; - } - } finally { - fclose($stream); - } - if ($bytes != $manifest->artifact->bytes || count($ids) != $manifest->artifact->record_count - || hash_final($hash) !== $manifest->artifact->sha256 - || (($this->clock)() - $started) > self::MAX_VALIDATION_SECONDS) { - return 'invalid'; - } - - $knownHash = $context['batches'][$manifest->batch_id] ?? null; - if ($knownHash !== null) { - return hash_equals($knownHash, hash('sha256', $manifestJson)) ? 'duplicate' : 'conflict'; - } - if (isset($context['sequences'][$manifest->source_sequence])) { - return 'conflict'; - } - if ($manifest->source_sequence <= $context['highest_sequence']) { - return 'superseded'; - } - if ($review || $ids === [] || $manifest->scope_version != $context['scope_version'] - || $manifest->source_config_version != $context['source_config_version']) { - return 'review'; - } - - return 'valid'; - } catch (JsonException) { - return 'invalid'; - } - } -} diff --git a/composer.json b/composer.json index 9b381405..0fe5d629 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,6 @@ "laravel/wayfinder": "^0.1.10", "league/flysystem-aws-s3-v3": "^3.0", "opcodesio/log-viewer": "^3.15", - "opis/json-schema": "^2", "spatie/laravel-backup": "^10.0", "spatie/laravel-permission": "^8.0" }, diff --git a/composer.lock b/composer.lock index 8885018f..2c2b0f31 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "800b88f54d20308f01182e3643483fa3", + "content-hash": "a8fb57045fe9c9ec14a3eb9b4f58aedf", "packages": [ { "name": "aws/aws-crt-php", @@ -3757,196 +3757,6 @@ }, "time": "2026-02-28T09:04:57+00:00" }, - { - "name": "opis/json-schema", - "version": "2.6.0", - "source": { - "type": "git", - "url": "https://github.com/opis/json-schema.git", - "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opis/json-schema/zipball/8458763e0dd0b6baa310e04f1829fc73da4e8c8a", - "reference": "8458763e0dd0b6baa310e04f1829fc73da4e8c8a", - "shasum": "" - }, - "require": { - "ext-json": "*", - "opis/string": "^2.1", - "opis/uri": "^1.0", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "ext-bcmath": "*", - "ext-intl": "*", - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Opis\\JsonSchema\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Sorin Sarca", - "email": "sarca_sorin@hotmail.com" - }, - { - "name": "Marius Sarca", - "email": "marius.sarca@gmail.com" - } - ], - "description": "Json Schema Validator for PHP", - "homepage": "https://opis.io/json-schema", - "keywords": [ - "json", - "json-schema", - "schema", - "validation", - "validator" - ], - "support": { - "issues": "https://github.com/opis/json-schema/issues", - "source": "https://github.com/opis/json-schema/tree/2.6.0" - }, - "time": "2025-10-17T12:46:48+00:00" - }, - { - "name": "opis/string", - "version": "2.1.0", - "source": { - "type": "git", - "url": "https://github.com/opis/string.git", - "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opis/string/zipball/3e4d2aaff518ac518530b89bb26ed40f4503635e", - "reference": "3e4d2aaff518ac518530b89bb26ed40f4503635e", - "shasum": "" - }, - "require": { - "ext-iconv": "*", - "ext-json": "*", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "Opis\\String\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Marius Sarca", - "email": "marius.sarca@gmail.com" - }, - { - "name": "Sorin Sarca", - "email": "sarca_sorin@hotmail.com" - } - ], - "description": "Multibyte strings as objects", - "homepage": "https://opis.io/string", - "keywords": [ - "multi-byte", - "opis", - "string", - "string manipulation", - "utf-8" - ], - "support": { - "issues": "https://github.com/opis/string/issues", - "source": "https://github.com/opis/string/tree/2.1.0" - }, - "time": "2025-10-17T12:38:41+00:00" - }, - { - "name": "opis/uri", - "version": "1.1.0", - "source": { - "type": "git", - "url": "https://github.com/opis/uri.git", - "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/opis/uri/zipball/0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", - "reference": "0f3ca49ab1a5e4a6681c286e0b2cc081b93a7d5a", - "shasum": "" - }, - "require": { - "opis/string": "^2.0", - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "phpunit/phpunit": "^9" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "Opis\\Uri\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "Apache-2.0" - ], - "authors": [ - { - "name": "Marius Sarca", - "email": "marius.sarca@gmail.com" - }, - { - "name": "Sorin Sarca", - "email": "sarca_sorin@hotmail.com" - } - ], - "description": "Build, parse and validate URIs and URI-templates", - "homepage": "https://opis.io", - "keywords": [ - "URI Template", - "parse url", - "punycode", - "uri", - "uri components", - "url", - "validate uri" - ], - "support": { - "issues": "https://github.com/opis/uri/issues", - "source": "https://github.com/opis/uri/tree/1.1.0" - }, - "time": "2021-05-22T15:57:08+00:00" - }, { "name": "paragonie/constant_time_encoding", "version": "v3.1.3", diff --git a/database/factories/DatasetSourceFactory.php b/database/factories/DatasetSourceFactory.php new file mode 100644 index 00000000..a6cc450a --- /dev/null +++ b/database/factories/DatasetSourceFactory.php @@ -0,0 +1,26 @@ + */ +class DatasetSourceFactory extends Factory +{ + public function definition(): array + { + return [ + 'code' => 'nl-amsterdam-parkeervakken-e6a', 'name' => 'Amsterdam', + 'selection' => 'e6a-all', 'target_type' => 'municipal', + 'source_url' => 'https://api.data.amsterdam.nl/v1/parkeervakken/parkeervakken/', + 'attribution' => 'Gemeente Amsterdam — CC0', + 'terms_url' => 'https://data.overheid.nl/dataset/318a98b8-ef87-4335-9674-f5405f2bc4be', + 'municipality_id' => Municipality::factory()->state(['name' => 'Amsterdam'])->for(Province::factory()->state(['geocode' => 'NL-NH'])->for(Country::factory()->state(['code' => 'NL']))), + 'bounds' => [4.65, 52.2, 5.15, 52.5], 'publication_enabled' => true, + ]; + } +} diff --git a/database/factories/MunicipalImportFactory.php b/database/factories/MunicipalImportFactory.php new file mode 100644 index 00000000..54e43dae --- /dev/null +++ b/database/factories/MunicipalImportFactory.php @@ -0,0 +1,21 @@ + */ +class MunicipalImportFactory extends Factory +{ + public function definition(): array + { + return [ + 'dataset_source_id' => DatasetSource::factory(), 'delivery_id' => fake()->uuid(), + 'fingerprint' => hash('sha256', 'example'), 'retrieved_at' => now()->subMinute(), + 'dataset_config' => fn (array $attributes) => DatasetSource::findOrFail($attributes['dataset_source_id'])->configuration(), + 'records' => [], + ]; + } +} diff --git a/database/migrations/2026_09_14_065958_create_municipal_import_tables.php b/database/migrations/2026_09_14_065958_create_municipal_import_tables.php new file mode 100644 index 00000000..66117a91 --- /dev/null +++ b/database/migrations/2026_09_14_065958_create_municipal_import_tables.php @@ -0,0 +1,66 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->string('selection'); + $table->string('target_type')->default('municipal'); + $table->text('source_url'); + $table->text('attribution'); + $table->text('terms_url'); + $table->foreignId('municipality_id')->constrained()->restrictOnDelete(); + $table->jsonb('bounds'); + $table->boolean('publication_enabled')->default(false); + $table->jsonb('terms_review')->nullable(); + $table->timestampTz('last_published_retrieved_at', 6)->nullable(); + $table->timestampsTz(); + }); + Schema::create('municipal_imports', function (Blueprint $table) { + $table->id(); + $table->foreignId('dataset_source_id')->constrained()->restrictOnDelete(); + $table->uuid('delivery_id'); + $table->string('fingerprint', 64); + $table->timestampTz('retrieved_at', 6); + $table->string('state')->default('pending'); + $table->jsonb('dataset_config'); + $table->jsonb('records'); + $table->jsonb('before_values')->nullable(); + $table->foreignId('submitted_by')->nullable()->constrained('users')->nullOnDelete(); + $table->foreignId('reviewed_by')->nullable()->constrained('users')->nullOnDelete(); + $table->text('review_reason')->nullable(); + $table->timestampTz('reviewed_at')->nullable(); + $table->timestampsTz(); + $table->unique(['dataset_source_id', 'delivery_id']); + }); + Schema::table('parking_municipal_spaces', function (Blueprint $table) { + $table->integer('number')->nullable()->change(); + $table->foreignId('dataset_source_id')->nullable()->constrained()->restrictOnDelete(); + $table->string('external_id')->nullable(); + $table->jsonb('source_record')->nullable(); + $table->jsonb('last_imported_values')->nullable(); + $table->timestampTz('last_checked_at')->nullable(); + $table->unique(['dataset_source_id', 'external_id']); + }); + } + + public function down(): void + { + Schema::table('parking_municipal_spaces', function (Blueprint $table) { + $table->dropUnique(['dataset_source_id', 'external_id']); + $table->dropConstrainedForeignId('dataset_source_id'); + $table->dropColumn(['external_id', 'source_record', 'last_imported_values', 'last_checked_at']); + }); + Schema::dropIfExists('municipal_imports'); + Schema::dropIfExists('dataset_sources'); + // Nullable capacity remains: restoring NOT NULL would lose unknown values. + } +}; diff --git a/docs/development/data-import-contract.md b/docs/development/data-import-contract.md index 48316819..af4f6d2a 100644 --- a/docs/development/data-import-contract.md +++ b/docs/development/data-import-contract.md @@ -1,6 +1,6 @@ # Voorlopige gegevenslevering -Status: werkafspraak voor [#1214](https://github.com/NIPKaart/core/issues/1214). Eén JSON-bestand voor de eerste handmatige import; het formaat wordt pas vastgezet nadat de producer en core samen zijn beproefd. De schema's en validator uit [PR #1222](https://github.com/NIPKaart/core/pull/1222) blijven een prototype en worden in #1215 vervangen of verwijderd. +Status: werkafspraak voor [#1214](https://github.com/NIPKaart/core/issues/1214). Eén JSON-bestand voor de eerste handmatige import; het formaat wordt pas vastgezet nadat de producer en core samen zijn beproefd. De schema's, validator, Opis-dependency en fixturecorpus uit [PR #1222](https://github.com/NIPKaart/core/pull/1222) zijn vervangen door één daadwerkelijke intake in #1215. De volledige Amsterdamse bronproef blokkeert nog op tien ongeldige polygonen; het formaat is daarom nog geen geaccepteerde productieaansluiting. ## Van bron naar gebruiker @@ -42,11 +42,11 @@ Core accepteert alleen het bekende formaat en de toegelaten dataset/selectie; va | `geometry` | Oorspronkelijke GeoJSON `Polygon` in WGS84 voor Amsterdam, met `[longitude, latitude]`. Behoud ringen; begrens omvang en valideer bereik en geometrie. Geen verzonnen bronpunt. | | `number` | Niet-negatief geheel aantal of `null`; voor Amsterdam een bronschatting, geen geverifieerde telling. Nul en onbekend blijven verschillend; maak van een bronaggregaat geen verzonnen losse bays. | | `street` | Bronadres of `null`; een nabijheidsadres is geen exact parkeeradres. | -| `access_category` | `general`, `personal` of `unknown`. `general` betekent niet persoonsgebonden gehandicaptenparkeren, geen beschikbaarheid of parkeren zonder vergunning. | +| `access_category` | Voor deze pilot uitsluitend `general`; een andere waarde blokkeert de levering. `general` betekent niet persoonsgebonden gehandicaptenparkeren, geen beschikbaarheid of parkeren zonder vergunning. | | `source_attributes` | Voor Amsterdam: `regimes`, `orientation` en `version_date`. Alle regimes met hun tijden/dagen/datums/opmerkingen behouden; geen generiek regelsysteem of uitspraak “nu beschikbaar”. | | `source_updated_at` | Alleen een datum met bekende betekenis als wijziging van het bronrecord; anders `null`. Portaalverwerking is geen veldcontrole. | -Core bewaart de brongeometrie bij de bronclaim en gebruikt na validatie PostGIS `ST_PointOnSurface` voor het afgeleide kaartpunt (`geometry_method=point_on_surface`). Dit blijft een benadering binnen het parkeervlak, geen ingang of individueel vak. Core schrijft de bestaande latitude/longitude-kolommen; PostgreSQL blijft de bestaande `location` afleiden. Zo zijn geen extra Python-geometriepackage of twee concurrerende afleidingen nodig. Zie [PostGIS](https://postgis.net/docs/ST_PointOnSurface.html) en de [bestaande opslagafspraak](postgresql.md#spatial-representation). +Core bewaart de brongeometrie bij de bronclaim en gebruikt na validatie PostGIS `ST_PointOnSurface` voor het afgeleide kaartpunt (vaste afleiding voor deze pilot). Dit blijft een benadering binnen het parkeervlak, geen ingang of individueel vak. Core schrijft de bestaande latitude/longitude-kolommen; PostgreSQL blijft de bestaande `location` afleiden. Zo zijn geen extra Python-geometriepackage of twee concurrerende afleidingen nodig. Zie [PostGIS](https://postgis.net/docs/ST_PointOnSurface.html) en de [bestaande opslagafspraak](postgresql.md#spatial-representation). Land en administratieve relaties worden door core uit de toegelaten datasetconfiguratie gekoppeld. Geen Nederlandse verplichte codes voor Europese bronnen en geen interne foreign keys in het bestand. Deze pilot ondersteunt alleen de aangetroffen Polygon-geometrie. Puntbronnen of andere geometrieën krijgen pas ondersteuning wanneer ze worden aangesloten. Nuttige broninformatie wordt daarbij nooit stilzwijgend weggegooid. @@ -62,7 +62,7 @@ Land en administratieve relaties worden door core uit de toegelaten datasetconfi | Een veld wijzigt bij dezelfde bron-ID | Nieuwe bronwaarde tonen voor beoordeling; identiteit, favorieten en detailverwijzingen behouden. | | Een record ontbreekt in een complete selectie | Markeren als mogelijk verdwenen en beoordelen; geen automatische verwijdering. Terugkeer gebruikt dezelfde identiteit. | | Lege/incomplete/ongeldige levering of mislukte fetch | Geen publicatie; bestaande gegevens en laatste geldige export blijven behouden. | -| Bronwaarde botst met geaccepteerde correctie | Bronwaarde afzonderlijk bijwerken; correctie behouden en conflict tonen. Ook in de eerste importimplementatie. | +| Bronwaarde botst met geaccepteerde correctie | Bronwaarde in de ontvangen levering bewaren; correctie behouden en publicatie van de levering blokkeren bij een botsing. Ook in de eerste importimplementatie. | | Onbekende toegang of gewijzigde scope | Geen automatische algemene publicatie of vergelijking van ontbrekende records; eerst beoordelen. | Core controleert volgorde en actuele correcties opnieuw bij publicatie, ook als twee beoordeelde imports tegelijk klaarstaan. `retrieved_at` is een eenvoudige volgorderegel voor één producent met correcte UTC-klok; het bewijst geen transactiesnapshot bij de bron. Toekomstige of onlogische tijdstippen vragen beoordeling. Geen gedistribueerde teller bouwen voor de pilot. @@ -75,3 +75,19 @@ Core controleert volgorde en actuele correcties opnieuw bij publicatie, ook als 4. **#1217, disabled-parking #775 en core #1216:** provider kiezen, dezelfde levering automatisch uploaden en ontdekken. De gekozen opslaggrens moet voorkomen dat core een gedeeltelijk bestand verwerkt. Gewone CI werkt offline met kleine voorbeelden van packageobjecten. Eén afzonderlijke begrensde live proef bewijst bronophaling; een daadwerkelijke beoordeelde core-import bewijst de volgende stap. Geen van beide wordt door alleen een fixturetest vervangen. + +## Lokale uitvoering en herstel + +1. Voer de migraties uit op de bedoelde ontwikkelomgeving en zorg dat de bestaande geografische relaties voor Amsterdam aanwezig zijn. Er worden geen legacygegevens automatisch gekoppeld of vervangen. +2. Registreer de aansluiting met `php artisan nipkaart:register-amsterdam `. Het commando controleert Amsterdam, land `NL` en provincie `NL-NH`; publicatie staat standaard uit. De ingestelde bbox `[4.65, 52.2, 5.15, 52.5]` is een ruime operationele begrenzing, geen officiële gemeentegrens. +3. Open als beheerder **Gemeentelijke imports**. Controleer de bronvoorwaarden en leg de onderbouwing vast vóór de eerste te publiceren levering. Een configuratiewijziging maakt oudere beoordelingen ongeldig; haal daarna een nieuwe levering op. +4. Bied het ongewijzigde producerbestand aan. Configureer PHP `upload_max_filesize` op minstens `32M` en `post_max_size` en de webserver-bodylimiet hoger dan 32 MiB voor multipart-overhead. Kleinere serverlimieten gelden vóór de applicatiecontrole. +5. Controleer aantallen, oorspronkelijke bronvelden, alle regelingen en een kaartsteekproef. De lijst toont maximaal 50 records per pagina; het parkeervlak en afgeleide punt zijn per record te openen. Goedkeuren en afwijzen vereisen een reden. + +`MunicipalImportService::intake()` is het gedeelde toegangspunt voor de upload en de latere bucketconsumer. Autorisatie geldt ook in de service. Eén datasetrij wordt vergrendeld tijdens publicatie; actuele bronrijen en importstatus worden opnieuw gecontroleerd. Een verouderde reviewtoken vereist opnieuw beoordelen. De levering, mutaties en laatste gepubliceerde ophaaltijd worden samen gecommit of teruggedraaid. Opnieuw aanbieden van dezelfde bytes geeft de bestaande status terug, ook na een configuratiewijziging. + +Bronclaims staan in `source_record`; `last_imported_values` bewaart de laatst afgeleide waarden. De huidige velden zijn de effectieve waarden: een afwijking ten opzichte van de vorige import geldt conservatief als handmatige correctie. Niet-conflicterende correcties en zichtbaarheid blijven behouden; een gelijktijdige afwijkende bronwijziging blokkeert publicatie. `last_checked_at` registreert de geslaagde controle, `source_updated_at` blijft onbekend. Bestaande niet-aangesloten gemeentelijke records blijven ongemoeid; aansluiting of reconciliatie daarvan is afzonderlijk werk. + +`municipal_imports.before_values` bewaart voor gewijzigde records de vorige databasewaarden en voor nieuw aangemaakte records `null`. De ontvangen levering en beoordeling blijven bewaard. Dit ondersteunt een onderbouwde herstelbeslissing; er is geen automatische terugzetknop die latere handmatige correcties kan overschrijven. Bij een databasefout blijft de levering te beoordelen en kan dezelfde publicatie opnieuw worden geprobeerd. De reviewpagina vergelijkt altijd met de huidige gegevens, ook na publicatie; historische verschillen zijn niet hetzelfde als deze actuele vergelijking. + +Een migratierollback verwijdert de importaudit en bronkoppeling. Maak eerst een databaseback-up en beoordeel herstel op gegevensniveau; een code-rollback is geen gegevensherstel. Onbekende capaciteit blijft nullable, ook na rollback. Productieactivering, legacyoverdracht, bucketcredentials en planning vallen buiten deze stap. diff --git a/docs/development/data-import-pilot.md b/docs/development/data-import-pilot.md index 0750e6b6..1b1b53d9 100644 --- a/docs/development/data-import-pilot.md +++ b/docs/development/data-import-pilot.md @@ -1,6 +1,6 @@ # Pilot: algemene gehandicaptenparkeerplaatsen Amsterdam -Status: geselecteerd voor implementatie van [#1214](https://github.com/NIPKaart/core/issues/1214), bronproef 2026-09-13. Amsterdam biedt bruikbare broninhoud en een controleerbaar totaal. De bestaande package verliest nog relevante velden; de live aansluiting is daarom nog niet gereed. Eerst de generieke package verbeteren, vervolgens disabled-parking #774 en core #1215. Geen productiepublicatie of automatische levering is uitgevoerd. +Status 2026-09-14: `odp-amsterdam` 7.0.0 is uitgebracht en [disabled-parking #783](https://github.com/NIPKaart/disabled-parking/pull/783) is gemerged. De live producer levert het afgesproken bestand. De eerste daadwerkelijke core-intake in #1215 weigert de volledige levering vanwege tien zelfdoorsnijdende polygonen. Bronophaling is bewezen; succesvolle ketenacceptatie en publieke ingebruikname nog niet. ## Waarom deze bron @@ -12,7 +12,7 @@ De leverancier is Gemeente Amsterdam. De dataset wordt in de [overheidscatalogus | --- | --- | | Datasetcode | `nl-amsterdam-parkeervakken-e6a` | | Selectiecode | `e6a-all` = alle records uit `parkeervakken/parkeervakken` met exact `eType=E6a`; geen bbox of aanvullende stille filtering. | -| Package | `odp-amsterdam` 6.0.0 is onderzocht; de verbeterde versie moet vóór gebruik expliciet worden vastgelegd. | +| Package | `odp-amsterdam==7.0.0` wordt door de producer gebruikt; de eerste onderzoeksproef gebruikte 6.0.0. | | Identiteit | `properties.id`, als volledige string binnen de dataset. De GeoJSON-wrapper `parkeervakken.` wordt niet als tweede identiteit gebruikt. | | Geografie | Nederland (`NL`), Noord-Holland (`NL-NH`), gemeente Amsterdam (`nl:cbs:municipality`, `0363`). Core koppelt deze codes aan relaties. | | Betekenis | Algemene gehandicaptenparkeerplaats, mogelijk met tijdsbeperkingen. Geen actuele beschikbaarheid, geen garantie op toegankelijkheid voor ieder voertuig. | @@ -37,7 +37,7 @@ Requests: [volledige selectie](https://api.data.amsterdam.nl/v1/parkeervakken/pa Dit bewijst volledige ontvangst ten opzichte van het toen gerapporteerde totaal, geen volledige werkelijkheid op straat of gegarandeerde transactiesnapshot. Het bronbestand blijft tijdelijk onderzoeksmateriaal; we voegen geen gemeentelijke fixturecorpus toe aan core. -De bestaande package doet één request met een limiet, geeft geen totalen/paginering door en gebruikt slechts delen van het eerste regime. Daardoor verdwijnen tijdsbeperkingen en de versiedatum; `int(aantal)` kan bovendien ongeldige fractionele waarden afronden. Succesvol parsen betekent dus niet dat de levering inhoudelijk volledig is. +De destijds onderzochte package 6.0.0 deed één request met een limiet, geeft geen totalen/paginering door en gebruikt slechts delen van het eerste regime. Daardoor verdwijnen tijdsbeperkingen en de versiedatum; `int(aantal)` kan bovendien ongeldige fractionele waarden afronden. Succesvol parsen betekent dus niet dat de levering inhoudelijk volledig is. ID's zijn nu uniek en als bron-ID beschikbaar, maar toekomstige hernummering is niet uitgesloten. Grote identiteitswisselingen en verdwenen records vragen beoordeling. Niet terugvallen op coördinatenmatching. De API-documentatie kondigt verplichte API-keys aan; de proef werkte zonder key. Ondersteuning voor eventuele bronauthenticatie hoort in de universele package/producer, nooit in het afleverbestand. De bron biedt geen bewezen mutatieversie over meerdere pagina's: controleer aantallen vóór/na en geef bij verschillen geen complete levering af; gelijke aantallen bewijzen geen snapshotisolatie. @@ -80,16 +80,16 @@ Regimes zijn broninformatie die core moet tonen voordat deze records worden gepu De volledige levering volgt [het ene JSON-bestand](data-import-contract.md): `format=nipkaart-municipal-pilot-1`, bovengenoemde dataset/selectie, een werkelijke delivery-UUID en ophaaltijd, `complete=true`, een gecontroleerd `source_count` en alle records. Het voorbeeld hierboven is één record en mag nooit als volledige Amsterdamse levering worden aangeleverd. De toekomstige export mag 1.420 niet hardcoderen. -## Eerst oplossen in de universele package +## Opgelost in de universele package -Uitvoerbaar package-issue: [python-odp-amsterdam #1291](https://github.com/klaasnicolaas/python-odp-amsterdam/issues/1291). Dit is de eerste afhankelijkheid van disabled-parking #774, geen Python-werk in core: +Afgerond met packageversie 7.0.0, oorspronkelijk package-issue: [python-odp-amsterdam #1291](https://github.com/klaasnicolaas/python-odp-amsterdam/issues/1291). Deze afhankelijkheid van disabled-parking #774 is inmiddels geleverd. De oorspronkelijke opdracht was: 1. Stel de bron-ID, volledige geometrie, alle regimes en versiedatum beschikbaar. Bewaar oorspronkelijke aantallen zonder verliesgevende conversie; test onbekend, nul, fractioneel en meerdere regimes. 2. Bied een publieke volledige ophaling met totalen/pagina-informatie aan. Controleer de laatste pagina en unieke ID's; één `limit=2000` is geen blijvend volledigheidsbewijs. Behoud de bestaande beperkte ophaalmethode voor andere packagegebruikers indien die onderdeel is van de publieke API. 3. Test generieke paginering en gewijzigde/ontbrekende bronvelden upstream. Maak geen NIPKaart-bestandsformaat of publicatiebeleid onderdeel van de package. 4. Leg de geteste packageversie en één afzonderlijke begrensde live proef vast. Pas daarna de adapter op de nieuwe publieke package-interface aansluiten. -#774 maakt vervolgens één live commando en vervangt de tijdelijke Hamburg-route. #1215 implementeert één intakepad met geometryvalidatie, beoordeling en veilig behoud van identiteit/correcties. De eerste werkende keten blijft handmatig; de private bucket volgt bij automatisering. +[disabled-parking #783](https://github.com/NIPKaart/disabled-parking/pull/783) levert inmiddels het live commando en heeft de tijdelijke Hamburg-route vervangen. #1215 implementeert de ontvangende kant met geometrievalidatie, beoordeling en behoud van identiteit/correcties. De eerste werkende keten blijft handmatig; de private bucket volgt bij automatisering. ## Andere onderzochte kandidaten @@ -99,3 +99,19 @@ Uitvoerbaar package-issue: [python-odp-amsterdam #1291](https://github.com/klaas ## Broncontrole na implementatie Offline tests bewijzen gedrag tegen bekende voorbeelden. Een periodieke begrensde live controle in #775 controleert daarnaast endpoint, velden, identiteit en volledigheid. Ook een HTTP 200 met gewijzigde betekenis kan een fout zijn. Bij een bronfout geen nieuwe complete levering publiceren; de laatste geaccepteerde gegevens blijven staan en de producent meldt de storing. De broncheck staat los van gewone package-CI. + +## Werkelijke producer → core-proef op 2026-09-14 + +De live export bevat 1.420 unieke bronrecords en 1.579 regimes, is 1.384.804 bytes groot en heeft SHA-256 `99a6744f950a5c4307d9851524a84790f4a9ff3bb704819d02bd976df1abcd31`. Leverings-ID: `7982ffb8-87d7-401e-8989-424a81284738`; ophaalstart: `2026-09-13T23:41:47.974385Z`. Dit is een andere representatie dan de oorspronkelijke onderzoeksresponse hierboven. + +PostGIS controleerde alle geometrieën op de afzonderlijke core-testdatabase. Alle liggen binnen de ingestelde bbox, maar `ST_IsValidReason` meldt een zelfdoorsnijding voor bron-ID's `114185488210`, `114187488001`, `118990486331`, `119459482101`, `119478482386`, `121780485119`, `123005483477`, `123773490356`, `123778490343` en `124156485525`. + +Daarom is niets uit deze levering gepubliceerd of gedeeltelijk geïmporteerd. De foutmelding identificeert alle betrokken bron-ID's. De producer garandeert structurele volledigheid; dat is geen garantie op geometrische geldigheid. Kleine synthetische voorbeelden testen het veilige publicatiepad, maar vervangen deze ontbrekende acceptatie niet. + +Vervolg vóór het afvinken van #1215/#774: laat de brongeometrie corrigeren, of spreek expliciet een beoordeelde geometrie-afleiding af die de oorspronkelijke claim bewaart en afwijkingen zichtbaar maakt. Daarna dezelfde volledige keten opnieuw beproeven. Geen stil `ST_MakeValid`, geen tien records overslaan en alsnog `complete=true` verklaren. Bucketautomatisering blijft afhankelijk van een geaccepteerde lokale keten. + +## Afzonderlijke implementatiecontrole + +`composer ci:check` slaagde lokaal met 276 backendtests (1.106 assertions), drie frontendtests, linting, types en productiebuild. De gerichte importtests draaien ook op DDEV/PHP 8.4 met PostGIS. De frontendbuild draaide op de host omdat de lokaal geïnstalleerde native buildmodule voor macOS is; dezelfde `node_modules` in de Linux-container gebruiken is niet ondersteund. + +Een afzonderlijke operationele proef op `nipkaart_test` liet twee PHP-processen gelijktijdig dezelfde synthetische levering publiceren terwijl de datasetrij eerst vergrendeld was. Eén publicatie slaagde, de andere kreeg “Deze levering is al beoordeeld”; er bleef precies één gemeentelijk record bestaan. De browserproef controleerde desktop en 390px mobiel, de polygoon/kaartpuntweergave en daadwerkelijke goedkeuring van een synthetische wijziging van onbekende capaciteit naar nul. Dit bewijst implementatiegedrag, geen geaccepteerde Amsterdam-import of productiepublicatie. diff --git a/docs/development/quality-checks.md b/docs/development/quality-checks.md index 9b794e13..c9883572 100644 --- a/docs/development/quality-checks.md +++ b/docs/development/quality-checks.md @@ -70,6 +70,8 @@ Permanent spatial coverage lives in `ParkingLocationTest`, `ParkingDiscoveryTest See [Pest TIA documentation](https://pestphp.com/docs/tia) for baseline storage, invalidation and replay behavior. -## Snapshot contract prototype +## Municipal file intake -The PHP unit suite includes `tests/Unit/Support/SnapshotContractTest.php`, an experimental validator from draft PR #1222. Its passing fixtures do not prove a working adapter or reviewed import. Core contains no Python environment. A Python proof exists locally in disabled-parking; no companion PR or cross-repository CI has been delivered. See the [current delivery scope](data-import-contract.md) and [source findings](data-import-pilot.md) before treating the prototype as a settled contract. +`tests/Feature/MunicipalImportTest.php` controleert de daadwerkelijke intake, autorisatie, PostGIS-validatie, beoordeling, herhaling, bronvolgorde, bescherming van handmatige waarden en transactieherstel op de afzonderlijke PostgreSQL/PostGIS-testdatabase. Kleine inline voorbeelden vervangen de experimentele schema's, Opis-validator en fixturecorpus uit #1222. Core bevat geen Python-omgeving. + +Een geslaagde test bewijst geen geldige livebron of toestemming voor productiepublicatie. Zie de [uitvoering en herstelafspraken](data-import-contract.md#lokale-uitvoering-en-herstel) en de [afzonderlijke bronproef](data-import-pilot.md). De huidige Amsterdamse export bevat tien zelfdoorsnijdende polygonen en wordt volledig geweigerd; de lokale ketenacceptatie blijft daarom open. diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index acc6ccd9..62cdafa2 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,3 +1,4 @@ +import { index as municipalImports } from '@/actions/App/Http/Controllers/Admin/MunicipalImportController'; import { NavFooter } from '@/components/nav-footer'; import { NavUser } from '@/components/nav-user'; import { Sidebar, SidebarContent, SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem } from '@/components/ui/sidebar'; @@ -85,6 +86,7 @@ export function AppSidebar() { href: parkingMunicipal.index(), icon: icons.Building, }, + hasRole('admin') && { title: t('municipal_imports'), href: municipalImports(), icon: icons.FileInput }, can('parking-rule.view_any') && { title: t('rules'), href: parkingRules.index(), diff --git a/resources/js/components/map/card-location-marker.tsx b/resources/js/components/map/card-location-marker.tsx index 61bb62e3..2341960e 100644 --- a/resources/js/components/map/card-location-marker.tsx +++ b/resources/js/components/map/card-location-marker.tsx @@ -10,6 +10,7 @@ type Props = { onChange?: (lat: number, lng: number) => void; draggable?: boolean; nearbySpaces?: ParkingSpace[]; + children?: React.ReactNode; }; const { BaseLayer, Overlay } = LayersControl; @@ -25,7 +26,7 @@ const NearbyParkingMarkers = React.memo(function NearbyParkingMarkers({ spaces } return {markers}; }); -export default function LocationMarkerCard({ latitude, longitude, onChange, draggable, nearbySpaces }: Props) { +export default function LocationMarkerCard({ latitude, longitude, onChange, draggable, nearbySpaces, children }: Props) { const isDraggable = draggable ?? typeof onChange === 'function'; return ( @@ -64,6 +65,7 @@ export default function LocationMarkerCard({ latitude, longitude, onChange, drag + {children} + +
+
+

{t('title')}

+

{t('intro')}

+
+ {datasets.length === 0 &&

{t('no_datasets')}

} + {datasets.map((dataset) => ( +
+

{dataset.name}

+

{dataset.attribution}

+ + {dataset.publication_enabled ? ( +

{t('terms_enabled')}

+ ) : ( +
+ {({ errors, processing }) => ( + <> + + + + {Object.entries(errors).map(([field, message]) => ( + + ))} + + + )} + + )} +
+ ))} +
+

{t('upload')}

+
+ {({ errors, processing }) => ( + <> + + + {Object.entries(errors).map(([field, message]) => ( + + ))} + + + )} + +
+
+

{t('deliveries')}

+
    + {imports.data.map((item) => ( +
  • + + {item.dataset_source?.name} · #{item.id} + +

    + {t(`states.${item.state}`)} · {new Date(item.retrieved_at).toLocaleString()} +

    +
  • + ))} +
+ +
+
+ + ); +} diff --git a/resources/js/pages/backend/municipal-imports/show.tsx b/resources/js/pages/backend/municipal-imports/show.tsx new file mode 100644 index 00000000..613b1ef1 --- /dev/null +++ b/resources/js/pages/backend/municipal-imports/show.tsx @@ -0,0 +1,173 @@ +import { index, show, update } from '@/actions/App/Http/Controllers/Admin/MunicipalImportController'; +import InputError from '@/components/input-error'; +import LocationMarkerCard from '@/components/map/card-location-marker'; +import { Button } from '@/components/ui/button'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import AppLayout from '@/layouts/app-layout'; +import { Form, Head, Link } from '@inertiajs/react'; +import type { Polygon } from 'geojson'; +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { GeoJSON } from 'react-leaflet'; +import type { Dataset, Import } from './index'; + +type Claim = { external_id: string; street: string | null; number: number | null; geometry: Polygon; source_attributes: Record }; +type Row = { + external_id: string; + status: string; + fields: string[]; + conflicts: string[]; + before: Claim | null; + after: Claim | null; + current: Record | null; + point?: { latitude: number; longitude: number }; +}; +type Props = { + import: Import; + dataset: Dataset; + review: { counts: Record; blockers: string[]; rows: Row[]; token: string }; + page: number; + pages: number; +}; + +export default function Show({ import: delivery, dataset, review, page, pages }: Props) { + const { t } = useTranslation('backend/municipal-imports'); + const [mapRecord, setMapRecord] = useState(null); + return ( + + +
+
+

{dataset.name}

+

+ {t(`states.${delivery.state}`)} · {new Date(delivery.retrieved_at).toLocaleString()} +

+

{t('restrictions')}

+
+
+ {Object.entries(review.counts).map(([kind, count]) => ( +
+
{t(`counts.${kind}`)}
+
{count}
+
+ ))} +
+

{t('missing_note')}

+ {delivery.state !== 'pending' &&

{t('current_comparison')}

} + {delivery.state === 'pending' && ( + <> + {review.blockers.length > 0 && ( +
    + {review.blockers.map((message) => ( +
  • {message}
  • + ))} +
+ )} +
+ {({ errors, processing }) => ( + <> + + +