Skip to content

fix: stop deletion after incomplete cleanup - #1124

Open
ben-kaufman wants to merge 17 commits into
fix/profile-delete-session-recoveryfrom
fix/profile-cleanup-outcomes
Open

ben-kaufman wants to merge 17 commits into
fix/profile-delete-session-recoveryfrom
fix/profile-cleanup-outcomes

Conversation

@ben-kaufman

@ben-kaufman ben-kaufman commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Validated auth transport parent composition — November 3

Signed additive merge 21d6a22036cb0f5d9068b1eb25d346eafd1ea70a incorporates live #1122 parent b44f0253c4b8ecc39e4e38fee2716844e26092da. The four incoming files exactly match that validated parent: typed auth request-send classification, its tests, and the system-Paste DEBUG fixture with unchanged copy assertions. Independent review verified that this PR's own 24-file patch remains byte-identical, including strict cleanup failures, original-session revalidation and contact/endpoint handling.

The composed tree passed 2,976 unit tests and five affected UI cases (Profile Recovery plus all four Contact Import cases), with one explicitly unprovisioned USDT fixture skip and zero failures/retries. All three auth controls passed. All 1,693 frozen inputs match; full SwiftFormat reports 0/829, with 108 configured exclusions. Complete raw and nested diagnostics were inspected, and the owner verified 9,091 evidence members. Six QoS observations in unchanged test bodies and two AppIntents notices remain documented; no timing-cause or blanket warning-free claim is made.

All 17 live-base commits verify locally and on GitHub, and draft=false is verified. Current-head native CI 41738718686 and SwiftFormat 41738718729 passed on attempt 1. Both jobs used synthetic checkout be69f4a4, with the exact published tree and live parent. Canonical results record 2,976 unit passes and 39 UI passes, five explicit fixture skips and zero failures or method retries. Thirty unique locale/appearance launch configurations account for the physical count. The camera, clipboard, three auth controls and both scheduler regressions all passed. Hosted formatting is 0/829, with 108 configured exclusions; nine migration jobs were skipped.

Complete logs, nested diagnostics and every result reference were inspected. The artifact digest, all 6,189 archive entries and 9,342 sealed evidence members verify. The 572 canonical linker warnings (286 distinct messages) match retained provenance with identical actual-parent project/lock inputs; simulator/CoreGraphics, keyboard and other runtime notices remain disclosed. Runtime detectors, leak checking and per-test timeouts are disabled in the existing workflow, so this is not a detector-clean claim. The original camera failure and the different scheduling failure in its single authorized retry remain separate historical results. This is validated new-source CI, not another retry. Hosted report: work/validation/hosted-ios1124-21d6-20261103/report.json, SHA256 7f8078b3ff2dbf7aca0494b4dd0e9136224cbdd46810a3b3771addf26564e3e5. Evidence: work/validation/ios1124-auth-parent-final-audit-20261103/report.json (SHA256 fc38ee9a7e8fae1f108569d475adcd44c3c474b971c8daa10e5e13f3de287221) and ios1124-auth-parent-composition-20261103. This validation adds no new authenticated cleanup, cross-device contact CAS, or terminal complete-WALLET result. Earlier failures, case-specific acceptance and limitations below retain their original revisions.

Scheduled-backup completion repair composed — November 3

Signed additive merge ce0784bdb416ad40cf1c601dd0394ecaaa7f3354 incorporates #1122 595898e1 and #1123’s independently reproduced scheduling fix. Scheduled-job admission is removed before publishing idle in the same serial queue operation. Profile cleanup, required restore gates and cancellation drains retain their existing behavior; the affected composition review found no new interaction.

The actual combined tree passed 2,973 unit tests and one Profile Recovery UI test, zero failures/retries, with one opt-in native-fixture skip. The qualified scheduled-idle regression passed. All 1,693 frozen inputs match. SwiftFormat reports 0/829, with 108 exclusions. Complete raw and nested results reconcile the passing case omitted by MCP. Six QoS observations in parent-identical method bodies and two AppIntents notices remain documented, without a timing-cause claim. The owner verified 5,989 diagnostic members. All 16 live-base commits verify locally and on GitHub.

The earlier e8aaa4b0 hosted camera failure and its single authorized retry remain below. That retry passed the camera case but failed a late-retry scheduling wait; its private job-map state was not captured, so the independently reproduced admission gap is not claimed as a proven historical cause. This is a new validated source head, not another unchanged CI retry. New-head hosted native CI 41708815108 passed on attempt 1: 2,973 units and 39 UI executions, zero failures or test retries, with one unit and four UI fixture skips. The camera and late-retry scheduling cases both passed. The synthetic checkout 74112f83 exactly matches the published tree and live parent 595898e1. Hosted SwiftFormat reports 0/829; nine branch-gated migration jobs were skipped, not passed. Complete logs, nested XCResult diagnostics and checksum/CRC-verified artifacts are audited; all 9,243 sealed members verified. The 286 distinct linker warnings match the actual-parent set. These passes do not establish the historical failures' cause. Evidence: work/validation/hosted-ios1124-ce078-20261103/report.json. Existing authenticated/manual and terminal complete-WALLET gaps are unchanged. Evidence: work/validation/ios1124-idle-final-diagnostics-20261103/report.json and work/validation/ios1124-idle-composition-20261103.

Historical hosted native failure at e8aaa4b0 — November 3

Native run 41675649974, attempt 1 at e8aaa4b0, failed one UI execution. 2,972 units + 38 UI executions passed, five prerequisite cases skipped, and UsdtWalletUITests.testReceiveAndInvalidPaymentStaySeparateFromBitcoin failed at line 288: the original camera prompt remained after Allow was tapped and the unchanged five-second dismissal wait. The later manual-entry/invalid-recipient assertions were not reached in that execution. The exact UI file matches actual base #1122/2d604929; this source equality does not establish failure cause. Synthetic checkout 0457d6c4 exactly matches published tree a4ea694c.

Complete logs, every nested result, failure hierarchy and captured system diagnostics were inspected; 10,788 evidence members and all archive digests/CRCs were verified. The event targeted the center of the captured Allow button. HID/event-delivery, accessibility automation, simulator diagnostics and a preceding system searchd crash remain recorded; none proves why the prompt persisted. The failure mechanism remains unclassified, and no hosted retry has been made. Formatting passed 0/829, with 108 configured skips; nine migration jobs skipped. The 286 unique linker messages retain their actual-base comparison. Passing local/component results do not waive this hosted failure or supply missing authenticated acceptance. Existing João/Ovi requests remain pending. Evidence: work/validation/hosted-ios1124-e8aa-20261103/report.json.

Cached backup protection propagated — November 3

Signed additive merge e8aaa4b03c4998a339b119940a67ea63253338bb incorporates live #1122/2d6049298ac2b9081b6f8446b62bdcd0908b93df. Only the three reviewed parent inputs change: cached Blocktank capture, its native SQLite regression tests and the parent changelog. Unreadable cached info now fails capture and retains the acknowledged backup/pending revision; absent cached info remains valid. Released Core nil-ID list reads were already local, so this is not evidence of fixing a network-refresh cause. Cleanup outcomes and session-restoration gates are unchanged.

The actual combined tree passed 2,972 unit tests and one ProfileRecovery UI case, with one explicit opt-in native fixture skip, zero failures/retries and formatting 0/829. All 1,693 frozen inputs match. Complete raw logs and all 2,974 nested test summaries were inspected; four QoS observations and two metadata notices are retained, with no introduced source/format finding. Two sealed parent persistence reviews and an independent child interaction review cover this composition. All 15 commits against the live parent verify locally and on GitHub. New-head hosted checks are pending; the prior hosted results below remain historical. This adds no authenticated cleanup J1, remote backup or terminal complete-WALLET acceptance.

Evidence: work/validation/ios1124-cached-blocktank-composition-20261103, work/validation/ios1124-cached-blocktank-diagnostics-20261103/report.json.

Current-head hosted validation — November 2

Hosted native CI at 3991f3a2f8d70cf24412e4ae3251cf956e7965cb passed 2,970 unit tests and 39 UI executions on iOS Simulator 26.2, with five explicit skips and no failures or test retries. The verified synthetic checkout tree equals the published head. Complete logs and all 3,074 XCResult references were inspected, including 67 attachment occurrences; archive digests/CRCs and every sealed evidence payload were verified. Formatting is 0/828.

The 286 distinct linker warnings retain their recorded reference/input comparison. There were 72 invalid-number runtime observations and one keyboard-queue timeout in a passing navigation test; passing tests do not establish their causes. Fixture/hardware skips, migration jobs and excluded integration selectors are not passes. This adds no authenticated profile-removal, wallet-reset or complete-WALLET acceptance. Existing QA cases and limits remain. Evidence: work/validation/hosted-ios1124-3991-20261101-current-a/NATIVE-REPORT.md and work/validation/hosted-four-tail-root-integrity-20261102.json.

Validated parent composition — November 1

Signed additive merge 3991f3a2f8d70cf24412e4ae3251cf956e7965cb incorporates live #1122/5a5044a7e8bf711ff63c5ff6e96edafe822eea74. The complete 24-file cleanup patch is unchanged. The final tree 6d35816e0f7cc23f49c730ff94a153ac837ec1a9 passed 2,970 unit and 39 UI executions, with five explicit skips, no failures and no failure retries. The 30 launch executions cover 15 locales in two appearances; the canonical logical count is 2,980 passes. All 1,692 frozen inputs match. SwiftFormat is 0/828, with 108 configured exclusions.

The sole audit inspected complete logs and nested XCResult diagnostics, resolving all 3,075 references and 67 attachments. One passing backoff/jitter test omitted by the wrapper was reconciled against canonical/session records. Thirteen QoS warnings, three AppIntents notices, cache and UI runtime observations remain disclosed; no timing-cause or warning-free claim is made. Fixture/hardware skips and four integration-class exclusions are not passes. The owner verified all 12,444 sealed evidence files and the affected composition review. All 14 live-base commits verify locally and on GitHub (verified=true, reason=valid).

This PR remains ready. Current-head hosted native inspection is recorded above. The composed native run does not establish a new authenticated cleanup-fault journey or fix terminal complete-WALLET availability. Existing authored QA IDs and limitations remain applicable. Evidence: work/validation/ios1124-parent5a50-final-a/inspection/structured-report.json and work/validation/ios1124-final-parent5a50-20261101/.

Current parent composition — October 31

Signed additive merge dd180c6c1bb5765cf43878f6bf04ef1d07b5a8a4 incorporates live #1122/a6647db2614227a2b423c665701976034d995d7f; combined tree c0b519ef61b2ce8c2c9aecb3de69585678028379. The complete 24-file child patch is unchanged after normalizing only blob IDs and hunk offsets. One fresh combined composition review is clean; prior unaffected security coverage is retained.

The final build and selected native gate passed 2,968 unit +39 UI physical executions, with five explicit skips, zero failures and no retries (2,978 logical passes). All 1,692 frozen source hashes match. Complete raw logs, all 3,012 nested summaries, 3,071 referenced records and 67 attachments were inspected. The owner verified 18,632 sealed evidence files and 28 external inputs. SwiftFormat 0.64 is 0/828 with 108 exclusions; translations have zero errors and 15 specific warnings verified against the actual parent.

Diagnostics retain 12 canonical QoS issues/13 stdout emissions, 48 NaN messages and two AppIntents notices. The wrapper omitted three passing cases and all 12 QoS issues; canonical/raw/session records reconcile them. No timing-cause or pristine dependency-checkout claim is made: missing Git alternates still limit dependency working-tree certification. Native fixture, hardware and local-fork skips and four workflow integration exclusions are not passes.

All 12 live-base commits verify locally and on GitHub (verified=true, reason=valid), with no history rewrite; draft=false is verified. Current-head native CI and SwiftFormat are now fully audited: 2,968 unit +39 UI physical passes, five explicit skips, zero failures/retries, formatting 0/828 with 108 reported skips. Both attempt-1 jobs checked out synthetic b40808ea, whose tree exactly equals this published head against actual parent a6647db2. The artifact digest and all 6,175 ZIP members are verified; complete logs, all nested result references, canonical/session counts and 67 attachments were inspected. The owner verified 18,658 sealed evidence files and four external inputs. All 571 linker events/286 distinct messages match the sealed hosted actual parent; two AppIntents notices and other runtime/fixture warnings remain retained. No QoS or NaN marker was observed in this hosted run; that does not erase the local observations or establish a repair. Main Thread Checker injection is recorded, unit OS-log emission is disabled, and sanitizer coverage is not established. A real unsupported LIGHTNING_CONNECTIONS backup diagnostic remains disclosed and is not full-WALLET acceptance. Reader-cache and unavailable console-channel errors were retained; they did not rerun tests or CI. The original three-second route failure remains causally unproved, with its deadline/assertions unchanged. Full authenticated J1 cleanup/subscription/fault coverage and safe manual continuation remain distinct gaps; this composition is not a new authenticated journey pass.

Historical parent composition — October 30, 22:47 UTC

Signed additive merge de683f315d91aab493b17e5d41e1958e5ef5fd13 incorporates #1122 at 49f88a067d2c4947fd104329a4ae603719c23522. The original child patch is preserved. Final tree 175bcc1c9feb8bc8b0c39fc7816c4b2d5e4ac6b7 passed build-for-testing, 2,959 unit tests and five selected ProfileRecovery/ContactImport UI tests, with zero failures/retries. One opt-in encrypted native-recovery fixture test was skipped because its fixture URL was unset; the four workflow integration classes remain excluded. These are not additional runtime passes.

SwiftFormat 0.64.0: 0/827 findings; translation validation: zero errors and 15 warnings matching the actual parent. All 1,685 frozen inputs match. Complete raw build/test output, nested results and diagnostics were inspected; five QoS observations, three AppIntents notices and simulator/UI runtime diagnostics remain retained. Warning-bearing test methods match the parent, without a parent runtime recurrence or causal claim. Two independent affected source reviews are clean. All eleven live-base range commits verify locally and on GitHub (verified=true, reason=valid).

That head’s native CI and SwiftFormat passed on the exact published tree: 2,959 unit and 39 physical UI passes, five explicit skips, zero failures or test reruns, formatting 0/827. Complete logs, every nested result, diagnostic payload and checksum/CRC-verified archive were inspected; the owner verified all 9,378 sealed evidence files. The 286 distinct linker messages match the actual parent; two AppIntents notices, one keyboard timeout and 48 NaN observations remain disclosed without a causal or harmlessness claim. Excluded integration classes and skipped migration/fixture cases are not passes.

This PR remains ready. The historical route-appearance failure is not explained by this passing selected run, and the remaining authenticated/manual cleanup cases retain their existing evidence limits. No deadline was weakened or unchanged CI retried. The earlier ENOSPC merge failure and intact pre-merge state remain recorded; disk recovery allowed this validated composition.

Twin: synonymdev/bitkit-android#1690
Dependency: #1122

This PR stops profile deletion after incomplete payment-link or contact cleanup instead of discarding the cleanup error and local contact evidence.

Description

  • Stops profile deletion when private payment-link withdrawal or contact cleanup is incomplete.
  • Propagates contact read errors and preserves unknown or retained contact data for reconciliation.
  • Propagates profile-deletion errors instead of interpreting 404/missing text as successful deletion and signing out.
  • Refuses profile removal while any payer/payee subscription is active, including a subscription without a saved contact. Checks precede cleanup and repeat at strict contact removal and profile deletion. Fixed-term subscriptions show the latest known expiry only when every blocking record has a valid finite end; unknown expiry keeps a neutral refusal instead of promising an unavailable cancellation.

Out of Scope

  • Original-session/attempt ownership, failure-dialog Retry/Disconnect binding and restart reconciliation remain tracked in Bind profile deletion cleanup to the original sign-in bitkit-android#1687 and the paired iOS investigation. This PR does not make a manual destructive retry safe.
  • Native contact removal still has no expected-record precondition for cross-device edits/re-adds/unblocks. No such atomicity is claimed.
  • Full-WALLET separation, SDK artifact adoption and the historical contact/acquisition incident cause remain separate.

Design

Existing Profile › Profile Edit in the Bitkit handoff. This changes failure handling; it adds no visual design.

Preview

N/A — existing error UI is reused.

QA Notes

Setup

Use this branch's normal released dependencies: Core 0.8.0-rc11, Paykit 0.1.1-rc2 and VSS 0.5.26-rc1. Component tests use synthetic identities and injected service failures; no real profile, wallet or funds are needed. For J1, provision a disposable authenticated identity and a homeserver fault proxy that can hold/fail private endpoint withdrawal, lose a contact-removal acknowledgement, and return a partial contact-removal acknowledgement, or fail profile deletion with a 404-like error without confirming the outcome. Also prepare active recurring subscriptions in both payer/payee roles, including one without a saved contact and an unrelated saved contact. Record endpoint/contact state, confirm removal, then end or wait for expiry of the subscription and explicitly confirm again. Include a fixed-term subscription for which cancellation is unavailable; verify truthful expiry guidance. Those controls are unavailable in the standard journey fixture. The committed journeys/profile/profile-cleanup-failure.xml describes the sequence; do not use a personal profile or reset another worker's wallet.

Journeys

  • J1: profile-cleanup-failure.xml behavioral matrix completed by the author on current b14d5a1b through normal authenticated app UI, including subscription refusal/ended-subscription progress, private/partial/unknown cleanup failure, 404, Cancel and cold restart. Exact configuration and limits are below; skipped component tests are not counted as runtime passes.

Manual Tests

  • 1. External reviewer reported native authenticated private-cleanup, unconfirmed contact-write and partial-removal cases at a0626189; failure appeared before profile deletion and retained evidence stayed intact. See the scoped review evidence below.

  • 2. External reviewer reported profile GET 200 followed by DELETE 404 at a0626189; failure UI, session and sharing settings remained. See the scoped review evidence below.

Automated Checks

  • updated PubkyProfileManagerTests.swift — the actual view workflow stops after private/contact cleanup failure, retains the session/evidence and releases deletion admission.
  • updated ContactsManagerTests.swift — unavailable contact reads preserve local evidence; existing partial-removal coverage remains in place.
  • updated PaykitSdkIdentityCheckTests.swift and manager tests — both roles, orphan/non-Bitkit records, unknown/invalid/expired subscription ends, strict empty/nonempty batches, read failure/cancellation and early workflow refusal.

Current-head authenticated acceptance — October 30

The author completed the frozen J1 behavioral matrix at b14d5a1b through the normal app on a dedicated unfunded iPhone 16/iOS 26.2 (23C54) simulator, using real Homegate/Pubky grants and encrypted native storage:

  • Subscription refusal: fixed-term payee/non-Bitkit, open-ended payer, and orphan-with-unrelated-contact cases refused before cleanup. Finite expiry showed November 7 in local time; open-ended terms showed neutral refusal without an invented date. Exact future unpaid requests were then canceled once through their original grants, with ACK and zero proofs.
  • Private withdrawal failure: native peer lease17 caused failure before contacts/profile deletion. Cancel, exact release before expiry and actual cold restart preserved the profile/contact.
  • Partial native contact removal: one of two contacts was acknowledged removed; the exact Published/Blocked contact record and visible row remained. Failure prevented profile deletion; Cancel retained the profile.
  • Unknown contact acknowledgement: the selected contact shared-state write committed201 once but its ACK was replaced. The normal app reported failure and never reached profile DELETE. While reconciliation remained rejected, Contacts still displayed the unacknowledged peer. Cancel retained the original profile. A separate same-boundary case cold-restarted65616→74798 after fault cleanup/native reconciliation; the exact profile hash remained and the profile DELETE count stayed zero.
  • Unconfirmed profile DELETE404: one exact-owner DELETE was rejected before storage. Failure/Cancel/cold restart preserved profile/sharing with no replay. Earlier acknowledged contact removal remained applied.
  • Healthy fresh confirmation: after ended subscriptions and resolved native state, a separately confirmed deletion completed profile DELETE204 and native sign-out, public GET404, and New Pubky UI. Private sharing was already disabled and contacts were already acknowledged removed; this is not an additional private-on withdrawal variant.

These manually driven normal-app cases satisfy the committed journey's required boundaries; no automated XML-runner execution is claimed. App pins remain Paykit RC2; separately validated companion helpers use RC3 source, not a published/adopted RC3. The signed configuration-only installed copy preserves audited unsigned executable/native bytes and normal startup/UI.

All failed preparations remain: blocked/linking preconditions, wrong-phase lost-ACK attempta (it hit later public cleanup after acknowledged profile deletion), and one sandbox/CoreSimulator launch failure recovered without reinstall/reset. Caseb's local-contact screenshot came after reconciliation, so a distinct narrow casec captured local retention while the fault was still active. Multiple rejected background reconciliation PUTs are disclosed (124/48); only one selected write was forwarded per case, with no profile DELETE. Reservation/outbound differences are not called immutable.

Complete runtime/structured diagnostics were inspected, including the25,283-line ended run,3,866-line lost-ACK run and3,248-line restart/success/local-retention capture. Injected storage errors, startup balance/RGS failures, temporary WALLET unavailability and post-sign-out Shop identityChanged remain disclosed; no clean-runtime or historical-cause claim. All faults are now accept, exact consumed fences remain, and native read-only observation found zero pending markers. No payment, live funds or wallet reset. The final disposable profile is preserved.

Evidence: work/ios1124-authenticated-acceptance-final-20261030.md; exact case receipts/screens/native journals under work/fixtures/ios1124-acceptance-20261029/{payer-refusal-a,orphan-refusal-a,partial-contact-b,unknown-contact-b,unknown-contact-c,final-success-a}/, plus the earlier payee/private/404 receipts. The prior112-entry seal remains byte-valid. Current source is unchanged, so matching final/native/format/signature evidence below is reused. Safe original-attempt Retry/Disconnect, Ring-owned cleanup, full-wallet redesign and cross-device contact CAS remain separate scopes.

Evidence

Current signed additive head b14d5a1b93ab274928fda044771d6e12c27d2475 incorporates validated live #1122 bee8975a9927d806beae82472cd76f2d63a49e49. All ten actual-base commits verify locally and as verified=true/reason=valid on GitHub; draft=false remains verified. The atomic subscription refusal and strict cleanup delta are preserved. Final combined-tree build/tests passed 2,883 units and five selected UI cases (four ContactImport, one ProfileRecovery), with one encrypted-native fixture skip, zero failures/retries. Other UI and four workflow integration classes were unselected. All 1,671 frozen hashes, 2,889 nested summaries and the 13,739-file audit seal match. SwiftFormat 0.64 reports 0/824, 100 configured exclusions; translations zero errors/15 warnings verified against the actual parent. Six Paykit initialization QoS observations and two AppIntents notices remain documented without a causal claim. One independent affected-delta review is clean, reusing the unchanged queue/subscription risk reviews. Current-head native CI and formatter passed on synthetic f814ed91, whose tree equals this head: 2,883 units and 39 physical UI passes, five prerequisite skips, zero failures or retries. All 2,927 nested summaries, eight complete diagnostic logs, 53 payloads and the checksum/CRC-verified archive were inspected; the owner verified 9,171 evidence hashes. Formatter reports 0/824 with 100 exclusions. All eight added test methods passed. The 286 distinct linker messages and two AppIntents notices match actual-parent sealed diagnostic metadata and producer inputs. Forty-eight NaN observations and one keyboard timeout in passing tests remain disclosed without a causal or harmlessness claim. Nine migration jobs were skipped; integration, external E2E and translation lanes do not run on this stacked target. The separate upstream failures/retries remain unwaived. The separately recorded current-head authenticated J1 matrix above is complete within its explicit fixture limits. Native/component CI remains distinct evidence. Affected human re-review is requested after evidence publication; no approval or merge is implied.

Previous signed head 2df5bad1821193ceb1125536a1de428e583010d9 incorporates live #1122 940d7f3ec035bf27b6fa64d7b981a8188f1dc232 and finite-expiry guidance 6665a443. All nine actual-base commits verify locally and report verified=true/reason=valid on GitHub. The PR remains ready (draft=false). Current-head hosted native tests and SwiftFormat passed on the exact synthetic tree: 2,868 units plus 39 physical UI executions, zero failures/retries and five explicit skips (2,878 unique passes). Formatting reports 0/823. Both full logs, all 2,912 nested summaries, checksum/CRC-verified artifact and diagnostic files were inspected; the 11,944-file seal was verified. The 286 distinct linker warnings match the actual parent. No QoS observation was found; 72 CoreGraphics NaN notices remain in two passing USDT UI cases, with unproven cause. Nine migration jobs were skipped, not passed. These results do not execute the authenticated J1 matrix.

Prior 2df5bad1 native build/tests on iPhone 16 / iOS 26.2 (23C54) passed 2,868 unit tests plus 39 physical UI executions, zero failures or retries and five explicit fixture/hardware skips: 2,878 unique passes across 2,912 physical summaries. Four integration classes remain excluded. All 1,664 frozen source hashes match. Complete logs, nested XCResult issues and the 9,348-file evidence seal were inspected and verified. Twelve QoS observations, two AppIntents notices and 24 CoreGraphics NaN notices in the passing invalid-payment UI case remain disclosed; timing causes are unproven. SwiftFormat 0.64.0 reports 0/823, with 99 configured exclusions; translations report zero errors and 15 verified actual-parent warnings. The independent composition review is clean and preserves the prior risk-scoped review of the unchanged subscription guard. Three added expiry/unknown/cancellation controls passed. This is native component/UI evidence, not the authenticated J1 matrix.

Previous a2b21861 hosted native tests and formatting passed on its exact synthetic tree: 2,832 units and 39 UI executions, five skips, no failures/retries, format 0/821. Its full artifact and diagnostics remain revision-scoped; no old-head pass is carried onto the current tree.

The new subscription checks preserve the existing original-session validation and SDK operation lock. Unknown or malformed expiry remains active; a failed subscription read propagates its error. Generic bulk contact removal retains its existing behavior. Profile cleanup uses the strict batch, so a known active subscription cannot silently filter its contact and delete unrelated contacts. Final profile deletion retains the native expected-revision CAS. Cross-device atomicity across separate SDK calls is not claimed.

The controlled native before test failed for both payer and payee orphan subscriptions: the profile mutation ran when it should have refused (four assertion instances). The repaired focused SDK/manager/contact suites passed 251 cases with no failures/skips. The early workflow test verifies refusal before endpoint/contact cleanup and preserved session/sharing state. These tests inject native records and failures; they are not authenticated runtime acceptance.

Previous composition c26d7b13, parent f3df6cda, passed 2,828 units and 39 UI executions locally, with three explicit Trezor skips. Hosted native tests and formatter passed on its exact tree: 2,828 units + 39 UI passes, five explicit fixture/hardware skips, no failures/retries, format0/821. Complete logs and checksum/CRC-verified nested artifacts were inspected. Duplicate-symbol and runtime diagnostics remain revision-scoped in the recorded evidence. These prior results do not validate the new subscription branch.

Gaps

Current-head J1 is complete within the configuration and limits above. Local-only overrides were not separately altered in the author fixture; their unchanged controls and older attributed manual evidence retain their scope. Safe uncertain remote Retry/Disconnect, native cross-device contact CAS, Ring credential behavior and historical incident causation remain separate work and are not certified here. No human approval, app merge or RC3 adoption is inferred.

Models used

  • Planning/scoping: gpt-6-astra (reasoning: xhigh)
  • Implementation: gpt-6-astra (reasoning: xhigh)
  • Review: gpt-6-astra (reasoning: xhigh), gpt-6.1-sol (reasoning: high)

Prior hosted failure

At a0626189, run40165956252 failed the three-second pending-wallet route appearance assertion while later path/preparation assertions and 39 UI executions passed. Complete logs/nestedXCResult and the separate exact iOS 26.2 diagnostic pass are retained. Keyboard overlap did not establish causation. No deadline was weakened; later green compositions are not a causal repair claim.

External authenticated review evidence — October26

Review5653598069 reports five controlled cases at a0626189, using iPhone 17/SE simulators on iOS 26.5, Core 0.8.0-rc11 and Paykit 0.1.0. A debug fixture provisioned disposable native identities and displayed call/state evidence; the deletion workflow/SDK used a local homeserver proxy. Private-read503, accepted contact write with rejected shared-state publication, partial removal and GET200/DELETE404 preserved appropriate session/contact/sharing evidence and prevented later deletion. Cancel plus cold launch restored the same identity with no profile DELETE during the reported 60-second observation. Videos/traces are linked in that review.

These are attributed external manual 1/2 results, not current-head author runs or proof of the added subscription cases, safe manual retry or native cross-device CAS. The earlier approval does not waive an unresolved finding or failed check.

@ovitrif

ovitrif commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

Triage: 3.1.0, with its Android twin synonymdev/bitkit-android#1690.

@talosmachina talosmachina 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.

No findings. The Edit Profile deletion path now uses the throwing private endpoint withdrawal and the throwing deleteAllContacts(), a failed contact read is no longer treated as an empty list, and the best-effort wrappers on this path are gone. Reviewed 76e0f80, full tier, stacked on #1122 (fix/profile-delete-session-recovery), so the delta against that base is what I read.

What I checked, and 5 candidates I ruled out

Read in full: ContactsManager.deleteAllContacts and isMissingContactsDataError, PubkyProfileManager.deleteProfile(for:cleanPrivatePaykitEndpoints:) and both removePrivatePaykitEndpoints overloads, EditProfileView.deleteProfile / performDeleteProfile, PaykitSdkService.contactRecords / removeContacts, and remove_contacts_and_block_peers / contact_records in paykit-rs.
Call sites traced: deleteAllContacts (only EditProfileView), deleteAllContactsBestEffort (no callers left), removePrivatePaykitEndpointsBestEffort (now only AppReset.wipe, out of this PR's scope), pubkyProfile.deleteProfile (only EditProfileView, with cleanPrivatePaykitEndpoints: false).
CI: SwiftFormat green; Run Tests still running when this was posted.

Ruled out

  • A missing-storage read now blocks deletion: contactRecords is a local SDK storage transaction, so a 404-looking message there is not evidence of an empty list; the list loader keeps its own missingDataIsEmpty path, so only deletion changed.
  • deleteProfile(cleanPrivatePaykitEndpoints: true) now throws where it used to continue: no caller passes true (the default) today, so no flow changes behaviour through it.
  • Session switch between cleanup steps: requireCurrentSession is still checked after each step in the extracted performDeleteProfile, and the view's catch ignores errors when the public key changed.
  • Contacts the SDK retains by design (active recurring request, peer lease, published marker) block deletion on every retry: the stated intent of the PR and its journey; the existing PreservesContactsNotRemovedByTheBulkOperation test pins it.
  • Parity with bitkit-android#1690: both stop on endpoint failure before touching contacts, both propagate the contact read error, both keep unacknowledged contacts and overrides; iOS additionally clears local-only overrides on full success, which Android gets from the store reset in signOut.

Not built here: iOS does not build on this reviewer's Linux host, so this is reasoned from code and CI.

Merge confidence: 4/5, no findings, but the change is reasoned from code rather than built here, and the unit test job had not finished.

Reviewer model: claude-opus-5-5
Reasoning effort: Unknown (not exposed to the reviewing session)

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Code scan: no findings.

Checked and clean:

  • When cleanup is incomplete, the user gets the existing Retry / Sign out / Cancel alert. Retry is idempotent after a late signOut failure, because an absent profile reads as None.
  • No path locks the user out of deletion or sign-in for good. Private endpoints are now withdrawn before the profile delete, and the cleanup flag is cleared on any successful withdrawal (PubkyProfileManager.swift:1498), so the user's private lists come back right after Cancel.
  • profile-cleanup-failure.xml's fault point is right: contact removal goes through the shared-state storage adapter (PubkyShared, put_locked), so a homeserver proxy can fail it.
  • With #1122 removing the automatic replay, each stop is final until a manual Retry.
  • Twin synonymdev/bitkit-android#1690 keeps its cleanup flag set after a stop; that's raised there.

Device gate (partial): a062618 — not run yet. profile-cleanup-failure.xml needs a homeserver fault proxy. The partial-removal stop can be driven without one, using a contact with an active recurring payment request on two devices. Device results will follow in a separate review.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for a062618: iPhone 17 and iPhone SE simulators, iOS 26.5; bitkit-core 0.8.0-rc11 and Paykit 0.1.0. A debug fixture provisioned disposable native identities and displayed call/state evidence. The real deletion workflow and SDK ran against a local homeserver fault proxy; no SDK result was replaced.

Test 1: Manual 1 / J1 — private cleanup fails ✅ Failure appears before contact or profile deletion.

A native shared-state read returned 503 during private cleanup. After Cancel, the inspector and call trace confirmed the session, contacts, overrides and sharing settings remained intact, with no contact or profile deletion entered.

endpoint-2x.2x.mp4

Private cleanup failure and retained state, at 2× speed.

Test 2: Manual 1 / J1 — contact write is unconfirmed ✅ Local evidence survives and profile deletion is not entered.

The proxy accepted the native write-ahead record, then rejected shared-state publication with 503. The SDK reported an unconfirmed write. The failure dialog appeared, both local contacts and overrides remained, and the profile-delete counter stayed at zero.

contact-final-2x.2x.mp4

Unconfirmed native contact cleanup, at 2× speed.

Test 3: Manual 1 / J1 — partial native removal ✅ Only the acknowledged contact and override disappear.

The native SDK retained a contact with a published public marker and acknowledged the other removal. The remaining contact and override survived; the app showed failure without entering profile deletion. The marker was provisioned through the native SDK.

partial-2x.2x.mp4

Partial removal and retained evidence, at 2× speed.

Test 4: Manual 2 — profile deletion returns 404 ✅ Failure preserves the session and sharing settings.

The request trace confirmed profile GET 200 followed by DELETE 404. The app kept its failure UI, authenticated session and both sharing settings. Earlier acknowledged contact removals remained applied.

profile404-2x.2x.mp4

Rejected profile deletion and retained session, at 2× speed.

Test 5: J1 — Cancel and cold restart ✅ Normal app startup restores the same identity without replaying profile deletion.

After unconfirmed contact cleanup and Cancel, a normal app launch restored the same identity on Home. A fresh UI snapshot also confirmed the Profile screen. The native trace recorded a successful profile read and no profile DELETE during the 60-second observation. A separate cold profile-manager initialization after Test 4 also restored its identity without replay.

normal-startup-final-2x.2x.mp4

Cold launch through the normal app, at 2× speed.

Check 1: focused native regression checks ✅ The unmodified head passes the affected suites and formatting.

ContactsManagerTests and PubkyProfileManagerTests passed. SwiftFormat 0.64.0 passed the changed Swift files, and git diff --check passed. This does not replace the full hosted suite: its existing SendConfirmation lifecycle failure remains outside these focused checks.

Full review of d1745fb..a062618, all seven changed files. The manager's default cleanup entry point and local missing-file deletion error had source/unit coverage rather than separate device cases. Active-recurring and peer-lease retention variants were not separately driven; the published-marker case exercised the native partial-result branch. Manual Retry/Disconnect ownership and cross-device mutation safety remain deferred by this PR.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup matches the cleanup/error propagation behavior in the reviewed source; Android's local finalization differs.

Reviewer model: gpt-6.1-sol
Reasoning effort: high


Reviewed by gpt-6.1-sol-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full review of the complete PR diff against merge base d1745fb, at a062618. No saved assessment baseline was available.

No new actionable code findings.

Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return.

Parity with bitkit-android#1690 at 2daec90 matches on stopping deletion after incomplete cleanup and on keeping the session after a 404-like profile deletion error. When the remote contact list is empty, Android returns without clearing local-only contacts; iOS still clears that local-only state after the empty read succeeds.

bitkit-ios#1122 at ca10ace was checked only for the no-replay contract this branch is based on, not as a full review. Profile-deletion sources at that head match this PR's base. Profile deletion still runs only from the Edit Profile confirmation and its Retry action. Retry and Sign out are still not bound to the sign-in that started the attempt. That limitation remains bitkit-android#1687.

Validation: inspected PubkyProfileManagerTests and ContactsManagerTests at this head and did not execute them. SwiftFormat passed. Unit tests failed in SendConfirmationLifecycleTests.testPendingWalletSwitchResumesAfterTags (confirm versus utxoSelection). That file is outside this diff and was not re-run here.

Device testing: not performed in this review. An earlier simulator pass at this same head reports the failure cases; those results are that reviewer's.

Suggested additional test cases

  • iOS simulator. Disposable signed-in profile with one saved contact and private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app navigates to profile choice or intro, and the contact is gone.

Reviewer model: grok-4.7
Reasoning effort: high

@jvsena42

jvsena42 commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Device gate (partial): a062618 — iPhone 17 simulator, fresh wallet, disposable profile and one saved contact on staging, through a local homeserver fault proxy.

profile-cleanup-failure.xml: actions 1, 2, 3, 7 and 8 passed.

  • In 6 of 6 attempts the private-endpoint withdrawal failed (5 from a refused connection, 1 on its own with privateUnavailable), and deletion stopped before any contact or profile removal: no Deleted all contacts / Removed contact lines, and Contacts and the profile were still there afterwards.
  • "Unable to Delete Profile" offered Retry, Disconnect and Cancel, with no success toast and no onboarding navigation.
  • Cancel started no second deletion, and two restarts replayed nothing (Paykit session restored, Loaded 1 contacts, no deletion lines for 40–60 s).

⚠️ Not run: actions 4–6. They need request-level faults inside Pubky TLS (one lost contact-removal acknowledgement, a partial removal, a 404 on DELETE), and the proxy can only cut or refuse whole connections.

Still to check before the final line: after a refused-connection attempt, the next deletion in the same process failed in 1.4 s with no fault active and no new homeserver connection, and background refreshes failed with the same error until a relaunch. It may predate this PR; the base build hasn't been run yet. The faulted attempt logged the same error (below); the unfaulted one followed at 10:27:59 → 10:28:00.732 UTC with no proxy CONNECT in between.

[2026-10-27 10:23:58.425 UTC] WARN⚠️: Failed to remove private Paykit endpoints before clearing session: Transport(code: "transport_error", context: "acquire Pubky write lock") - EditProfileView.deleteProfile
[2026-10-27 10:23:58.425 UTC] ERROR❌: Failed to delete profile: Transport(code: "transport_error", context: "acquire Pubky write lock") - EditProfileView [EditProfileView.swift: deleteProfile() line: 165]

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full PR at 7e172c6 after the comparison base changed from d1745fb to 7f54d7b. No inherited coverage. Merge base is 7f54d7b.

No new actionable code findings.

Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return. Cancel and a later cold start leave profile deletion unstarted. Retry and Sign out still use the current session rather than the attempt that failed. That ownership gap remains bitkit-android#1687.

Parity with bitkit-android#1690 at 2daec90 matches on those stop and preserve outcomes. bitkit-ios#1122 at 7f54d7b was checked only for that no-replay contract, not as a full review.

Validation: inspected PubkyProfileManagerTests.swift and ContactsManagerTests.swift and did not execute them. SwiftFormat passed at this revision. Unit tests were still running. Device testing: not performed in this review. A partial simulator pass at a062618 covers journey actions 1, 2, 3, 7 and 8 only.

Recommended before device testing

Wait for the unit-test run at this revision to finish.

Suggested additional test cases

  • iOS simulator. Disposable signed-in profile, one saved contact, private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app opens profile choice or intro, and the contact is gone.
  • iOS simulator. Same disposable profile, with a proxy that refuses the private-endpoint connection once and then allows it. Confirm deletion, cancel the failure dialog, clear the fault, and confirm deletion again without relaunching. Expected: the second attempt opens a new homeserver connection, and the first transport error does not block a later successful deletion.

Reviewer model: grok-4.7
Reasoning effort: high

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Approving. The code scan at a062618 had no findings, the only new commit is a merge of the parent branch, and the device gate passed at 7e172c6.

Device gate: 7e172c6, iPhone 17 simulator, a disposable profile with a saved contact on staging, through a local homeserver fault proxy. profile-cleanup-failure.xml actions 1–3, 7 and 8 were driven twice.

  • After the private-endpoint withdrawal fails, deletion stops with contacts and profile kept, and "Unable to Delete Profile" offers Retry, Disconnect and Cancel.
  • The base 7f54d7b, on the same steps, went past privateUnavailable and deleted the contacts and the profile, which is the bug this PR fixes.
  • The follow-up acquire Pubky write lock failure I noted earlier is not from this PR. It reproduces on base too (head 2 of 2, base 1 of 1), clears within about 60 s without a relaunch, and nothing in Bitkit holds the lock. The earlier "until relaunch" reading came from a run that re-applied the fault. Paykit 0.1.1-rc2 now logs two causes:
    • HTTP 405: pubky-sdk 0.16.2 caches a failed /info fetch as an empty feature list for 60 s and falls back to the legacy LOCK route, which the homeserver rejects.
    • 423 shared_state_busy: a lock granted just as the tunnel was cut keeps its 60 s server lease because the unlock never arrived.
  • Perf: not relevant.

⚠️ Device gate: actions 4–6 not run. They need request-level faults inside Pubky TLS (a lost contact-removal acknowledgement, a partial removal, a 404 on DELETE), and my proxy can only cut or refuse whole connections. Those outcomes rest on the code scan and the unit tests.

Run Tests was still pending at 7e172c6 when I posted; confirm it's green before merge.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ⚠️ Not verified — the successful deletion case did not establish that a contact remained immediately before the final attempt.

Findings: None in the code inspected.

QA for c18e747: iPhone 17 simulator, iOS 26.5. A debug fixture provisioned disposable identities and contacts and displayed call counters and state; the real deletion workflow and native SDK ran against a controlled homeserver. The fixture did not replace SDK results.

Test 1: Private cleanup failure ✅ A shared-state read returned 503 and deletion stopped before contacts or profile were deleted.

The request trace confirmed the 503; the inspector showed the session, two contacts, two overrides and sharing settings retained.

private-2x.2x.mp4

Private cleanup failure, at 2× speed.

Test 2: Unconfirmed contact removal ✅ An unconfirmed native write preserved both contacts and stopped profile deletion.

The request trace showed the write-ahead record accepted and shared-state publication rejected with 503. The inspector showed both contacts and overrides retained.

unknown-2x.2x.mp4

Unconfirmed contact cleanup, at 2× speed.

Test 3: Partial removal ✅ The retained contact and override survived and profile deletion did not begin.

A native public marker retained one contact; the inspector showed one contact and override remaining after the other was removed.

partial-2x.2x.mp4

Partial removal, at 2× speed.

Test 4: Profile deletion returns 404 ✅ Failure preserved the session and sharing settings.

The request trace showed profile GET 200 followed by DELETE 404. Previously acknowledged contact removals remained applied.

profile404-2x.2x.mp4

Rejected profile deletion, at 2× speed.

Test 5: Cancel and normal restart ✅ The normal app restored the same profile without replaying deletion during the observed startup.

After an unconfirmed contact write and Cancel, normal startup restored the identity; the request trace showed no profile DELETE during the observation.

normal-2x.2x.mp4

Cancel and normal startup, at 2× speed.

Test 6: Recovery after connection failure ✅ A second deletion completed after connectivity returned without an app relaunch.

The fixture showed failure before cleanup, then the success route and cleared session and contacts after the connection was restored.

refusal2-2x.2x.mp4

Connection interruption and recovery, at 2× speed.

Test 7: Successful deletion with a saved contact ⏭️ The normal app completed deletion, but contact presence immediately before the attempt was not established.

The trace showed profile DELETE 204 and the normal app opened the profile introduction. The earlier unconfirmed contact write could have reconciled on restart. A separate fixture run started with two contacts and reached its success route after cleanup, but it does not prove the normal-app precondition.

success-2x.2x.mp4

Normal-app deletion, at 2× speed.

recovery-2x.2x.mp4

Fixture cleanup and success, at 2× speed.

The earlier authenticated review covered these fault paths at a062618; the Paykit dependency changed since then. The later device report covers private cleanup and restart at 7e172c6. Current-head hosted tests and formatting passed.

Reviewed the deletion interactions and scanned the main production changes in a062618..c18e747; this did not exhaust every changed test, journey, localization, or dependency implementation. The device cases did not exercise the unrelated merged USDT, backup, or payment changes.

Twin: bitkit-android#1690 matches the stop-on-incomplete-cleanup behavior; local finalization differs.

Test fixture

Not pushed. Provisions disposable identities and observes native calls without replacing their results.

diff --git a/Bitkit/BitkitApp.swift b/Bitkit/BitkitApp.swift
index 961935aa..12b4fcb1 100644
--- a/Bitkit/BitkitApp.swift
+++ b/Bitkit/BitkitApp.swift
@@ -184,7 +184,9 @@ struct BitkitApp: App {
     var body: some Scene {
         WindowGroup {
             #if DEBUG
-                if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
+                if ProcessInfo.processInfo.arguments.contains("-profile-cleanup-probe") { // test hook
+                    ProfileCleanupProbeView() // test hook
+                } else if ProcessInfo.processInfo.arguments.contains("-contact-import-ui-test") {
                     ContactImportUITestFixture()
                 } else if ProcessInfo.processInfo.arguments.contains("-profile-recovery-ui-test") {
                     ProfileRecoveryUITestFixture()
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 7be9cb33..1bc3ad58 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -1847,9 +1847,22 @@ actor PaykitSdkService {
     private nonisolated static func config() throws -> PaykitSdkConfig {
         var config = try Paykit.defaultConfig(appId: "bitkit")
         config.publicContactSharing = .privateOnly
+        #if DEBUG
+            if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+                config.publicContactSharing = .enabled // test hook: permit native marker provisioning
+            }
+        #endif
         return config
     }
 
+    #if DEBUG
+        func reviewPublishContact(_ key: String) async throws { // test hook: stage real native retained contact
+            try await withStateRevisionTracking { sdk in // test hook
+                _ = try await sdk.publishPublicContact(publicKey: key) // test hook
+            }
+        }
+    #endif
+
     nonisolated static var clientID: String {
         switch Env.network {
         case .bitcoin: "bitkit.to"
diff --git a/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
new file mode 100644
index 0000000..20f6834
--- /dev/null
+++ b/Bitkit/Utilities/Testing/ProfileCleanupProbe.swift
@@ -0,0 +1,145 @@
+#if DEBUG
+import Paykit
+import SwiftUI
+
+// test hook: provisions disposable native data and observes real SDK operations; no result is replaced.
+@MainActor
+final class ProfileCleanupProbe: ObservableObject {
+    static let shared = ProfileCleanupProbe()
+    @Published var status = "Preparing disposable identity"
+    @Published var reads = 0
+    @Published var removals = 0
+    @Published var deletes = 0
+    @Published var evidence = ""
+    @Published var owner = ""
+    @Published var ready = false
+
+    func note(_ message: String) {
+        Logger.info("CLEANUP_PROBE \(message)", context: "ProfileCleanupProbe")
+    }
+}
+
+// test hook: all deletion decisions remain in EditProfileView and the existing managers.
+struct ProfileCleanupProbeView: View {
+    @StateObject private var probe = ProfileCleanupProbe.shared
+    @StateObject private var profile = PubkyProfileManager(profileDeleter: { key in
+        await MainActor.run {
+            ProfileCleanupProbe.shared.deletes += 1
+            ProfileCleanupProbe.shared.note("profile delete entered")
+        }
+        try await PubkyService.deletePaykitProfile(expectedIdentity: key)
+    })
+    @StateObject private var contacts = ContactsManager(contactRecords: {
+        await MainActor.run {
+            ProfileCleanupProbe.shared.reads += 1
+            ProfileCleanupProbe.shared.note("contact read entered")
+        }
+        return try await PubkyService.contactRecords()
+    }, removeContactRecords: { keys in
+        await MainActor.run {
+            ProfileCleanupProbe.shared.removals += 1
+            ProfileCleanupProbe.shared.note("contact remove entered count=\(keys.count)")
+        }
+        let removed = try await PubkyService.removeContacts(publicKeys: keys)
+        await MainActor.run { ProfileCleanupProbe.shared.note("contact remove returned count=\(removed.count)") }
+        return removed
+    })
+    @StateObject private var app = AppViewModel()
+    @StateObject private var navigation = NavigationViewModel()
+
+    var body: some View {
+        VStack(spacing: 4) {
+            CaptionText(probe.status).accessibilityIdentifier("CleanupStatus")
+            CaptionText("Reads \(probe.reads), removals \(probe.removals), deletes \(probe.deletes)")
+                .accessibilityIdentifier("CleanupCounters")
+            CaptionText(probe.evidence).accessibilityIdentifier("CleanupEvidence")
+            Button("Inspect evidence") { inspect() }.accessibilityIdentifier("CleanupInspect")
+            if probe.ready {
+                NavigationStack(path: $navigation.path) {
+                    EditProfileView()
+                        .navigationDestination(for: Route.self) { _ in CaptionText("Deletion success route") }
+                }
+            }
+        }
+        .environmentObject(profile)
+        .environmentObject(contacts)
+        .environmentObject(app)
+        .environmentObject(navigation)
+        .environment(KeyboardManager()) // test hook: normal Edit Profile environment dependency
+        .preferredColorScheme(.dark)
+        .task { await provision() }
+    }
+
+    private func inspect() {
+        let overrides = ContactsManager.backupContactProfileOverrides()?.count ?? 0
+        let publicSharing = UserDefaults.standard.bool(forKey: PublicPaykitService.publishingEnabledKey)
+        let privateSharing = UserDefaults.standard.bool(forKey: PrivatePaykitService.publishingEnabledKey)
+        probe.evidence = "Session \(profile.currentSession == nil ? "absent" : "retained"); contacts \(contacts.contacts.count); overrides \(overrides); sharing \(publicSharing)/\(privateSharing); deleting \(profile.isDeletingProfile); route \(navigation.path.count)"
+        probe.note(probe.evidence)
+    }
+
+    private func provision() async {
+        guard !probe.ready else { return }
+        probe.note("setup e2e=\(Env.isE2E) localHost=\(Env.pubkyLocalTestnetHost ?? "nil")") // test hook
+        do {
+            if ProcessInfo.processInfo.arguments.contains("-prepare-wallet"), try Keychain.loadString(key: .bip39Mnemonic(index: 0)) == nil { // test hook
+                _ = try StartupHandler.createNewWallet(bip39Passphrase: nil) // test hook: disposable unfunded wallet for normal AppScene startup
+            }
+            if ProcessInfo.processInfo.arguments.contains("-resume-cleanup") { // test hook: invoke real startup without reseeding evidence
+                await profile.initialize()
+                guard profile.currentSession != nil else { throw PubkyServiceError.sessionNotActive }
+                let records = try await PubkyService.contactRecords()
+                contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+                probe.owner = profile.publicKey ?? ""
+                probe.status = "Restored through normal profile initialization"
+                inspect()
+                probe.ready = true
+                probe.note("resumed owner=\(probe.owner)")
+                return
+            }
+            if ProcessInfo.processInfo.arguments.contains("-new-cleanup-identity") { // test hook: discard failed test provisioning only
+                await PaykitSdkService.shared.clearState()
+                try Keychain.delete(key: .pubkySecretKey)
+                try Keychain.delete(key: .paykitSession)
+            }
+            let key: String
+            if let stored = try Keychain.loadString(key: .pubkySecretKey) {
+                key = stored
+                _ = try await PubkyService.signIn(secretKeyHex: key)
+            } else {
+                var bytes = [UInt8](repeating: 0, count: 32)
+                guard SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes) == errSecSuccess else { return }
+                key = bytes.map { String(format: "%02x", $0) }.joined()
+                try Keychain.upsert(key: .pubkySecretKey, data: Data(key.utf8))
+                let homeserver = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "00", count: 32))
+                _ = try await PubkyService.signUp(secretKeyHex: key, homeserverZ32: homeserver)
+                let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+                try await PubkyService.publishPaykitProfile(Paykit.PaykitProfile(displayName: "Disposable cleanup identity", imageUri: nil, extraJson: nil), expectedIdentity: publicKey)
+                let peerKeys = try ["11", "22"].map { try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: $0, count: 32)) }
+                _ = try await PubkyService.saveContacts(updates: peerKeys.enumerated().map { ContactUpdate(publicKey: $0.element, label: "Peer \($0.offset + 1)") }, expectedIdentity: publicKey)
+            }
+            let publicKey = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: key)
+            profile.publicKey = PubkyPublicKeyFormat.normalized(publicKey) ?? publicKey
+            profile.authState = .authenticated
+            profile.profile = PubkyProfile(publicKey: profile.publicKey!, name: "Disposable cleanup identity", bio: "Local testnet", imageUrl: nil, links: [], status: nil)
+            if ProcessInfo.processInfo.arguments.contains("-retain-public-contact") { // test hook
+                let peer = try PubkyService.pubkyPublicKeyFromSecret(secretKeyHex: String(repeating: "22", count: 32))
+                try await PaykitSdkService.shared.reviewPublishContact(peer) // test hook: real native publication
+            }
+            let records = try await PubkyService.contactRecords()
+            contacts.contacts = records.map { PubkyContact(publicKey: PubkyPublicKeyFormat.normalized($0.publicKey) ?? $0.publicKey, profile: .placeholder(publicKey: $0.publicKey)) }
+            ContactsManager.restoreContactProfileOverrides(Dictionary(uniqueKeysWithValues: contacts.contacts.map { ($0.publicKey, PubkyProfileData.from(profile: $0.profile)) }))
+            UserDefaults.standard.set(true, forKey: PublicPaykitService.publishingEnabledKey)
+            UserDefaults.standard.set(true, forKey: PrivatePaykitService.publishingEnabledKey)
+            probe.owner = profile.publicKey!
+            probe.note("ready owner=\(probe.owner) contacts=\(records.count)")
+            probe.status = "Authenticated local testnet identity"
+            inspect()
+            probe.ready = true
+        } catch {
+            probe.status = "Provisioning failed: \(error)"
+            probe.note(probe.status)
+        }
+    }
+}
+#endif
private: [{'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/profile.json', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/profile.json', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143485, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143518, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143518, 'delayed_ms': 1, 'held': False, 'method': 'GET', 'owner': 'gytpba9586axacqcbg9k9dkxsq78t9ju75db9ognzfh5a47smsfo', 'path': '/pub/paykit/v0/shared-state.bin', 'status': 503}]
unknown: [{'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143564, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143585, 'delayed_ms': 1, 'held': False, 'method': 'PUT', 'owner': '9czuate5go5f58m8twfq475od78t9yuofajbcaa67e33ycy1zzqo', 'path': '/pub/paykit/v0/shared-state.bin', 'status': 503}]
profile404: [{'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143683, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143684, 'delayed_ms': 1, 'held': False, 'method': 'DELETE', 'owner': 'j44b3gpywszx59pq7pn8m619h1x7suapega41p4fs8sm3ouoexao', 'path': '/pub/paykit/profile.json', 'status': 404}]
normal: [{'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143980, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793143981, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}]
success: [{'at': 1793144015, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'DELETE', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/profile.json', 'status': 204}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144016, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}, {'at': 1793144017, 'delayed_ms': 0, 'held': False, 'method': 'GET', 'owner': '7j4ekku4586o98o6pwyxsh4uitb84cwmunazkwa9b7ifj6h3w5yy', 'path': '/pub/paykit/v0/shared-state-writes/', 'status': 404}]


Reviewed by gpt-6.1-terra-high via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full PR at c18e747 after the merge base changed since the saved assessment. No inherited coverage. Merge base is 162ea52.

No new actionable code findings.

Incomplete private-endpoint withdrawal, a failed contact read, a partial contact removal, and an unconfirmed profile-deletion error stop Edit Profile deletion before sign-out. Contacts and overrides that were not acknowledged as removed stay in place. Confirmed profile absence still completes: PaykitSdkService.deletePaykitProfile returns when fetchPaykitProfile is nil, and sign-out follows that return. Cancel and a later cold start leave profile deletion unstarted. Retry and Sign out use the session that is current when they are tapped. That ownership gap remains bitkit-android#1687.

Parity with bitkit-android#1690 at 2daec90 matches on those stop and preserve outcomes. After the remote contact list is fully removed, or the read succeeds and is empty, iOS clears remaining local-only contact state before profile deletion; Android removes only the acknowledged remote contacts. bitkit-ios#1122 at 162ea52 was checked only for the no-replay contract: deleteProfileWithSessionRetry is absent, and profile deletion still runs from the Edit Profile confirmation and its Retry action.

Validation: inspected PubkyProfileManagerTests.swift and ContactsManagerTests.swift and did not execute them. SwiftFormat and unit tests passed at this revision.

Device testing: not performed in this review. A simulator pass at this revision reports the failure and recovery cases; contact presence immediately before the successful deletion was not established. An earlier partial journey pass at 7e172c6 covers actions 1–3, 7 and 8 only.

Ready for device testing

Suggested additional test cases

  • iOS simulator. Disposable signed-in profile, one saved contact still present, private sharing on, and no homeserver fault. Confirm deletion from Edit Profile. Expected: no failure dialog, the session ends, the app opens profile choice or intro, and that contact is gone.

Reviewer model: grok-4.7
Reasoning effort: high

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of c26d7b13 against merge base f3df6cda after the comparison base changed. The complete PR diff and affected callers/tests were inspected; no coverage was inherited.

No new actionable code findings.

Cleanup failures stop subsequent deletion, and unacknowledged contacts/overrides remain available. Confirmed profile absence still completes through the service's optional profile result. Targeted comparisons with Android #1690 at 2daec90 and iOS #1122 at f3df6cda checked cleanup/error propagation and the absence of automatic deletion replay; neither comparison was a separate full review. Paykit contracts were checked at 01de5dd.

Existing concern: Retry and Disconnect still use the sign-in current when tapped; the ownership gap tracked in #1687 remains outside this repair.

Validation: hosted tests and SwiftFormat passed on merge 3d572813, containing the exact reviewed head/base. Logs confirm the new cleanup-retention and deletion-acknowledgement cases passed. Locally, diff whitespace checks and journey XML validation passed; native tests were inspected but not executed because Xcode/Swift are unavailable.

Device testing: not performed in this review. Earlier fault-injection results at c18e747 remain revision-bound; J1 needs its controlled proxy on this composition.

Suggested additional test cases

  • iOS: use a disposable authenticated profile with private sharing enabled and one saved contact confirmed present immediately before deletion, with no injected fault. Delete from Edit Profile; expect the session and contact to clear, profile choice/intro navigation, and no failure dialog.

Reviewer model: gpt-6.1-sol
Reasoning effort: high

@ben-kaufman
ben-kaufman requested a review from jvsena42 October 28, 2026 19:12
@ovitrif ovitrif modified the milestones: 3.1.1, 3.1.0 Oct 29, 2026
@ovitrif

ovitrif commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

@ben-kaufman Moving this PR to 3.2.0 per the 3.1.0 cut line (https://synonymworkspace.slack.com/archives/C07BJ7DNPCG/p1793307568629859): the restore and profile-deletion stacks harden behaviour that already shipped in 3.0, so they keep landing on master and ship in 3.2.

@ovitrif ovitrif modified the milestones: 3.1.0, 3.2.0 Oct 29, 2026

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for b14d5a1: iPhone 17 simulator, iOS 26.5; Paykit 0.1.1-rc2 and bitkit-core 0.8.0-rc11. A debug fixture provisioned disposable authenticated identities, encrypted links and accepted native subscriptions. The actual EditProfileView and native SDK handled removal; no deletion decision or SDK result was substituted.

Fixed-term subscriptions show their latest expiry ✅ Deletion is refused with the later expiry date, before any cleanup.

Native setup logs confirmed a payee subscription ending 7 November and a payer subscription ending 14 November. The warning showed “Expires 14 November 2026”. The recorded inspector confirmed zero private-withdrawal, contact-read, contact-removal and profile-delete entries, with the session, three local contacts and overrides, and both sharing settings retained.

finite2x.2x.mp4

Latest finite expiry and retained state, at 2× speed.

Open-ended subscriptions suppress expiry guidance ✅ Adding an open-ended subscription removes the date while continuing to refuse cleanup.

Native setup logs confirmed an accepted open-ended payer subscription alongside both fixed-term subscriptions. The warning contained no expiry date or generic Retry dialog. The recorded inspector showed all cleanup counters still at zero, with the session, existing local contacts and overrides, and sharing settings retained. Native contact storage also retained the newly provisioned peer.

mixed2x.2x.mp4

Mixed subscription terms and refusal without a date, at 2× speed.

Check: Native regressions and translations ✅ The unmodified head builds and the affected suites pass.

xcodebuildmcp simulator build built the pinned head. xcodebuild test passed PaykitSdkIdentityCheckTests, PubkyProfileManagerTests and ContactsManagerTests, including finite/unknown expiry and failed/cancelled subscription reads. SwiftFormat passed the seven PR Swift files; translation validation and diff whitespace checks passed. The test log retains a QoS warning and AppIntents metadata notices. The full suite was not rerun locally.

Other J1 cleanup, Cancel/restart and healthy fresh-confirmation cases were not repeated: I relied on the author’s current-head authenticated acceptance report. The previous authenticated review at a2b2186 supplies the unavailable-subscription-state and transport-recovery results; their failed-read propagation and explicit retry behavior are unchanged. These are attributed results, separate from the two device cases above. Complete app runtime logs from this run were not retained; the recordings, snapshots and captured native setup observations support these two results.

Reviewed the 108-file contiguous a2b2186..b14d5a1 delta, focusing on expiry guidance, reached deletion callers and interactions with merged parent changes. The device cases did not exercise unrelated inherited payment, backup or CI behavior. Malformed expiry and alternate manager entry points have source/unit coverage; Ring unlink was not driven separately. Original-attempt Retry/Disconnect ownership and cross-device conditional cleanup remain explicitly deferred by this PR.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup at 815e85c matches subscription refusal and latest-expiry selection; iOS also checks empty strict contact batches, and local finalization differs.


Reviewed by gpt-6.1-sol-high

@ovitrif ovitrif left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

utAck

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Full reassessment of the complete PR diff at b14d5a1 against merge base bee8975a, after the comparison base changed from 940d7f3 to bee8975 and no coverage baseline was supplied. No coverage was inherited.

No new actionable code findings.

Edit Profile deletion and unlink refuse an active payer, payee, or contactless subscription before private-endpoint withdrawal. The same active-recurring check runs again at strict contact removal and profile deletion. The expiry line is the latest finite end only when every blocking record has a parsed end; an open-ended or unparseable end stays a neutral refusal. A failed withdrawal, contact read, or partial acknowledgement stops sign-out and leaves unacknowledged contacts and overrides in place. A thrown profile-deletion error, including 404 text, keeps the session and sharing flags. Confirmed absence still completes when profile fetch returns nil.

The shared cleanup and subscription contract matches bitkit-android#1690 at 815e85c0, including journeys/profile/profile-cleanup-failure.xml. iOS also checks an empty contact batch and repeats the check inside profile deletion. bitkit-ios#1122 at bee8975 was inspected for deletion admission and that confirmed-absence path.

Non-blocking consistency suggestions. iOS formats the refusal expiry as a long local date with the time omitted, matching the existing iOS contact-deletion toasts. Android’s profile toast uses toLocalizedTimestamp, which includes the time. Both platforms still block until the exact end instant.

Validation was source inspection of the pinned diff, callers, and unit tests. Those tests were not executed. Device testing: not performed in this review. Safe manual Retry/Disconnect after uncertain cleanup remains bitkit-android#1687.

Ready for device testing.

Reviewer model: grok-4.7
Reasoning effort: high

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Re-reviewed to b14d5a1. No new findings.

a2b218613 and 6665a4431 refuse profile removal while a recurring subscription is active (endsAt null, unparseable or in the future). This applies as payer and payee, with or without a contact. The toast shows the latest end date only when every blocker has a parsable end. Open-ended subscriptions can be ended in-app by either role, and wallet wipe doesn't go through this check. The rule matches synonymdev/bitkit-android#1690. The only merge with a conflict, 2df5bad1, resolved a test-file conflict by keeping both tests.

My approval is pending a device run of the new behaviour.

Device gate (partial): b14d5a1, not run yet. Pending: profile-cleanup-failure.xml with two linked wallets (actions 1–3), and actions 4–11 with the homeserver fault proxy. l10n: profile__delete_active_subscription changed in every locale.

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for de683f3:

Check: Cleanup and queued publication regressions ✅ The unmodified head builds and the focused native suites pass.

Built with xcodebuildmcp simulator build and ran the selected xcodebuildmcp simulator test suites on an iPhone 17 simulator, iOS 26.5. Cleanup failures, subscription refusal, SDK queue admission, session replacement, public/private reconciliation and payment-request regressions passed without failures or skips. The native result and raw log confirm the pass, including one method omitted from the wrapper’s case list. SwiftFormat 0.64.0, translation validation and diff whitespace checks passed. QoS runtime warnings and an AppIntents metadata notice remain in the evidence; no warning-free claim. The full hosted suite was still running when this review ended.

Check: Earlier profile-cleanup device results ✅ The delta preserves the paths covered by the passing J1 reports.

No device journey was repeated. EditProfileView, both cleanup managers, the subscription/contact-removal checks, the journey and the native dependency pins are unchanged from b14d5a1. The private-cleanup change forwards a session check to app registration, which profile deletion skips through deferAppSync: true; the SDK revision-tracking extraction preserves admission and error propagation.

The b14d5a1 device report proves latest finite expiry and open-ended refusal. The authenticated a2b2186 report covers payer/payee refusal, unavailable subscription state, private withdrawal failure, unknown/partial contact removal, rejected profile deletion, Cancel/restart and fresh-confirmation recovery. The saved-contact success report and the author’s authenticated acceptance report supply the normal-app success and later J1 matrix evidence. These remain attributed results at their stated revisions, not new device passes on de683f3.

Reviewed the contiguous b14d5a1..de683f3 delta, concentrating on strict deletion’s interaction with the merged parent’s SDK admission and endpoint reconciliation. Unrelated inherited payment, backup and CI changes were not driven on devices. Alternate manager entry points and malformed expiry retain source/unit coverage; Ring unlink was not separately driven. Original-attempt Retry/Disconnect ownership and cross-device conditional cleanup remain explicitly deferred.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup at 7ef3432 matches subscription refusal, latest-expiry selection and stopping on incomplete cleanup. iOS additionally checks empty strict contact batches; local finalization differs.


Reviewed by gpt-6.1-sol-high

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for dd180c6:

Check: Cleanup, recovery and SDK admission regressions ✅ The unmodified head builds and the selected native suites pass.

Built with xcodebuildmcp simulator build and ran xcodebuildmcp simulator test on iPhone 17 / iOS 26.5 with Xcode 27.0. Cleanup, subscription refusal, profile recovery, SDK queue/session admission, private/public reconciliation, allowance admission and send lifecycle checks passed without failures or skips. The native result bundle and raw execution log agree. The new pending-profile recovery and queued-acceptance controls passed. SwiftFormat 0.64.0, translation validation, journey XML parsing and diff whitespace checks passed. QoS runtime warnings and AppIntents metadata notices remain in the evidence. The full local unit/UI and integration suites were not rerun.

Check: Earlier profile-cleanup device results ✅ The delta preserves the paths covered by the passing J1 reports.

No device journey was repeated. The deletion workflow, strict contact/subscription checks, private cleanup, journey and native pins are unchanged. The profile recovery change suppresses remote refetch only while profile setup is pending; it leaves restoration after cleanup failure intact.

The finite/open-ended subscription report, authenticated cleanup and recovery report and normal-app saved-contact deletion report cover J1 and manual cases 1–2. These remain attributed device results at their stated revisions, rather than fresh device passes on dd180c6.

Reviewed the contiguous de683f3..dd180c6 delta and reached cleanup/session callers. The child patch is unchanged against the updated parent. Inherited allowance-toggle, Request Payment and USDT presentation changes received source inspection; no device case drove them. Ring-only unlink and alternate manager entry points retain source/unit coverage. Original-attempt Retry/Disconnect ownership and cross-device conditional cleanup remain explicitly deferred.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup at 7ef3432 matches subscription refusal, expiry selection and stopping after incomplete cleanup. iOS also checks empty strict batches; local finalization differs.


Reviewed by gpt-6.1-sol-high

@jvsena42 jvsena42 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Device gate result. Code findings from my review at b14d5a1 still apply; the PR-owned diff is unchanged at dd180c6 (the delta is parent and master merges only).

Device gate: dd180c6 — profile/profile-cleanup-failure.xml on iPhone 17 and iPhone 17e simulators, two linked wallets (A payee, B payer) with an active open-ended monthly subscription paid for one period, homeserver faults through a local TLS-passthrough proxy. perf: n/a. l10n: profile__delete_active_subscription checked in en, de, el, fr, es, ru and pt-BR on iPhone 17; every string wraps to two lines with no truncation.

  • Action 1, refusal with an active subscription: pass for the open-ended case, as payee and as payer. The toast reads "Active subscriptions prevent removing this profile." and nothing after requireNoActiveSubscriptions runs.
  • Action 2, no Retry dialog and no cleanup: pass. No private list clearing, contact removal or profile deletion; both wallets keep their contacts.
  • Action 3, deletion after ending the subscription: pass. After B cancelled (detail still shows "Active, EXPIRES 1 December"), deletion ran the full cleanup through sign-out.
  • Action 4, fault on before deletion: pass. It fails after the subscription check with acquire Pubky write lock (connection failure).
  • Action 5, failure dialog: pass. Retry, Disconnect and Cancel; no success toast and no navigation.
  • Action 6, private withdrawal failure: pass. Deletion stops at list pending Pubky shared-state writes; no contact or profile deletion follows.
  • Action 9, uncertain profile deletion: pass for the transport-loss variant. The DELETE reached the server and its response was dropped. The app showed the failure dialog, kept the session and the sharing toggles, and replayed nothing after relaunch.
  • Action 10, Cancel: pass. No second deletion in the following 40 s.
  • Action 11, restart: pass. Two relaunches restored the session with no deletion lines in 70 s.

Skipped:

  • ⚠️ Action 7 (unknown contact removal): not run. The re-created profile only had one-sided contacts, so the private stage stopped first with privateUnavailable. It needs a linked counterparty online, and no third device was free.
  • ⚠️ Action 8 (partial contact removal): not run. removeContactsAndBlockPeers is one SDK write, so a partial acknowledgement cannot be produced from the network.
  • ⚠️ The finite-end, mixed and unparseable subscription cases, and the date line subscriptions__expires_date: not run. Bitkit's create form always sends endsAt: nil and cancelling sets .canceled, so the app cannot reach them. They need a third-party issuer.
  • ⚠️ A literal "404 Not Found" response on profile deletion was not forged (TLS); only the dropped-response variant ran.
  • ⚠️ The subscription "without a contact" case: not run. Contact deletion is blocked while a subscription with that contact is active, so I found no UI path to that state.

@piotr-iohk piotr-iohk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

QA review

Scope: Re-reviewed the full PR at dd180c6 against merge base a6647db2 because the comparison base changed from bee8975 to a6647db. No earlier coverage was inherited.

No new actionable code findings.

Edit Profile deletion now checks for active subscriptions before it withdraws private endpoints. The check covers both payer and payee roles and subscriptions with no saved contact. It runs again during strict contact removal and inside deletePaykitProfile, so a known subscription can no longer cause its contact to be silently filtered out while other contacts are deleted. Failures in private-endpoint withdrawal, the contact read, or partial contact removal now stop the flow before profile deletion. In each case the session stays signed in, and contacts and overrides that were not confirmed as removed stay in place. A profile-deletion error, including one with 404 text, keeps the session and sharing settings. Confirmed absence still signs out: at the pinned paykit-rs 01de5dd2, fetch_paykit_profile returns nil for a missing profile, and contact_records only reads local storage, so it has no 404 path. That makes the removed missing-data fallbacks unnecessary in supported flows. The refusal toast is localized in all 15 locales.

The cleanup and subscription contract matches the twin bitkit-android#1690 at 9bf19f19, including journeys/profile/profile-cleanup-failure.xml. The dependency bitkit-ios#1122 has moved to 2f80b445. Its change since this PR's base only adds an admission hook to allowance acceptance and does not overlap this PR's hunks or deletion contracts. Only that compatibility was checked; #1122 was not fully reviewed.

Validation: I inspected the pinned diff, its callers, the SDK source and the updated unit tests. I did not run the tests myself. The current-head Run Tests and SwiftFormat checks passed. Device testing was not performed in this review. A reviewer reports a device gate at dd180c6 that covered most journey actions. It skipped unknown or partial contact removal, finite or unparseable subscription ends, and a literal 404 response. Making a manual Retry/Disconnect safe after an uncertain cleanup is tracked separately in bitkit-android#1687.

Reviewer model: claude-opus-5-5
Reasoning effort: high

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for 3991f3a:

Check: Cleanup, queued allowance and channel-closure regressions ✅ The unmodified head builds and the focused native suites pass.

Built with xcodebuildmcp simulator build and ran selected xcodebuildmcp simulator test suites on iPhone 17 / iOS 26.5 with Xcode 27.0. Cleanup, subscription refusal, SDK queue/session admission, allowance acceptance, receive-invoice publication and payment-proof checks passed without failures, skips or test retries. The native result bundle and raw execution log agree. The queued-offer pause/reactivation regression and last-channel public-invoice withdrawal regression passed, as did the three updated billing-period cases.

SwiftFormat 0.64.4, diff whitespace and journey XML checks passed. QoS runtime warnings and AppIntents metadata notices remain in the evidence. The full local unit/UI and integration suites were not rerun. These are component checks, not new authenticated device journeys.

Check: Earlier profile-cleanup device results ✅ Previously tested cleanup paths are unchanged.

No cleanup journey was repeated. The complete cleanup patch is unchanged against the updated parent, as are the deletion workflow, strict contact/subscription checks, private cleanup, journey and native dependency pins. I retained these attributed results at their recorded revisions:

Together these cover J1 and manual cases 1–2. Unknown/partial contact removal, finite/mixed terms, literal DELETE 404 and contactless subscriptions skipped in the latest report have earlier passing reports above; the skips themselves are not passes. Ring-only unlink, malformed expiry and alternate manager entry points retain source/unit coverage rather than separate device claims.

Reviewed the complete eight-file contiguous dd180c6..3991f3a delta and reached cleanup, SDK admission and publication callers. The inherited allowance and channel-closure changes received source/component coverage; no new device journey drove them. Original-attempt Retry/Disconnect ownership and cross-device conditional cleanup remain explicitly deferred.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup at 7040c89 matches subscription refusal, latest-expiry selection and stopping after incomplete cleanup. iOS also checks empty strict batches; local finalization differs. This was a contract comparison, not a full twin review.


Reviewed by gpt-6.1-sol-high

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for ce0784b:

Check: Profile cleanup and backup completion unit suites ✅ The head builds and 279 of 279 selected tests pass, with none skipped.

Ran xcodebuild build-for-testing and test-without-building at the unmodified head on iPhone 17 / iOS 26.5. The PR's suites (PubkyProfileManagerTests, ContactsManagerTests, PaykitSdkIdentityCheckTests) and the two suites the delta changed (BackupForegroundRecoveryTests, BlocktankBackupTests) ran 279 tests: 279 passed, 0 failed, 0 skipped. Counts are from the result bundle, not the wrapper. One QoS priority-inversion runtime warning came from ContactsManagerTests. The full unit and UI suites were not rerun.

Check: Earlier profile-cleanup device results ✅ This push leaves the cleanup code those reports drove unchanged.

I didn't repeat a device journey. After dropping blob ids and hunk offsets, the PR's own 24-file patch is byte-identical against 5a5044a at 3991f3a and against 595898e at ce0784b. The delta changes only BackupService (scheduled-job removal now runs in the same backup-queue step that publishes idle; Blocktank capture reads cached data). ContactsManager, PubkyProfileManager, PubkyService and EditProfileView never reference BackupService, and no code outside it reads backup job state (checked with grep). The same normalized patch is also identical at b14d5a1. The only PR-owned commit between a2b2186 and b14d5a1, 6665a44, changes only the refusal's expiry date and its strings, which the b14d5a1 report drove; the contact-removal and profile-DELETE steps are unchanged since a2b2186. These reports cover every profile-cleanup-failure.xml action and manual cases 1–2:

The author's J1 matrix at b14d5a1, in the PR description, also reports partial and unknown contact removal and DELETE 404 at that patch.

Ring-only unlink, malformed expiry and alternate manager entry points still have source and unit coverage only.

Reviewed the 3991f3a..ce0784b delta: two merges of #1122 (which carries #1123's scheduling fix), with five files changed, all in the parent. No device case drove the parent's backup scheduling or Blocktank capture change; its unit suites passed above. Run Tests CI at ce0784b was still pending when this review was written; SwiftFormat passed. Original-attempt Retry/Disconnect ownership and cross-device conditional cleanup remain deferred, as before.

Twin: bitkit-android#1690 fix: stop deletion after incomplete cleanup moved from 7040c89 to 2e79e10 through master and parent merges only, with no cleanup files changed. It still matches subscription refusal, latest-expiry selection and stopping after incomplete cleanup.


Reviewed by claude-opus-5-5-medium

@ovi-reviewer ovi-reviewer Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verdict: ✅ Approve

Findings: None.

QA for 21d6a22:

Profile copy and QR copy through the system Paste button fixture ✅ The ProfileRecovery UI test passes with the new system Paste button.

Ran ProfileRecoveryUITests on iPhone 17 / iOS 26.5 at the head: 1 of 1 passed. The video shows the fixture's system Paste button reading not-copied after Reset copy, then the copy toast and the pasted profile key, then the failed-load and Try Again states. This answers whether the PasteButton is reachable by its accessibility identifier: it is.

profile-recovery-paste-ui.2x.mp4
Check: Profile cleanup and backup retry unit suites ✅ The head builds and 318 of 318 selected tests pass, with none skipped.

Ran xcodebuild build-for-testing and test-without-building at the head on iPhone 17 / iOS 26.5. This covers the PR's suites (PubkyProfileManagerTests, ContactsManagerTests, PaykitSdkIdentityCheckTests) and the backup suites, including the new BackupServiceTests cases for auth send-failure retry and permission-denied no-retry. Result: 318 passed, 0 failed, 0 skipped. The counts come from the result bundle. The full unit and UI suites were not rerun.

Check: Earlier profile-cleanup device results ✅ This push leaves the cleanup code those reports drove unchanged.

I didn't repeat a device journey. The PR's own patch against b44f025 is identical, line for line, to its patch against 595898e at ce0784b. The delta is exactly #1122's change to four files: BackupService (classifying auth send failures), the DEBUG-only profile recovery fixture and their tests. The attributed device reports listed in the ce0784b review still apply at their recorded revisions.

Not driven: the cleanup journeys (profile-cleanup-failure.xml). This push leaves their code unchanged, and earlier reports cover every action in them. Ring-only unlink, malformed expiry and alternate manager entry points still have source and unit coverage only. Retry/Disconnect ownership of the original attempt and cross-device conditional cleanup are still deferred.

Reviewed the ce0784b..21d6a22 delta. It is #1122's auth transport retry, and those lines belong to #1122's diff, not this PR's. One note for #1122: any AuthError that matches error sending request for url (…) is retried at a capped backoff, and that includes TLS errors. This matches how ConnectionError is handled. The backup retry classification ran only in unit tests, not on a device.

Twin: bitkit-android#1690 at 2e79e10: its own patch is identical, line for line, to the one at 7ef3432 that earlier reviews matched, so it still matches.


Reviewed by claude-opus-5-5-medium

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.

5 participants