feat: ECR17 transport, orchestration, response parsing & full command set#4
Conversation
…Phase 0) Redesign the public Nitro API for real end-to-end operation: - Ecr17Client commands become async (Promise<T>): connect/status/pay/payExtended/ reverse/preAuth/incrementalAuth/preAuthClosure/verifyCard/closeSession/totals/ sendLastResult/enableEcrPrinting/reprint/vas; configure/configuration stay sync. Adds event setters: onProgress, onReceiptLine, onConnectionStateChange. - New Ecr17Transport HybridObject spec (Swift/Kotlin) for the native LAN socket. - New request/result types in types/client.ts mirroring the spec response tables (PaymentResult, ReversalResult, PreAuthResult, CardVerificationResult, TotalsResult, CloseSessionResult, VasResult, CurrencyExchange, tokenization, ...). - nitro.json: register Ecr17Transport autolinking. - Add self-contained package/tsconfig.ci.json so `tsc --noEmit` runs without the private @padosoft/config package (the repo's tsconfig extends it; not on npm). - Add PROGRESS.md (resumable status) and docs/LESSON.md (accumulated learnings). nitrogen regenerates 2 HybridObjects cleanly; standalone typecheck passes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PacketCodec: SOH branch now checks data.back() == EOT before accepting the frame as PROGRESS. Previously any SOH-starting buffer was silently classified as a valid progress update regardless of the terminator byte. Adds a regression test (DecodeSohWithoutEotIsUnknown). Ecr17Client: update status() (and all newly spec'd methods) to the async Promise-based signatures that Nitrogen will generate from the updated client.nitro.ts spec. The old PosStatusResponse status() return type mismatched the new spec, which would have made HybridEcr17Client abstract and non-compilable after nitrogen regeneration. All new command and event methods are stubbed to reject/no-op pending Phase 1+ implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Verify & correct the autonomous Copilot review edits (it edits in --yolo mode):
- Ecr17Client.cpp: stubs used `Promise<T>::async([](auto& res){...})`, but the
Nitro API is `async(std::function<T()>)` — lambda takes no args and returns T.
Rewrote as `Promise<T>::async([]() -> T { throw ...; })` (void variant for
connect/enableEcrPrinting/reprint) via a [[noreturn]] helper.
- Ecr17Client.hpp: setOnConnectionStateChange must match the generated spec
exactly — ConnectionState is passed BY VALUE
(`std::function<void(ConnectionState)>`), not `const ConnectionState&`, else the
override doesn't match and the class stays abstract.
- Kept Copilot's genuine improvement: SOH decode now requires the final byte to
be EOT (+ its regression test).
- LESSON.md: recorded that copilot --yolo edits autonomously and its nitro/C++
signatures must be verified against the generated headers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ctionState) Add std::function members and have the setters store the callbacks instead of discarding them, so the session can fire them in later phases. Addresses the local Copilot review finding. Still Phase-0 stubs otherwise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extend Ecr17Protocol with every remaining command, fixed-width and validated: - Payment family (167B): 'P' payment (now takes paymentType/cardPresent/ additionalData/receiptText with back-compatible defaults), 'X' extended, 'p' pre-auth (shared buildPaymentLike). - Pre-auth follow-ups (176B): 'i' incremental, 'c' closure (shared layout with the original pre-auth code field). - 'H' card verification (39B), 'C' close session + 'T' totals (26B), 'G' send last result (22B), 'E' enable/disable ECR printing (11B), 'R' reprint (22B). - 'K' VAS (length-prefixed XML, max 1024) and 'U' additional-data/tokenization (privative TAG content + 0x1B terminator) + formatTokenizationTag helper (Intesa 0COF/0REC ... |0FNZ03 mapping). - Reuse leftPad (overflow-throwing) + new rightPad; amount/field validation. Adds test_protocol_commands.cpp asserting the exact byte layout and validation of every builder. Existing payment/status/reversal tests remain green (new params default to the previous behaviour). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New Ecr17Response unit (plain structs, Nitro-independent so it's unit-testable):
- parsePayment ('E'/'V'): result code -> outcome, positive (PAN/txType/authCode/
hostDateTime), negative (description), common (cardType/acquirer/STAN/idOnline),
and the DCC currency-exchange block for 'V'.
- parseStatus ('s'): terminalId, raw DDMMYYhhmm, numeric status, SW release.
- parseTotals ('T'), parseClose ('C', pos/host totals or description+actionCode).
- parsePreAuth ('e'): incl. preAuthCode + preAuthorizedAmount + actionCode.
- parseVas ('K'): concatenation flag, id message, raw XML + RESPID/RESPMSG/
ORDER_ID extracted from the XML.
All parsing is defensive (out-of-range fields come back empty, never UB).
test_response.cpp synthesizes payloads field-by-field at the exact spec offsets
(width-guaranteed helpers) for positive/negative/DCC/short-payload cases.
Wired into the test build and android/CMakeLists.txt.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hase 3) Ecr17Session drives one request/response exchange over the abstract Transport: - frames the request; physical ACK/NAK handshake with retransmission up to retryCount on NAK or ackTimeout (spec: up to 3); - waits for the application response within responseTimeout, forwarding SOH progress messages (onProgress) and 'S' receipt lines (onReceiptLine seen before the result), ACKing valid frames and NAKing invalid-LRC ones; - a stream framer (extractFrameLocked) splits the rx byte stream into ACK/NAK (3B), STX..ETX+LRC, and SOH..EOT frames, resynchronising past junk; - thread-safe rx buffer fed by the transport data callback (mutex + condvar), throws on disconnect or timeout. Adds header-only FakeTransport (deterministic, scriptable: each STX request pops the next queued reply; control sends don't) and test_session.cpp covering happy path, NAK->retransmit, ack-timeout exhaustion, bad-LRC->NAK, progress/receipt forwarding, response timeout, and disconnect. Wired into both build files (+Threads link for the test exe). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Process artifacts only (LESSON.md: copilot focused-prompt efficiency, Threads link, LrcMode include, session/FakeTransport notes; PROGRESS.md: Phase 4 plan). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New ts-checks.yml: bun install -> nitrogen codegen -> tsc --noEmit via the self-contained tsconfig.ci.json (deterministic gate for the TS specs/codegen). - Bump actions/checkout@v4 -> v5 (Node20 deprecation warning). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The TS/codegen job failed because @padosoft/config (root devDep) is a private GitHub Packages package; bunfig.toml maps the padosoft scope to npm.pkg.github.com via $GESCAT_NPM_TOKEN. Provide the token from secrets (GESCAT_NPM_TOKEN, falling back to GITHUB_TOKEN) with packages:read. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The job can't run: every `bun install` resolves the root devDep @padosoft/config (private GitHub Packages) and gets 403 with the available token. This also blocks any native build job. Re-add once a GESCAT_NPM_TOKEN Actions secret with read:packages exists (or @padosoft/config is public). The C++ unit-test CI (the real gate) stays green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on user) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h needed) CI typecheck/nitrogen don't use @padosoft/config (we use tsconfig.ci.json and skip biome), so strip it from the root devDeps + drop the lockfile before bun install. Removes the private GitHub Packages 403 blocker entirely — no token or package-visibility change required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expo prebuild + gradle assembleDebug compiles the nitro module's C++ (CMake) and Kotlin. Uses the same @padosoft/config strip for install. Baselines current (compiling) code so it catches errors when the client wiring + native transport land. May need iteration (heavy Expo+NDK build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o PROGRESS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ase 4) - NativeTransportAdapter: bridges the Nitro Ecr17Transport HybridObject to the C++ Transport interface (ArrayBuffer<->vector); session uses it for I/O. - Ecr17Session: add sendAckOnly() for ACK-only commands (enable-print/reprint), with fast FakeTransport tests (ack / NAK-retransmit / timeout). - HybridEcr17Client: real implementation — lazily creates the transport via the Nitro registry, builds an Ecr17Session from the config, and implements every command (build -> exchange -> parse -> map to the generated result struct). connect/disconnect/isConnected + progress/receipt/connection events wired. Generated struct/enum field names verified against nitrogen output. - Rename parser DCC struct CurrencyExchange -> DccInfo to avoid clashing with the generated CurrencyExchange in the same namespace. - android/CMakeLists.txt: compile NativeTransportAdapter.cpp. Client/adapter compile only in the native build (Phase 8b Android job); the session additions are covered by the fast C++ unit CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Android: HybridEcr17Transport.kt — java.net.Socket + daemon reader thread, forwards bytes to C++ via onData(ArrayBuffer.copy); Promise.parallel for connect. Nitro generates the C++<->Kotlin JNI bridge (no manual JNI). - iOS: HybridEcr17Transport.swift — Network.framework NWConnection (best-effort; no iOS CI runner yet, must be verified on a real iOS build; mirrors Kotlin). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… review) - disconnect() no longer triggers a spurious onDisconnect: the reader's finally suppresses the callback when the close was caller-initiated (intentionalDisconnect). - connect() tears down any previous connection and joins the old reader thread before opening a new socket (no leaked thread/socket on reconnect). Pure logic; uses the same APIs the green Android build already compiled. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ROGRESS Captures the mandatory per-phase workflow, CI strategy, and verified nitro/build know-how so future sessions respect them without rediscovery. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…in, opt-in terminal test - Client auto-connects (ensureConnected blocks the worker on the transport's awaitable connect Promise); keepAlive keeps the socket open. No manual connect() required before commands. - Tokenization (U) wired end-to-end: pay/payExtended/preAuth/verifyCard build with withAdditionalData when request.tokenization is set, then send the 'U' message via the new Ecr17Session::exchangeWithAdditionalData (P -> ACK -> U -> ACK -> result). - Receipt streaming after the result: SessionConfig.receiptDrainMs (+ Ecr17Config field) forwards trailing 'S' receipt lines until the terminal goes quiet. - Refactor exchange() = sendAckOnly + waitForResult (all existing session tests preserved); add tests for additional-data flow and receipt drain. - Opt-in real-terminal integration test (PosixTcpTransport + test_integration_terminal, gated by ECR17_TERMINAL_HOST; skipped in CI, run locally against a terminal). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…view) drainReceipts now NAKs bad-LRC application frames like waitForResult does, instead of silently dropping them (which would stall the drain). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uto-connect, terminal test) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On a disconnect during a command, if config.autoReconnect is set the socket is
reconnected. Retry policy is SAFETY-AWARE to avoid double-charging:
- read-only / idempotent ops (status, totals, sendLastResult 'G', enableEcrPrinting)
are retried after reconnect;
- financial ops (pay, payExtended, reverse, preAuth, incremental, preAuthClosure,
verifyCard, vas, closeSession, reprint) reconnect but RE-THROW — the caller
recovers the lost result via sendLastResult ('G'), never a blind re-send.
All command exchanges routed through runTransaction()/runAckOnly().
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extract the retry decision into shouldRetryAfterReconnect() (Session/RetryPolicy.hpp) and lock the invariants in test_retry_policy.cpp: a financial command is NEVER replayed (no double-charge), a safe/idempotent command is replayed only on autoReconnect after a real drop. Client routes its decision through this function. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nt 82 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…mains Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…r RN/native Add a 'Why this exists' section: ECR17 specs are NDA-gated, no RN/native open-source option existed; positions this as the most complete one for React Native & native mobile, with an approachable async API. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish, untrack Nexi doc - FIX: Ecr17Session never reset disconnected_ after a drop, so a reconnected transport still threw "disconnected" on the next command — auto-reconnect did not actually recover. Add resetForNewTransaction() (clear disconnected_ + rxBuffer_) at the start of every transaction; extract ackHandshake() so exchange/exchangeWithAdditionalData reset once. Regression test Session.RecoversAndSucceedsAfterReconnect (FakeTransport drop -> rearm). - README: official PUBLIC source is the Nexi developer portal (linked, not vendored); position as the most complete open-source ECR17 toolkit for RN/native; highlight the C++<->Kotlin JNI bridge; iOS verified-on-device; drop the completed Roadmap; 83 tests. - Untrack the full Nexi doc (git rm --cached + .gitignore): public docs aren't free to republish without permission; kept local/private, official URL linked. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Fix Session.RecoversAndSucceedsAfterReconnect: it built the response frame from two different temporaries' begin()/end() (UB / wrong range); use append(). - Add root CLAUDE.md and a README "Vibe-coding batteries included" section: the repo ships AGENTS.md / CLAUDE.md / docs/LESSON.md / PROGRESS.md so AI assistants get accurate context and respect the payment-safety invariants. - AGENTS.md: add the money-critical never-retry-financial rule up top. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
fde8e62 to
00100c4
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 00100c408d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| try { | ||
| return doExchange(); |
There was a problem hiding this comment.
Serialize command exchanges on the shared session
Every public command runs inside Promise::async, so two JS calls can enter runTransaction at the same time and share the same session_/native callbacks. Because Ecr17Session::exchange() resets the receive buffer and then both exchanges wait on the same callback stream, concurrent calls such as pay() plus status() can clear or consume each other's frames, ACK the wrong response, or return a mismatched result. Add a transaction-level mutex around all runTransaction/runAckOnly exchanges so only one ECR17 command is in flight per client.
Useful? React with 👍 / 👎.
| override fun isConnected(): Boolean { | ||
| val s = socket | ||
| return s != null && s.isConnected && !s.isClosed | ||
| } |
There was a problem hiding this comment.
Treat closed Android sockets as disconnected
When the terminal drops the TCP connection, the reader loop exits and sets running = false, but the socket remains non-null and not explicitly closed. On Android/Java, Socket.isConnected only means the socket has connected before, so after an unexpected peer close this can still return true; runTransaction then computes dropped=false, skips auto-reconnect/retry, and later ensureConnected() also refuses to open a fresh socket. Include running or close/null the socket in the reader's finally path so unexpected EOF is observable as disconnected.
Useful? React with 👍 / 👎.
| // Common fields (per spec offsets; note pos 48 overlaps the positive amount | ||
| // field in the spec table, so cardType is only meaningful for KO responses). | ||
| r.cardType = at(p, 48, 1); |
There was a problem hiding this comment.
Avoid reporting pre-auth amount digits as card type
For successful pre-auth responses, the parser reads preAuthorizedAmount from positions 41-48 and then unconditionally reads cardType from position 48, so any approved amount ending in 1, 2, or 3 is exposed to JS as debit/credit/other even though that byte is just the last amount digit. This affects successful preAuth() results with those amount endings; leave cardType empty for the OK layout or parse it from a non-overlapping KO/common offset.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Implements the full ECR17 stack on top of the existing framing/LRC core: complete request builders, response parsers, an ACK/NAK + retransmit session orchestrator with timeouts, a native TCP transport (Kotlin + best-effort Swift), and an async Promise/event-based HybridEcr17Client that ties them together. Payment-safety is enforced via an isolated RetryPolicy that prevents financial commands from being blindly replayed on auto-reconnect, with recovery via sendLastResult ('G'). Adds 82 C++ unit tests, an opt-in real-terminal integration test, CI workflows (ts-checks, android-build), self-contained CI tsconfig, and substantial README/agent-context documentation.
Changes:
- Complete ECR17 command builders, response parsers, session orchestration, and native transport (Kotlin/Swift) wired through a Nitro async client API with progress/receipt/connection events.
- Money-safety policy (
RetryPolicy.hpp) keeping financial commands from being replayed after reconnect, with extensive C++ test coverage including aFakeTransportand opt-in real-terminal test. - New CI (ts-checks, android-build) and refreshed README /
AGENTS.md/CLAUDE.md/LESSON.md/PROGRESS.mddocumentation.
Reviewed changes
Copilot reviewed 38 out of 39 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| PROGRESS.md | New session/phase progress log (contains some duplicated stale checklist entries). |
| package/tsconfig.ci.json | Self-contained typecheck config avoiding the private @padosoft/config. |
| package/src/types/client.ts | New request/result/event TypeScript types for the full command set. |
| package/src/specs/transport.nitro.ts | Nitro spec for the native Ecr17Transport (Swift/Kotlin). |
| package/src/specs/client.nitro.ts | Expanded client spec with async commands, events, and receiptDrainMs. |
| package/src/index.ts | Re-exports the new transport spec. |
| package/README.md | Major rewrite documenting the full feature set, async API, events, and AI-agent context. |
| package/nitro.json | Registers the new Ecr17Transport HybridObject. |
| package/ios/HybridEcr17Transport.swift | iOS Swift TCP transport via Network.framework (best-effort, no iOS CI). |
| package/cpp/Transport/NativeTransportAdapter.{hpp,cpp} | Adapts the Nitro transport spec to the C++ Transport interface. |
| package/cpp/Transport/FakeTransport.hpp | Deterministic in-memory transport for session tests. |
| package/cpp/Session/RetryPolicy.hpp | Money-critical decision: never replay financial commands after reconnect. |
| package/cpp/Session/Ecr17Session.{hpp,cpp} | ACK/NAK + retransmit orchestrator, progress/receipt forwarding, receipt drain. |
| package/cpp/PacketCodec/PacketCodec.cpp | Rejects SOH frames not terminated with EOT. |
| package/cpp/Ecr17Response/Ecr17Response.{hpp,cpp} | Defensive parsers for E/V/s/T/C/e/K responses (incl. DCC). |
| package/cpp/Ecr17Protocol/Ecr17Protocol.{hpp,cpp} | Complete command builder set with fixed-width validation. |
| package/cpp/Ecr17Client/Ecr17Client.{hpp,cpp} | Async client wiring transport + session + parsers, tokenization flow, retry policy. |
| package/cpp/tests/* | New tests: protocol commands, responses, session, retry policy, integration, packet codec regression. |
| package/cpp/tests/CMakeLists.txt | Builds new test files and links pthread. |
| package/cpp/tests/PosixTcpTransport.hpp | Test-only POSIX TCP transport for the opt-in integration test. |
| package/android/src/main/java/com/margelo/nitro/ecr17/HybridEcr17Transport.kt | Kotlin TCP transport with intentional-disconnect handling. |
| package/android/CMakeLists.txt | Adds new C++ sources to the Android build. |
| docs/LESSON.md, CLAUDE.md, AGENTS.md | Agent-facing project guidance / accumulated lessons / non-negotiables. |
| .gitignore | Ignores the privately-kept full Nexi spec markdown. |
| .github/workflows/{ts-checks.yml,android-build.yml,cpp-tests.yml} | New CI for TS+nitrogen and Android build; checkout bumped to v5. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } catch (const std::exception&) { | ||
| const bool autoReconnect = config_.autoReconnect.value_or(false); | ||
| const bool dropped = !transport_ || !transport_->isConnected(); | ||
| if (autoReconnect && dropped) { | ||
| ensureConnected(); // restore the socket for subsequent commands | ||
| } | ||
| if (shouldRetryAfterReconnect(autoReconnect, dropped, safeToRetry)) { | ||
| return doExchange(); // only read-only/idempotent ops may be replayed | ||
| } | ||
| throw; // financial op: surface the error (recover via sendLastResult / 'G') | ||
| } |
| } | ||
| // Ignore any progress frames that may precede the ACK. |
| } | ||
| // else: surface the original error (already rethrown below for non-retry) | ||
| else { |
| - [ ] Phase 3 — Ecr17Session orchestration + FakeTransport | ||
| - [ ] Phase 4 — HybridEcr17Client async + events | ||
| - [ ] Phase 5 — native transport (Swift/Kotlin + JNI; ref corasan/image-compressor#11) | ||
| - [ ] Phase 6 — C++ tests expanded | ||
| - [ ] Phase 7 — README (Roadmap/Feature status ✅) + example | ||
| - [ ] Phase 8 — CI: typecheck + nitrogen check + Android/iOS build jobs | ||
| - [ ] Phase 9 — distill LESSON.md into AGENTS.md / rules / skills |
| void HybridEcr17Client::configure(const Ecr17Config& config) { | ||
| config_ = config; | ||
| // Force re-init so a new configuration rebuilds the session/timeouts. | ||
| session_.reset(); | ||
| adapter_.reset(); | ||
| transport_.reset(); | ||
| } |
Money-critical / correctness: - session: don't drop an APPLICATION result that arrives before/without the physical ACK — stash it for waitForResult() so a completed transaction is never lost to a handshake timeout (+ regression test). - client: serialize command exchanges with a transaction mutex; concurrent Promise workers shared one session_/RX buffer and could ACK each other's frames or clobber the buffer. - response(preAuth): stop leaking the approved-amount last digit (pos 48, inside preAuthorizedAmount 41-48) as cardType; only read it for the KO layout (+ regression test). Robustness / quality: - client: on auto-reconnect failure, surface the original exchange error instead of the reconnect error (runTransaction + runAckOnly). - client(configure): disconnect the existing transport before tearing it down so the native socket doesn't leak. - client(runAckOnly): clarify retry control flow (no orphaned else). - android transport: isConnected() now also checks the reader's `running` flag — Socket.isConnected stays true after a peer close, defeating auto-reconnect/retry. - docs(PROGRESS): remove the stale duplicate Phase 3-9 checklist. Verified locally: g++ 16 (C++20, -Wall -Wextra) RED->GREEN on the two core regression tests + sanity; full gtest suite runs in CI. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Overview
Implements the full ECR17 stack on top of the framing/LRC core: TCP/LAN transport,
ACK/NAK + retransmit orchestration, response parsing, the complete command set,
an async Promise API with events, and payment-safety throughout. Base:
main.CI:
cpp-tests82/82 ✅ ·ts-checks✅ ·android-build✅ (manual dispatch).iOS Swift transport is written best-effort (no macOS CI runner — see roadmap).
What's included
Ecr17Protocol): all commands —P X p i c H U C T G E R K s S—fixed-width, validated, byte-exact vs spec.
Ecr17Response):E/V/s/T/C/e/Kincl. DCC; defensive (no UB).Ecr17Session): physical ACK/NAK handshake, retransmit-up-to-3,timeouts, progress (
SOH) + receipt (S) forwarding, stream framer; testedagainst an in-memory
FakeTransport.HybridEcr17Client):connect/status/pay/payExtended/reverse/preAuth/incrementalAuth/preAuthClosure/verifyCard/closeSession/totals/sendLastResult/enableEcrPrinting/reprint/vas+ progress/receipt/connection events. Auto-connect; tokenization (
U) flow; configurable receipt drain.HybridEcr17Transport.kt, CI-built) + SwiftNetwork.framework (best-effort). Nitro generates the C++↔Kotlin JNI bridge.
💰 Payment safety (money-critical)
autoReconnectrestores the socket, but afinancial command is never blindly re-sent — payments/reversals/pre-auths
reconnect and surface the error; recover the outcome via
sendLastResult()(spec command
G). Only read-only/idempotent ops are retried. The decision isisolated in
RetryPolicy.hppand unit-tested (test_retry_policy.cpp).Tests
82 C++ tests (LRC, codec edge cases, every builder layout, every parser, session
flows incl. tokenization/receipt-drain/NAK-retransmit/timeout/disconnect, and the
retry-safety invariants) + an opt-in real-terminal integration test
(
ECR17_TERMINAL_HOST=… ctest -R Integration).Process: each change went through local tests → local Copilot review → CI green.
Learnings in
docs/LESSON.md, status inPROGRESS.md, workflow distilled intoroot
AGENTS.md.Remaining (roadmap)
G).🤖 Generated with Claude Code