Skip to content

Architecture refactor: encapsulation + testability across Core / TapToPay#16

Merged
thejohnnybot merged 66 commits into
mainfrom
worktree-assessment+swift-library-architecture
May 11, 2026
Merged

Architecture refactor: encapsulation + testability across Core / TapToPay#16
thejohnnybot merged 66 commits into
mainfrom
worktree-assessment+swift-library-architecture

Conversation

@thejohnnybot
Copy link
Copy Markdown
Contributor

@thejohnnybot thejohnnybot commented May 6, 2026

Summary

  • Documents and resolves an end-to-end architecture audit of the SDK against authoritative Swift library guidance (Apple's API Design Guidelines, SE-0386 package access, WWDC19 #410, swift-nio / swift-log / swift-argument-parser conventions). Findings + decision-ready proposals live in Documentation/ArchitectureAssessment-2026-05-06.md; the bite-sized implementation plan lives in Documentation/Plans/2026-05-06-architecture-refactor.md.
  • Implements every actionable finding (17 of 19; F8 and F17 explicitly deferred in the assessment, F18 skipped because PaymentCardReader.isSupported is load-bearing) and all five proposals, with xcodebuild test green after every commit.
  • Establishes a single shared session backbone (PayabliSession), a transport seam (PayabliTransport + AuthenticatedTransport decorator) so bearer-header injection and 401-refresh-and-retry happen in one place, and a real PayabliSDKTestUtils library product (modeled on NIOTestUtils / swift-log's InMemoryLogging) so host apps and future modules can depend on the SDK's stub fixtures instead of re-implementing them.

Highlights of the structural change

  • Core gains every cross-module primitive: RetryPolicy, generic EventMulticaster<Event>, PayabliTransport protocol, AuthenticatedTransport decorator (internal), PayabliSession, mapPayabliHTTPError free function, and PayabliAuth.tokenChanges() AsyncStream.
  • PayabliConfig is now a value type. PayabliSession is the identity-bearing object that wires one PayabliAuth + one PayabliService for sharing across components — when PayIn re-lands from develop it constructs from the same session.
  • No more open-coded 401 retry in TapToPay. tryUpdate calls session.transport.perform(...) and the decorator handles the refresh-and-retry once; double-401 throws .tokenExpired. AppAttestService follows the same path (no manual bearer injection).
  • Public surface tightened: SessionManager, SessionTierValidator, FiservCardReader.Credentials, AppAttestService hardware-id providers, PayabliService.makeDefaultSession, AppAttestService+Defaults providers, AuthenticatedTransport, KeychainStorage non-protocol methods, and PayabliEnvironment.local (gated behind #if DEBUG) all demoted from public to internal (or DEBUG-only).
  • PayabliSDKTestUtils shipped as a real library product. Six fixtures (StubURLProtocol, InMemorySecureStorage, MockAppAttestor, MockTapToPayProvider, MockDeviceAttestationService, InMemoryTelemetryTransport) consolidated into Sources/PayabliSDKTestUtils/, with a smoke-test bundle. All mock state is now NSLock-synchronized so the @unchecked Sendable conformances are honest.
  • @testable import hygiene: 12 of 23 test files no longer need @testable after public-surface tightening; remaining 11 keep it for documented internal-access reasons.
  • Dead code removed: TapToPayProviderFactory (no production caller), the duplicated service/auth ivars on PayabliTTP, the Locked/UncheckedSendableBox types extracted from the facade into a dedicated file.
  • Module versions now read from Bundle(for:).infoDictionary["CFBundleShortVersionString"] with "0.0.0" fallback — no more "1.0.0" literal drift.

Resolution table

Full mapping of every finding/proposal to its commit SHA(s) is at the bottom of Documentation/ArchitectureAssessment-2026-05-06.md under "Resolution status". Of the three follow-ups originally noted there, two are closed in this PR (AppAttestService+Requests transport-seam migration; AuthenticatedTransport demoted to internal). The remaining follow-up — PayabliSession.auth/.servicepackage access — stays as a separate PR since it requires verifying package works across test bundles.

API surface changes

The two consumer-facing inits on PayabliTTP (the accessToken:-based convenience init and the config:-based init) keep their signatures verbatim. The @objc convenience init chain is intact. The config: init is now a convenience init that builds a PayabliSession and delegates; the new session: init is the designated init.

Three accidentally-public methods on KeychainStorage (data(forKey:), set(_ data:forKey:), removeAll()) are demoted to internal. They were never documented in the README, never part of the SecureStorage protocol, and existed only because the original struct didn't tighten its access modifiers. The audit (Finding 19e) recommended trimming them. Anyone reaching directly into KeychainStorage for raw Data storage rather than through the SecureStorage protocol would need to switch to the protocol. No documented consumer is affected.

Test plan

  • CI: xcodebuild test -scheme PayabliSDK-Package -destination 'platform=iOS Simulator,name=iPhone 17 Pro,OS=26.4.1' — passes locally on every commit; verify clean run on the GitHub Actions runner.
  • Smoke: build a host app against this branch and confirm import PayabliSDKTapToPay plus the existing PayabliTTP(accessToken:tokenProvider:entryPoint:appId:environment:) init compile unchanged.
  • Smoke: inspect Documentation/ArchitectureAssessment-2026-05-06.md resolution table and confirm each row's commit SHA exists on this branch (git show <sha>).
  • Spot check: read Sources/PayabliSDKCore/Public/PayabliSession.swift and Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift end-to-end — these are the new architectural seams.
  • Decide: PayabliSession.{auth, service} visibility (only remaining open follow-up).

🤖 Generated with Claude Code

Copilot AI review requested due to automatic review settings May 6, 2026 13:03
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR implements a cross-module architecture refactor to improve encapsulation and testability across PayabliSDKCore and PayabliSDKTapToPay, introducing a shared PayabliSession backbone and a transport seam (PayabliTransport + AuthenticatedTransport) while consolidating reusable test fixtures into a shipped PayabliSDKTestUtils product.

Changes:

  • Introduces PayabliSession (Core) and a transport decorator to centralize bearer-header injection and 401 refresh-and-retry behavior.
  • Moves/generalizes shared primitives into Core (e.g., generic EventMulticaster, transport-level RetryPolicy) and updates TapToPay clients to use the transport seam.
  • Adds PayabliSDKTestUtils as a real SPM product and migrates common fixtures from test bundles into it; updates tests/import hygiene accordingly.

Reviewed changes

Copilot reviewed 63 out of 63 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
Tests/PayabliSDKTestUtilsTests/PayabliSDKTestUtilsTests.swift Adds smoke tests for the new PayabliSDKTestUtils product.
Tests/PayabliSDKTapToPayTests/TapToPayProviderFactoryTests.swift Removes tests for the deleted provider factory.
Tests/PayabliSDKTapToPayTests/SecureStorageTests.swift Updates tests to use fixtures from PayabliSDKTestUtils.
Tests/PayabliSDKTapToPayTests/PayabliTTPTests.swift Updates tests to use fixtures from PayabliSDKTestUtils.
Tests/PayabliSDKTapToPayTests/PayabliTTPSessionInitTests.swift Adds coverage for shared-session facade initialization.
Tests/PayabliSDKTapToPayTests/PayabliTTPEventCodeMappingTests.swift Drops @testable usage for public mapping test.
Tests/PayabliSDKTapToPayTests/MockTapToPayProvider.swift Deletes in-test mock moved to PayabliSDKTestUtils.
Tests/PayabliSDKTapToPayTests/MockAppAttestor.swift Deletes in-test mock moved to PayabliSDKTestUtils.
Tests/PayabliSDKTapToPayTests/EventMulticasterTests.swift Deletes TapToPay-local multicaster tests (now in Core).
Tests/PayabliSDKTapToPayTests/AppAttestServiceTests.swift Updates tests to use fixtures from PayabliSDKTestUtils.
Tests/PayabliSDKCoreTests/TelemetryClientTests.swift Updates version assertion and uses in-memory telemetry transport fixture.
Tests/PayabliSDKCoreTests/StubURLProtocol.swift Deletes in-test StubURLProtocol moved to PayabliSDKTestUtils.
Tests/PayabliSDKCoreTests/RetryPolicyTests.swift Updates tests for Core retry primitives and stronger error assertions.
Tests/PayabliSDKCoreTests/PayabliTransportTests.swift Adds compile-time conformance test for the transport seam.
Tests/PayabliSDKCoreTests/PayabliSessionTests.swift Adds tests for PayabliSession construction and URLSession injection.
Tests/PayabliSDKCoreTests/PayabliServiceTests.swift Updates tests to use fixtures from PayabliSDKTestUtils.
Tests/PayabliSDKCoreTests/PayabliSDKCoreTests.swift Drops @testable import where not needed.
Tests/PayabliSDKCoreTests/PayabliErrorCodeMappingTests.swift Adds validation error code mapping test coverage.
Tests/PayabliSDKCoreTests/PayabliEnvironmentTests.swift Gates .local assertions behind #if DEBUG.
Tests/PayabliSDKCoreTests/PayabliAuthTests.swift Adds coverage for tokenChanges() stream emission.
Tests/PayabliSDKCoreTests/EventMulticasterTests.swift Adds tests for the new generic Core multicaster.
Tests/PayabliSDKCoreTests/AuthenticatedTransportTests.swift Adds tests for auth injection + 401 refresh-and-retry decorator behavior.
Sources/PayabliSDKTestUtils/StubURLProtocol.swift Promotes StubURLProtocol to public test utility with body-stream draining.
Sources/PayabliSDKTestUtils/PayabliSDKTestUtils.swift Adds a TestUtils namespace + bundle-derived version accessor.
Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift Adds a public mock TapToPay provider fixture.
Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift Adds a public mock device attestation fixture.
Sources/PayabliSDKTestUtils/MockAppAttestor.swift Adds a public mock App Attest fixture.
Sources/PayabliSDKTestUtils/InMemoryTelemetryTransport.swift Adds an in-memory TelemetryTransport fixture.
Sources/PayabliSDKTestUtils/InMemorySecureStorage.swift Adds an in-memory SecureStorage fixture.
Sources/PayabliSDKTelemetry/PayabliSDKTelemetry.swift Switches telemetry module version to bundle-derived value.
Sources/PayabliSDKTapToPay/TTPTransactionClient.swift Refactors to depend on PayabliTransport instead of raw service/auth.
Sources/PayabliSDKTapToPay/TTPConfigClient.swift Refactors to depend on PayabliTransport and shared HTTP error mapping.
Sources/PayabliSDKTapToPay/TapToPayProviderFactory.swift Removes unused provider factory implementation.
Sources/PayabliSDKTapToPay/SessionManager.swift Tightens visibility of SessionManager to internal.
Sources/PayabliSDKTapToPay/SecureStorage.swift Removes in-module in-memory storage; points tests to TestUtils fixture.
Sources/PayabliSDKTapToPay/PayabliTTP+Charge.swift Removes open-coded 401 retry; uses session transport for updates.
Sources/PayabliSDKTapToPay/PayabliTTP.swift Introduces session: designated init + uses shared session/transport; extracts ObjC helpers.
Sources/PayabliSDKTapToPay/PayabliSDKTapToPay.swift Switches TapToPay module version to bundle-derived value.
Sources/PayabliSDKTapToPay/KeychainStorage.swift Trims extra KeychainStorage methods from public surface.
Sources/PayabliSDKTapToPay/EventMulticasterAlias.swift Adds TapToPay alias to Core EventMulticaster<PayabliTTPEvent>.
Sources/PayabliSDKTapToPay/EventMulticaster.swift Removes TapToPay-local multicaster implementation (now in Core).
Sources/PayabliSDKTapToPay/DeviceAttestationService.swift Adds explicit initializer to AssertionHeaders.
Sources/PayabliSDKTapToPay/AppAttestService+Defaults.swift Demotes default hardware-id providers to internal visibility.
Sources/PayabliSDKTapToPay/AppAttestService.swift Splits init: public convenience + internal designated for provider overrides.
Sources/PayabliSDKTapToPay/Adapters/FiservCardReader.swift Tightens visibility of Credentials and setCredentials.
Sources/PayabliSDKTapToPay/_ObjCBridging.swift Extracts ObjC bridging helpers from the facade file.
Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift Removes Core in-memory transport; uses Core version for sdkVersion default.
Sources/PayabliSDKCore/Public/PayabliSession.swift Adds shared session backbone exposing auth/service and computed transport.
Sources/PayabliSDKCore/Public/PayabliEnvironment.swift Gates .local environment behind #if DEBUG.
Sources/PayabliSDKCore/Public/PayabliConfig.swift Converts PayabliConfig to a value type (struct).
Sources/PayabliSDKCore/PayabliSDKCore.swift Switches Core version to bundle-derived value.
Sources/PayabliSDKCore/Networking/RetryPolicy.swift Generalizes retry docs and fallback error type in Core.
Sources/PayabliSDKCore/Networking/PayabliTransport.swift Introduces the transport protocol seam.
Sources/PayabliSDKCore/Networking/PayabliService.swift Conforms to PayabliTransport and factors shared HTTP error mapping.
Sources/PayabliSDKCore/Networking/AuthenticatedTransport.swift Adds auth-injecting decorator with 401 refresh-and-retry behavior.
Sources/PayabliSDKCore/Models/PayabliError.swift Adds .validation error code and maps validation errors correctly.
Sources/PayabliSDKCore/Concurrency/EventMulticaster.swift Adds generic Core EventMulticaster<Event>.
Sources/PayabliSDKCore/Auth/SessionTierValidator.swift Demotes SessionTierValidator visibility to internal.
Sources/PayabliSDKCore/Auth/PayabliAuth.swift Adds tokenChanges() AsyncStream and emits on refresh.
Package.swift Adds PayabliSDKTestUtils product/target and wires tests to it.
Documentation/Plans/2026-05-06-architecture-refactor.md Adds detailed implementation plan document.
Documentation/ArchitectureAssessment-2026-05-06.md Adds architecture assessment + resolution status.
CLAUDE.md Codifies an “umbrella inclusion rule” for module packaging.
Comments suppressed due to low confidence (3)

Sources/PayabliSDKTapToPay/KeychainStorage.swift:42

  • Changing data(forKey:) from public to internal on a public type is a source-breaking change for any consumer calling it directly. If the goal is to tighten the public surface without breaking downstream code, consider keeping it public but deprecating it (and/or update the PR’s “No public-API breaks” statement / versioning).
    internal func data(forKey key: String) -> Data? {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key,

Sources/PayabliSDKTapToPay/KeychainStorage.swift:65

  • The set(_ data:forKey:) overload being demoted from public to internal is a source-breaking API change for any consumer that stored raw Data in KeychainStorage. If this is intended, it should be called out in release notes / semver; otherwise consider deprecating rather than removing public access.
    internal func set(_ data: Data, forKey key: String) throws {
        let baseQuery: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service,
            kSecAttrAccount as String: key

Sources/PayabliSDKTapToPay/KeychainStorage.swift:100

  • removeAll() was previously public and is now internal, which is a source-breaking change for any external cleanup code using it. If you need to hide it, consider a deprecation period or updating the PR/release messaging to reflect this public API removal.
    internal func removeAll() {
        let query: [String: Any] = [
            kSecClass as String: kSecClassGenericPassword,
            kSecAttrService as String: service
        ]

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

Comment on lines +25 to +29
/// Returns all recorded batches and clears the internal buffer.
public func drainBatches() -> [[TelemetryEvent]] {
let snapshot = batches
batches.removeAll()
return snapshot
Comment on lines +19 to +22
func testMockTapToPayProviderInitializes() {
_ = MockTapToPayProvider()
XCTAssertTrue(true)
}
Comment thread Package.swift Outdated
Comment on lines +118 to +119
"PayabliSDKTapToPay",
"PayabliSDKTelemetry"
Comment on lines +6 to +10
public final class MockTapToPayProvider: TapToPayProvider, @unchecked Sendable {
public static var providerId: String { "mock" }

public var eligibility: Result<Void, PayabliTTPError> = .success(())
public var prepareReaderResult: Result<Void, Error> = .success(())
Comment on lines +7 to +11
public final class MockDeviceAttestationService: DeviceAttestationService, @unchecked Sendable {
public var isAlreadyAttested: Bool = false
public var cachedDeviceId: String?
public var attestResult: Result<AttestationResult, Error> = .success(
AttestationResult(keyId: "mock_key", deviceId: "mock_device")
Comment on lines +6 to +10
public final class MockAppAttestor: AppAttestor, @unchecked Sendable {
public var isSupported: Bool = true
public var generatedKeyId: AppAttestKeyId = AppAttestKeyId("mock_keyId")
public var attestationPayload: AttestationObject = AttestationObject(Data("attest".utf8))
public var assertionPayload: AppAttestAssertion = AppAttestAssertion(Data("assert".utf8))
@thejohnnybot
Copy link
Copy Markdown
Contributor Author

Follow-up batch landed (7 commits on top of 4651fbe):

Item Commit What
1 32b0147 refactor(taptopay): route AppAttestService through PayabliTransport — closes the last transport-seam bypass; device-attestation requests now flow through AuthenticatedTransport (bearer injection + 401 refresh-and-retry + typed error mapping) like every other endpoint client.
2 d971ca5 refactor(core): make AuthenticatedTransport internal — concrete decorator type hidden; reachable only via PayabliSession.transport returning any PayabliTransport.
3 (no commit) Verified Example/PayabliDemo source uses only preserved public API (PayabliTTP(accessToken:...), PayabliTTPSessionState, PayabliTTPPaymentDetails, PayabliTTPEventToken). No demo changes required.
4 5ff4330 docs(core): add per-property docs to PayabliSessionconfig, auth, service get their own /// doc comments.
5 bf9e12b test(core): verify custom URLSession is actually wired through PayabliSession — replaces the previously-vacuous XCTAssertNotNil with a real StubURLProtocol round-trip.
6 6d71c65 style(core): reorder case validation in PayabliErrorCode — alphabetical placement after userCancelled.
7 5e342f6 chore(core): precondition RetryPolicy.maxAttempts >= 1 — guards against fatal range errors on 1...0.
7bd33c4 docs: mark AuthenticatedTransport visibility follow-up resolved — resolution table reflects the close.

Both originally-listed follow-ups (AppAttestService+Requests bypass, AuthenticatedTransport public) are now closed. The remaining follow-up — PayabliSession.auth/.servicepackage — stays a separate PR since it requires package adoption verification across the test bundles.

xcodebuild test green after every commit on the iOS Simulator scheme. CI rerunning on the new HEAD.

thejohnnybot and others added 26 commits May 6, 2026 11:07
Outputs from the architecture audit pass: the assessment document
(findings + decision-ready proposals) and the implementation plan
that turns those proposals into bite-sized, TDD-shaped tasks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RetryPolicy, Retry, and RetryableError are general-purpose networking
utilities with no TapToPay-specific behaviour. Moving them to
PayabliSDKCore/Networking/ makes them reusable by all modules and removes
the PayabliTTPError fallback throw, replaced with PayabliGenericError(.networkError).
Test coverage moved to PayabliSDKCoreTests accordingly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EventMulticaster is now generic over `Event: Sendable`, making it a
reusable fan-out primitive in PayabliSDKCore/Concurrency/. TapToPay
keeps a local typealias `TTPEventMulticaster = EventMulticaster<PayabliTTPEvent>`
and updates the one construction site in PayabliTTP. Core tests exercise
the generic via `EventMulticaster<Int>` and `EventMulticaster<String>`,
with no dependency on TapToPay types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code review on Phase 1 flagged that RetryPolicy's doc comment still
referenced a TapToPay-specific endpoint, that EventMulticaster's
public methods lost their doc comments during the generalization
move, and that the retry-exhaustion test wasn't asserting the
error type that propagates. All three are restored / tightened
without changing behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
SessionManager is an implementation detail of the PayabliTTP facade and
has no business appearing on the public API surface. Strip public from
the class, its @published properties, and its initializer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d internal init

Split the single 8-arg public init into a 4-arg public convenience init
(uses platform defaults) and an 8-arg internal designated init (used by
tests to inject deterministic hardware-id providers). Host apps and the
production convenience init in PayabliTTP both use the 4-arg surface;
test code reaches the internal init via @testable import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Credentials is a pure implementation detail — it's constructed from the
/config dict inside configure() and passed immediately into the Fiserv SDK.
Host apps never create or inspect Credentials directly, so making it
(and setCredentials) internal removes leakage of a low-level wire type
from the public surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on is real

The validator is a stub (always returns tier1Transactional). Exposing it
as public API would lock the detection contract before the JWT-claim path
is implemented in phase 2+ (§16.7). Internal access keeps it reachable
by TapToPay via @testable import without broadening the public surface.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
.local points at a developer ngrok tunnel and must never ship in a
release binary. Wrapping the case and its baseURL branch in #if DEBUG
ensures release (non-debug) builds cannot reference it while tests
(which are always DEBUG) continue to exercise it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PayabliValidationError wraps HTTP 400 server-side field errors — that is
semantically distinct from a JSON decoding failure. Add a .validation
case to PayabliErrorCode (raw value "VALIDATION_ERROR") and update
PayabliValidationError.code to return it. A new test asserts the mapping.
The existing .decodingError case is retained for genuine decode failures.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dging.swift

Both types exist solely to bridge ObjC completion blocks into Swift
CheckedContinuations in the @objc convenience init. Moving them out of
the 300-line PayabliTTP facade into a dedicated file gives each file a
single clear responsibility. No behaviour change; both types remain
internal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…local-env test

Code review on Phase 2 flagged two follow-ups:
- AppAttestService+Defaults.{hardwareId,deviceName,model,osVersion}
  were left public after the init split — they only need internal
  access since the convenience init forwards them within the module.
- PayabliEnvironmentTests referenced .local unconditionally; .local
  is now DEBUG-only, so the test file must wrap that assertion in
  #if DEBUG to keep release-config test builds compiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Class identity adds nothing — there's no inheritance, no shared
mutable state, no need for reference semantics. The @unchecked
Sendable opt-out hid the fact that PayabliTokenRefresh is already
@sendable, so a struct is naturally Sendable.

Audit found zero call sites comparing configs by reference; this
is a pure semantic change with no behavioural impact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Components now share a single PayabliAuth/PayabliService when
constructed with the same PayabliSession. The existing config:
convenience init is preserved (delegates to a fresh session)
so no public API breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lets future modules and telemetry observe token rotations. Each
subscriber gets an independent stream; continuations are cleaned
up automatically on iterator release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a `PayabliTransport` protocol with `perform(_:)` and `performV2(_:decoding:)`
requirements as a seam for decorators. `PayabliService` gains the conformance.
Proven by a compile-time test in `PayabliTransportTests`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-retry

Implements a `PayabliTransport` decorator that injects `Authorization: Bearer`
on every request and performs a single refresh-and-retry on 401. A second
consecutive 401 after refresh throws `.tokenExpired`. Tested with a `MockTransport`
actor for deterministic scripted responses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `PayabliSession.transport: any PayabliTransport` computed property that
wraps the session's `PayabliService` in an `AuthenticatedTransport`. Endpoint
clients consume this property instead of holding separate `service` + `auth`
pairs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the `service: PayabliService, auth: PayabliAuth` pair with a single
`transport: any PayabliTransport`. Removes the manual `Authorization` header
from `initiate(_:)` — bearer injection is now the transport's responsibility.
`PayabliTTP` passes `session.transport` at construction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces `service: PayabliService, auth: PayabliAuth` with a single
`transport: any PayabliTransport`. Removes the manual bearer merge —
AuthenticatedTransport injects the header. Attestation headers remain
inline (component-specific). HTTP 401 at the response layer is now handled
by the transport's refresh-and-retry; a body-level 401 (attestation revoked)
still propagates as `.tokenExpired`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds `let session: PayabliSession` ivar to `PayabliTTP` and uses
`session.transport` in `tryUpdate`. The manual token-fetch / 401-check /
invalidateAndRefresh / retry sequence is removed; `AuthenticatedTransport`
now owns that responsibility. The Retry loop still handles network-retryable
errors (5xx, timeouts) as before.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Error

Extracts the switch-on-status-code logic from `PayabliService.mapHTTPError` into
a public free function `mapPayabliHTTPError(response:override:)`. The `override`
parameter lets component clients supply status-code → domain-error translations
before falling through to the standard PRD §8 mapping.

`PayabliService.mapHTTPError` now delegates to the free function. `TTPConfigClient`
replaces its manual 403/non-2xx guard with a single `mapPayabliHTTPError` call
that overrides 403 → `PayabliTTPError.devicePendingActivation`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…performV2

Spec review on Phase 5 caught that AuthenticatedTransport.performV2
was decoding non-2xx responses straight into PayabliV2Envelope and
throwing .decodingError when that failed. PayabliService.performV2
calls mapPayabliHTTPError before decoding so 4xx/5xx surface as the
correct typed errors (.validation, .decline, .server, ...). This
restores the same behaviour through the decorator path used by
TTPTransactionClient.initiate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the transport-seam migration, PayabliTTP no longer routes
through `service`/`auth` — every consumer reaches them via
`session.transport` instead. The stored properties had become
dead weight that misleadingly suggested a second access path.

The session-sharing test now asserts shared `session` identity,
which is structurally equivalent (one PayabliSession owns one
auth) and keeps the proof point of the test intact.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The factory had no production caller — PayabliTTP wired
FiservCardReader() directly. Removing dead public surface is
cheaper than evolving it; reintroduce a registry once a real
second-provider use case materializes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thejohnnybot and others added 17 commits May 6, 2026 11:07
Moves Tests/PayabliSDKTapToPayTests/MockAppAttestor.swift into
Sources/PayabliSDKTestUtils/MockAppAttestor.swift and promotes all
declarations to public. AppAttestServiceTests already imports
PayabliSDKTestUtils from the StubURLProtocol step.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extracts MockTapToPayProvider from Tests/PayabliSDKTapToPayTests/MockTapToPayProvider.swift
into Sources/PayabliSDKTestUtils/MockTapToPayProvider.swift with all
declarations promoted to public. The test file is updated to retain only
MockDeviceAttestationService (moved in the next commit). Adds
import PayabliSDKTestUtils to PayabliTTPSessionInitTests.swift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…stUtils

Extracts MockDeviceAttestationService from Tests/PayabliSDKTapToPayTests/MockTapToPayProvider.swift
(which now no longer needs to exist) into
Sources/PayabliSDKTestUtils/MockDeviceAttestationService.swift with all
declarations public. Also adds a public memberwise init to AssertionHeaders
in DeviceAttestationService.swift, which lacked one (the auto-synthesized
init was internal, blocking compilation from the TestUtils module boundary).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Utils

Moves InMemoryTelemetryTransport out of Sources/PayabliSDKCore/Telemetry/TelemetryClient.swift
(where it was a test fixture embedded in production code) into
Sources/PayabliSDKTestUtils/InMemoryTelemetryTransport.swift. Adds
import PayabliSDKTestUtils to TelemetryClientTests.swift.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a PayabliSDKTestUtilsTests target with three smoke tests that verify
StubURLProtocol.makeSession() injects the stub protocol class,
InMemorySecureStorage round-trips set/get/remove, and MockTapToPayProvider
initializes without crashing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…age protocol

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d of hard-coding

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Append a Resolution status appendix to the assessment doc. 49 commits
on this branch implement all 5 proposals (A-E) and 17 of 19 findings;
F8 and F17 were deferred in the original assessment, F18 was skipped
because PaymentCardReader.isSupported is load-bearing for the import
the audit hypothesized was redundant. Three follow-ups surfaced
during code review (AppAttestService+Requests still bypasses the
transport seam; AuthenticatedTransport public visibility; package
access for PayabliSession.auth/service) are listed for a future pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The attestation HTTP path was the last consumer that constructed
its own Authorization header and called PayabliService.perform
directly, bypassing the AuthenticatedTransport decorator that
every other module now uses. Migrating it lands the full Phase 5
intent: every authenticated SDK request flows through one place
that handles bearer-header injection, 401 refresh-and-retry, and
typed HTTP error mapping.

AppAttestService now takes a `transport: any PayabliTransport`
in place of (service, auth). The host-facing convenience init on
PayabliTTP was already constructing AppAttestService internally,
so no public-API consumer is affected.

Closes follow-up #1 in the resolution table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Demote AuthenticatedTransport and its init(base:auth:) to internal.
The type is reachable from the public surface only via PayabliSession.transport
(which returns `any PayabliTransport`), so hiding the concrete type reduces
the public API footprint. Add @testable import PayabliSDKCore to
AppAttestServiceTests so the fixture helper can still construct the type.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Document the three public stored properties (config, auth, service) with
inline doc comments so Xcode Quick Help and generated documentation convey
each property's role and the recommended usage pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…iSession

Replace the trivially-passing XCTAssertNotNil on a non-optional property with
a real integration test: configure StubURLProtocol, pass a custom URLSession to
PayabliSession, perform a request via session.service, and assert the stubbed
status code and body are returned — proving the injected session is actually
used for outbound calls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Move case validation after userCancelled within the client-side block,
matching alphabetical order within the group (u < v). Previously it
appeared between decodingError and userCancelled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a precondition guard at the top of RetryPolicy.init so that passing
maxAttempts: 0 (or negative) traps immediately with a clear message rather
than crashing inside Retry.run with a fatal 1...0 range error. Add a
positive test confirming maxAttempts == 1 constructs without trapping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closed by the demotion in commit d971ca5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thejohnnybot thejohnnybot force-pushed the worktree-assessment+swift-library-architecture branch from 7bd33c4 to b616a67 Compare May 6, 2026 15:08
thejohnnybot and others added 8 commits May 6, 2026 11:12
The original resolution table was committed before this branch was
rebased against main's "README adjustments" commit. The rebase
rewrote every SHA on this branch; the table is now updated to
reference the current commits on the rebased history.

39 SHA references remapped by commit message; baseline reference
to `180dff6` (on main) preserved. No content changes beyond the SHAs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add PayabliSDKTestUtils to the Modules table — Phase 8 of the
  refactor shipped it as a real library product (mirroring NIOTestUtils
  and InMemoryLogging).
- Add a one-paragraph note in Installation showing how to link the
  test-utils product from a host app's testTarget, plus the list of
  fixtures it ships.
- Fix broken anchor link: the `entryPoint` parameter row referenced
  #payabli-entry-point but the section heading is "Payabli entrypoint"
  (one word), so the GitHub-generated anchor is #payabli-entrypoint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Refactor-caused updates:
- Module count is now five (added PayabliSDKTestUtils library product
  in Phase 8); add a dedicated section for it.
- PayabliSDKCore section now lists the new types: PayabliSession,
  PayabliTransport / AuthenticatedTransport, RetryPolicy / Retry,
  EventMulticaster<Event>, mapPayabliHTTPError, and PayabliAuth's
  tokenChanges() AsyncStream.
- PayabliSDKTapToPay description no longer claims to host retry
  policy or EventMulticaster — both moved to Core in Phase 1.
- Auth flow now mediated by AuthenticatedTransport; updated wording
  so contributors look there first instead of in each endpoint client.
- Sources/ folder rules row mentions PayabliSDKTestUtils.
- Testing section reflects the new test-utils library product and
  the @testable cleanup from Phase 7.

Pre-existing inaccuracies cleaned up while in the file:
- Build commands now say up-front that swift build/test fail on
  macOS for this package (ProximityReader is iOS-only); show the
  xcodebuild invocations CI uses.
- TTP state-machine list had six wrong state names (attesting,
  reading, charged, declined, cancelled, reinitialized, ...);
  replaced with the actual nine cases per SessionManager.
- Platform-gating note no longer claims macOS builds compile
  without TTP — they don't.
- Reference section says Example/PayabliDemo needs Secrets.swift
  (the file that actually exists), not Config.xcconfig.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…servable contract

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… NSLock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e behind NSLock

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thejohnnybot
Copy link
Copy Markdown
Contributor Author

Addressed @copilot-pull-request-reviewer's findings (5 commits on top of c38cc06):

Comment Status Commit
Package.swift — TestUtils depends on PayabliSDKTelemetry but never uses it ✅ Fixed c5a05ae (drop the dependency)
PayabliSDKTestUtilsTests.swiftXCTAssertTrue(true) is vacuous ✅ Fixed 0d3f2f7 (assert providerId == "mock")
MockAppAttestor.swift@unchecked Sendable + unsynchronized state ✅ Fixed 80467c4 (NSLock around all mutable state + counters)
MockTapToPayProvider.swift — same ✅ Fixed 3315518
MockDeviceAttestationService.swift — same ✅ Fixed cd0999e
InMemoryTelemetryTransport.swiftdrainBatches() should be async ❌ Declined InMemoryTelemetryTransport is a public actor; actor methods auto-async at the call site. Marking the declaration async would suggest the implementation suspends when it doesn't. The call site already requires await.
KeychainStorage.swift — three demotions are source-breaking ⚠ Acknowledged The three methods (data(forKey:), set(_ data:forKey:), removeAll()) were trimmed deliberately per audit Finding 19e — they were never documented, never in the SecureStorage protocol, and not advertised as the host-app surface. PR description updated to call this out explicitly under "API surface changes."

Tests green after each commit.

@thejohnnybot thejohnnybot merged commit 9801da2 into main May 11, 2026
7 checks passed
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.

2 participants