ksef_client 0.1.0.rc1 → 0.1.0.rc2

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