Skip to content

Dedupe re-imported venues by content fingerprint (#456)#519

Merged
ibanner56 merged 4 commits into
mainfrom
isaacbanner-venue-import-dedupe-456
Jul 23, 2026
Merged

Dedupe re-imported venues by content fingerprint (#456)#519
ibanner56 merged 4 commits into
mainfrom
isaacbanner-venue-import-dedupe-456

Conversation

@ibanner56

Copy link
Copy Markdown
Owner

Closes #456

Problem

The additive CompendiumArchiveImporter minted a fresh venue id for every bundled venue, so re-importing the same bundle inserted duplicate venue records and orphaned the previously-imported ones (the provenance-deduped program was repointed to the newly-minted venue). Fresh-mint exists deliberately so an untrusted bundle can never overwrite/hijack an existing local venue — any dedupe primitive had to preserve that.

Approach (maintainer-resolved spec: content fingerprint, never-overwrite, strong-key-only)

  • New pure core helper venueFingerprint(Venue) -> String? and VenueFingerprintIndex (imports/venue_dedupe.dart). The fingerprint normalizes name + city + stateProv + address1 (trim, lowercase, collapse whitespace).
  • Strong-key threshold: a key is returned only when name and (address1 or city) are present; a name-only venue is too weak to match on and always fresh-mints (no false merge).
  • On a unique existing match, the incoming venue is dropped and the program is repointed to the existing venue. On no match or an ambiguous match (more than one existing venue shares the fingerprint), the venue is fresh-minted (never a wrong guess). Each mint is folded into the index so two fingerprint-equal venues within one bundle also collapse.

Security (untrusted-bundle guarantee preserved)

  • A match is strictly a repoint, never an overwrite: the matched venue's fields are never touched, so a malicious bundle claiming a victim's venue identity cannot mutate/poison that record — it can at most point its own imported program at the real venue.
  • A deduped (matched) venue is a pre-existing record, so it is never added to insertedVenueIds, so undo/compensation never delete a venue the user already had.
  • The fingerprint excludes the redactable contact fields (contact1/2*), notes, price, website, schedule, so a Share-bundle venue gathering with contact-PII redaction + opt-in consent dialog #515-redacted shared venue still matches a local one.

Scope / non-goals

  • No schema or archive-format change (schema stays v14; migration gate N/A).
  • Replace-mode backup restore is untouched (it preserves ids and must not dedupe).
  • compendium_core stays Flutter-free.

Tests

  • New test/imports/venue_dedupe_test.dart: fingerprint normalization, strong-key threshold, separator-collision safety, index matching / ambiguity poisoning / fold-in.
  • Extended compendium_archive_import_test.dart: re-import strong-key venue gives 0 new venues + program repointed (the Venue re-import dedupe / provenance key (cross-import) #456 regression); weak-key still duplicates; never-overwrite of matched fields; ambiguous fresh-mints; two fingerprint-equal venues in one bundle collapse to one insert; undo after dedupe deletes only newly-minted venues.

Gates

dart format (0 changed), dart analyze (core + app clean), full compendium_core suite green (2194 passed). Pre-existing, unrelated app failure: test/l10n/no_hardcoded_ui_strings_test.dart (flags shorthand-mapping screens untouched by this PR; identical on main).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes cross-import venue duplication in the additive CompendiumArchiveImporter by introducing a conservative, never-overwrite dedupe mechanism based on a normalized content fingerprint, matching the #456 spec.

Changes:

  • Added a pure venueFingerprint(Venue) -> String? helper plus VenueFingerprintIndex to dedupe venues by strong descriptive keys (name + city/address1), while avoiding false merges.
  • Updated CompendiumArchiveImporter to repoint programs to an existing matched venue (no venue overwrites; ambiguous/weak keys still fresh-mint).
  • Added/extended core tests to cover fingerprint normalization, strong-key thresholding, ambiguity handling, and importer behavior (including undo semantics and query-count invariants).

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/compendium_core/lib/src/imports/venue_dedupe.dart New fingerprint + in-memory index used to match incoming venues to existing ones safely.
packages/compendium_core/lib/src/imports/compendium_archive_import.dart Integrates fingerprint index into venue import flow; ensures “repoint, never overwrite,” and avoids N+1 venue existence reads.
packages/compendium_core/lib/compendium_core.dart Exports the new venue dedupe API from the core package.
packages/compendium_core/test/imports/venue_dedupe_test.dart New unit tests for fingerprint normalization/threshold and index match/ambiguity behavior.
packages/compendium_core/test/imports/compendium_archive_import_test.dart Expanded integration tests covering #456 regression, weak/strong-key behavior, ambiguity, within-bundle collapse, undo safety, and query-count expectation changes.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/compendium_core/lib/src/imports/venue_dedupe.dart
Comment thread packages/compendium_core/test/imports/venue_dedupe_test.dart
ibanner56 added a commit that referenced this pull request Jul 22, 2026
Addresses Copilot review on #519. VenueFingerprintIndex.add() left the
prior fingerprint->id mapping in place when the same venue id was re-added
with different descriptive fields. In the importer this happens on the
"last-seen wins" collapse of a repeated original venue id within one
(untrusted) bundle: the minted row is refreshed to the last-seen content
and re-added under a new fingerprint, but the old fingerprint kept pointing
at that id. A later distinct venue matching the stale fingerprint could then
be wrongly repointed at the now-changed venue.

Fix: track each id's current fingerprint (_fingerprintById) and retire the
stale mapping before indexing the new one ("last-seen wins"). Ambiguous
(poisoned) fingerprints stay poisoned — consistent with the never-false-merge
stance. Repoint-never-overwrite and the untrusted-bundle guarantee are
unchanged.

Tests: add index unit tests for changed-fingerprint re-add (stale key
retired, current key matches), a distinct venue not repointed via a retired
key, and a strong->weak re-add clearing the stale key.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ibanner56 and others added 2 commits July 22, 2026 16:27
The additive CompendiumArchiveImporter minted a fresh venue id for every
bundled venue, so re-importing the same bundle inserted duplicate venue
records and orphaned the previously-imported ones. Add a cross-import
dedupe primitive that matches an incoming venue to one the receiver
already holds by a normalized content fingerprint (name + city +
stateProv + address1), then repoints the program at the existing venue
instead of minting a duplicate.

The match is strictly a repoint, never an overwrite: the matched venue's
fields are left untouched and it is never added to insertedVenueIds, so
the fresh-mint guarantee (an untrusted bundle cannot mutate/hijack a
local venue by claiming its identity) is preserved and undo never
deletes a venue the user already had. A weakly-described venue (name
only) or an ambiguous match (>1 existing venue shares the fingerprint)
always fresh-mints, never a wrong guess. The fingerprint excludes the
redactable contact fields so a #515-redacted shared venue still matches.

No schema or archive-format change; replace-mode restore is untouched.
compendium_core stays Flutter-free.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Addresses Copilot review on #519. VenueFingerprintIndex.add() left the
prior fingerprint->id mapping in place when the same venue id was re-added
with different descriptive fields. In the importer this happens on the
"last-seen wins" collapse of a repeated original venue id within one
(untrusted) bundle: the minted row is refreshed to the last-seen content
and re-added under a new fingerprint, but the old fingerprint kept pointing
at that id. A later distinct venue matching the stale fingerprint could then
be wrongly repointed at the now-changed venue.

Fix: track each id's current fingerprint (_fingerprintById) and retire the
stale mapping before indexing the new one ("last-seen wins"). Ambiguous
(poisoned) fingerprints stay poisoned — consistent with the never-false-merge
stance. Repoint-never-overwrite and the untrusted-bundle guarantee are
unchanged.

Tests: add index unit tests for changed-fingerprint re-add (stale key
retired, current key matches), a distinct venue not repointed via a retired
key, and a strong->weak re-add clearing the stale key.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@ibanner56
ibanner56 force-pushed the isaacbanner-venue-import-dedupe-456 branch from b317a39 to 7ac3f65 Compare July 22, 2026 23:27
@isaacbanner
isaacbanner requested a review from Copilot July 22, 2026 23:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Comment thread packages/compendium_core/lib/src/imports/venue_dedupe.dart
Comment thread packages/compendium_core/lib/src/imports/compendium_archive_import.dart Outdated
Addresses Copilot re-review on #519 (two findings):

1. venueFingerprint separator hardening. The `\u0000` field separator was
   only guaranteed absent because the import path sanitizes text on decode,
   but a locally-created/edited venue never passes through that sanitizer, so
   a stored venue field could carry an embedded NUL (the separator itself) or
   a bidi/invisible control — leaking a control char into the key and risking
   a false merge. `_normalizeField` now runs each field through
   `sanitizeImportedText` (the same C0/C1/DEL + bidi + invisible strip used on
   import) so the separator invariant and never-false-merge intent hold for
   ALL stored venues, not just imported ones.

2. Within-bundle refresh scope. A repeat of an original venue id that had
   *deduped* onto another original id's freshly-minted venue previously
   entered the "last-seen wins" refresh branch (the check keyed off the target
   being minted, not off this id having minted it), letting one id's later
   occurrence overwrite a minted row other ids were repointed to — a
   non-deterministic clobber for a malformed/untrusted bundle. Now only an id
   whose first occurrence actually minted a venue (`mintingOriginalIds`) may
   refresh its row; a repeat of a deduped id is a no-op. Replaces the
   `insertedVenueIdSet` target-check.

Tests: venue_dedupe_test gains a control/bidi-stripping fingerprint test;
importer tests gain "a repeated deduped id never overwrites another id's
minted venue" and "a repeated minting id still refreshes to last-seen
content". Full core suite green (2200 passed).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Comment thread packages/compendium_core/lib/src/imports/compendium_archive_import.dart Outdated
Addresses Copilot re-review on #519. The fingerprint-index preload
(`_venues.listAll()`) ran whenever the bundle carried any venues, even when
every bundled venue was below the strong-key threshold (null fingerprint) and
so could never dedupe cross-import — making the SELECT pure waste.

Now the preload runs only when at least one bundled venue produces a non-null
fingerprint (`archive.venues.any(...)`); a bundle with no venues, or only
weakly-described ones, issues zero venue reads. Behavior is otherwise
unchanged: an all-weak bundle already fresh-mints every venue, and an empty
index yields the same result.

Tests: the query-counter test now uses strong-key venues (so it still asserts
the preload is a single SELECT for N programs — the no-N+1 guarantee); a new
counter test asserts a weak-key-only bundle issues zero venue SELECTs. Full
core suite green (2201 passed).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

@ibanner56
ibanner56 merged commit 3a31ba1 into main Jul 23, 2026
8 checks passed
@ibanner56
ibanner56 deleted the isaacbanner-venue-import-dedupe-456 branch July 23, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Venue re-import dedupe / provenance key (cross-import)

2 participants