ksef_client 0.1.0.rc1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1424 -0
  3. data/CONTRIBUTING.md +45 -6
  4. data/README.md +377 -25
  5. data/SECURITY.md +25 -2
  6. data/docs/REFERENCE.md +3099 -34
  7. data/docs/errors.md +33 -4
  8. data/docs/field_mapping.md +390 -0
  9. data/lib/ksef/auth/access_token.rb +150 -0
  10. data/lib/ksef/auth/authorization_policy.rb +88 -0
  11. data/lib/ksef/auth/challenge.rb +53 -0
  12. data/lib/ksef/auth/client.rb +143 -0
  13. data/lib/ksef/auth/initiation.rb +22 -0
  14. data/lib/ksef/auth/operation_status.rb +34 -0
  15. data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
  16. data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
  17. data/lib/ksef/auth/signature_template.rb +120 -0
  18. data/lib/ksef/auth/signer.rb +132 -0
  19. data/lib/ksef/auth/status.rb +69 -0
  20. data/lib/ksef/auth/token.rb +121 -0
  21. data/lib/ksef/auth/token_info.rb +27 -0
  22. data/lib/ksef/auth/token_request.rb +142 -0
  23. data/lib/ksef/auth/tokens.rb +24 -0
  24. data/lib/ksef/auth/validator.rb +84 -0
  25. data/lib/ksef/auth/xades.rb +40 -0
  26. data/lib/ksef/auth.rb +43 -0
  27. data/lib/ksef/client/receipt.rb +37 -0
  28. data/lib/ksef/client/session.rb +68 -0
  29. data/lib/ksef/client.rb +314 -0
  30. data/lib/ksef/crypto/certificate.rb +78 -0
  31. data/lib/ksef/crypto/digest.rb +25 -0
  32. data/lib/ksef/crypto/encryptor.rb +131 -0
  33. data/lib/ksef/crypto/public_keys.rb +146 -0
  34. data/lib/ksef/crypto.rb +66 -0
  35. data/lib/ksef/environments.rb +1 -1
  36. data/lib/ksef/errors.rb +35 -2
  37. data/lib/ksef/fa3/address.rb +64 -0
  38. data/lib/ksef/fa3/advance_checks.rb +55 -0
  39. data/lib/ksef/fa3/advance_invoice.rb +55 -0
  40. data/lib/ksef/fa3/advance_reader.rb +59 -0
  41. data/lib/ksef/fa3/attachment.rb +43 -0
  42. data/lib/ksef/fa3/attachment_checks.rb +121 -0
  43. data/lib/ksef/fa3/attachment_reader.rb +99 -0
  44. data/lib/ksef/fa3/attachment_table.rb +98 -0
  45. data/lib/ksef/fa3/builder/advances.rb +57 -0
  46. data/lib/ksef/fa3/builder/corrections.rb +46 -0
  47. data/lib/ksef/fa3/builder/subjects.rb +71 -0
  48. data/lib/ksef/fa3/builder.rb +149 -0
  49. data/lib/ksef/fa3/business_validator.rb +134 -0
  50. data/lib/ksef/fa3/canonical.rb +44 -0
  51. data/lib/ksef/fa3/corrected_invoice.rb +53 -0
  52. data/lib/ksef/fa3/correction.rb +151 -0
  53. data/lib/ksef/fa3/correction_checks.rb +106 -0
  54. data/lib/ksef/fa3/correction_reader.rb +88 -0
  55. data/lib/ksef/fa3/data_block.rb +75 -0
  56. data/lib/ksef/fa3/document_mapping.rb +142 -0
  57. data/lib/ksef/fa3/document_validator.rb +174 -0
  58. data/lib/ksef/fa3/element_tree.rb +49 -0
  59. data/lib/ksef/fa3/field_checks.rb +151 -0
  60. data/lib/ksef/fa3/formatting.rb +273 -0
  61. data/lib/ksef/fa3/generated/enums.rb +652 -0
  62. data/lib/ksef/fa3/generated/types.rb +3308 -0
  63. data/lib/ksef/fa3/invoice.rb +259 -0
  64. data/lib/ksef/fa3/issue.rb +32 -0
  65. data/lib/ksef/fa3/line.rb +162 -0
  66. data/lib/ksef/fa3/meta_entry.rb +45 -0
  67. data/lib/ksef/fa3/model_validator.rb +219 -0
  68. data/lib/ksef/fa3/nip.rb +59 -0
  69. data/lib/ksef/fa3/node_reader.rb +46 -0
  70. data/lib/ksef/fa3/order.rb +55 -0
  71. data/lib/ksef/fa3/order_line.rb +70 -0
  72. data/lib/ksef/fa3/parser.rb +208 -0
  73. data/lib/ksef/fa3/provenance.rb +137 -0
  74. data/lib/ksef/fa3/rounding_inference.rb +87 -0
  75. data/lib/ksef/fa3/row_reader.rb +66 -0
  76. data/lib/ksef/fa3/serializer.rb +140 -0
  77. data/lib/ksef/fa3/subject.rb +128 -0
  78. data/lib/ksef/fa3/subject_checks.rb +126 -0
  79. data/lib/ksef/fa3/subject_reader.rb +76 -0
  80. data/lib/ksef/fa3/summaries.rb +107 -0
  81. data/lib/ksef/fa3/summary_checks.rb +75 -0
  82. data/lib/ksef/fa3/table_column.rb +49 -0
  83. data/lib/ksef/fa3/totals.rb +119 -0
  84. data/lib/ksef/fa3/validator.rb +79 -0
  85. data/lib/ksef/fa3/vat_rate.rb +94 -0
  86. data/lib/ksef/fa3.rb +59 -0
  87. data/lib/ksef/http/connection.rb +47 -4
  88. data/lib/ksef/http/json_decoder.rb +53 -0
  89. data/lib/ksef/http/retry.rb +105 -0
  90. data/lib/ksef/invoices/client.rb +72 -0
  91. data/lib/ksef/ksef_number.rb +150 -0
  92. data/lib/ksef/sessions/invoice_codes.rb +70 -0
  93. data/lib/ksef/sessions/invoice_state.rb +68 -0
  94. data/lib/ksef/sessions/online.rb +169 -0
  95. data/lib/ksef/sessions/session_codes.rb +73 -0
  96. data/lib/ksef/sessions/session_state.rb +48 -0
  97. data/lib/ksef/sessions/status.rb +138 -0
  98. data/lib/ksef/sessions/upo_page.rb +36 -0
  99. data/lib/ksef/sessions.rb +91 -0
  100. data/lib/ksef/upo/client.rb +154 -0
  101. data/lib/ksef/upo/document.rb +74 -0
  102. data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
  103. data/lib/ksef/upo/validation.rb +37 -0
  104. data/lib/ksef/upo/validator.rb +124 -0
  105. data/lib/ksef/upo.rb +55 -0
  106. data/lib/ksef/version.rb +1 -1
  107. data/lib/ksef.rb +18 -0
  108. metadata +105 -4
data/CHANGELOG.md CHANGED
@@ -10,6 +10,1426 @@ gem version for which API state".
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.1.0] — 2026-09-14
14
+
15
+ **Targets:** KSeF API 2.0 · FA(3) `1-0E` · upstream `CIRFMF/ksef-api@1c34fe27`,
16
+ `CIRFMF/ksef-client-csharp@406904d6`, `CIRFMF/ksef-pdf-generator@2b7c1dae` (sample corpus,
17
+ `docs/REFERENCE.md` §1.4)
18
+
19
+ > **What "works" means in this section.** As of 2026-08-24 the certificate/XAdES flow **and**
20
+ > the full send path have run against the live KSeF TEST service: a session opened, an invoice
21
+ > built by this gem encrypted, submitted and accepted, a KSeF number assigned, and the signed
22
+ > UPO retrieved and hash-verified. The **KSeF-token auth call and the crypto module went live
23
+ > the same day** — a stale `timestampMs` refused by TEST, and `certificateId`/`publicKeyId`
24
+ > recomputed against real certificates (run `32704511675`, `docs/REFERENCE.md` §9). **Token
25
+ > refresh and invoice download have since run against TEST too** (2026-08-26), each with a
26
+ > committed cassette, so nothing in the shipped surface is WebMock-only any more. Batch has no
27
+ > code at all yet, so it is absent rather than stubbed.
28
+
29
+ ### Added
30
+
31
+ - **`Ksef::Client#wait_for_session`** — waits until KSeF has finished processing a whole
32
+ session, rather than just closing it.
33
+
34
+ **Closing and finishing are two different clocks.** `#session` closes on the way out, which
35
+ *starts* asynchronous generation of the collective UPO; the session then sits at `170` until
36
+ that completes at `200`. `#wait_until_accepted` does not cover it — an accepted invoice says
37
+ nothing about the session's own progress — so a caller that sends, waits for the invoice and
38
+ then asks for `#collective_upo` is racing.
39
+
40
+ The wait already existed on `Sessions::Status` and simply was not reachable through the
41
+ facade, which left `#collective_upo`'s own documentation instructing callers to poll
42
+ `#session_status` by hand. Two places in this repository did exactly that, and the live one
43
+ got it wrong — see below.
44
+
45
+ - **A recorded flow for the two retrieval paths nothing had ever run**:
46
+ `GET /invoices/ksef/{ksefNumber}` and the pre-signed **storage** leg. All 31 interactions in
47
+ the first three cassettes were on the API host, and none touched `/invoices/ksef/` — so
48
+ `Invoices::Client#download`, `HTTP::Connection.storage` and `x-ms-meta-hash` verification *on
49
+ that route* were carried, documented and WebMock-verified without ever having run. DESIGN.md
50
+ §9.1 asserted the storage request "is part of the cassette too", which was never true:
51
+ `Client#upo` deliberately uses the metered per-invoice route, so the unmetered link is only
52
+ reached by `#collective_upo`. The `uri_without_param` matcher existed the whole time for a
53
+ request nothing made. One invoice covers both paths.
54
+
55
+ Scrubbing that request needed two changes. The **request URI** is now redacted, not just
56
+ bodies — the storage leg puts the signature in the request line, where nothing was looking.
57
+ And the placeholder stays a query *parameter* (`sig=<REDACTED>`), because a bare marker leaves
58
+ a query the matcher cannot strip, so a request replayed from the scrubbed body would not match
59
+ the recorded one. All twelve SAS parameters are ignored for matching now, not just the six
60
+ that look secret.
61
+
62
+
63
+ - **A golden file for the attachment** (`spec/fixtures/fa3/golden/vat_attachment.xml`), carrying
64
+ two blocks. A mutation audit found eight of thirty-eight mutations surviving a suite with
65
+ 100% line, branch and method coverage of this code — coverage was measuring reachability, not
66
+ constraint. Two facts had nothing pinning them: attribute-versus-child write order and
67
+ `<WKom/>` versus `<WKom></WKom>` are invisible to the XSD *and* to the round-trip law, because
68
+ parsing collapses both; and every corpus sample has exactly one `BlokDanych`, so taking
69
+ `blocks.first` anywhere dropped the rest in silence.
70
+
71
+ - **`Zalacznik`, the FA(3) attachment**, at build and parse level — the last implementation item
72
+ in Phase 3's scope (DESIGN.md §7.4). `Ksef::FA3::Attachment`, `DataBlock`, `MetaEntry`,
73
+ `AttachmentTable` and `TableColumn`, read by `AttachmentReader` and built through
74
+ `Ksef::FA3.build`'s `f.attachment`. The Ministry's two attachment samples listed 17 attachment
75
+ paths in `#unmapped_elements`; that set is now empty, and both round-trip.
76
+
77
+ An FA(3) attachment carries no bytes and no MIME type: it is a structured document of
78
+ headings, key/value metadata, paragraphs and tables, sitting beside `Fa` rather than inside
79
+ it, so it touches no summary. Three schema facts shape the model — **rows are ragged** (`Kol`
80
+ and `WKom` each repeat 1..20 and are related nowhere, and the corpus has one-cell rows heading
81
+ nine-cell ones), **an empty cell is legal and distinct from an absent one** (`TZnakowy2` has
82
+ `minLength="0"`), and **`MetaDane` is the only mandatory child of a block**. Operational
83
+ constraints on *sending* one stay out of 0.1 scope. (`docs/REFERENCE.md` §8.7.)
84
+
85
+ - **The release workflow now creates a GitHub release**, with the version's `CHANGELOG.md`
86
+ section as its body (`rake 'release:notes[X.Y.Z]'` prints what will be published). It is a
87
+ second job, running **after** the gem is pushed and with its own `contents: write`, so the job
88
+ that runs this gem's own code during publishing never holds write access to the repository —
89
+ and a release can never announce a publish that then failed. Prerelease status comes from
90
+ `Gem::Version#prerelease?`, so `v0.1.0.rc1` is flagged without the workflow keeping its own
91
+ opinion about version strings. Tagging while the entries are still under `[Unreleased]` fails
92
+ the job rather than publishing empty notes.
93
+
94
+
95
+ - **`spec/tasks/fa3_codegen_spec.rb`** — the codegen had no unit spec and was outside SimpleCov
96
+ entirely, so `method: 100` never applied to it. Its only checks were `rake fa3:verify`, which
97
+ proves determinism rather than correctness, and assertions about the one schema it reads. Three
98
+ defects survived that arrangement. It is now driven against a synthetic schema written to hold
99
+ the constructs — a `complexContent` extension, an anonymous type nested in a named one, an
100
+ attribute on a nested type, inline enumerations on both an element and an attribute — because
101
+ testing only against FA(3) is what let the extension defect through: FA(3) has exactly one
102
+ extension and it is empty.
103
+
104
+
105
+ - **`POST /auth/token/refresh` has now run against live TEST**, closing the last of §4.2's six
106
+ auth calls to have never been exercised — and settling three assumptions the implementation
107
+ was making. The response carries `accessToken` **and nothing else**, so reading
108
+ `body["accessToken"]` is right and `renew!` is right to keep the refresh token. A renewed
109
+ token gets a **full fresh fifteen minutes**, not the remainder of the old one, so a long
110
+ session renews indefinitely within the refresh token's seven days — which are exactly seven
111
+ days on TEST, to the second. (`docs/REFERENCE.md` §4.2a.)
112
+
113
+ - **A recorded cassette for `POST /auth/token/refresh`** — the one auth call whose real response
114
+ had never been seen (`docs/REFERENCE.md` §4.2: the 2026-08-23 live run covered five of six).
115
+ `Auth::Client#refresh` reads `body["accessToken"]` on the OpenAPI contract's word alone, and a
116
+ wrong envelope would not raise — it yields a credential holding no token. The example advances
117
+ its clock to 90% of the token's observed lifetime, past the 80% refresh threshold and short of
118
+ expiry, so it exercises the *proactive* renewal rather than an expiry. It costs an
119
+ authentication and no invoice. (DESIGN.md §9.1.)
120
+
121
+ - **`rake vcr:record` takes an optional target**, and so does the dispatch workflow:
122
+ `rake 'vcr:record[spec/recorded/auth_refresh_spec.rb]'`. `record: :all` re-records everything
123
+ it runs and each session-flow example submits an unwithdrawable invoice, so recording one new
124
+ flow used to cost two permanent TEST invoices for flows that had not changed. The replay step
125
+ still runs the whole tier, because a recording that replays only what it wrote cannot see what
126
+ it broke.
127
+
128
+ - **Attribute enumerations declared inline are now captured by the codegen** as `values:` on the
129
+ attribute. FA(3) has exactly one — `Kol/@Typ`, the six column types of an attachment table
130
+ (`date`, `datetime`, `dec`, `int`, `time`, `txt`) — and `Generated::Enums` cannot see it,
131
+ because it keys on named `xsd:simpleType`. Without this the only way to enforce the six is to
132
+ restate them in Ruby, which DESIGN.md §7.1 forbids. Groundwork for `Zalacznik`.
133
+ (`docs/REFERENCE.md` §18.2.)
134
+
135
+ - **The recorded test tier** — three VCR cassettes, 31 interactions, replaying in about 1.4
136
+ seconds with **no credentials present**: authenticate, open a session, encrypt and submit an
137
+ invoice, poll to acceptance, fetch the UPO, and renew an access token. They pin two things no stub can give —
138
+ a KSeF number whose CRC-8 agrees with ours, and a UPO that is XAdES-signed although upstream's
139
+ own UPO schema declares no `ds:Signature` (`docs/REFERENCE.md` §14.7).
140
+
141
+ - **Its harness** (`spec/support/vcr.rb`, `rake vcr:record`) — VCR wired to
142
+ the same WebMock the rest of the suite uses, scrubbing for every secret
143
+ `spec/cassette_hygiene_spec.rb` scans for, and a `:recorded` tag excluded until a cassette
144
+ exists. **Recording stays a deliberate human-run step** rather than part of `rake`: it needs
145
+ TEST credentials, creates a permanent TEST invoice and burns rate-limited quota. A
146
+ `:release_check` gate refuses 0.1.0 while `spec/cassettes/` is empty, so an emptied tier
147
+ cannot be forgotten. (DESIGN.md §9.1.)
148
+
149
+ Two decisions bind. **Requests are never matched on the body** — `Encryptor.generate` draws a
150
+ random key and IV and RSA-OAEP padding is randomised, so a recorded body cannot be reproduced
151
+ and a body matcher would present as a flaky test rather than an impossible one. And
152
+ **`record: :none` unless `KSEF_VCR_RECORD=1`**, so a deleted cassette fails loudly instead of
153
+ silently reaching TEST and recording a fresh invoice.
154
+
155
+ - **`Ksef::Client#session(encryptor:)` and `#send_invoice(encryptor:)`** — a seam for the
156
+ recorded tier alone. A session's symmetric key is per-session by design
157
+ (`docs/REFERENCE.md` §11.2a) and callers should keep letting it be generated; a replay has to
158
+ supply the key its recording used, and without this the tier would bypass the facade and stop
159
+ testing the path users call.
160
+
161
+ - **`docs/field_mapping.md`** — the English↔Polish field table, listing every attribute this
162
+ model carries against the FA(3) element it reads and writes, with the element's XSD type,
163
+ **effective** cardinality and the Ministry's own description in full. **Generated** by `rake fa3:field_mapping`
164
+ from a declared mapping plus the pinned schema, and `rake fa3:verify` fails if the committed
165
+ file is stale — the same gate `lib/ksef/fa3/generated/` gets.
166
+
167
+ Three guards make drift loud rather than silent: an element path that does not resolve
168
+ against the schema aborts the run, an attribute that is not a member of its model aborts, and
169
+ a model member that is neither mapped nor given a reason aborts. Adding a field to a model
170
+ without saying where it goes fails the build.
171
+
172
+ - **Validator tier 3** — `Ksef::FA3::BusinessValidator`, reached through **`Invoice#warnings`**. It holds **one rule**, and the size is the finding rather than a shortfall: no
173
+ file in `CIRFMF/ksef-api` states a reconciliation rule anywhere, and the only business
174
+ validation KSeF ever proposed was withdrawn after it turned out to reject legal invoices. So
175
+ the tier is built on the one grounding that needs no *catalogue*: measurement over the
176
+ Ministry's 26 worked examples. It is **empirical, not definitional** — `P_15`'s annotation says
177
+ nothing about the buckets, and checking `P_13_1` against the rows instead is falsified by ten
178
+ of the fourteen modelled stated-summary samples, because a correction's buckets are deltas.
179
+
180
+ **It is advisory and never makes an invoice invalid**, which is the whole design. The rule is
181
+ `Σ P_13_* + Σ P_14_* ≈ P_15` compared over figures the *document* states — never against the
182
+ model's own derivation — with a one-grosz tolerance and a bucket-presence guard. A Polish
183
+ invoice whose nets are computed back from round gross prices misses it by roughly a grosz per
184
+ line and is entirely legal; the Ministry's own Przykład 1 is one. Making that an error would
185
+ refuse legal documents through `Client#send_invoice`, which is exactly what got KSeF's own
186
+ proposed business rule withdrawn. The `W` twins are excluded — `P_14_1W` is a PLN equivalent,
187
+ not a second tax. (`docs/REFERENCE.md` §17.)
188
+
189
+ - **`Invoice#warnings`**, the advisory tier — empty unless a document's own figures disagree.
190
+
191
+ - **`Invoice#stated_gross`** and **`Invoice#summary_buckets`**, both public. The first carries
192
+ the document's `P_15` when it differs from the derived figure; the second is the summary as
193
+ the document will carry it, rounded per bucket.
194
+
195
+ ### Fixed
196
+
197
+ - **The live integration suite asserted a terminal session code without waiting for one**, so
198
+ the nightly of 2026-09-13 went red on a commit that passed on 09-12 and 09-14. It read
199
+ `session_status` once, immediately after `wait_until_accepted`, and required `code >= 200` —
200
+ but `170` means closed with the collective UPO still generating, exactly as
201
+ `SessionCodes`' own documentation says. A race, not a flake: roughly one night in ten on
202
+ this sample, which is enough to reset a three-consecutive-night release gate. It now uses
203
+ the new `Client#wait_for_session`.
204
+
205
+
206
+ - **`json` 3.0.0 broke every API response, for users as well as CI.** json 3 dropped the second
207
+ *positional* argument to `JSON.parse`, and `Faraday::Response::Json#parse` still passes one —
208
+ so every JSON response from KSeF raised
209
+ `Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`. Since the gemspec
210
+ requires `faraday "~> 2.0"`, faraday declares `json >= 0`, and **faraday 2.14.3 is the latest
211
+ release**, a plain `gem install ksef_client` produced a client that could not read any
212
+ response. Measured in a clone bundled against json 3.0.0: **1580 examples, 163 failures**, one
213
+ cause; with the fix, **0 failures** with the coverage gates enforced.
214
+
215
+ It arrived with no commit to this repository. `Gemfile.lock` is gitignored by library
216
+ convention, so CI resolves fresh — and rubocop 1.90.0 relaxed its own `json ~> 2.3` pin to
217
+ `>= 2.3`, which let json 3.0.0 in. A transitive development pin had been shielding the build
218
+ by accident.
219
+
220
+ Faraday's response middleware takes a caller-supplied decoder, so `Ksef::HTTP::JsonDecoder`
221
+ now provides one and only the failing call changes. Everything else the middleware does is
222
+ retained — the content-type match with its `;` split, the `to_str` guard, blank body to `nil`,
223
+ and `Faraday::ParsingError` wrapping — which also leaves the spec pinning the middleware
224
+ ordering by class working untouched. `docs/REFERENCE.md` §4.9 records three measured traps
225
+ (a frozen options hash raises; a shared one silently reverts; `decoder: JSON` returns nil for
226
+ a valid body) and a **removal trigger**: faraday merged json 3 support in PR #1687 on
227
+ 2026-08-12 but has not released it, and the shim goes when it does.
228
+
229
+ No new dependency, and no version pin — pinning `json` would have fought faraday's own
230
+ `json >= 0` and rubocop's `json >= 2.3`, making the gem uninstallable next to them.
231
+
232
+ - **The cassette-recording workflow discarded the recordings it had just paid for.**
233
+ `record-cassettes.yml` has never succeeded: four dispatches, four failures. The two that got
234
+ furthest recorded real cassettes, passed the credential scan, then failed the *replay* step —
235
+ and `upload-artifact` carried no `if:`, so its implicit `success()` skipped it. Each of those
236
+ runs created a permanent, unwithdrawable TEST invoice and produced nothing retrievable. It is
237
+ why every cassette in this repository was recorded locally, against DESIGN.md §9.1's own
238
+ decision that recording belongs in CI.
239
+
240
+ Now `if: always() && steps.hygiene.outcome == 'success'`. Deliberately **not** `if: always()`,
241
+ which would publish after a failed credential scan — exactly when a cassette may hold a live
242
+ credential, to an artifact anyone with the run id can fetch. Verification that runs after an
243
+ irreversible step must not be able to destroy its output: fail the job, keep the artifact.
244
+
245
+ - **The "refuse to run against production" guard could not fire.** Both credentialed workflows
246
+ set `KSEF_ENV: test` in the guard step's own `env:` and then asked whether it equalled `prod`
247
+ — a literal against a different literal, in the step whose whole purpose is to be able to
248
+ fail. `KSEF_ENV` now sits on the job, so the guard validates the one setting every step
249
+ inherits, and it is an allow-list (`!= "test"`) rather than a `prod` denylist, matching
250
+ `rake vcr:record`'s own guard.
251
+
252
+ - **The rules the workflows must obey are asserted now, not reviewed.**
253
+ `spec/workflows_spec.rb` reads `.github/workflows/*.yml` and fails on an expression
254
+ interpolated into a `run:` script (a hard rule that had already been violated once, enforced
255
+ only by comments), an unpinned action, a `KSEF_ENV` that is not `test`, a guard that sets the
256
+ value it checks, and an upload not gated on the credential scan. Three defects in three
257
+ workflows — this, the guard above, and the release-announce bug below — were all in code no
258
+ spec could reach.
259
+
260
+ Development-only; nothing shipped changes.
261
+
262
+ - **The GitHub-release job would have failed on every release, after the gem was published.**
263
+ `release.yml` passed `github.ref_name` — the *tag*, `v0.1.0` — to `ReleaseNotes.for`, which is
264
+ keyed on the CHANGELOG heading, `0.1.0`. One character, and it raises. The next step in the
265
+ same job strips the `v` correctly, so the two disagreed and the first one lost. `announce`
266
+ runs `needs: publish`, so the failure lands after the push to RubyGems: the irreversible half
267
+ succeeds and the recoverable half breaks.
268
+
269
+ Never caught because the spec and the workflow never met. Eight examples exercised
270
+ `ReleaseNotes.for`, all passing a bare version; the workflow was the only caller that exists,
271
+ and it passed a tag. The mapping is now `ReleaseNotes.version_from_tag`, in one place with a
272
+ test, and `spec/tasks/release_notes_spec.rb` reads `release.yml` and asserts the call — not
273
+ just the function.
274
+
275
+ Two things about the guard, since they look like bugs and are not: `Gem::Version.correct?("")`
276
+ is **true** (its pattern is entirely optional), which is what the tag `v` reduces to — so the
277
+ check requires a leading digit. And `0.1.O` is a *legal prerelease*, not a typo, so this
278
+ refuses tags that are not versions and deliberately does not try to catch mistyping.
279
+
280
+ Development-only; `tasks/` is not packaged.
281
+
282
+ - **Live integration specs could not reach the network at all**, so the nightly ran red for six
283
+ consecutive nights (2026-08-28 onward: 27 examples, 27 failures, one error class). The
284
+ recorded tier's `hook_into :webmock` installs a *global* WebMock stub, and
285
+ `StubRegistry#response_for_request` consults global stubs **before** WebMock asks whether a
286
+ real connection is allowed — so `WebMock.allow_net_connect!`, the live tier's opt-in since
287
+ long before VCR arrived here, stopped being read. VCR also aliases
288
+ `WebMock.net_connect_allowed?` to answer `true` while it is turned on. With no cassette in use
289
+ and `record: :none`, VCR refused every request with `UnhandledHTTPRequestError`.
290
+
291
+ The seam is now `spec/support/live_network.rb`, which turns VCR off for the example as well.
292
+ Nothing in the suite could have caught this — the live tier is the only tier that opens the
293
+ seam, so neither per-push CI nor `rake` exercises the hook — so `spec/live_network_spec.rb`
294
+ now asks `StubRegistry#response_for_request`, the exact call `Net::HTTP#request` makes, what
295
+ is handling a request with the seam open and closed. It needs no socket and no credential, and
296
+ its first example is the negative control that keeps the rest meaningful.
297
+
298
+ Test-only; no shipped behaviour changed.
299
+
300
+ - **`UPO::Client#fetch` judged a pre-signed link's expiry against the wall clock**, which made it
301
+ the one route decision in the library that `Ksef::Client.new(clock:)` did not reach. KSeF signs
302
+ a UPO `downloadUrl` for **exactly three days** (measured across three cassettes and two
303
+ recording sessions; `docs/REFERENCE.md` §14.2), so the retrieval cassette replayed correctly
304
+ for three days and then began taking the metered fallback — a request no recording contained.
305
+ The recorded tier went red on 2026-08-29 and stayed unseen until 2026-09-03, because nothing
306
+ pushed in between.
307
+
308
+ `UPO::Client` now takes a `clock:`, threaded from the facade. **Both branches had been covered
309
+ since the method was written**, using `Time.now + 600` and `Time.now - 60` — built from the
310
+ very clock the code read, so the two agreed by construction and no coverage criterion could
311
+ distinguish an injected clock from a wall-clock one. The regression examples pin a clock that
312
+ disagrees with the wall clock in both directions.
313
+
314
+ This affects a caller who holds a `UPO::Client` open across the link's three-day lifetime;
315
+ everything reached through `Ksef::Client` behaves as before.
316
+
317
+ - **The VCR record hooks raised on any body VCR delivered as `ASCII-8BIT`.** A UTF-8 pattern
318
+ matched against a binary string raises as soon as it holds a non-ASCII byte, and KSeF's bodies
319
+ are full of Polish. The first three cassettes never hit it because their bodies arrived tagged
320
+ UTF-8; the first `application/xml` responses did not, and a real recording died after creating
321
+ a permanent TEST invoice. `before_record` does not run on replay, so no local check exercised
322
+ it. Every pattern now matches in binary — all of them are pure ASCII, as is everything they
323
+ match — with the body's encoding restored afterwards, and `spec/recorded_tier_spec.rb` calls
324
+ the hooks with bodies encoded as VCR delivers them.
325
+
326
+
327
+ - **`#errors` raised instead of reporting for two attachment shapes.** `Invoice#attachment` was
328
+ a public constructor field tier 1a never inspected, so a value that was not an `Attachment`
329
+ gave `NoMethodError`, and a U+0000 in any of the attachment's nine text fields gave a bare
330
+ `ArgumentError` from libxml2 — outside this gem's error hierarchy, which the docs tell callers
331
+ to rescue. `FieldChecks` had the guard and predicted this exact failure in its own comments;
332
+ the attachment never reached it. `AttachmentChecks` now walks the node and reports with a
333
+ field path like `attachment.blocks[0].tables[0].rows[1][0]`, and `Attachment.wrap` moved into
334
+ the constructor so `Invoice.new` and `#with` cannot walk around it.
335
+
336
+ - **A legal attachment invoice over 1 MB was refused.** `DocumentValidator` hard-coded the
337
+ no-attachment ceiling with a comment saying attachments "are batch-only, so 0.1 has no reason
338
+ to carry the larger figure" — true exactly as long as the model could not carry one. The
339
+ ceiling now follows the document (3 MB with an attachment, `docs/REFERENCE.md` §6.2). This is
340
+ the "refuses legal invoices" failure §15.6 and §14.3 exist to prevent, introduced by the
341
+ change that made it reachable.
342
+
343
+ - **`DataBlock` and `AttachmentTable` froze the caller's array in place**, so building two
344
+ blocks from one accumulator raised `FrozenError` far from its cause. The `dup` that
345
+ `Correction` documents three files away was missing from two of five sites.
346
+
347
+ - **`docs/field_mapping.md` printed `paragraphs` and `totals` as required**, contradicting §8.7
348
+ in the same commit: both sit inside optional parents (`Tekst`, `Suma`). The cardinality walker
349
+ only considered wrappers inside an element's own complexType, and no declared path had crossed
350
+ an optional *element* before. It now reports cardinality relative to each model's own element
351
+ — "given a `Tabela`, is there a `Suma`?" — which is the question that section's reader is
352
+ asking. `rows` also showed `WKom`'s 1–20 where the attribute holds rows (1–1000).
353
+
354
+ - **`AttachmentReader`'s documented contract was the opposite of its behaviour**, claiming
355
+ nothing refuses a document and naming a reporter that did not exist. Eight structurally
356
+ invalid shapes raise; the comment now says which, why that is consistent with the rest of the
357
+ parser, and what the alternative would cost.
358
+
359
+ - Corrections found by the same audit: `docs/REFERENCE.md` §8.7 was physically inside chapter 18
360
+ — the mistake chapter 19's own introduction describes being repaired the same day — and cited
361
+ "§16" for a rule that is in §15.5; `serializer.rb` still said the model does not carry
362
+ `Zalacznik` three lines above the member added for it; the ragged-row description said
363
+ "heading a group of nine-cell ones" where the corpus alternates and one table is six wide; and
364
+ `CLAUDE.md`'s verification line carried the old branch figure in the sentence telling you to
365
+ keep it current.
366
+
367
+
368
+ - **A live pre-signed URL was committed in two cassettes.** KSeF hands out the UPO as an Azure
369
+ user-delegation SAS, where the query string *is* the authorisation — and both session cassettes
370
+ carried one, read-only and valid for three days. DESIGN.md §9.1 requirement 1 names this exact
371
+ value alongside tokens and key material; the requirement's URI-matching half shipped and its
372
+ scrubbing half did not. All four hygiene checks passed over it, because a SAS `sig` is neither
373
+ `Bearer`-prefixed, nor JWT-shaped, nor a value the scanning machine holds. The query is now
374
+ stripped on record, the committed cassettes are scrubbed, and the scanner has a fifth check.
375
+ History was deliberately not rewritten: the capability is read-only, scoped to one synthetic
376
+ TEST document, and expires 2026-08-29 by itself (DESIGN.md §9.1).
377
+
378
+ - **Command injection in the one workflow holding a live credential.** `${{ inputs.target }}` was
379
+ interpolated into a `run:` script that also exports `KSEF_TEST_TOKEN` — as was
380
+ `${{ inputs.confirm }}`, inside the guard enforcing the typed confirmation, on a step that runs
381
+ before checkout. Inputs now reach the shell through `env:`; `rake vcr:record` passes its target
382
+ as an argv entry rather than a command fragment, and its guard is anchored rather than a
383
+ `start_with?` that accepted `spec/recorded; …`. Both credential-bearing workflows declare
384
+ `permissions: contents: read`, and the cassette artifact expires after a day.
385
+
386
+ - **The cassettes would have stopped replaying on 2027-09-29.** `Client#public_keys` built
387
+ `Crypto::PublicKeys` without the clock, and `#for_usage` filters published certificates on
388
+ `valid_at?` — so the recorded certificate list expiring in 2027 would have taken the whole tier
389
+ with it. The same defect as the fifteen-minute one, in the second wall-clock consumer of the
390
+ same path.
391
+
392
+ - **`compositor_of` missed a `complexContent` extension**, so `Faktura/Podmiot1/AdresKoresp`
393
+ reported no content model and `Serializer` refused its children while naming an **empty** list
394
+ of permitted ones. A false statement about the schema, sourced from metadata.
395
+ (`docs/REFERENCE.md` §18.2.)
396
+
397
+ - **The codegen dropped element-level `fixed` and inline enumerations**, which is why
398
+ `DocumentMapping#header` hand-wrote `"WariantFormularza" => 3` six lines below a comment saying
399
+ the fixed values come from the metadata. Three elements carry an inline enumeration and one
400
+ carries `fixed`; all are captured now, and the header reads the enumeration.
401
+
402
+ - **`spec/cassette_hygiene_spec.rb`'s anti-vacuity guard was itself vacuous** —
403
+ `expect(cassettes.size).to be >= 0` — in the file written to expose vacuous passes. It now
404
+ asserts the scanner found every committed cassette. The env-secret check also failed *open* on
405
+ a body with invalid bytes, since `String#include?` answers false rather than raising.
406
+
407
+ - **A cassette held an interaction from an earlier, aborted recording**, on a different auth
408
+ reference number and 56 minutes older than the flow around it. It never replayed, but
409
+ `RecordedClock` seeds from the first interaction, so that cassette's pinned "now" was 56
410
+ minutes before the flow — making §9.1's claim about pinning false for one of three cassettes.
411
+
412
+ - **`ModelValidator`'s leaf test** asked whether the metadata had an entry, which stopped meaning
413
+ "takes a text value" the moment the codegen began emitting `simpleContent` types. It now asks
414
+ about `:content`.
415
+
416
+ - **The cassette hygiene scan read the file, and a cassette is not entirely text.** Psych stores
417
+ a body it cannot write as a plain scalar as `!binary`, and one non-ASCII byte is enough — five
418
+ of the tier's bodies are stored that way. A JWT inside one matches no regex over the file, and
419
+ `include?` of a known secret fails too. Since `Łódź` in a seller name is routine in Polish
420
+ e-invoicing, and the redeem response — the one that has actually leaked — carries both tokens
421
+ in a JSON body, this was the guard added after that leak being blind to the same leak. It now
422
+ decodes every body and header first, with a planted-secret example proving it.
423
+ (DESIGN.md §9.1.)
424
+
425
+ - **The recorded tier spent eight of its nine seconds asleep.** Authentication is asynchronous,
426
+ so `Auth::Client#wait_until_complete` sleeps between status polls — including on replay, where
427
+ every answer is already on disk. `Ksef::Client.new` now takes `sleeper:`, the same seam
428
+ `Sessions::Status#poll` has always had, and the tier replays in 1.6 s again.
429
+
430
+ - **`SimpleCov.add_filter` is deprecated in SimpleCov 1.1** and printed two lines on every run,
431
+ including inside `rake`, where a real coverage failure has to be spotted among them. Replaced
432
+ with `skip`.
433
+
434
+ - **The recorded cassettes stopped replaying about twelve minutes after they were recorded.**
435
+ A KSeF access token is valid for fifteen minutes and `Auth::AccessToken` refreshes at 80% of
436
+ that, so a replay against the real clock found the recorded credential stale and issued a
437
+ `POST /auth/token/refresh` the cassette has no interaction for. The suite went red overnight
438
+ with nothing changed; the verification that declared the tier green had run inside the window.
439
+ `Ksef::Client.new` now takes `clock:`, and the recorded specs pin it to the cassette's own
440
+ `recorded_at` — the response is never rewritten, because the tier's claim is that these are
441
+ the bytes KSeF sent. (DESIGN.md §9.1.)
442
+
443
+ - **The codegen attributed an XML attribute to every type above the one declaring it.** The
444
+ extractor used a descendant axis, so seven complexTypes claimed an attribute where only two
445
+ declare one. It survived because it produced a *correct document*: `DocumentMapping#header`
446
+ found `kodSystemowy`/`wersjaSchemy` on `TNaglowek`, one level above where `KodFormularza`
447
+ declares them,
448
+ and a second defect made that the only lookup available — anonymous types nested inside a
449
+ **named** type were never collected, so `TNaglowek/KodFormularza` did not exist. Both fixed,
450
+ and `Generated::Types` gained one entry. (`docs/REFERENCE.md` §18.2.)
451
+
452
+ - **`Renderer::KEY_ORDER` omitted two keys it renders**, and `sorted_keys` gives every unlisted
453
+ key the same rank while `sort_by` is unstable — so `use` and `fixed` tied on every rendered
454
+ attribute. The same trap produced a macOS/Linux difference in `tasks/field_mapping.rb`. Every
455
+ key a rendered Hash can hold is now listed. (§18.2.)
456
+
457
+
458
+ - **A `VAT` invoice derived `P_15` instead of reading it**, so re-serialising the Ministry's
459
+ Przykład 1 produced an invoice a grosz cheaper — 2050.99 against a stated 2051 — with tier 2
460
+ clean, `#errors` empty and `#unmapped_elements` silent, because `P_15` is present either way
461
+ and a path-difference diagnostic cannot see a changed value. Even the round-trip law held: a
462
+ parsed invoice is already a fixed point. The same class as the `P_9A` rounding defect, found
463
+ the same way, by measuring the corpus. `P_15` is now **numerically unchanged** on
464
+ re-serialisation for all 22 modelled samples, and a spec asserts it — numerically, not
465
+ byte-for-byte, since 17 of the 22 reformat (`2051` → `2051.00`). (`docs/REFERENCE.md` §17.2.)
466
+
467
+ ### Added
468
+
469
+ - **Every markdown file in `CIRFMF/ksef-api` is now pinned** — thirteen more, taking the
470
+ mirror under `docs/upstream/` from 19 of 32 to 32 of 32, all verified byte-for-byte against
471
+ their upstream blob SHAs at the same commit `1c34fe27`. They had been left out deliberately,
472
+ on the rule that only documents a milestone derives facts from belong in the manifest; the
473
+ rule was right and its application was wrong, because two of them define `offlineMode` and
474
+ `hashOfCorrectedInvoice` — parameters `Sessions::Online#send_invoice` has accepted since
475
+ Phase 2. New ledger section `docs/REFERENCE.md` §16.
476
+
477
+ The fact that reaches callers: **declaring `offlineMode: false` does not mean KSeF treats
478
+ the invoice as online.** It compares `P_1`'s calendar day against the moment it accepts the
479
+ document, and marks the invoice offline if `P_1` is earlier — an invoice dated yesterday and
480
+ sent one second after midnight is offline (§16.1). Nothing is enforced for this, because it
481
+ is the service's classification and `P_1` is the caller's choice; `#send_invoice` now
482
+ documents it, along with what a *technical correction* actually is (§16.2).
483
+
484
+ ### Fixed
485
+
486
+ - **`#valid?` answered `true` for XML that is not XML.** `Invoice#errors` ran the schema tier
487
+ over `#to_xml` — bytes this gem had just produced, well-formed by construction — so it could
488
+ not see the input at all, while libxml2's recovery made the parsed tree look fine.
489
+ `Invoice#source_errors` now reports what libxml2 said about the document it was given, and
490
+ `#errors` reports it first. (`docs/REFERENCE.md` §19.2.)
491
+
492
+ - **Text that is validly encoded but is not UTF-8 crashed the tier meant to report it.**
493
+ `String#valid_encoding?` answers true for `Windows-1250` and `ISO-8859-2` — what a Polish ERP
494
+ emits — so such a name passed every guard and then raised `Encoding::CompatibilityError` out
495
+ of `#errors`, `#to_xml` and `Ksef::Client#send_invoice`. It is now reported, naming the
496
+ encoding. Bytes tagged `ASCII-8BIT` are accepted when they *are* UTF-8, which is what
497
+ `File.binread` produces. (§19.3.)
498
+
499
+ - **`net_by_rate` and `vat_by_rate` summed a correction's before-state into its after-state.**
500
+ On the Ministry's Przykład 2 that gave 3089.42 against a `net_total` of −162.60 — a per-rate
501
+ VAT report nineteen times the truth, with no error and a passing `#valid?`. Rows marked
502
+ `StanPrzed` are now skipped. (§19.1.)
503
+
504
+ - **`NaN` and `Infinity` passed the money gate.** `BigDecimal("NaN")` succeeds where
505
+ `BigDecimal("abc")` raises, so a document stating `<P_11>NaN</P_11>` reached the model and
506
+ serialised as `NaN.00` — and broke the `==`/`hash` contract on the way, since `NaN != NaN`.
507
+ Refused now, for the same reason a `Float` is.
508
+
509
+ - **`Invoice#to_h` handed out the entire source document.** `#inspect` redacts `raw_document`
510
+ so `p invoice` stays readable; `to_h` is a `Data` freebie and did not, so
511
+ `JSON.dump(invoice.to_h)` embedded the whole XML. Both `to_h` and `deconstruct_keys` now
512
+ redact it, as `#==` already ignored it.
513
+
514
+ - **A parsed invoice could lose the `P_15` it was given.** Settling the rounding strategy by
515
+ copying the invoice meant the copy's constructor re-ran with `stated_gross` already dropped,
516
+ so a `:per_summary` document was re-serialised with a derived total. The strategy is now
517
+ decided from the lines *before* the invoice is built. Every one of the 26 pinned samples
518
+ infers `:per_line`, so the corpus could not have caught it.
519
+
520
+ - **The advisory tier raised.** `Invoice#warnings` read `P_15` and the buckets off the retained
521
+ document with no tolerance for an empty or unparseable one — the very case
522
+ `Parser#readable_gross` had just been taught to survive. It also stripped namespaces from
523
+ that document in place, mutating the value object's retained source from a read-only query.
524
+
525
+ - **A unit price was silently rounded from eight decimal places to two.** `P_9A` — and `P_9AZ`
526
+ on an order position — is `TKwotowy2`, *"22 znaki max, w tym 8 znaków po przecinku"*, not the
527
+ two-place `TKwotowy` of the amounts it produces. A row priced at `1626.0125` is schema-valid;
528
+ this model read it, stored `1626.01` and re-emitted `1626.01`, with `#errors` empty and
529
+ `#unmapped_elements` empty because the element path never changed. A **stated amount altered
530
+ in silence**, which is the most serious defect this model can carry. `Formatting.unit_price`
531
+ keeps up to eight places and still pads `150` to `150.00`, and `TKwotowy2`'s pattern allows
532
+ one to eight, so every existing document is byte-identical under the fix.
533
+ (`docs/REFERENCE.md` §8.6.)
534
+
535
+ - **`Correction#exchange_rate_before` held a rate the document cannot carry.** `KursWalutyZK`
536
+ is `TIlosci`, whose six decimal places are a ceiling and not a preference. Stored unrounded,
537
+ a correction built with `4.12345678` emitted `4.123457` and then failed the round-trip law
538
+ against itself — with tier 2 silent, because what reached the document was valid. It was the
539
+ only field in the model not rounded to its element's scale (§8.2b).
540
+
541
+ - **`Line#vat` answered `0` for a row whose tax is unknown.** `#net` and `#gross` already
542
+ answered `nil` for a row that states no amount; `#vat` claimed the tax was nothing, so
543
+ `lines.sum(&:vat)` under-reported in silence while `sum(&:net)` raised. It now answers `nil`
544
+ for that row and keeps `0` for the case that really is zero — a rate code such as `zw`, `oo`
545
+ or `np I`, where the amount is stated and carries no tax.
546
+
547
+ - **Tier 1 addresses a rateless row to the field that fixes it**, `lines[0].vat_rate` rather
548
+ than `lines[0]`. Supplying `P_12` is the whole remedy, so the issue points at it. A row that
549
+ states no amount stays addressed to the row, because its remedies are several.
550
+
551
+ - **VAT rate code `np II` reported into the wrong summary bucket** — the third bug of this
552
+ exact class, after the shared-bucket accumulation bug and rate code `3`. `np II` is
553
+ *"świadczenie usług o których mowa w art. 100 ust. 1 pkt 4 ustawy"*, and `P_13_9` is *"suma
554
+ wartości świadczenia usług, o których mowa w art. 100 ust. 1 pkt 4 ustawy"* — the same
555
+ statutory scope, word for word. `P_13_8`, where both `np` codes went, reads *"z wyłączeniem
556
+ kwot wykazanych w polach P_13_5 i **P_13_9**"*: the one bucket `np II` may not use. The
557
+ effect was an intra-EU services figure declared in the wrong category on an XSD-valid
558
+ document that tier 1 passed. `VatRate.unreachable_elements` now also names `P_13_11` (the
559
+ margin scheme, declared through `Adnotacje/PMarzy` rather than a rate), and a spec asserts
560
+ every *other* net bucket is reachable — an incomplete list there is what made `P_13_9` look
561
+ like a deliberate gap (`docs/REFERENCE.md` §8.1a).
562
+
563
+ - **Two more falsifications of the tier 1a contract** ("what this passes, `#to_xml` can
564
+ serialise"):
565
+ - A Hash nested under a **leaf** annotation — `annotations: {"P_16" => {"X" => 1}}` — passed
566
+ the model tier and then made `#to_xml` raise. The recursion added in the previous release
567
+ resolved each child's type the way the serializer does, but read a leaf's empty element
568
+ list as "the codegen changed" rather than "this element takes text".
569
+ - `address_errors` still checked `respond_to?(:line1)` and then read `line2` and `country`,
570
+ so an object answering only the first made `#errors` raise `NoMethodError` — the symptom
571
+ the surrounding guards had already been changed to `is_a?` to prevent.
572
+
573
+ - **A correction built without stated totals derived its summary from the rows, counting
574
+ `StanPrzed` rows as sales.** `docs/REFERENCE.md` §8.4 says a correction's summaries are read
575
+ and never computed; that held in the parser and not in the serializer, which fell back to
576
+ the line-derived buckets whenever no `Totals` was given. A `StanPrzed` row is the position
577
+ *as it was before the correction* — already invoiced on the original document — so summing
578
+ it counts the amount twice with the wrong sign. The Ministry's Przykład 2, built through
579
+ this gem's own DSL without `f.totals`, declared `P_15 = 3799.98` for a correction worth
580
+ `-200.00`: a refund emitted as a charge, XSD-valid, with `invoice.errors` empty and
581
+ `#unmapped_elements` showing nothing. **Tier 1 now requires stated totals whenever a line is
582
+ marked `state_before`** — scoped to the marker rather than to the invoice type, because a
583
+ correction whose rows already *are* the deltas computes correctly, and Przykład 3 is exactly
584
+ that shape.
585
+
586
+ - **`NrWierszaFa` was read as octal.** `Formatting.integer` called `Integer(value)` with no
587
+ base, so Ruby honoured a leading zero. `<NrWierszaFa>010</NrWierszaFa>` is schema-valid and
588
+ means ten; it parsed as **eight** and re-serialised as `8` — in the one field that pairs a
589
+ correction's before/after rows once `UU_ID` is dropped. `"08"` failed the other way, raising
590
+ on a document the schema accepts. A `Float` is now refused rather than truncated.
591
+
592
+ - **Tier 1 rejected KSeF numbers the FA(3) schema allows.** It judged a referenced
593
+ `NrKSeFFaKorygowanej` with `Ksef::KsefNumber::FORMAT`, which comes from the **OpenAPI
594
+ contract** and admits only the NIP issuer form; the **XSD** additionally admits `M\d{9}` and
595
+ `[A-Z]{3}\d{7}`. The two artifacts are both right about their own domain — the contract
596
+ governs lookup URLs, the XSD governs documents — so tier 1 no longer checks the format and
597
+ tier 2 owns it (`docs/REFERENCE.md` §8.4b).
598
+
599
+ - **Silent drops and silent rewrites**, each found by the same audit:
600
+ - A **symbol-keyed** element passed the serializer's own unknown-key check, which compares
601
+ `keys.map(&:to_s)`, and was then dropped by a write loop asking `key?(name)` for a String.
602
+ - An **absent `Adnotacje`** was read as the defaults, emitting eight affirmative tax
603
+ declarations the document never made. It now reads as "nothing declared".
604
+ - A **`DaneFaKorygowanej` stating neither branch** of the schema's choice was re-serialised
605
+ with `NrKSeFN`, asserting the corrected invoice had been issued outside KSeF. Refused.
606
+ - `Ksef::FA3::Totals` **dropped a bucket** given under two spellings (`:P_13_1` and
607
+ `"P_13_1"`); it now refuses the collision.
608
+ - The `Adnotacje` key check reached one level deep while the serializer recurses, so a bad
609
+ nested key passed the model and raised on the way out.
610
+
611
+ - **Round-trip equality** held for fewer invoices than documented. `Ksef::FA3.parse(x.to_xml)
612
+ == x` now survives `number: 123`, `vat: 23`, a buyer flag given as `"1"`, and a
613
+ `row_number` that merely repeats its position — all of which describe exactly the document
614
+ their canonical spellings do. Text bound for an `xsd:token` element is canonicalised on the
615
+ way in, which also closed a case where `vat_rate: " 23 "` passed tier 1 and then made
616
+ `#to_xml` raise.
617
+
618
+ - **Errors that escaped this gem's hierarchy.** `Invoice#errors` is documented to answer
619
+ rather than raise; it raised for a mojibake NIP, for a mojibake KSeF number, and for a
620
+ `totals:` or `correction:` of the wrong class (the duck-typed guards accepted this gem's
621
+ *other* value objects, then called methods they do not have). `Formatting.date_time` raised
622
+ `NoMethodError` for a non-time, `Builder#totals` for `net: nil`, and `Correction.new` mangled
623
+ a Hash into pairs. All now `Ksef::ValidationError` — except the Hash, which no longer
624
+ becomes pairs at all: it is carried as a single entry and reported by `#errors` as
625
+ `correction.corrected[0]: is not a Ksef::FA3::CorrectedInvoice`, the addressed error the
626
+ rest of this bullet is about.
627
+
628
+ - **`Formatting.to_date` guessed.** `Date.parse("junk")` answers the first of June, and
629
+ `"12"` answers the twelfth of the current month — a value that changes with the clock.
630
+ A String must now be ISO-8601, which is the only form an FA(3) document can carry.
631
+
632
+ - **`BigDecimal("-0.00")` broke the `==`/`hash` contract**, comparing equal to `0.00` while
633
+ hashing differently, so a line whose net arrived as `"-0.00"` failed as a Hash key. Negative
634
+ zero is normalised.
635
+
636
+ - **Frozen state.** `Ksef::FA3::Correction` no longer freezes the caller's own array, and
637
+ `Totals#buckets` and `Invoice#lines` are frozen, so an invariant cannot be edited around
638
+ after construction.
639
+
640
+ ### Added
641
+
642
+ - **`UPR`, `KOR_ZAL` and `KOR_ROZ` — the last three types. All seven now build, parse,
643
+ round-trip and validate** (DESIGN.md §7.4, `docs/REFERENCE.md` §8.6). Twenty-two of the
644
+ Ministry's twenty-six worked examples go through end to end; the four that do not are
645
+ refused for a **construct** rather than for their type — two priced gross, two identifying
646
+ their buyer by something other than a NIP.
647
+
648
+ `KOR_ZAL` and `KOR_ROZ` needed one element between them. **`P_15ZK` means two different
649
+ things**, and the invoice type decides which: the amount *paid* before the correction on a
650
+ `KOR_ZAL`, the amount *left to pay* before it on a `KOR_ROZ`. `Correction#paid_before`
651
+ carries the figure and does not name it more precisely than the schema does.
652
+
653
+ `UPR` needed the real change: **a row that states no amount at all.**
654
+
655
+ ```ruby
656
+ f.invoice_type "UPR"
657
+ f.line name: "wiertarka Wiertex mk5" # and nothing else — this is the whole row
658
+ f.totals gross: "450", net: { "23" => "365.85" }, vat: { "23" => "84.15" }
659
+ ```
660
+
661
+ Every child of `FaWiersz` but `NrWierszaFa` is `minOccurs="0"`, so an amount-less row is
662
+ the schema's own default and this model was the strict one. `Ksef::FA3::Line#net` now
663
+ answers **nil rather than raising**, and nil is not zero: the row states nothing, rather
664
+ than stating that it is worth nothing. The same change unblocked **Przykład 7**, the one
665
+ Ministry correction this model could not read — its single row names goods, a `CN` code and
666
+ a quantity, with no amount anywhere — so all five corrections now go through.
667
+
668
+ **The parser stopped refusing two shapes, and tier 1 took them over.** An unpriced row, and
669
+ a row with an amount but no `P_12`, are both legal. But on an invoice that *derives* its
670
+ summary from its rows the amount is simply absent from the tax base, and neither tier can
671
+ see that — the XSD is blind to arithmetic and `#unmapped_elements` to values. So the check
672
+ is line-addressed and fires only where the summary is derived:
673
+
674
+ ```
675
+ lines[0]: states no amount, and this invoice derives its summary from its rows…
676
+ lines[1]: states an amount but no P_12 rate code, so there is no bucket to put it in…
677
+ ```
678
+
679
+ **Gross pricing is still refused at parse time**, and the distinction is deliberate: a
680
+ gross-priced row carries a number this model has nowhere to put, so reading it would drop a
681
+ real amount. An unpriced row has no number to drop.
682
+
683
+ - **`ZAL` and `ROZ` — the advance invoice and the settlement invoice that closes it out**
684
+ (DESIGN.md §7.4, `docs/REFERENCE.md` §8.5). Four of the seven types now build, parse,
685
+ round-trip and validate, and fifteen of the Ministry's twenty-six worked examples go through
686
+ end to end.
687
+
688
+ A `ZAL` documents money received before delivery, and carries **no invoice rows at all** —
689
+ the order or contract of art. 106f ust. 1 pkt 4 takes their place:
690
+
691
+ ```ruby
692
+ f.invoice_type "ZAL"
693
+ f.order total: "375150" # the whole order, including tax
694
+ f.order_line name: "mieszkanie 50m^2", qty: 1, unit: "szt.",
695
+ net_unit_price: "300000", net_amount: "300000",
696
+ vat_amount: "69000", vat: "23"
697
+ f.totals gross: "20000", net: { "23" => "16260.16" }, vat: { "23" => "3739.84" }
698
+ ```
699
+
700
+ `f.order`'s `total:` is `WartoscZamowienia`, the whole order **including tax** — 375 150
701
+ against 20 000 actually received. They are different numbers, and conflating them would be a
702
+ large error. An order position states its own tax through `vat_amount:`, because FA(3) gives
703
+ it a field (`P_11VatZ`) that an invoice row does not have; nothing about an order is derived.
704
+
705
+ The `ROZ` issued once the goods are delivered names each advance invoice it settles, in
706
+ either of the two forms the schema allows:
707
+
708
+ ```ruby
709
+ f.settles ksef_number: "5265877635-20250826-0100001AF629-AF" # issued through KSeF
710
+ f.settles number: "FZ/2026/02/150" # issued outside it
711
+ ```
712
+
713
+ **The choice here is inverted from a correction's**: `NrKSeFZN` marks an advance invoice
714
+ issued *outside* KSeF and pairs with the plain number, while the KSeF branch is the number
715
+ alone. The two branches name different fields, so `Ksef::FA3::AdvanceInvoice` carries both
716
+ and requires exactly one — a single nil-able field, which was enough for `CorrectedInvoice`,
717
+ would assert the wrong provenance here.
718
+
719
+ **Both types state their tax summary rather than deriving it**, and that is measured rather
720
+ than assumed: across every sample of the family the stated buckets never equal the row
721
+ totals. A `ZAL` has no rows to derive from; a `ROZ` describes the goods in its rows and
722
+ states what is left to pay **after** the advance — an amount this document does not contain
723
+ enough to compute. Tier 1 now requires a stated summary on three structural triggers: a
724
+ `state_before` row, an order, or a settled advance invoice.
725
+
726
+ Not modelled, and visible through `#unmapped_elements` if a parsed document carries them:
727
+ `ZaliczkaCzesciowa` (in none of the twenty-six samples), `P_15ZK` (scoped to the `KOR_`
728
+ combinations, which are the remaining work), `Platnosc` and `DodatkowyOpis`.
729
+
730
+ - **`KOR`, the correction — building, parsing and validating** (DESIGN.md §7.4,
731
+ `docs/REFERENCE.md` §8.4). A correction says what it corrects, why, and when it takes effect:
732
+
733
+ ```ruby
734
+ f.invoice_type "KOR"
735
+ f.correction reason: "obniżka ceny o 200 zł", effect: 3
736
+ f.corrects number: "FV/2026/02/150", issue_date: "2026-02-15", ksef_number: "…"
737
+ f.totals gross: "-200.00", net: { "23" => "-162.60" }, vat: { "23" => "-37.40" }
738
+ ```
739
+
740
+ `corrects` may be called up to fifty thousand times — art. 106j ust. 3 lets one correction
741
+ carry a discount across a whole period — and takes no `ksef_number` when the corrected invoice
742
+ was issued outside KSeF, which writes the `NrKSeFN` marker instead. `Ksef::FA3::Correction`
743
+ also carries `period`, `corrected_number`, and the previous state of either party
744
+ (`Podmiot1K`/`Podmiot2K`), linked to the live record by the `buyer_id` the schema calls
745
+ `IDNabywcy`. A row can be marked `state_before: true` to show a position as it was, and
746
+ `row_number:` gives the before/after pair the shared number that ties them together.
747
+
748
+ **A correction's tax summary is stated, not computed**, through `f.totals` — and that is the
749
+ decision worth knowing about. Its buckets are *deltas*, and FA(3) does not require the rows to
750
+ determine them: of the Ministry's five worked corrections, two carry no `FaWiersz` at all and
751
+ a third has a row stating no amount anywhere. Deriving the figures would invent a tax base the
752
+ document already states. `Invoice#lines` may therefore be empty, but **only** when totals are
753
+ stated; without either there is nothing to declare tax from, and the constructor says so.
754
+
755
+ Four of the Ministry's five corrections now parse, re-serialise and validate; the fifth is
756
+ refused for a row that states no price at all, with a message naming the construct.
757
+ `Ksef::FA3::Totals` is keyed by summary-element name rather than rate code, because that
758
+ mapping is not invertible — `"23"` and `"22"` share `P_13_1`.
759
+
760
+ One thing validation deliberately does **not** do: check the CRC-8 of a referenced KSeF
761
+ number. All six such numbers in the Ministry's own worked corrections fail it — they are
762
+ well-formed placeholders — so the check would refuse the Ministry's own documents. The shape
763
+ is checked; the checksum is not (`docs/REFERENCE.md` §8.4a).
764
+
765
+ - **`Ksef::FA3.parse` — reading an FA(3) document back into the model** (DESIGN.md §7.6).
766
+ The round-trip law runs green over a pinned corpus of the Ministry's own sample invoices,
767
+ which turned out not to live in `ksef-api` at all: they come from `CIRFMF/ksef-pdf-generator`
768
+ and `CIRFMF/ksef-client-csharp`, each pinned at its own commit (`docs/REFERENCE.md` §1.4).
769
+
770
+ Three things are worth knowing before using it. **Parsing is not validating** — a document
771
+ KSeF rejected still parses, because inspecting one is usually *why* you are parsing.
772
+ **`#raw_document` is always retained**, and `#unmapped_elements` names what re-serialising
773
+ would drop, computed by difference against the serializer so it cannot drift from what is
774
+ actually written; FA(3) is much larger than this model, so re-serialising a document you did
775
+ not write is lossy. And it **refuses what it cannot represent faithfully** rather than
776
+ guessing: an invoice type the model does not carry, a row with no `P_12` rate code, a row
777
+ priced gross, a row that states no price at all, and a buyer identified by anything but a
778
+ NIP. Those messages say the document is
779
+ fine and the model is the limit, and name the construct.
780
+
781
+ - **Validator tier 1, in two halves** (DESIGN.md §7.7, amended). `Ksef::FA3::ModelValidator`
782
+ checks the invoice object — required fields, enum membership read from the generated schema
783
+ metadata, NIP checksums, string lengths against the schema's own facets, and an issue date
784
+ that is not in the future. `Ksef::FA3::DocumentValidator` checks the serialized bytes for the
785
+ four admission rules of `docs/REFERENCE.md` §15.1 that **neither a model tier nor tier 2 can
786
+ see** — a byte-order mark, a prolog declaring anything but UTF-8, processing instructions, and
787
+ the Unicode characters KSeF refuses — plus that the bytes are UTF-8 at all, and the
788
+ million-byte ceiling, which `max_bytes:` overrides because upstream marks it a negotiable
789
+ default rather than a limit of the format.
790
+
791
+ `Invoice#errors` runs model → document → schema and returns `Issue` values carrying a field
792
+ path (`lines[2].vat_rate`), so a caller learns which value to fix rather than reading a
793
+ libxml2 message about a facet. `#validate!` lists every problem instead of the first.
794
+
795
+ The model tier short-circuits deliberately: serialisation *raises* on a bad NIP, a nameless
796
+ seller or a line with no derivable net, so running it after a model failure would collapse a
797
+ list of addressed errors into a single exception. Its aim is the stronger statement — *what
798
+ the model tier passes, `#to_xml` can serialise* — held as an aim rather than a proof, since a
799
+ review falsified it twice before release. And `#errors` reports rather than raises, including
800
+ for text tagged UTF-8 that is not: a method asked what is wrong should answer.
801
+
802
+ - **`Ksef::FA3::Invoice#annotations`** — the `Adnotacje` block is carried, not defaulted, so
803
+ re-serialising cannot deny a declaration the document made (cash accounting, reverse charge,
804
+ split payment, a real VAT exemption).
805
+
806
+ - **Pinned the Ministry's 26 worked FA(3) examples** as test fixtures
807
+ (`spec/fixtures/fa3/mf-samples/`, `docs/REFERENCE.md` §1.5) — all seven `RodzajFaktury` values,
808
+ and the only corpus of non-`VAT` invoice types in existence: two independent sweeps of every
809
+ CIRFMF repository, all branches and full history, found every FA(3) fixture there to be `VAT`.
810
+ All 26 validate against the pinned XSD. They are not packaged in the gem.
811
+
812
+ This is the first artifact pinned from outside the CIRFMF organisation, so §1.2's MIT reasoning
813
+ does not reach it; the redistribution rests on `podatki.gov.pl`'s site-wide statement that use
814
+ requires no consent, with the caveat — recorded rather than glossed — that the files sit on a
815
+ subdomain carrying no licence statement of its own.
816
+
817
+ - **Pinned `faktury/weryfikacja-faktury.md`** (`docs/REFERENCE.md` §15), the invoice-admission
818
+ rules KSeF applies on submission. It settles two open questions: validator tier 3's
819
+ business-rule catalogue is **absent from upstream**, not merely unpinned (§15.6), while
820
+ tier 1 — which had no first-tier source at all — is now specified exactly. NIP checksums are
821
+ validated **in production only** (§15.3), so no TEST run can ever exercise that rule.
822
+
823
+ - **`Ksef::Client` — the facade, and DESIGN.md §8's snippet now runs.** A spec drives that
824
+ snippet as written, from `Ksef::FA3.build` through `send_invoice`, `wait_until_accepted`
825
+ and `upo`, so the README's headline example can no longer drift from the code.
826
+
827
+ **`Receipt#reference` returns `self`,** and that is not a trick. §8 reads
828
+ `client.wait_until_accepted(result.reference)`, which looks like it wants a string — but
829
+ every status and UPO endpoint is keyed on **both** the session and the invoice, so an
830
+ invoice reference alone looks nothing up. Rather than bend §8 into two arguments, or cache
831
+ the session on a client that has to stay thread-safe, the pair *is* the reference.
832
+
833
+ **`#upo` uses the metered per-invoice route**, against §14.2's stated preference —
834
+ deliberately, and only there. Obtaining the unmetered pre-signed link costs a metered
835
+ status call first, so for one invoice the direct route is one request rather than two. The
836
+ link earns its keep on collective UPOs, which is what `#collective_upo` uses it for.
837
+
838
+ Thread-safe by construction rather than by promise (DESIGN.md §5.2): frozen configuration,
839
+ stateless connections, and the only mutable state — the memoised credential — behind one
840
+ mutex. **No session is ever held on the client**, which was the deciding argument for
841
+ opening a fresh one per send: a cached session would be mutable state two threads could
842
+ submit into at once. A spec runs six concurrent sends and asserts one authentication.
843
+
844
+ Authentication is lazy: constructing a client performs no I/O, and the first call needing
845
+ a credential runs the whole KSeF-token flow.
846
+ - **`Ksef::Invoices::Client`** — `GET /invoices/ksef/{ksefNumber}`, returning the invoice
847
+ verbatim. 8 req/s but only **64 req/h**, one of the tightest ceilings in the API: it is a
848
+ per-document fetch, so a month of invoices exhausts the hourly allowance long before the
849
+ per-second limit bites, and the 0.2 package export is the bulk route. The number is
850
+ CRC-checked locally rather than spending one of those 64 requests on a 404.
851
+ - **`Ksef::UPO` — retrieval, over a connection that has no credential.** A UPO is the legal
852
+ proof that KSeF received an invoice, fetched over an unauthenticated storage link, and
853
+ three properties follow from that.
854
+
855
+ **The access token is never sent to a `downloadUrl`.** Those links are pre-signed Azure
856
+ Blob URIs carrying their own authorisation in the query string, and the contract says
857
+ outright not to send the token — it would hand a live KSeF credential to third-party
858
+ storage. Rather than remembering that per call site, `Ksef::HTTP::Connection.storage`
859
+ builds a second connection with **no bearer and no base URL**, so no code path can leak
860
+ one. It also omits JSON encoding and parsing, since a UPO is XML that must survive as the
861
+ exact bytes received.
862
+
863
+ **The bytes are archived verbatim.** The Ministry's XAdES signature covers octets, not an
864
+ abstract tree, so even a lossless XML round-trip can produce a document that no longer
865
+ verifies. `UPO::Document` holds the received string and offers no parsed form, no
866
+ `#to_xml` and no re-encode; `#write` uses `binwrite` so a newline translation cannot
867
+ corrupt an archive.
868
+
869
+ **`x-ms-meta-hash` is verified** — the only integrity check available on bytes fetched
870
+ outside the API. `#fetch` prefers the unmetered, hash-verified link and falls back to the
871
+ metered route when it expires, which is §14.2's resolution after an earlier reading had it
872
+ backwards. `for_ksef_number` parses the number first, so a mistyped one fails on its CRC-8
873
+ locally rather than as an opaque 404.
874
+ - **`Ksef::UPO::Validator`** — offline schema validation for a received UPO, and the place
875
+ §14.3's trap would otherwise have caught us. `upo-v4-3.xsd` fixes the receiving party's
876
+ name to `"Ministerstwo Finansów"` while every non-production environment appends a marker,
877
+ so **a strict validator rejects every UPO that TEST and DEMO issue** — measured: all six of
878
+ upstream's own worked examples fail upstream's own schema, each with exactly one error,
879
+ always that element.
880
+
881
+ The mismatch is reported as a **warning** rather than relaxing the constraint, which keeps
882
+ strictly more: in production the fixed value is presumably right, so a mismatch there is a
883
+ real anomaly a relaxed schema would never mention. The observed value is read from the
884
+ document by XPath, and doubles as a way to tell which environment issued a UPO.
885
+
886
+ **There is deliberately no `validate!`.** A UPO is legal proof that an invoice was
887
+ received; whether it satisfies a schema is never a reason to discard the bytes or fail an
888
+ operation that already succeeded at the far end. Offering a raising method would make
889
+ gating the path of least resistance, so a spec asserts its absence.
890
+ - **`Ksef::IntegrityError`** — raised when downloaded bytes do not match the published hash.
891
+ Its own class because the right response is unlike every other error here: *fetch it
892
+ again*. Nothing is wrong with the request, the credentials or the document — the transfer
893
+ was corrupted. Silently archiving corrupt bytes as proof of receipt is the one outcome
894
+ worth refusing loudly.
895
+ - **`Ksef::Sessions::Status`** — single-shot session and per-invoice reads, plus
896
+ deadline-bounded waits. Capped exponential backoff (1s, 2s, 4s … 30s, five-minute
897
+ deadline) rather than the reference clients' fixed 1s × 60: at 1/s a single wait spends 60
898
+ of the 1200 requests an hour a context gets, and a 60-second ceiling is far too short for
899
+ a large session. A timeout says the operation has **not failed but outlasted the wait**,
900
+ because conflating the two invites a needless resend.
901
+
902
+ **It exposes no list-sessions call at all.** `GET /sessions` is 10 req/min — the tightest
903
+ budget in the API — against 1200/h for the two endpoints polling should use. Omitting the
904
+ method is the simplest way to make that mistake impossible rather than merely discouraged.
905
+
906
+ `InvoiceState` surfaces what the endpoint already returns instead of making callers fetch
907
+ it twice: the per-invoice UPO link, and on a duplicate the **original** submission's KSeF
908
+ number and session reference, which is what makes a resend reconcilable. `UpoPage` keeps
909
+ its pre-signed URL out of `#inspect` and `#to_s` — the link carries its own authorisation
910
+ in the query string, so it is a credential — while still exposing it to a caller that asks.
911
+ - **Corrected: both `100` and `150` mean "keep polling"**, not `150` alone. The earlier
912
+ reading came from `OnlineSessionUtils.cs`, whose poller returns as soon as the code is
913
+ anything but `150`, and it is wrong on the contract's own wording — `100` is *"przyjęta do
914
+ dalszego przetwarzania"*, accepted for **further** processing, so an invoice sitting there
915
+ is undecided and has no KSeF number. A poller that stops at `100` reports a pending
916
+ invoice as though it were settled.
917
+
918
+ The rule is now `code < 200 is in progress` rather than a list of intermediate codes, so a
919
+ code upstream adds later behaves correctly by default. This is deliberately the inverse of
920
+ the treat-the-unknown-as-terminal rule used for *authentication* status, and the asymmetry
921
+ is justified in both places: auth polls without a deadline so it must not loop for ever,
922
+ while session polling is bounded — and "unresolved" is a more honest answer about an
923
+ invoice than "prematurely final". The same rule makes `170` → `200` work for sessions,
924
+ where closing starts asynchronous UPO generation and "closed" is one step short of done.
925
+ - **`Ksef::Sessions::Online` — open, send, close.** Stateless and thin, like
926
+ `Auth::Client`: it maps requests and responses and holds no session of its own. Both
927
+ official clients do the same, threading the reference through as a parameter.
928
+
929
+ **The `Encryptor` is bound to the `Session`, not passed per send**, and that is the
930
+ decision worth knowing about. The symmetric key is agreed once, at open, and every invoice
931
+ in the session is encrypted under it — so an invoice encrypted with any other key is
932
+ undecryptable at the far end, and the only symptom is per-invoice status `435` arriving
933
+ **asynchronously**, long after the send returned `202`. There is no synchronous error to
934
+ catch. Binding the key to the session makes the mistake unrepresentable rather than merely
935
+ documented, the same move as `Encryptor#seal` returning both digests together.
936
+
937
+ Sends `X-KSeF-Feature: upo-v4-3` on open — a header in neither the contract nor any
938
+ upstream prose, but sent by both official clients, which selects the UPO format the session
939
+ produces. This gem bundles `upo-v4-3.xsd` and nothing else, so silence would mean accepting
940
+ a version it might not be able to validate (`docs/REFERENCE.md` §14.6).
941
+
942
+ `formCode` comes from the contract rather than from `srodowiska.md`, whose prose misspells
943
+ the PEF system codes and omits `FA_RR (1)` entirely. Reference numbers are shape-checked
944
+ before reaching a URL path.
945
+ - **`send_invoice` opens a fresh session per call**, with `client.session { |s| ... }` for
946
+ deliberate batching. Resolves the session-reuse `[VERIFY]` in DESIGN.md §6.5, which the
947
+ facts left open: sessions last 12 hours and take 10 000 invoices, and neither official
948
+ client offers a composite to copy. A reused session is mutable state on a client that must
949
+ be thread-safe; an opened-but-unused session is cancelled with status `440`; and the API
950
+ returning the *original's* KSeF number on a duplicate suggests resends are an expected
951
+ hazard rather than one to make likelier by hiding session state.
952
+ - **`Ksef::KsefNumber`** — parses and validates the identifier KSeF assigns to
953
+ an accepted invoice. CRC-8 with polynomial `0x07`, verified against the Ministry's own
954
+ documented example, which doubles as the golden vector. Checking the checksum locally is
955
+ the point: these numbers get copied between systems and read down telephones, and a CRC-8
956
+ catches exactly those slips, turning a silent lookup failure into a specific error naming
957
+ both the carried and the computed value. `assigned_on` is a `Date` because it is not
958
+ metadata — it is the invoice's **official receipt date**.
959
+ - **`Ksef::Auth::AccessToken`** — tracks expiry from the response's `validUntil`, never by
960
+ decoding the JWT: §4.3 excludes the `jwt` dependency and the contract carries `validUntil`
961
+ precisely so no decoding is needed. Refreshes at ~80% of the observed lifetime rather than
962
+ on expiry, so a request never carries a credential that dies mid-flight — on an invoice
963
+ submission, a failure that *might* have been delivered is the situation this gem works
964
+ hardest to avoid. Thread-safe with the staleness check re-run inside the lock, so a burst
965
+ of threads yields one refresh rather than a stampede.
966
+ - **`Ksef::Crypto` — the encryption layer.** AES-256-CBC with PKCS#7 for payloads,
967
+ RSA-OAEP with SHA-256 *and* MGF1-SHA-256 for wrapping, and the Ministry's published
968
+ certificates fetched, cached and selected by declared usage. Every parameter is ledgered
969
+ at `docs/REFERENCE.md` §10 from first-tier documentation, not inferred from client
970
+ behaviour.
971
+
972
+ **The IV is not prefixed to the ciphertext**, whatever `sesja-interaktywna.md` says. It
973
+ travels once as a discrete field of the session-open request, and each invoice ciphertext
974
+ is bare. There is now a fourth witness against the prose, and it is the sharpest:
975
+ upstream's own worked example pairs a 6480-byte invoice with a 6496-byte ciphertext, which
976
+ is exactly one block of PKCS#7 padding and no room at all for a 16-byte IV (§14.1).
977
+
978
+ DESIGN.md §6.4 asked for golden vectors ported from the C# client. **There are none to
979
+ port** — neither reference client commits plaintext/ciphertext pairs. The primitives are
980
+ pinned to their standards instead: **NIST SP 800-38A F.2.5** for AES-256-CBC, byte for
981
+ byte, and **FIPS 180-4** for SHA-256. The two parameters that genuinely could have gone
982
+ wrong are pinned behaviourally: the longest accepted OAEP plaintext is 190 bytes, which
983
+ fixes the digest at SHA-256 without trusting an option name, and a ciphertext made with
984
+ MGF1-SHA-256 provably fails to decrypt under MGF1-SHA-1. That last one matters —
985
+ OpenSSL's MGF1 digest defaults to SHA-1, so naming only `rsa_oaep_md` yields a different
986
+ scheme that fails at the far end and nowhere else.
987
+ - **`Ksef::Crypto::PublicKeys`** — `GET /security/public-key-certificates`, cached for an
988
+ hour behind a mutex, with the documented selection rule: filter by usage, require validity
989
+ *at the moment of use*, and prefer the latest `validFrom` where several qualify. The
990
+ endpoint is unauthenticated, so keys can be fetched before any credential exists.
991
+
992
+ Two rotation modes must not be conflated. Re-certification keeps the key pair, so
993
+ `publicKeyId` is unchanged; key rotation changes it, and an **emergency** rotation drops
994
+ the old certificate from the list immediately. `#with_key_rotation` implements §10.2's
995
+ recovery for that window — on a `400`/`21470` it re-fetches and re-runs the operation.
996
+ That is remediation and not a blind replay: a 21470 means the request was declined
997
+ outright, and the second attempt carries a *different* key identifier, so DESIGN.md
998
+ §6.7's never-auto-retry-a-POST rule is intact. Any other API error surfaces untouched.
999
+ - **`Ksef::Crypto::Encryptor#seal`** returns the ciphertext together with the hash *and*
1000
+ size of both the plaintext and the ciphertext. `POST /sessions/online/{ref}/invoices`
1001
+ requires all four (§11.1) and hashing the wrong artifact is a silent error only the server
1002
+ can catch, so the two digests are produced together rather than left to a caller to pair
1003
+ up. Sizes are byte counts, not character counts — the distinction is not academic when
1004
+ every KSeF document is full of Polish characters.
1005
+ - **`Ksef::Auth::Token` — the KSeF-token credential of DESIGN.md §8**, and
1006
+ `Auth::Client#submit_ksef_token`. **Both authentication methods now exist.** The token is
1007
+ never sent as a bearer: it is RSA-OAEP-encrypted alongside the challenge's own
1008
+ `timestampMs`, which the docs describe as a replay nonce — so the method takes a
1009
+ `Challenge` object rather than its string, and refuses the string outright. Inventing the
1010
+ timestamp locally would fail with nothing to point at.
1011
+
1012
+ Only `Nip` and `InternalId` contexts are offered, though the contract's enum has four: a
1013
+ token can only be *issued* in those two, so a token for the other two cannot exist to be
1014
+ presented (§4.1). `#to_s` and `#inspect` are redacted; the token is reachable only by
1015
+ building the request.
1016
+ - **`Ksef::CryptoError`** — a new branch of the DESIGN.md §6.7 hierarchy, for "no published
1017
+ key is valid for this usage" and for malformed key material. Neither an `ApiError` (the
1018
+ request succeeded; the list has nothing usable) nor a `ConfigurationError` (documented as
1019
+ local and pre-request). Reasoning recorded at `docs/REFERENCE.md` §10.3, alongside the
1020
+ other decisions upstream does not state.
1021
+ - **Live integration for the crypto module** (`spec/integration/crypto_spec.rb`). Two things
1022
+ no offline test can reach: that `certificateId` and `publicKeyId` are derived as §10.2
1023
+ claims — recomputed from the real certificates, which matters because the library only
1024
+ ever echoes the server's value back — and that KSeF **decrypts** what this gem wrapped,
1025
+ since an access token is issued only if the RSA-OAEP ciphertext unwrapped to the right
1026
+ `token|timestampMs`. Unlike the XAdES suite this needs no PESEL, so it reuses the stored
1027
+ `KSEF_TEST_NIP`/`KSEF_TEST_TOKEN` rather than provisioning a test person.
1028
+
1029
+ **Superseded 2026-08-24 — the nightly ran and all three specs went green.** `crypto_spec`
1030
+ resolved both of its open questions against live TEST (run `32704511675`,
1031
+ `docs/REFERENCE.md` §9), so the crypto module and the KSeF-token auth call *are*
1032
+ live-verified. As written at the time: *"These specs have only ever run against stubs. They
1033
+ are written, not yet exercised; the nightly is their first real run. Nothing in the crypto
1034
+ module is live-verified."*
1035
+ - `rake fa3:generate` — codegen producing committed `lib/ksef/fa3/generated/`: 59 content
1036
+ models and 21 enumerations read from the pinned FA(3) XSD. Hand-written models consume
1037
+ this for element ordering, occurrence rules and enum membership.
1038
+ - `rake fa3:verify` — regenerates and fails on any byte difference, so a non-deterministic
1039
+ generator or a stale `generated/` breaks the build. Runs in the default task and on every
1040
+ CI matrix leg.
1041
+ - Pinned `AuthTokenRequest` schemas (auth v2-0 and v2-1) ahead of the certificate auth flow.
1042
+ - **FA(3) models and serializer.** A plain `VAT` invoice can be described with English
1043
+ keyword arguments and serialised to schema-valid XML. `Ksef::FA3::Invoice`,
1044
+ `Subject`, `Line`, `Address`, plus `NIP` checksum validation, `VatRate` bucket mapping and
1045
+ centralised `Formatting`. Both rounding strategies from DESIGN.md §7.3 are implemented.
1046
+ - **`Ksef::FA3::Validator`** — offline XSD validation against the bundled schema. The
1047
+ schema's one remote `xsd:import` is redirected in memory, so validation needs no network
1048
+ and the pinned file stays byte-identical.
1049
+ - The serializer reads element order from the generated metadata rather than hand-listing
1050
+ it, and raises on element names the schema does not define at that position instead of
1051
+ dropping them silently.
1052
+ - **Live integration specs, and the nightly schedule enabled.**
1053
+ `spec/integration/auth_flow_spec.rb` exercises the real authentication flow against TEST.
1054
+ Opt-in twice over — tagged `:integration` *and* excluded unless `KSEF_INTEGRATION=1`,
1055
+ because RSpec ANDs exclusion filters with CLI inclusions, so a tag alone would either
1056
+ always run or never run. WebMock is re-enabled around each example rather than globally,
1057
+ so a failure cannot leave the network open for whatever runs next.
1058
+
1059
+ **DESIGN.md §12 item 4 is resolved.** Running the bootstrap against TEST established what no
1060
+ offline test could: KSeF accepts the XAdES-BES signature this gem produces, the 2.0
1061
+ namespace is correct, `/testdata/person` really is unauthenticated, and a self-signed
1062
+ certificate is accepted on TEST. Recorded at `docs/REFERENCE.md` §6a.4.
1063
+ - **`rake auth:bootstrap`** — provisions a TEST credential end to end, retiring the
1064
+ workaround `docs/REFERENCE.md` §6a.2 used to describe (a one-time out-of-band mint via
1065
+ the official C# client). It invents a checksum-valid NIP and PESEL, registers them
1066
+ through the **unauthenticated** `/testdata/person` endpoint — the only reason the chain
1067
+ is not circular, since `POST /tokens` needs a session — authenticates by XAdES with a
1068
+ self-signed certificate, and mints the token. A real qualified certificate can be
1069
+ supplied instead.
1070
+
1071
+ It lives in `tasks/`, so it is never packaged, but it is **not** an untested script:
1072
+ every method is covered against stubs. A checksum bug would otherwise surface as an
1073
+ opaque rejection from a remote server. It refuses any environment whose `test_data_api?`
1074
+ capability is false, so DEMO is refused as well as PROD, and the guard is on the
1075
+ capability rather than the name so a `custom` environment cannot slip past.
1076
+ - **`Ksef::Auth::Client`** — the six HTTP calls of the authentication flow, with typed
1077
+ responses (`Challenge`, `Initiation`, `OperationStatus`, `Tokens`, `TokenInfo`) and a
1078
+ poller. Deliberately thin: it maps requests and responses and nothing else. Only
1079
+ `wait_until_complete` has policy, and it has **no timeout by default** — on DEMO and PROD
1080
+ the operation legitimately stays "in progress" while the certificate's status is checked
1081
+ with its issuer over OCSP/CRL, so a fixed deadline would report failure for
1082
+ authentications that were about to succeed.
1083
+ - **`Ksef::Auth::Status`** — the twelve authentication status codes. These are not HTTP
1084
+ statuses; they arrive inside a 200 response. An unrecognised code is treated as
1085
+ **terminal**, because assuming otherwise polls a dead operation for ever, and elapsed
1086
+ time cannot distinguish "still legitimately 100" from "stuck".
1087
+ - `TokenInfo` redacts **both `#inspect` and `#to_s`**. Redacting only `#inspect` leaves the
1088
+ leak that actually happens — interpolating a token into a log line. Extracting the value
1089
+ is an explicit `#token` call, and `Auth::Client` does that when setting the header.
1090
+ - **`Ksef::Auth::Signer`** — the XAdES-BES enveloped signature for
1091
+ `POST /auth/xades-signature`, built on Nokogiri and stdlib `openssl` with no new
1092
+ dependency. Every algorithm is from the Ministry's published allow-list, and the shape is
1093
+ corroborated against both official clients. Its specs recompute each digest and verify
1094
+ the `SignatureValue` **without reusing any of the signer's own code**, so a test cannot
1095
+ pass by agreeing with a bug; they also assert that tampering with the payload, the
1096
+ signing time, or the signature itself is detected, and that the result validates against
1097
+ the pinned xmldsig schema.
1098
+
1099
+ It signs a String and returns a String on purpose. A digest over "the document" has to
1100
+ match what the verifier computes after parsing the bytes sent, and Nokogiri pretty-prints
1101
+ on output without adding text nodes to the tree — so an in-memory tree and its serialised
1102
+ form can canonicalise differently. Output is emitted with `FORMAT` off; re-indenting after
1103
+ signing would invalidate every signature while leaving it internally consistent.
1104
+ - **`Ksef::Auth::SignatureTemplate`** and **`Ksef::Auth::Xades`** — the signature XML and
1105
+ the algorithm vocabulary, split out so that rendering and cryptography are separable.
1106
+ - **`Ksef::Auth::TokenRequest`** — the `AuthTokenRequest` document, step 2 of the
1107
+ authentication flow. Sends the **2.0** namespace by default, validates the challenge
1108
+ format locally
1109
+ before a signature is spent on it, and emits the `ContextIdentifier` choice and the
1110
+ optional `AuthorizationPolicy` in schema order regardless of the caller's argument
1111
+ order. A spec compares the generated document against upstream's own pinned example,
1112
+ canonicalised, so the implementation is tied to an artifact rather than to a reading of
1113
+ one.
1114
+ - **`Ksef::Auth::AuthorizationPolicy`** — the client-IP whitelist as its own value object,
1115
+ since the schema treats it as a distinct structure with its own rules (three list kinds,
1116
+ each capped at ten, fixed order, mandatory `AllowedIps` wrapper). IP *values* are left
1117
+ to the schema rather than re-validated in Ruby, which would mean maintaining a second
1118
+ and divergent source of truth.
1119
+ - Pinned the **W3C xmldsig and ETSI XAdES v1.3.2/v1.4.1 schemas** that upstream
1120
+ redistributes in its PEF bundle, so the signature namespaces come from an artifact
1121
+ rather than from memory. All three compile offline — their imports are relative, so
1122
+ unlike the FA(3) schema they need no `schemaLocation` rewrite — which means the signer
1123
+ will get real structural validation. Kept under `spec/fixtures/`, not `lib/`: validating
1124
+ a signature is a test-time concern, and these are W3C/ETSI documents whose terms are not
1125
+ the repository's MIT licence that §1.2 relied on for bundling the FA schemas.
1126
+ - **`Ksef::Auth::Validator`** — offline XSD validation for auth documents, mirroring
1127
+ `Ksef::FA3::Validator`. Validates a document in either namespace, taking the rules from
1128
+ v2.1's file with its target namespace rewritten in memory, because v2.0's file cannot be
1129
+ compiled at all. A namespace that is not a known schema version is refused rather than
1130
+ validated against itself.
1131
+ - **`Ksef::FA3.build` — the keyword DSL of DESIGN.md §8.** That section's snippet now runs
1132
+ verbatim and produces schema-valid FA(3) XML, so the README's headline example is no
1133
+ longer aspirational. The DSL accepts the English shorthand from the spec (`qty:`,
1134
+ `vat:`) alongside the canonical names, and coerces an address given as a Hash or as an
1135
+ already-formatted String. It is a thin front end over the existing value objects —
1136
+ every computation and every schema default stays in `Invoice`, so there is one
1137
+ implementation of each rule rather than two.
1138
+
1139
+ Unknown or misspelled keys **raise**, naming what was permitted, on the same reasoning
1140
+ as the serializer's treatment of unknown element names: the alternative is an invoice
1141
+ that is silently missing a field. Passing both a shorthand and its canonical name
1142
+ (`qty:` and `quantity:`) is an error rather than a silent last-one-wins. Single-value
1143
+ fields set twice do take the later value, which is what a builder should do.
1144
+ - **Pinned the normative subset of upstream's prose documentation** (`docs/upstream/`, 19
1145
+ files) plus the UPO schema and its six worked examples, all at the same
1146
+ `CIRFMF/ksef-api@1c34fe27` already used for the OpenAPI contract and FA schemas. The
1147
+ repository holds 77 files and only 4 had been pinned; most of what the ledger listed as
1148
+ "unverified" turned out to be documented prose nobody had read. Newly ledgered from
1149
+ first-tier sources: crypto parameters, XAdES signature requirements, online session
1150
+ semantics, the UPO format, per-endpoint rate limits, size limits, and the KSeF number
1151
+ structure. Four of the five open items in `docs/REFERENCE.md` §9 are now closed,
1152
+ including both that were marked as blocking.
1153
+ - `docs/REFERENCE.md` §14 — a new section for **contradictions within upstream's own
1154
+ sources**, kept separate from §7's divergences from this project's design document. Four
1155
+ are recorded, each with the resolution and the evidence for it.
1156
+ - `spec/openapi_contract_spec.rb` — asserts the ledger's claims against the pinned
1157
+ contract, rather than only that the contract has not changed. It covers the two findings
1158
+ that had **no code when they were ledgered** — §14.1's discrete IV field, since
1159
+ implemented by `Ksef::Crypto`, and §14.2's pre-signed link, since implemented by `Ksef::UPO::Client` — because
1160
+ those are the ones that would otherwise rot unnoticed until someone implemented crypto
1161
+ or session handling from a stale conclusion.
1162
+ - Coverage is now gated on three criteria rather than one, at **line 99, branch 95,
1163
+ method 100**. Branch coverage was 83% behind 99% line coverage, so seventeen conditional
1164
+ paths were untested; closing the real gaps brought it to 97%, and line coverage to 100%. Fixes uncovered on the way:
1165
+ proxy configuration was entirely unexercised, and the `Retry-After` parser's past-date
1166
+ and unparseable-value fallbacks had no tests. (Branch was ratcheted to 96 and then 97 on 2026-08-24; `spec/spec_helper.rb` is the gate that decides.)
1167
+
1168
+ ### Changed
1169
+
1170
+ - The challenge format check moved from `Auth::TokenRequest::CHALLENGE_FORMAT` to
1171
+ **`Auth::Challenge::FORMAT`**, with `Challenge.validate_format!` alongside it. Both
1172
+ authentication methods consume the same challenge, so the XAdES document and the
1173
+ KSeF-token JSON body would otherwise have carried their own copies of one rule.
1174
+ - **`bigdecimal` constraint widened from `~> 3.1` to `>= 3.1, < 5`.** The old constraint
1175
+ made this gem uninstallable alongside bigdecimal 4, which has been out since 2026-03.
1176
+ A library should not force that choice on its users over an arithmetic dependency.
1177
+ - **Certificate/XAdES authentication moved from 0.3 into 0.1.** A KSeF token can only be
1178
+ issued after a one-time XAdES authentication, so a token-only client cannot bootstrap a
1179
+ credential from nothing.
1180
+ - The prior-art claim in DESIGN.md §1 ("there is no Ruby SDK") is withdrawn: `ksef-rb`
1181
+ exists and is a working KSeF 2.0 client. This gem's distinction is FA(3) authoring and
1182
+ validation rather than transport alone.
1183
+
1184
+ ### Fixed
1185
+
1186
+ - **VAT rate code `3` reported into the wrong summary bucket.** Buckets pair a current rate with
1187
+ the one it replaced — 23/22, 8/7, 4/3 — and bucket 5 is not a rate bucket at all: `P_14_5` is
1188
+ *"kwota podatku od wartości dodanej"*, foreign VAT under the OSS procedure, whose per-line rate
1189
+ lives in `P_12_XII`. Mapping `3` there meant a domestic 3% sale was declared as OSS foreign VAT,
1190
+ on a document the XSD accepts. Found by comparing against `ksef-pdf-generator`, the only
1191
+ official code that renders these buckets (`docs/REFERENCE.md` §8.1a).
1192
+
1193
+ - **`Ksef::FA3.parse` refused a valid document for the wrong reason.** Keyword arguments evaluate
1194
+ in source order, so the row reader ran before the invoice-type check — and the Ministry's
1195
+ collective corrections carry no `FaWiersz` at all, so a `KOR` was rejected with "Invoice has no
1196
+ FaWiersz rows". True, and the wrong diagnosis. The type is now checked first, and a row priced
1197
+ gross (`P_9B`/`P_11A` under art. 106e ust. 7-8) is named as such rather than reported as
1198
+ missing a net value.
1199
+
1200
+ - **Two failures from the first live nightly** (2026-08-24), both ours rather than the
1201
+ service's. An integration spec asserted `be_success` on a deliberately duplicated invoice —
1202
+ a contradiction, since `440` is a terminal rejection; KSeF behaved exactly as
1203
+ `docs/REFERENCE.md` §12.1 describes, returning the original's `originalKsefNumber`. And
1204
+ **`Ksef::UPO::Validator` reported an error on every real UPO**: a genuine one is
1205
+ XAdES-signed by the Ministry, `upo-v4-3.xsd` declares no `ds:Signature`, and **none of
1206
+ upstream's six published examples is signed** — so nothing offline could reveal it. The
1207
+ signature is now removed from a copy before the schema runs, which leaves §14.3's warning
1208
+ and every other violation intact, and `UPO::Validator.signed?` tells the two kinds of
1209
+ document apart (§14.7).
1210
+
1211
+ - **Summary buckets are accumulated, not overwritten.** Several VAT rate codes report into one
1212
+ bucket — `"23"` and `"22"` both into `P_13_1`/`P_14_1`, `"np I"` and `"np II"` into `P_13_8`
1213
+ (`docs/REFERENCE.md` §8.1a) — and assigning per rate code let the last one win. An invoice
1214
+ with a 23% line of 100 and a 22% line of 200 emitted `P_13_1=200.00` and `P_14_1=44.00`
1215
+ while `P_15` carried the correct `367.00`: the tax base understated by a third,
1216
+ `P_13_1 + P_14_1 ≠ P_15`, and **XSD-valid**, because a schema cannot see an arithmetic
1217
+ inconsistency. This affected documents the builder produced, not only parsed ones.
1218
+
1219
+ - **`Ksef::FA3::Validator` no longer passes XML that is not well-formed.** libxml2 parses in
1220
+ recovery mode, so an unclosed root or trailing text after it yielded a usable tree that
1221
+ validated clean; `document.errors` was never consulted. Well-formedness is KSeF's first
1222
+ admission rule (§15.1).
1223
+
1224
+ - **`Data#with` now re-runs the constructor** (`Ksef::FA3::Canonical`). On Ruby 3.2 — this
1225
+ gem's declared floor — `Data#with` does *not* call a custom `initialize`, though it does on
1226
+ 4.0, so every "canonicalise on the way in" invariant was bypassable through a public method
1227
+ there: `line.with(quantity: 0.1)` stored a `Float` in a monetary field, straight through the
1228
+ no-`Float` rule. RuboCop cannot see this; only running on 3.2 finds it.
1229
+
1230
+ - **Amounts and quantities are rounded to the scale their element permits**, at construction.
1231
+ `TKwotowy` is `fractionDigits="2"` and `TIlosci` is `"6"`, so a unit price of `150.125` is
1232
+ not a value FA(3) can express — and a quantity finer than six places made the document
1233
+ schema-invalid outright. Rounding on the way in means the model reports the figure the
1234
+ document will carry, and a line's net agrees with the price shown on the invoice.
1235
+
1236
+ - **`Formatting.decimal` no longer truncates a large `Rational`.** `BigDecimal`'s second
1237
+ argument is *significant digits*, not decimal places; the old `AMOUNT_SCALE + 10` turned
1238
+ `12345678901234.56` into `12345678901200.0` — the silent-rounding failure the `Float` ban
1239
+ exists to prevent, in the one type admitted as safe.
1240
+
1241
+ - **Malformed dates and numbers raise `Ksef::ValidationError`** instead of `Date::Error` and
1242
+ `ArgumentError`, so rescuing this gem's own hierarchy — which its docs tell you to do —
1243
+ actually catches the empty and malformed field text a rejected document contains.
1244
+
1245
+ - **`P_7` and `Podmiot2/Adres` are optional**, as the XSD says (`minOccurs="0"`; the address is
1246
+ *"opcjonalne dla przypadków określonych w art. 106e ust. 5 pkt 3"*, the simplified invoice).
1247
+ Requiring them refused valid FA(3) while reporting it as malformed. Absent optional row
1248
+ fields are now omitted rather than written as empty elements, which failed `TZnakowy512`.
1249
+
1250
+ - **`nokogiri` is required centrally.** Every file that uses it required it, but Zeitwerk defers
1251
+ loading those files, so the constant did not exist after `require "ksef_client"` until
1252
+ something touched the serializer — making one spec pass or fail on the RSpec seed.
1253
+
1254
+ - **`bundle exec rake` was never enforcing the coverage floors.** `rake spec` invokes
1255
+ `rspec --pattern spec/**{,/*/**}/*_spec.rb`, and that pattern *value* starts with `spec/` —
1256
+ which `spec_helper`'s filtered-run check read as "the user narrowed the run", so it skipped
1257
+ `minimum_coverage` on every single `rake`. CI calls `bundle exec rspec` directly and has
1258
+ always been strict, so nothing shipped uncovered; but CLAUDE.md described `rake` as
1259
+ mirroring CI, and on the one criterion that matters most it was strictly weaker.
1260
+
1261
+ Fixed by stripping `--pattern` and its value before looking for selectors, and verified
1262
+ both ways: `rake` now fails on a shortfall, and a filtered run stays exempt — which is what
1263
+ keeps the nightly's `rspec --tag integration` from failing on coverage rather than on tests.
1264
+
1265
+ It hid because a coverage failure prints among the RuboCop lines, so reading output instead
1266
+ of exit codes conceals it. CLAUDE.md now says to check `rake`'s exit code.
1267
+ - **Authentication status code `480` was missing.** "Uwierzytelnienie zablokowane" — the
1268
+ authentication is blocked on suspicion of a security incident, and the user must contact
1269
+ the Ministry. `Ksef::Auth::Status` now names it and says so; before, it fell through to
1270
+ "unrecognised status code 480".
1271
+
1272
+ The cause is worth recording, because it is a precedence mistake rather than an oversight.
1273
+ `docs/REFERENCE.md` §4.8 had sourced the whole table from `ksef-client-csharp`'s enum, on
1274
+ the strength of upstream *prose* saying the full list "will be available in the endpoint's
1275
+ technical documentation". The pinned OpenAPI contract — a **first-tier** artifact — states
1276
+ the table in full, and carries a code the C# enum does not. Two smaller corrections came
1277
+ out of the same reading: `450` collapses **eight** distinct causes rather than four, and
1278
+ `400`/`401` are C#-only and not contract codes at all. Now asserted in
1279
+ `spec/openapi_contract_spec.rb` so the provenance cannot regress silently.
1280
+
1281
+ The general lesson, now in §4.8: when upstream prose says a fact is undocumented, read the
1282
+ OpenAPI descriptions before reaching for a reference implementation.
1283
+ - **A documentation-consistency pass over the whole repo**, which found no defect that would
1284
+ fail a live request but a good deal of drift worth correcting. The notable ones:
1285
+ `rake auth:bootstrap` told the operator to store a live KSeF credential as a *repository*
1286
+ secret, which is precisely what §6a.3 argues against; the coverage floors existed in three
1287
+ places with three different values, one of which would have told a contributor they passed
1288
+ when CI fails them; SECURITY.md promised a cassette-scanning spec that did not exist;
1289
+ DESIGN.md still instructed the reader to send the 2.1 auth namespace and to take token
1290
+ expiry from the JWT `exp` claim, both since ruled against; and `docs/errors.md` was missing
1291
+ `Ksef::CryptoError` entirely.
1292
+
1293
+ Also corrected: several documents claimed the certificate flow's live verification covered
1294
+ more endpoints than it did. Only four have ever reached KSeF — challenge,
1295
+ `xades-signature`, `GET /auth/{ref}` and `redeem`. **`refresh` is implemented but has never run
1296
+ live** (`ksef-token` has, as of 2026-08-24), and the ledger now says so where it previously
1297
+ claimed "the whole §4.2 flow works as ledgered".
1298
+ - **`spec/cassette_hygiene_spec.rb`** — scans every committed VCR cassette for `Bearer `
1299
+ headers and for any value held in `KSEF_TEST_TOKEN` / `KSEF_TEST_NIP`, finding them by
1300
+ content rather than by path so one saved somewhere unconventional is still caught. No
1301
+ cassette exists yet, so it passes vacuously — which is the point of adding it now, since
1302
+ SECURITY.md was already promising users that this check ran.
1303
+ - `spec.files` globbed only the FA(3) schema directory, so schemas added elsewhere under
1304
+ `lib/` would not have shipped — a failure that would appear only in the packaged gem.
1305
+
1306
+ ### Notes
1307
+
1308
+ Two further upstream defects found while implementing authentication, both recorded with
1309
+ evidence in `docs/REFERENCE.md` §14.4. The root cause is shared: **XML Schema regular
1310
+ expressions are implicitly anchored, and `^` / `$` are literal characters, not anchors.**
1311
+
1312
+ - **Neither official client validates the request locally, and both send the 2.0
1313
+ namespace.** C# serialises with `XmlSerializer` and no schema; Java marshals with JAXB
1314
+ and never calls `setSchema`. The bundled XSD is a codegen input, not a runtime check, so
1315
+ the defects below never fire for them — they emit the real identifier and let the server
1316
+ decide. This gem now does the same, and sends 2.0. The Java client even ships its own
1317
+ edited copy of the 2.0 schema with the IP patterns repaired but `TNipVatUE` and
1318
+ `TPeppolId` left broken, which independently confirms those two are defective.
1319
+ - **PESEL is a structured identifier and KSeF enforces it** (§6a.3) — the first fact in the
1320
+ ledger whose source is the API's own behaviour rather than a document or a reference
1321
+ client. A checksum-perfect PESEL was rejected with `400 [21405] Invalid PESEL format`;
1322
+ the first six digits encode a birth date with the century folded into the month field.
1323
+ Nothing upstream states this, and no amount of offline verification would have found it.
1324
+ - **A missing OpenSSL CA bundle looks like a broken server certificate** (§6a.5). Recorded
1325
+ because the error names the wrong thing entirely and the tempting fix — disabling
1326
+ verification — is a hard-rule violation.
1327
+ - **The PESEL checksum is now recorded** at §6a.3 — needed to invent a test person, and
1328
+ confirmed the way §6a.1 confirmed the NIP algorithm: it validates every PESEL the
1329
+ upstream documentation ships and rejects those values with the check digit altered.
1330
+ §6a.3 also states the NIP check digit precisely, because it is easy to get backwards —
1331
+ it *is* the weighted sum `mod 11`, not `11 - (sum mod 11)`.
1332
+ - **The authentication status codes are now recorded** at `docs/REFERENCE.md` §4.8, from
1333
+ the reference implementation — the Ministry's prose names only two of the twelve and says
1334
+ the rest "will be available in the endpoint's technical documentation". This narrows, but
1335
+ does not close, the open error-code catalogue in §9.
1336
+ - **A signed `AuthTokenRequest` can never be schema-valid.** The API requires an enveloped
1337
+ signature; the schema defining the document declares a closed sequence with no
1338
+ `xsd:any`, so that signature is an unexpected element. Both requirements are upstream's
1339
+ and they contradict each other. `Signer#sign` therefore validates *before* signing by
1340
+ default, which also catches a malformed document before a signature is spent on it.
1341
+ - **`schemat_auth_v2-0.xsd` does not compile as a schema at all.** Its IP patterns use
1342
+ `\b`, which XSD regex has no concept of, so libxml2 rejects the whole file rather than
1343
+ those facets. Anyone validating against v2.0 gets a compilation failure, not a
1344
+ validation result. Only v2.1 is usable, and it rewrote all three patterns correctly.
1345
+ - **Two of v2.1's four context identifiers cannot hold their real values.** `TPeppolId`'s
1346
+ pattern is written `^P[A-Z]{2}[0-9]{6}$`, so it matches only a value that literally
1347
+ starts with `^` and ends with `$`; `TNipVatUE`'s ends with a stray `$`. Upstream's own
1348
+ documented example value for `NipVatUe` fails the schema that defines it. The natural
1349
+ value is emitted regardless and local validation is reported as advisory for those two
1350
+ types — a KSeF token can only be issued in a `Nip` or `InternalId` context anyway, and
1351
+ both of those validate cleanly.
1352
+
1353
+ Three upstream inconsistencies found while pinning the documentation, each of which would
1354
+ have produced a working-looking client that fails in practice. All are recorded with
1355
+ evidence in `docs/REFERENCE.md` §14.
1356
+
1357
+ - **The AES initialisation vector is not prefixed to the ciphertext**, despite
1358
+ `sesja-interaktywna.md` saying it is. The pinned OpenAPI contract carries the IV as a
1359
+ discrete `EncryptionInfo.initializationVector` field, and both the C# and Java reference
1360
+ clients emit bare ciphertext. Following the prose yields payloads KSeF cannot decrypt.
1361
+ - **All six of upstream's UPO examples fail upstream's own UPO schema**, each with the same
1362
+ single error: `NazwaPodmiotuPrzyjmujacego` is `fixed="Ministerstwo Finansów"` in the XSD,
1363
+ but TEST issues `"Ministerstwo Finansów - środowisko testowe (TE)"`. A client that
1364
+ strictly validates a received UPO would reject every UPO that TEST issues.
1365
+ - **`upo.pages[].downloadUrl` is a pre-signed storage link, not a path to join.** The
1366
+ contract declares it `format: uri`, generated per status query, expiring at
1367
+ `downloadUrlExpirationDate`, **exempt from API rate limits**, and served with an
1368
+ `x-ms-meta-hash` SHA-256 integrity header — and says the access token must *not* be sent
1369
+ to it. Both reference clients implement it that way; C# passes `token: null` explicitly.
1370
+ Given how tight the session budgets are, this unmetered path is the better default, with
1371
+ `GET /sessions/{ref}/upo/{upoRef}` as the fallback once a link expires.
1372
+
1373
+ ## [0.1.0.rc2] — 2026-09-03
1374
+
1375
+ **Targets:** KSeF API 2.0 · FA(3) `1-0E` · upstream `CIRFMF/ksef-api@1c34fe27`,
1376
+ `CIRFMF/ksef-client-csharp@406904d6`, `CIRFMF/ksef-pdf-generator@2b7c1dae` (sample corpus,
1377
+ `docs/REFERENCE.md` §1.4)
1378
+
1379
+ > **The release candidate for 0.1.0.** Unlike `0.1.0.rc1`, this is a working client: it
1380
+ > authenticates, builds and validates FA(3), sends, polls, and retrieves the UPO — all verified
1381
+ > against the live KSeF **TEST** environment. It is a candidate rather than a release because
1382
+ > 0.1.0's gates are not yet met (below), and because this API has not yet been used by anyone
1383
+ > but its author.
1384
+ >
1385
+ > Prereleases are not installed by `gem install ksef_client`; ask for this version explicitly.
1386
+ > **Do not point it at production.**
1387
+
1388
+ ### Why this release exists
1389
+
1390
+ To run the publishing pipeline end to end before `0.1.0` does.
1391
+
1392
+ `0.1.0.rc1` proved trusted publishing — OIDC, no long-lived API key anywhere. It did not prove
1393
+ the rest: the job that creates the GitHub release from this file landed four days *after* that
1394
+ tag, and rc1's release entry was written by hand. That job had never executed, and when it was
1395
+ finally read it turned out to be broken — it passed the git tag where a version was expected, so
1396
+ it would have failed on any tag at all. It runs `needs: publish`, so the failure would have
1397
+ landed *after* the gem was irreversibly on RubyGems.
1398
+
1399
+ That is the shape of thing a rehearsal is for, and it is why this version exists rather than
1400
+ going straight to `0.1.0`.
1401
+
1402
+ ### What is in it
1403
+
1404
+ The 0.1.0 development line as it stood on 2026-09-03. The entries are kept under the 0.1.0
1405
+ heading rather than duplicated here — at the time of tagging that heading is `[Unreleased]`.
1406
+
1407
+ Since `0.1.0.rc1`, in summary:
1408
+
1409
+ - **Authentication**, both methods the Ministry documents: XAdES-BES with a qualified
1410
+ certificate, and the KSeF-token flow. Both have minted a token against live TEST.
1411
+ - **The FA(3) layer** — a builder DSL, a parser with a round-trip law green over the Ministry's
1412
+ own sample corpus, all seven invoice types (`VAT`, `KOR`, `ZAL`, `ROZ`, `UPR`, `KOR_ZAL`,
1413
+ `KOR_ROZ`), the attachment node, and a generated `docs/field_mapping.md`.
1414
+ - **Four validator tiers**, split by what each can actually see: the model, the serialised
1415
+ bytes, the XSD, and business checks. The last are advisory — `Invoice#warnings`, never
1416
+ `#errors` — because a Polish invoice priced from round gross prices legitimately misses the
1417
+ arithmetic by a grosz per line, and refusing those is what got KSeF's own proposed rule
1418
+ withdrawn.
1419
+ - **Online sessions** — open, send, poll, close — with UPO retrieval and invoice download, both
1420
+ integrity-checked against the hash KSeF publishes.
1421
+ - **`Ksef::Client`**, the facade, thread-safe by construction rather than by convention.
1422
+
1423
+ ### Known gaps
1424
+
1425
+ - **Batch sessions are absent**, not stubbed. 0.2.
1426
+ - `Rozliczenie`, `Platnosc`, `Stopka` and other optional FA(3) nodes are not carried.
1427
+ `Invoice#unmapped_elements` names exactly what a parsed document would lose on the way back
1428
+ out, so this is visible rather than silent. 0.2.
1429
+ - The error-code catalogue in `docs/errors.md` is incomplete. 0.2.
1430
+ - **This is a release candidate and the API may still change.** SemVer's promises begin at 1.0;
1431
+ until then see the support policy in the README.
1432
+
13
1433
  ## [0.1.0.rc1] — 2026-08-22
14
1434
 
15
1435
  **Targets:** KSeF API 2.0 · FA(3) `1-0E` (`kodSystemowy` `FA (3)`, variant 3)
@@ -43,6 +1463,10 @@ gem version for which API state".
43
1463
  always on, and error mapping.
44
1464
  - `Ksef::HTTP::SystemWarning` — surfaces the `X-System-Warning` advisory header the API
45
1465
  sets on successful responses.
1466
+ - Requests send `X-Error-Format: problem-details`, opting into RFC7807 error bodies.
1467
+ This is opt-in per request, not selected by content negotiation: without the header the
1468
+ API returns the deprecated envelopes, which carry no `traceId`, no structured `errors[]`
1469
+ codes on 400 and no `reasonCode` on 403.
46
1470
  - CI: test matrix across Ruby 3.2/3.3/3.4/4.0/head, nightly TEST integration, and a
47
1471
  tag-triggered release workflow using RubyGems Trusted Publishing.
48
1472