[PLA-2184] Android - P5.1 Card-present session state machine and /config/{entry} - #49
Conversation
…rvice client
`/config/{entry}` returns the card reader's credentials, and it is the only way
a device that was attested on an earlier run learns it still owes an activation
code. It was left out of this client because nothing consumed the credentials
until the card-reader work.
It is the first GET in the family and the first route whose path is not its own
template, so `post` splits into request assembly plus a shared `read` that both
verbs delegate to. The three-step ordering contract moves onto `read` unchanged.
`{entry}` names a merchant, so `route` carries the template and the resolved path
is never the loggable form, and an entry that is not one path segment is refused
before anything is sent.
A device the service does not hold as active is refused two ways: an envelope 403
inside a 200, and a real 403 from the gateway when the caller's token is not
scoped for the route. The gateway's arrives before any controller runs, so
`PayabliHttpErrors` classifies it first; `read` takes a status override that maps
it to the same failure the envelope produces, and a caller branches once.
The credentials are a typed class. Two of the ten fields are the reader vendor's
API secrets, and a string map prints every value it holds into any message built
from it.
…erialize its entry points Nine states with a table of legal moves, one writer, and a coordinator whose three entry points never overlap. The module could attest a device and spend an activation code; nothing sequenced those into a session. No mutator returns a value a caller can drop. `advance` moves the state and then runs the work under it, so entering a phase without announcing it cannot be expressed, and a refused move throws before the work is reached. The sibling SDK returns a boolean that every one of its call sites discards, and after an expiry its narrow table refused every move while each phase ran and reported success. Building a session therefore starts from the beginning whatever the caller left behind. A failure names its reason, over a closed set of four remedies. Without one a host cannot tell a discarded identity from a misconfigured paypoint and has to assume the most expensive repair. Same-kind callers join the run in flight and are given its outcome; a different kind waits and then runs, because repairing a session skips attestation and building one does not. Exclusion and joining are separate mechanisms: one mutex holds the region, one claim slot deduplicates, and removing either leaves the other's tests green. Activation runs inside the region too, since it moves the same state and a code spent against a handle a concurrent registration has replaced is a code wasted. Cleanup runs uncancellable. A claim left set with nobody to complete it wedges every later caller of its kind, and a joiner is given a withdrawal rather than the owner's cancellation, which would make its own scope look like it is unwinding while nothing has cancelled it. `fromstate` and `tostate` join the loggable field names: a record of a refused transition that names one end of the move says nothing about why it was refused.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a Tap to Pay session state machine and coordinator flow, including a /config device-service call for reader credentials, with comprehensive tests around transitions, warm start behavior, and concurrency serialization.
Changes:
- Introduces
TapToPaySessionState, transition rules (TapToPaySessionTransitions), and a single-writerTapToPaySessionManager. - Adds
TapToPaySessionCoordinatorto serialize/merge concurrent session entry points (initialize / repair / activate) and standardize failure landings. - Extends
DeviceServiceClientwith/config/{entry}GET + wire models (ConfigResponse,ReaderCredentials) and updated fixtures/tests.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionCoordinator.kt | Orchestrates session lifecycle with mutual exclusion and “join same kind” semantics |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionManager.kt | Single-writer state publisher enforcing transition table |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionTransitions.kt | Centralized legality table for state transitions |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionState.kt | Defines the session states and diagnostic names |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionFailures.kt | Maps failures to end states/recovery hints |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionException.kt | Defines session-level exception vocabulary |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPayFailureReason.kt | Defines host-facing failure remedies |
| taptopay/src/main/java/com/payabli/sdk/taptopay/session/ReaderProvider.kt | Introduces abstraction for reader configuration/preparation |
| taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceServiceClient.kt | Adds /config/{entry} GET route and path-segment validation |
| taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceWireFormat.kt | Adds config response + strongly typed, redacted reader credentials |
| taptopay/src/main/java/com/payabli/sdk/taptopay/enrollment/DeviceEnrollment.kt | Adds assertion() helper for warm-start credential fetch |
| taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceServiceException.kt | Updates documentation for forbidden cases and decode failures |
| core/src/main/java/com/payabli/sdk/core/logging/impl/LoggableFieldNames.kt | Whitelists fromstate/tostate log fields |
| taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPayTransitionMatrixTest.kt | Exhaustive transition-matrix test for the legality table |
| taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPaySessionManagerTest.kt | Tests manager publishing/refusal semantics |
| taptopay/src/test/java/com/payabli/sdk/taptopay/session/SessionWarmStartTest.kt | Verifies warm-start sequences don’t re-attest and behave correctly |
| taptopay/src/test/java/com/payabli/sdk/taptopay/session/SessionSerializationTest.kt | Tests exclusion/join behavior for concurrent entry points |
| taptopay/src/test/java/com/payabli/sdk/taptopay/session/SessionFixture.kt | Shared session fixture wiring enrollment/client/reader fakes |
| taptopay/src/test/java/com/payabli/sdk/taptopay/enrollment/EnrollmentFixture.kt | Adds config response body + config route constant + shared client |
| taptopay/src/test/java/com/payabli/sdk/taptopay/attestation/device/DeviceServiceConfigTest.kt | Tests /config request shape, decoding, redaction, and refusal mapping |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…a build One slot held whichever kind claimed it last, so three callers arriving as build, repair, build left the repair in the slot when the second build looked. That build then started a second run of work already in flight: with the region serializing them it ran the whole sequence again rather than joining. A claim per kind closes it. Publishing the claim only after acquiring the region would also close the reported case and leaves a window between acquiring and publishing, and cannot join a run that is queued but has not started. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…t raises them The KDoc named `NotRecoverable` for a device whose identity is gone. That is the refusal for a state a repair cannot start from. A device whose stored record is absent starts from a state that is repairable, gets as far as fetching the credentials, and fails with `AttestationRequired`. Both are now stated, and a test pins the one that was undocumented, so the sentence cannot drift from the code again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s that walk them all Two copies stood in two files. A copy that loses a member narrows whatever it feeds without failing anything, and the size assertion guarding one of them guarded only that copy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The queued-repair test holds both the region and the claim slot, so the note claiming every test separates cleanly stopped being true when that test landed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (2)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionManager.kt:64
- The failure message uses
state.valueafterwrite(to)returns false. Under concurrent callers (this class explicitly doesn’t serialize phases), another successful transition could updatestatebetween the refusal decision and message construction, producing an incorrectfromstate in the exception message. Consider returning the decidedfromstate fromwrite(e.g., a small result object{ from, permitted, published }) or providing awriteOrThrow(to)helper that capturesfromunder the same lock and uses it in the thrown message.
check(write(to)) {
// A defect in this SDK's own sequence rather than anything a host did, so it is not part of the
// failure vocabulary a caller handles. Both names are from the fixed state vocabulary.
"a session cannot move to ${to.diagnosticName} from ${state.value.diagnosticName}"
}
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionCoordinator.kt:202
TapToPaySessionState.AttestingDeviceis documented as “Skipped by a warm start” (inTapToPaySessionState.kt), butrunInitialize()always advances intoAttestingDevicebefore callingenrollment.enroll(), including warm-start cases where no attestation runs. Either update the state documentation to match the observed behavior, or adjust the coordinator/enrollment API so the session only entersAttestingDevicewhen attestation is actually performed (e.g., split enrollment into a warm-start check phase vs attestation phase, or introduce a separate state that accurately represents ‘enrollment’ even when attestation is skipped).
private suspend fun runInitialize() {
manager.reset()
val outcome = manager.advance(TapToPaySessionState.AttestingDevice) { enrollment.enroll() }
if (outcome is EnrollmentOutcome.Attested && outcome.activationRequired) {
// Registration already said so, so there is nothing to learn from asking for the credentials.
throw TapToPaySessionException.PendingActivation()
}
bringReaderUp()
}
…nfig route No code changed. The comments carried the argument around the fact: a paragraph defending each choice against an alternative nobody proposed, and a sentence naming what some other code would have done. The constraint stays, the argument goes. The gating half of the comment check was already clean. The loose half reported thirty blocks in the session package; four remain, and each is one the rule keeps: two name what a guard catches, one is a definition, and one states why a retained state holds an enum instead of a throwable that would carry a cause chain. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 20 out of 20 changed files in this pull request and generated no new comments.
Suppressed comments (2)
taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPaySessionManagerTest.kt:134
- The tests cancel the collector but don’t wait for it to actually stop, which can make
seennondeterministic/flaky (especially withDispatchers.Unconfined). Prefercollector.cancelAndJoin()(orcancel(); join()) before asserting, and consider usingUnconfinedTestDispatcher(testScheduler)instead ofDispatchers.Unconfinedto keep execution tied to the test scheduler while retaining 'immediate' behavior.
fun `a refused move is never briefly published`() =
runTest(timeout = TEST_TIMEOUT) {
val seen = mutableListOf<TapToPaySessionState>()
// An immediate dispatcher, so a collector would resume inside the write if one were made.
val collector = launch(Dispatchers.Unconfined) { manager.state.collect { seen += it } }
manager.advance(TapToPaySessionState.FetchingConfig)
runCatching { manager.advance(TapToPaySessionState.Ready) }
manager.invalidate()
collector.cancel()
assertEquals(
listOf(TapToPaySessionState.Idle, TapToPaySessionState.FetchingConfig),
seen,
)
}
@Test
fun `re-entering a state publishes nothing`() =
runTest(timeout = TEST_TIMEOUT) {
val seen = mutableListOf<TapToPaySessionState>()
val collector = launch(Dispatchers.Unconfined) { manager.state.collect { seen += it } }
manager.advance(TapToPaySessionState.FetchingConfig)
manager.advance(TapToPaySessionState.FetchingConfig)
collector.cancel()
assertEquals(listOf(TapToPaySessionState.Idle, TapToPaySessionState.FetchingConfig), seen)
}
taptopay/src/test/java/com/payabli/sdk/taptopay/session/SessionSerializationTest.kt:135
- After
owner.cancel(), the test immediately proceeds without ensuring the owner coroutine has fully completed its cancellation path (including releasing claims/locks and settling state). Usingowner.cancelAndJoin()(orowner.cancel(); owner.join()) before asserting/joining the joiner will reduce timing sensitivity and potential flakiness in this serialization test.
val owner = launch(UnconfinedTestDispatcher(testScheduler)) { fixture.coordinator.initialize() }
var joinerFailure: Throwable? = null
val joiner =
launch(UnconfinedTestDispatcher(testScheduler)) {
try {
fixture.coordinator.initialize()
} catch (failure: TapToPaySessionException) {
joinerFailure = failure
}
}
owner.cancel()
completing("the joining build") { joiner.join() }
The thrown message read `state.value` after `write` returned. That read happens outside the monitor the decision was made under, so a concurrent write lands between the two and the message names a state that had nothing to do with the refusal. `write` now returns the state it decided against, and one `writeOrThrow` builds the message from it. No caller reads the state a second time. Test: none. The window is between a returned value and a lambda evaluation, and no deterministic interleaving reaches it. The second read is gone rather than guarded. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The state claimed a warm start skips it. Building a session always advances into it and then calls enrollment, which reads the stored record and decides for itself whether the cold sequence is needed, so a warm start enters the state and leaves it without a round trip. A repair is the one that never enters it. Swept the other three warm-start sentences in the package; each describes where the device learns it owes an activation code, which is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sserting on what they left Two shapes, both timing-sensitive. The collector tests cancelled the collector and asserted on the list it fills without waiting for it to stop. The withdrawal test cancelled the owner and asserted on the state and the claim slot without waiting for the cancellation path, which releases the claim and settles the state under `NonCancellable`. `cancelAndJoin` in both. The collectors also move from `Dispatchers.Unconfined` to `UnconfinedTestDispatcher(testScheduler)`, which keeps the immediate resumption the assertion depends on and ties it to the test scheduler. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…y host-facing remedy It had no direct test and carried 69 of the 91 uncovered new lines and branches on this branch, which is what put new coverage under the gate at 79.7%. It is also the file where being wrong is least visible: a failure that lands on the wrong reason sends a host down a repair that cannot work, and every landing looks plausible from the call site. A table with one row per branch, and two invariants read back from the classifier rather than from the table: which failures ask a host to discard the device identity, and which leave the session alone. Derived from the expectations instead, those two would assert the table against itself and pass with any production mapping. Sabotage: mapping a not-found to the identity landing fails both the table and the discard invariant. The file now reports no missed lines and no missed branches. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (3)
taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPayTransitionMatrixTest.kt:37
- In Kotlin, ALL_CAPS names are conventionally reserved for
const valcompile-time constants. Since this is a runtimeval(and aFailed(...)instance), rename it to something likefailedInternal/internalFailureStateto reduce confusion and keep naming conventions consistent.
private val FAILED = Failed(TapToPayFailureReason.INTERNAL)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionManager.kt:90
reset()is described as 'the first act of building a session' and depends onIdlebeing universally reachable. Callingwrite()here silently logs-and-drops if the transition table ever regresses, which can make failures harder to diagnose (the build would proceed from the wrong state). Consider usingwriteOrThrow(TapToPaySessionState.Idle)here so a broken invariant fails fast and points directly at the defect.
fun reset() {
write(TapToPaySessionState.Idle)
}
taptopay/src/main/java/com/payabli/sdk/taptopay/attestation/device/DeviceServiceClient.kt:257
- The
require(...)message is a bit underspecified for debugging (it doesn’t indicate what character set is allowed). Since the entry value itself must not be echoed, consider expanding the message to include the allowed shape (e.g., 'unreserved RFC3986 characters: [A-Za-z0-9._~-]') or otherwise clarify what inputs are acceptable without printing the merchant identifier.
private fun pathSegment(entry: String): String {
require(PATH_SEGMENT.matches(entry)) { "entry must be usable as a single path segment" }
return entry
}
`reset` dropped the result of its write. Starting over is reachable from every state, so a refusal means the table is broken, and the build that follows would run every phase from a state nobody expects while reporting success. That is the shape this type exists to prevent, left open at the one call site that opens every build. It throws now, like both `advance` overloads. `settle` and `invalidate` still log and return: one is reached from a failure path where a throw would replace the caller's failure, and the other can lose a race legitimately. Test: none added. The matrix test already asserts every state reaches `Idle`, so the invariant is guarded; this makes the call site say so rather than continue. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is refused The refusal said an entry must be usable as a single path segment without saying what that allows, so a caller reading it learns only that their value is wrong. It now names the character set, which is safe to state and is what the caller needs. The value stays out of the message: an entry point identifies a merchant. Matches how the activation code states its own shape. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (2)
taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPayTransitionMatrixTest.kt:37
FAILEDis easy to confuse with theFailedstate type and also hides that it’s specifically the INTERNAL failure instance used as a representative in the table. Rename to something explicit likeFAILED_INTERNAL(orINTERNAL_FAILURE_STATE) to make the intent and equality semantics (data-class reason) clearer.
private val FAILED = Failed(TapToPayFailureReason.INTERNAL)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionTransitions.kt:55
- Each call allocates a new Set via
setOf(...). Ifpermits()is called frequently (e.g., on every state write and in logging paths), this creates avoidable allocations. Consider returning precomputed constant sets (e.g.,private val FROM_IDLE = setOf(...)) while keeping the exhaustivewhenfor compile-time coverage.
private fun reachableFrom(from: TapToPaySessionState): Set<TapToPaySessionState> =
when (from) {
Idle -> setOf(AttestingDevice, FetchingConfig)
AttestingDevice -> setOf(FetchingConfig, PendingActivation)
FetchingConfig -> setOf(InitializingReader, PendingActivation)
InitializingReader -> setOf(Ready)
Ready -> setOf(SessionExpired)
// Only into a re-initialization. Reaching config directly from here would skip the state that
// says a repair is under way, and that state is what a host shows.
SessionExpired -> setOf(Reinitializing)
Reinitializing -> setOf(FetchingConfig)
// The device owes a code. Confirming it puts the session back through attestation, since the
// service issues the credentials only to an active device.
PendingActivation -> setOf(AttestingDevice)
is Failed -> setOf(AttestingDevice, FetchingConfig)
}
…lure type it instantiates `FAILED` and `Failed` differ only in case and appeared on the same line of the table, where one is the state type and the other is the one instance the rows use. The naming rule here forbids two names a reader can confuse. `FAILED_INTERNAL` also says which instance it is, which the table depends on: the state is a data class, so equality includes the reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionManager.kt:148
TapToPaySessionManagerlogs a new field nameerrorkind, butLoggableFieldNamesonly addsfromstate/tostatein this PR. If the logging layer filters/validates field names againstLoggableFieldNames, this field may be dropped or could cause validation failures. Consider addingerrorkindtoLoggableFieldNames(or reusing an existing allowed field name) so failure-reason logging behaves consistently.
logger.info(
LogField.safe("event", "ttp_session_state"),
LogField.safe("state", to.diagnosticName),
LogField.safe("errorkind", (to as? TapToPaySessionState.Failed)?.reason?.name),
) { "session state changed" }
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionCoordinator.kt:150
- On a
Throwablethat is not anException(e.g.,OutOfMemoryError), the claim is released but the session state is not settled back toIdle(unlike theCancellationExceptionbranch). That can leave the system in a mid-phase state indefinitely and change the behavior of subsequent calls (e.g., repairs/refusals based on a stale state). Consider mirroring theCancellationExceptionbehavior by settling toIdleunderNonCancellable(or otherwise ensuring state is left in a safe baseline) before releasing the claim.
} catch (fatal: Throwable) {
// An OutOfMemoryError reaches the caller unchanged. The claim is still released, or every later
// caller of this kind waits for something that will never complete.
release(claim, TapToPaySessionException.SetupAbandoned())
throw fatal
}
…s on `INTERNAL` was an adjective with no noun, and the ambiguity ran the wrong way: an HTTP 500 is called an internal server error and lands on `SERVICE_UNAVAILABLE`, so a reader meeting `INTERNAL` had to guess which side it meant, and the wrong guess picks the wrong remedy. Its three siblings are conditions on their own and carry no suffix. This one is not, which is the same split the core error codes already make: `SERVER_ERROR` and `DECODING_ERROR` carry it, `PERMISSION_DENIED` and `RATE_LIMITED` do not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…n an SDK defect `DeviceEnrollment.assertion()` rethrows `DeviceKeyException.KeyLost` after discarding the record, and fetching the credentials calls it on every build and every repair. The classifier had no branch for that type, so a device whose Keystore key was wiped was told the SDK had an internal defect, which is the wrong remedy for the one condition attesting again fixes. All three key failures are mapped rather than the one reported, so a fourth fails to compile here. Only the lost key discards an identity: a signature that failed and a platform without crypto leave the key where it was, so neither is the positive match this landing requires. Swept the other exception reaching this classifier from the coordinator. `SecureStorageException` also falls to the default, and lands where it should: storage the device cannot use is not something a host repairs. Sabotage: mapping the lost key to the SDK-defect landing fails the table and the discard invariant, and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated no new comments.
Suppressed comments (2)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionCoordinator.kt:151
- In the
fatal: Throwablepath, joiners will observeSetupAbandonedeven though the actual failure was a fatal error (e.g.,OutOfMemoryError). That makes the outcome misleading and can encourage callers to retry when the underlying failure is non-recoverable. Consider completing the claim with the samefatalthrowable (or a dedicated wrapper that preserves the fatal as cause) so all waiters receive an accurate outcome while still ensuring the claim is always released.
} catch (fatal: Throwable) {
// An OutOfMemoryError reaches the caller unchanged. The claim is still released, or every later
// caller of this kind waits for something that will never complete.
release(claim, TapToPaySessionException.SetupAbandoned())
throw fatal
}
taptopay/src/test/java/com/payabli/sdk/taptopay/session/TapToPayTransitionMatrixTest.kt:37
- Using a single
FAILED_INTERNALinstance in the matrix can read like identity is relevant, even thoughFailedis a data class and equality is byreason. Consider inliningFailed(TapToPayFailureReason.SDK_INTERNAL_ERROR)(or introducing a tiny helper likefailed(reason)) in the sets to make it obvious the test is about value equality rather than a shared singleton instance.
private fun legalTargetsFrom(from: TapToPaySessionState): Set<TapToPaySessionState> =
when (from) {
Idle -> setOf(Idle, AttestingDevice, FetchingConfig, FAILED_INTERNAL)
AttestingDevice -> setOf(Idle, AttestingDevice, FetchingConfig, PendingActivation, FAILED_INTERNAL)
FetchingConfig -> setOf(Idle, FetchingConfig, InitializingReader, PendingActivation, FAILED_INTERNAL)
InitializingReader -> setOf(Idle, InitializingReader, Ready, FAILED_INTERNAL)
Ready -> setOf(Idle, Ready, SessionExpired, FAILED_INTERNAL)
SessionExpired -> setOf(Idle, SessionExpired, Reinitializing, FAILED_INTERNAL)
Reinitializing -> setOf(Idle, Reinitializing, FetchingConfig, FAILED_INTERNAL)
PendingActivation -> setOf(Idle, PendingActivation, AttestingDevice, FAILED_INTERNAL)
is Failed -> setOf(Idle, AttestingDevice, FetchingConfig, FAILED_INTERNAL)
}
private val FAILED_INTERNAL = Failed(TapToPayFailureReason.SDK_INTERNAL_ERROR)
… on something unclassified The fatal path handed waiters `SetupAbandoned`, which says nothing happened and asking again is safe. After an `OutOfMemoryError` neither is true, so a host following that answer retries into a process that is already going down. `SetupFailed` says the run failed and lands on the SDK-internal reason. It carries no cause: the owner keeps the original and it reaches that caller unchanged, while attaching it here would give every waiter a reference to whatever died. The token refresh in the core module draws the same line at the same place, for the same reason. Sabotage: handing the withdrawal outcome back fails the new test and nothing else. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
taptopay/src/main/java/com/payabli/sdk/taptopay/session/TapToPaySessionManager.kt:148
- This adds a new log field name
errorkind. If the SDK enforces an allowlist of loggable field names (as suggested byLoggableFieldNames),errorkindneeds to be present there or it may be dropped/blocked, reducing observability of failure reasons. Consider addingerrorkindto the allowlist (or reusing an existing standardized field name if one already exists in the logging vocabulary).
logger.info(
LogField.safe("event", "ttp_session_state"),
LogField.safe("state", to.diagnosticName),
LogField.safe("errorkind", (to as? TapToPaySessionState.Failed)?.reason?.name),
) { "session state changed" }
The script matches on what the client sent and called it a route throughout: the local holding `request.path`, the class prose, and the constant. That was harmless while every path equalled its own template, and stopped being harmless when `/config` arrived with an identifier in its path and a template beside it. The local is `path`, the prose says which of the two it keys on, and the config constant says it is resolved. The accessor keeps its name, since renaming it reaches every test on the branch; its documentation now says what it holds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Tickets: PLA-2184, the card-present session state machine and the
/config/{entry}call a session needs to reach a usable state.:taptopaycould attest a device and spend an activation code, and nothing sequenced those into a session: no state a host could observe, no entry point safe to call twice, and no way for a device attested on an earlier run to discover it still owes an activation code. This adds nine session states with a table of legal moves, one writer, and a coordinator whose three entry points never overlap. It also adds/config/{entry}, which returns the card reader's credentials and is the only route that answers the activation question on a warm start.Structure
The session holds its own state rather than publishing into
:core.SdkStatesays what the SDK can do at all, and it is written only by:core's own machine, which a capability module cannot reach.:payinholds its submission state the same way.Exclusion and joining are two mechanisms. A mutex holds the region for a whole run; a claim per entry-point kind lets two callers of the same kind share one run. The claim is per kind rather than one slot, so a build still joins a build when a repair is queued between them. One queue doing both jobs would make them impossible to break independently, and each is worth its own failing test.
ReaderProviderhas no implementation here. Without the seam, four of the nine states cannot be entered and cannot be tested. The card reader itself arrives with the charge work.Notable changes
advance(to) { work }moves the state and then runs the work under it, so a phase cannot run under a state that was never published. There is no boolean for a caller to drop.postin the device client splits into request assembly plus a shared response reader./configis the first GET in the family, and the first route whose path is not its own template./configdiscards the stored binding and fails the attempt. Attesting again from inside the failing call would spend a challenge and hide the credential rotation that caused it.fromstateandtostatejoin the loggable field names, so a refused transition records both ends of the move.Verification
253 unit tests in
:taptopay, green, along with:core's suite,ktlintCheckandlintDebug. Nothing added here calls an Android API, so there is no instrumented tier for it.Thirteen guarantees were each broken in turn and all but one was caught by a test; the exception is a diagnostic race no deterministic interleaving reaches, and the second read that caused it is gone rather than guarded. The harness deletes the results directory before each row, so a sabotage that fails to compile yields no verdict instead of a stale one; it refuses a patch whose anchor does not match exactly once; and it diffs the tree against a backup after every row. The row-by-row table is on the ticket.
Not included
The public facade and publishing the state type, the card reader and its two-tier failure classifier, and the first
EventMulticasteremitter. Each belongs to the charge work that follows this one.