Skip to content

Account reset, Monica import overhaul, duplicate detection, worker clustering#23

Open
bashar-qassis wants to merge 58 commits into
mainfrom
feat/v0.x-multi-area-improvements
Open

Account reset, Monica import overhaul, duplicate detection, worker clustering#23
bashar-qassis wants to merge 58 commits into
mainfrom
feat/v0.x-multi-area-improvements

Conversation

@bashar-qassis
Copy link
Copy Markdown
Owner

@bashar-qassis bashar-qassis commented May 16, 2026

Note: Supersedes #22, which started as a focused duplicate-detection fix but accumulated four additional themes over 45 commits. New branch + title to reflect the actual scope.

Scope

76 files · +10,962 / −5,070 · 45 commits. Five themes:

1. Duplicate detection

  • Replaced additive scoring with max-signal + bonus (email=0.85, phone=0.75, address=0.60, name=pg_trgm). Same-email/same-phone pairs no longer silently miss the 0.4 threshold.
  • Added address matching on normalized line1 + postal_code (case-insensitive, trimmed).
  • Case-insensitive email matching with both-sides contact_field_type filter.
  • LIKE 'mailto%' protocol matching to handle seeded vs custom-created colon inconsistency.
  • All three import workers (MonicaApiCrawlWorker, ImportSourceWorker, ImportWorker) enqueue DuplicateDetectionWorker on success.
  • Pagination on duplicates panel + duplicates page (prevents timeout on large result sets).
  • Merge flow: handle duplicate photos, reduce merge to 3 steps.

2. Account reset completeness

Refactored AccountResetWorker into a thin orchestrator over per-domain Cleanup modules. Each module owns account-scoped deletion for its context:

  • Kith.Imports.Cleanup + Kith.Imports.JobCancellation (cancels in-flight Oban jobs)
  • Kith.Storage.AccountCleanup (file wipe)
  • Kith.Contacts.Cleanup (contacts + tags)
  • Kith.Conversations.Cleanup · Kith.Journal.Cleanup · Kith.Tasks.Cleanup · Kith.Reminders.Cleanup · Kith.Activities.Cleanup · Kith.AuditLogs.Cleanup
  • Cross-account isolation tests + regression coverage on AccountResetWorker.

3. Monica import overhaul

Correctness:

  • Auto-merge contract fix, cartesian-explosion fix on duplicate handling, E.164 phone normalization.
  • Extracted photo sync to async MonicaPhotoSyncWorker with live sync_summary; queued on :imports (not :photo_sync).
  • Extracted Phase 4 to MonicaMiscDataWorker (per-contact extra data, plan-driven).

Performance:

  • Per-host rate limiter (55/min in prod, unlimited in test).
  • Collapsed double-retry to Req's built-in + RateLimiter.
  • Contacts.create_contact_field/3 accepts normalize: false to skip redundant E.164 normalization on Monica writes.
  • Replaced :persistent_term phone-CFT cache with a ref_data map passed through the call chain.

4. Worker clustering

  • Gated Oban queues by KITH_MODE in :prod (web = insert-only, worker = consumer).
  • Start PubSub + cluster discovery in base_children so both modes have them.
  • Replaced DNSCluster with libcluster Epmd strategy + shared RELEASE_COOKIE + DNS alias for cross-container clustering.
  • Switched to sname distribution (bare hostnames are illegal in name mode).
  • RELEASE_COOKIE documented in .env.example.

5. Infra / UX

  • Parameterized Docker host ports via .env.
  • Mounted official Oban Web dashboard for admins.

Test plan

  • mix compile --warnings-as-errors — clean
  • mix test — full suite passes (DuplicateDetectionWorker now has 20-case coverage; AccountResetWorker has regression + cross-account isolation tests; per-Cleanup module tests added across all 9 contexts)
  • mix quality — format, credo, sobelow, dialyzer all pass
  • Deploy-side: verify worker container picks up :imports/:exports/:reminders/:mailers/:purge queues and web container only inserts
  • Deploy-side: verify libcluster forms a cluster across webworker (check Node.list/0 in remote shell)
  • Deploy-side: confirm RELEASE_COOKIE is set in both containers
  • Manual: trigger Monica import → verify duplicate candidates appear afterward and photo sync runs async
  • Manual: trigger account reset → verify all 9 Cleanup modules execute and in-flight import jobs are cancelled
  • Manual: click "Scan now" → verify contacts sharing email/phone/address are detected

…ger after imports

The duplicate detection worker had several bugs preventing it from catching
obvious duplicates:

- Scoring formula (name*0.4 + email*0.35 + phone*0.25 with threshold 0.4)
  meant contacts sharing the same email but with different names scored 0.35,
  below the threshold — silently missed.
- Email comparison was case-sensitive.
- Only one side of email/phone field pairs had its type verified.
- Address data was completely ignored.
- No import worker triggered duplicate detection after completion.

Fixes:
- Replace additive scoring with max-signal + bonus approach where each signal
  independently qualifies (email=0.85, phone=0.75, address=0.60, name=similarity)
- Add case-insensitive email matching via LOWER() fragments
- Filter both cf1 and cf2 contact_field_types in email/phone queries
- Use LIKE 'mailto%' pattern to handle protocol colon inconsistency
- Add address matching on normalized line1 + postal_code
- Enqueue DuplicateDetectionWorker after successful completion in all three
  import workers (MonicaApiCrawlWorker, ImportSourceWorker, ImportWorker)
- Add comprehensive test suite (20 tests) for the detection worker
list_candidates now takes limit/offset opts (default 20 per page).
The LiveView loads one page at a time with a "Load more" button.
Dismiss removes the candidate from the current list without reloading.
The /contacts/duplicates route uses ContactLive.Index, not the standalone
Duplicates LiveView. Added limit/offset pagination with Load more button
and optimistic dismiss (no full re-query) to match the standalone page.
Photos with the same content_hash on both contacts caused a unique
constraint violation during merge. Now deletes duplicate photos from
the non-survivor before transferring the rest, matching the pattern
used for contact_tags and activity_contacts.

Also collapsed the merge flow from 4 steps to 3 by combining the
preview and confirm steps into a single "Review & merge" step.
From the duplicates page (contact preselected), merge is now 2 clicks
instead of 3.
Replace the custom hand-rolled Oban dashboard (412 LOC LiveView) with
the official `oban_web` package, now open-source and free as of Oban
2.20. Mount at /admin/oban behind a new :require_admin on_mount hook
gated by Kith.Policy.can?(user, :manage, :oban). Hide the "Jobs" nav
link from non-admin users.

Drops the dead photo_sync queue from Oban config — its worker
(PhotoBatchSyncWorker) was removed in commit e474853 when Monica
imports moved to API crawling.
Make every published host port configurable through env-var
substitution so contributors can resolve local port conflicts without
editing compose files. Defaults preserve current behavior with no
.env present.

Dev (docker-compose.dev.yml): MAILPIT_SMTP_PORT, MAILPIT_WEB_PORT,
APP_PORT join the existing DB_PORT and MINIO_*_PORT vars.

Prod (docker-compose.prod.yml): HTTP_PORT, HTTPS_PORT for Caddy.

Internal container ports remain hardcoded so services can address
each other on standard ports over the Docker network.
Photo import previously ran inline as Phase 4 of MonicaApi.crawl/5, which
buried it inside the contact-crawl job and left the import_history "Photo
Sync" UI panel stuck on "in progress" forever — sync_summary was never
written because the refactor that deleted PhotoBatchSyncWorker (commit
e474853) removed the only writers.

Move the photo crawl into its own MonicaPhotoSyncWorker on the photo_sync
queue, enqueued by MonicaApiCrawlWorker when api_options["photos"] is true.
The worker passes credentials through job args (matching the
MonicaDocumentImportWorker pattern) so the main worker can wipe the API key
from the DB immediately after contact import completes.

Drop the unauthenticated link fallback from the decoder — Monica's
/api/photos endpoint always returns dataUrl, so the previous Req.get(link)
path was likely 401'ing on protected storage URLs. If a photo lacks
dataUrl, it's now surfaced as a failed entry in sync_summary with a
no_data_url reason, instead of being silently dropped.

The worker writes sync_summary after each page so the UI shows live
progress — total/synced/failed/not_found counts plus a per-photo table —
and logs each page boundary at :info under the [MonicaPhotoSync] prefix.
Per-photo decisions log at :debug.

Tests cover: dataUrl import + avatar set, not_found on missing contact,
failed on missing dataUrl, dedup by content_hash, and mid-flight
sync_summary writes between pages.
The :photo_sync queue was removed in commit e474853 along with
PhotoBatchSyncWorker, but the Oban queues config in config.exs was
updated to drop it. Jobs queued to :photo_sync would sit forever with
no consumers. Switch to :imports to match MonicaApiCrawlWorker and
MonicaDocumentImportWorker.
- CLAUDE.md: remove photo_sync + api_supplement from the Oban queues list
  (those queues were removed in commit e474853); update queue count from 9
  to 7 to match config/config.exs.
- MonicaApiCrawlWorker moduledoc: clarify that photo import runs as a
  separate MonicaPhotoSyncWorker job, not inline.
- Delete docs/superpowers/specs/2026-03-21-extensible-import-system-design.md
  and docs/superpowers/plans/2026-03-22-extensible-import-system.md. Both
  describe the pre-refactor design (PhotoSyncWorker, ApiSupplementWorker,
  file-based Monica import, per-photo job model) that no longer exists.
  CLAUDE.md plus the live moduledocs are now the source of truth.
Spec captures the photo-sync-after-reset bug, its root cause (orphaned
import_records pointing at deleted contacts), and the decomposition of
AccountResetWorker into a thin orchestrator plus one Cleanup module per
data domain. Covers module layout, order-of-operations, account-scoped
Oban job cancellation, error handling, and the test plan including a
mandatory cross-account isolation check on every Cleanup module.
…efinement

Plan decomposes the work into 13 TDD tasks, each producing one cleanup module
(or the worker refactor) per commit. Spec refinement: split the proposed
TagsAndActivitiesCleanup into Contacts.Cleanup (handles tags as part of the
contacts axis) and Activities.Cleanup (its own context), aligning with the
SOLID-elixir SRP guidance flagged in the brainstorming pass.
reminder_rules is account-scoped (not reminder-scoped) and has no FK
relationship to reminders, so it's not CASCADE-cleared. Rules are 3
seeded-per-account pre-notification defaults treated as reference data.
…n explosion, E.164 normalization

Three independent bugs combined to make 1000 Monica imports surface as
~6000 entries in the duplicates tab even when "Auto-merge definite
duplicates" was checked:

Bug C (primary cause of "auto-merge did nothing"):
  MonicaApiCrawlWorker.build_opts/1 only forwarded "extra_notes" — every
  other wizard option, including "auto_merge_duplicates", was silently
  dropped before reaching MonicaApi.crawl/5. Auto-merge was structurally
  unreachable from the UI; only unit tests calling crawl/5 directly with
  their own opts had ever exercised it. Now forwards api_options
  unchanged, preserving the legacy extra_notes default-on semantic.

Bug A (primary cause of "6000 entries"):
  DuplicateDetectionWorker.find_phone_matches/1 joined contact_fields on
  the digit-normalized value but pre-filtered the *raw* value, so any
  phone whose digits-stripped form was empty ("+", "()", "-", "N/A")
  passed the filter, normalized to "", and matched every other zero-digit
  phone — C(N,2) false candidates. The email side had a smaller analog:
  no TRIM, no cf2 filter. Now strict equality on canonical values for
  phone, TRIM(LOWER(...)) plus per-side non-empty filters for email.

Bug B (auto-merge predicate too narrow):
  Within a name group the predicate compared values raw — Monica's
  CardDAV-sync duplicates (monicahq/monica#6175) escape it on trivial
  whitespace/casing artifacts. Name key now trims + collapses whitespace;
  predicate accepts shared email OR phone OR address with normalized
  comparators; addresses preloaded.

Bug D (heuristic phone storage):
  PhoneFormatter.normalize/1 was heuristic — "10 digits stays as-is",
  "11 digits starting with 1 becomes +1...", "00" IDD prefix unhandled —
  so the same number written two ways was stored as two different
  values. Replaced with ex_phone_number (libphonenumber port) producing
  E.164. normalize/2 takes a default_region for bare numbers; bare input
  without a region round-trips unchanged. PhoneRenormalizeWorker
  backfills existing rows once.

UX:
  auto_merge_duplicates wizard default flipped to true. New region
  picker pre-populated from account.locale, listing every
  libphonenumber-supported region with localized country names via
  ex_cldr_territories ∩ ExPhoneNumber.Metadata.get_supported_regions/0.

Detection worker phone match simplified to plain equality now that
values are canonical at write-time, removing the per-row regex and the
filter mismatch that caused the cartesian explosion.

Tests:
  - Boundary test in monica_api_crawl_worker_test that round-trips the
    wizard flag through build_opts (would have caught Bug C directly).
  - Cartesian-explosion regression in duplicate_detection_worker_test.
  - Email TRIM and phone-normalization-on-import in respective files.
  - phone_formatter_test rewritten for explicit-region semantics.
  - New phone_renormalize_worker_test (5 tests).
  - Production libphonenumber metadata in :test (was test-only metadata
    that diverged from real validation on "555" NANP prefixes).

Dialyzer:
  Added .dialyzer_ignore.exs suppressing two :contract_supertype
  warnings against Kith.Cldr.Territory — these are emitted from
  generated code in ex_cldr_territories, not actionable from our side.

1122 tests pass, mix quality clean.
…iner clustering

DNSCluster connects via `Node.connect(:"basename@<ip>")` — it uses the
raw IP as the host part of the node name. That requires each peer's
actual node name to be `name@<ip>`, which conflicts with Phoenix
release's default of `name@<hostname>`.

The user's Portainer deployment exposes containers under stable service
names (`app`, `worker`) that resolve via Docker DNS — but the BEAM
nodes are named after the container ID (`kith@64c98536e88c`), so
`Node.connect(:"kith@app")` fails the handshake.

Switch to libcluster's Epmd strategy which connects by explicit node
name (no IP rewriting). Each container is configured via env to:
  - `RELEASE_NODE=kith@app` (or `kith@worker`)
  - `KITH_CLUSTER_HOSTS=kith@app,kith@worker`
  - `RELEASE_COOKIE=<shared>`
  - `RELEASE_DISTRIBUTION=name`

libcluster runs Cluster.Strategy.Epmd which polls `Node.connect/1`
for each host periodically; once one direction connects, the
bidirectional distribution is established and PubSub spans both.

Dev and test are unaffected: `KITH_CLUSTER_HOSTS` is unset, so the
libcluster topology list is empty and Cluster.Supervisor no-ops.
Spec for the follow-up to PR #23's Monica normalization work — replaces the
hand-rolled NANP-only renderer in PhoneFormatter.format/2 with ExPhoneNumber
library calls so account.phone_format honors non-US phones.
Bite-sized TDD plan to replace the NANP-only renderer in PhoneFormatter.format/2
with ExPhoneNumber library calls. Includes IEx probe step to capture exact
library output strings, replacement of bug-pinning tests, non-NANP coverage
matrix, and a Playwright extension to guard the e2e display path.
PhoneFormatter.format/2 honored account.phone_format only for +1 numbers
because format_national/format_international were hand-rolled binary patterns
matching the NANP shape. Every non-NANP phone fell through to the unchanged-
pass-through clause, silently ignoring the user's display preference.

Replace with ExPhoneNumber.format/2 (libphonenumber port already declared
as :ex_phone_number in mix.exs). The phone's own country code drives the
national rendering; unparseable input passes through unchanged, matching
the existing normalize/2 contract.

Tests at lines 153-159 of phone_formatter_test.exs encoded the bug as
expected behavior; replaced with correct GB rendering plus FR/DE/JP/SA
coverage and legacy/garbage/empty-string tests.
Moduledoc now reflects the ExPhoneNumber-driven implementation and warns
contributors that any new phone-display UI must call format/2 with the
account's phone_format setting.

Also rename render/2's parameter from library_format to phone_number_format
with a clarifying inline comment, addressing a code-review nit about the
overload with the account's phone_format string field.
The existing Playwright spec only exercises +1 numbers, which is precisely
the country where the NANP-only renderer happened to work. Adding GB
national + international cases guards the e2e display path against a
regression where someone reintroduces locale-specific hand-rolled
rendering.
@bashar-qassis
Copy link
Copy Markdown
Owner Author

Follow-up: phone display format fix (5 new commits)

While reviewing the Monica import normalization work, I noticed the account-level phone_format setting (e164/national/international/raw) was silently being ignored for every non-NANP phone — French, German, Saudi, Japanese, etc. The hand-rolled NANP binary pattern in PhoneFormatter.format/2 only matched +1 numbers; all others fell through to the unchanged-pass-through clause. After this PR's E.164 storage normalization landed, a French contact imported from Monica is correctly stored as +33… — but no matter what the user picked in Account Settings → Phone Number Format, they still saw +33123456789 instead of 01 23 45 67 89.

The fix replaces the hand-rolled code with ExPhoneNumber.format/2 (the libphonenumber port this PR already declares as a dependency). The phone's own country code drives the rendering — no account-level region is needed at display time.

Commits added:

  • f4dfb8d docs(specs): design spec
  • 67f3688 docs(plans): implementation plan
  • e39a6d5 fix(phone): replace NANP-only renderer with ExPhoneNumber library calls
  • cd74b85 docs(phone): update moduledoc + clarify render parameter name
  • 375c98e test(phone): add non-NANP Playwright coverage

Diff stats (code-only): +147 / −25 across phone_formatter.ex, phone_formatter_test.exs, phone-format.spec.ts.

Verification: mix test 1151/0 · mix quality clean · spec compliance + code quality review passed task-by-task during execution.

Bug-pinning tests replaced: the existing phone_formatter_test.exs had two tests at lines 153-159 that literally asserted format("+442079460958", "national") == "+442079460958" — i.e., they pinned the bug as expected behavior. Replaced with proper GB national/international rendering plus FR/DE/JP/SA coverage.

Spec: docs/superpowers/specs/2026-05-16-phone-display-format-fix-design.md
Plan: docs/superpowers/plans/2026-05-16-phone-display-format-fix.md

Spec to address silent contact drop in Monica v4 /api/contacts listing.
v4 LIMIT/OFFSET pagination over ORDER BY created_at loses a deterministic
~1.7% of contacts at tie-group boundaries with no visible error. Design
adds a Phase 1.4 coverage check between the listing crawl and auto-merge:
re-fetch meta.total, compare against import_records, backfill the gap
via direct GET /api/contacts/:id for IDs in [min, max] not already seen,
applying Monica's same is_active and is_partial filters client-side to
avoid importing rows the listing deliberately hides. Partials ARE imported
to anchor relationship targets.
Bite-sized TDD plan in 7 tasks: thread ref_data through crawl_all_contacts,
add fetch_single_contact + accept_backfill_response helpers, implement
coverage_check_and_backfill/3 core algorithm, wire into crawl/5 between
Phase 1 and 1.5, extend summary, exhaustive test matrix (10 scenarios
covering happy path, mixed 200+404, inactive skip, partial import,
no-op-no-gap, unresolved-gap warning, early termination, auto-merge
interaction, safety margin, hard iteration cap).
Phase 1.4 (coverage backfill, next commits) needs ref_data so it can
call safe_import_api_contact/5 on directly-fetched contacts.
crawl_all_contacts/1 was already building ref_data per page but
discarding it on return; this commit threads it through to the
orchestrator. No behavior change.
…fill_response)

fetch_single_contact/2 wraps api_get/3 to distinguish 404 (Monica
soft-delete, expected) from other errors. Returns
{:ok, contact} | :not_found | {:error, reason}.

accept_backfill_response/1 mirrors Monica's listing filter
(->real()->active() = is_active=1 AND is_partial=0) on direct-GET
responses so the backfill won't import contacts Monica hides from the
listing. Partials are still accepted because they anchor relationship
targets.

Both helpers are unused in this commit; @compile {:nowarn_unused_function, ...}
directive suppresses warnings until coverage_check_and_backfill/3
wires them in the next commit.
Implements Phase 1.4 logic: re-fetch meta.total, compare against
import_records, iterate [min_id..max_id+50] for unseen IDs, dispatch
each via fetch_single_contact + accept_backfill_response, early-terminate
when gap closes, cap iterations at (max_id-min_id)+100 to guarantee
termination. Stats accumulator covers gap_detected, range_scanned,
imported_full, imported_partial, skipped_deleted, skipped_inactive,
errors, unresolved_gap.

Removes the @compile {:nowarn_unused_function, ...} directive added in
the previous commit (it didn't work in Elixir 1.18.4 anyway, and the
helpers are now used by coverage_check_and_backfill/3).

Wiring into crawl/5 lands in the next commit.
Phase 1.4 now runs between the listing crawl (Phase 1) and auto-merge
(Phase 1.5). Backfilled contacts participate in auto-merge and Phase 2
cross-reference resolution as first-class import-record holders.

Import summary now carries coverage_backfill.{gap_detected, range_scanned,
imported_full, imported_partial, skipped_deleted, skipped_inactive,
errors, unresolved_gap}. The unresolved_gap field is the self-reporting
safety net: if it ends up > 0, the operator knows the listing dropped
contacts the backfill couldn't recover.

The happy-path test exercises the full pipeline end-to-end: listing
returns 4 of 5 contacts, meta.total reports 5, backfill issues one
direct GET for the missing ID 4, returns 200, and the contact ends up
in import_records.

Also fix three existing tests to account for the new meta-total call made
during coverage backfill phase.
Adds: mixed 200+404 closure, inactive skip, partial import, no-op when
no gap, unresolved-gap log+summary, early termination, auto-merge
interaction, safety margin (past max_seen), and hard iteration cap.

Together with the happy path from the previous commit, this covers
every branch listed in the spec's test matrix.
Credo --strict flagged two 'nested too deep' findings in scan_gap_range/8
and fetch_and_dispatch_backfill/4 (depth 3, max 2). Surgical fix: extract
exactly two helpers, step_backfill/4 (the reduce_while body) and
dispatch_accepted_contact/4 (the verdict dispatch). Each function now
has nesting depth <= 2. No behavior change.
@bashar-qassis
Copy link
Copy Markdown
Owner Author

Follow-up: Monica import coverage backfill (8 new commits)

Investigation triggered by a missing-contact report: Monica id 1175 exists on the Monica server (direct GET /api/contacts/1175 returns 200) but never lands in Kith despite the import reporting skipped: 0, errors: 3. Deep dive established that Monica v4's /api/contacts paginated listing silently drops a deterministic subset of contacts under LIMIT/OFFSET over a sort-with-ties — in the affected account, 18 of 1079 contacts (1.7%) systematically missing across every sort variant we tried (default, created_at ASC/DESC, updated_at ASC/DESC; v4 rejects id/name/last_name with error 39).

Verified via Monica's open-source code: the listing endpoint applies ->real()->active() plus Account::contacts()->addressBook() which is WHERE address_book_id IS NULL. The user's 18 missing rows pass every visible filter (is_active=1, is_partial=0, address_book_id IS NULL, deleted_at IS NULL — all confirmed by direct DB query). The drop happens inside MySQL's index iteration at LIMIT/OFFSET boundaries; Monica's API has no escape hatch (no cursor pagination, no stable-sort param, no /api/addressbooks endpoint at all).

Fix

New Phase 1.4 in Kith.Imports.Sources.MonicaApi.crawl/5:

  1. After the listing crawl finishes, re-fetch meta.total via GET /api/contacts?limit=1&page=1.
  2. Compare against count(distinct source_entity_id) in import_records.
  3. For any gap, iterate [min_id..max_id + safety_margin] issuing GET /api/contacts/{id} on unseen IDs.
  4. Apply the same is_active=true && is_partial in [true, false] acceptance the listing applies — but partials are imported because they anchor relationship targets (this also unblocks Phase 2 cross-reference resolution that previously warned Could not resolve first_met_through).
  5. Early-terminate when the gap closes; hard-cap at (max_id - min_id) + 100 iterations.
  6. Self-reporting coverage_backfill.unresolved_gap in the import summary — so any future silent drop is visible.

Commits

  • 4919369 docs(specs): design
  • f928885 docs(plans): plan
  • 16596c9 refactor(monica): thread ref_data through crawl_all_contacts return
  • 4be5337 feat(monica): add backfill helpers (fetch_single_contact, accept_backfill_response)
  • 4ce8a3e feat(monica): coverage_check_and_backfill/3 core algorithm
  • 4744984 feat(monica): wire into crawl/5 + coverage_backfill summary key
  • 3039247 test(monica): edge-case coverage matrix
  • b96f08c refactor(monica): flatten helper nesting (credo-strict)

Diff stats: +2380 / −13 across monica_api.ex (+269/-13), monica_api_test.exs (+547), plus spec + plan docs.

Test plan

  • mix test — 1161 tests, 0 failures (61 in monica_api_test.exs; 9 new in a describe "coverage_check_and_backfill" block)
  • mix quality — compile + format + credo + sobelow + dialyzer all clean
  • Test matrix covers: happy path, mixed 200+404, inactive skip, partial import, no-op when no gap, unresolved-gap warning + log, early termination, auto-merge interaction, safety margin, hard cap
  • Manual smoke (deploy-side): trigger Monica import on the affected account, verify summary.coverage_backfill.imported_full + .imported_partial equals the expected gap (18 in observed data), unresolved_gap: 0, and contact 1175 appears in Kith afterward

Spec & Plan

  • docs/superpowers/specs/2026-05-17-monica-import-coverage-backfill-design.md
  • docs/superpowers/plans/2026-05-17-monica-import-coverage-backfill.md

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.

1 participant