ksef_client 0.1.0.rc1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1424 -0
  3. data/CONTRIBUTING.md +45 -6
  4. data/README.md +377 -25
  5. data/SECURITY.md +25 -2
  6. data/docs/REFERENCE.md +3099 -34
  7. data/docs/errors.md +33 -4
  8. data/docs/field_mapping.md +390 -0
  9. data/lib/ksef/auth/access_token.rb +150 -0
  10. data/lib/ksef/auth/authorization_policy.rb +88 -0
  11. data/lib/ksef/auth/challenge.rb +53 -0
  12. data/lib/ksef/auth/client.rb +143 -0
  13. data/lib/ksef/auth/initiation.rb +22 -0
  14. data/lib/ksef/auth/operation_status.rb +34 -0
  15. data/lib/ksef/auth/schema/schemat_auth_v2-0.xsd +109 -0
  16. data/lib/ksef/auth/schema/schemat_auth_v2-1.xsd +109 -0
  17. data/lib/ksef/auth/signature_template.rb +120 -0
  18. data/lib/ksef/auth/signer.rb +132 -0
  19. data/lib/ksef/auth/status.rb +69 -0
  20. data/lib/ksef/auth/token.rb +121 -0
  21. data/lib/ksef/auth/token_info.rb +27 -0
  22. data/lib/ksef/auth/token_request.rb +142 -0
  23. data/lib/ksef/auth/tokens.rb +24 -0
  24. data/lib/ksef/auth/validator.rb +84 -0
  25. data/lib/ksef/auth/xades.rb +40 -0
  26. data/lib/ksef/auth.rb +43 -0
  27. data/lib/ksef/client/receipt.rb +37 -0
  28. data/lib/ksef/client/session.rb +68 -0
  29. data/lib/ksef/client.rb +314 -0
  30. data/lib/ksef/crypto/certificate.rb +78 -0
  31. data/lib/ksef/crypto/digest.rb +25 -0
  32. data/lib/ksef/crypto/encryptor.rb +131 -0
  33. data/lib/ksef/crypto/public_keys.rb +146 -0
  34. data/lib/ksef/crypto.rb +66 -0
  35. data/lib/ksef/environments.rb +1 -1
  36. data/lib/ksef/errors.rb +35 -2
  37. data/lib/ksef/fa3/address.rb +64 -0
  38. data/lib/ksef/fa3/advance_checks.rb +55 -0
  39. data/lib/ksef/fa3/advance_invoice.rb +55 -0
  40. data/lib/ksef/fa3/advance_reader.rb +59 -0
  41. data/lib/ksef/fa3/attachment.rb +43 -0
  42. data/lib/ksef/fa3/attachment_checks.rb +121 -0
  43. data/lib/ksef/fa3/attachment_reader.rb +99 -0
  44. data/lib/ksef/fa3/attachment_table.rb +98 -0
  45. data/lib/ksef/fa3/builder/advances.rb +57 -0
  46. data/lib/ksef/fa3/builder/corrections.rb +46 -0
  47. data/lib/ksef/fa3/builder/subjects.rb +71 -0
  48. data/lib/ksef/fa3/builder.rb +149 -0
  49. data/lib/ksef/fa3/business_validator.rb +134 -0
  50. data/lib/ksef/fa3/canonical.rb +44 -0
  51. data/lib/ksef/fa3/corrected_invoice.rb +53 -0
  52. data/lib/ksef/fa3/correction.rb +151 -0
  53. data/lib/ksef/fa3/correction_checks.rb +106 -0
  54. data/lib/ksef/fa3/correction_reader.rb +88 -0
  55. data/lib/ksef/fa3/data_block.rb +75 -0
  56. data/lib/ksef/fa3/document_mapping.rb +142 -0
  57. data/lib/ksef/fa3/document_validator.rb +174 -0
  58. data/lib/ksef/fa3/element_tree.rb +49 -0
  59. data/lib/ksef/fa3/field_checks.rb +151 -0
  60. data/lib/ksef/fa3/formatting.rb +273 -0
  61. data/lib/ksef/fa3/generated/enums.rb +652 -0
  62. data/lib/ksef/fa3/generated/types.rb +3308 -0
  63. data/lib/ksef/fa3/invoice.rb +259 -0
  64. data/lib/ksef/fa3/issue.rb +32 -0
  65. data/lib/ksef/fa3/line.rb +162 -0
  66. data/lib/ksef/fa3/meta_entry.rb +45 -0
  67. data/lib/ksef/fa3/model_validator.rb +219 -0
  68. data/lib/ksef/fa3/nip.rb +59 -0
  69. data/lib/ksef/fa3/node_reader.rb +46 -0
  70. data/lib/ksef/fa3/order.rb +55 -0
  71. data/lib/ksef/fa3/order_line.rb +70 -0
  72. data/lib/ksef/fa3/parser.rb +208 -0
  73. data/lib/ksef/fa3/provenance.rb +137 -0
  74. data/lib/ksef/fa3/rounding_inference.rb +87 -0
  75. data/lib/ksef/fa3/row_reader.rb +66 -0
  76. data/lib/ksef/fa3/serializer.rb +140 -0
  77. data/lib/ksef/fa3/subject.rb +128 -0
  78. data/lib/ksef/fa3/subject_checks.rb +126 -0
  79. data/lib/ksef/fa3/subject_reader.rb +76 -0
  80. data/lib/ksef/fa3/summaries.rb +107 -0
  81. data/lib/ksef/fa3/summary_checks.rb +75 -0
  82. data/lib/ksef/fa3/table_column.rb +49 -0
  83. data/lib/ksef/fa3/totals.rb +119 -0
  84. data/lib/ksef/fa3/validator.rb +79 -0
  85. data/lib/ksef/fa3/vat_rate.rb +94 -0
  86. data/lib/ksef/fa3.rb +59 -0
  87. data/lib/ksef/http/connection.rb +47 -4
  88. data/lib/ksef/http/json_decoder.rb +53 -0
  89. data/lib/ksef/http/retry.rb +105 -0
  90. data/lib/ksef/invoices/client.rb +72 -0
  91. data/lib/ksef/ksef_number.rb +150 -0
  92. data/lib/ksef/sessions/invoice_codes.rb +70 -0
  93. data/lib/ksef/sessions/invoice_state.rb +68 -0
  94. data/lib/ksef/sessions/online.rb +169 -0
  95. data/lib/ksef/sessions/session_codes.rb +73 -0
  96. data/lib/ksef/sessions/session_state.rb +48 -0
  97. data/lib/ksef/sessions/status.rb +138 -0
  98. data/lib/ksef/sessions/upo_page.rb +36 -0
  99. data/lib/ksef/sessions.rb +91 -0
  100. data/lib/ksef/upo/client.rb +154 -0
  101. data/lib/ksef/upo/document.rb +74 -0
  102. data/lib/ksef/upo/schema/upo-v4-3.xsd +293 -0
  103. data/lib/ksef/upo/validation.rb +37 -0
  104. data/lib/ksef/upo/validator.rb +124 -0
  105. data/lib/ksef/upo.rb +55 -0
  106. data/lib/ksef/version.rb +1 -1
  107. data/lib/ksef.rb +18 -0
  108. metadata +105 -4
data/docs/REFERENCE.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Verification ledger
2
2
 
3
3
  Every fact the implementation depends on, with its source and retrieval date, per
4
- DESIGN.md §0.2 and §2. **Nothing about endpoint paths, XML element names, namespace
4
+ DESIGN.md §0 rule 2 and §2. **Nothing about endpoint paths, XML element names, namespace
5
5
  URIs or cryptographic parameters may enter the code unless it appears here.**
6
6
 
7
7
  Entries are `value + source URL + date`. When this ledger and DESIGN.md disagree, the
@@ -25,6 +25,8 @@ retained here as `LICENSE.upstream.txt`).
25
25
  | `lib/ksef/fa3/schema/bazowe/ElementarneTypyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `8daf4d3771de200b26b697294cc906a2add3de9acfbbd97f4b1bd4fc0e5ecb2f` |
26
26
  | `lib/ksef/fa3/schema/bazowe/KodyKrajow_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `48be2a9f181d7ff80f185c62491ba12604c5cacbbe21af8e2aaaf2c585bbd214` |
27
27
  | `lib/ksef/fa3/schema/bazowe/StrukturyDanych_v10-0E.xsd` | `faktury/schemy/FA/bazowe/…` | `cb08348374598e1e716e086c40d740390fb9e1bfa3aba1f4ec4cba0e1ef6d60f` |
28
+ | `lib/ksef/auth/schema/schemat_auth_v2-0.xsd` | `auth/schemy/schemat_auth_v2-0.xsd` | see `docs/artifacts.sha256` |
29
+ | `lib/ksef/auth/schema/schemat_auth_v2-1.xsd` | `auth/schemy/schemat_auth_v2-1.xsd` | see `docs/artifacts.sha256` |
28
30
 
29
31
  `rake verify:artifacts` re-checks these digests; treat a mismatch as an upstream change,
30
32
  not as a local bug.
@@ -42,6 +44,258 @@ The schemas are published under the repository's MIT licence, which permits
42
44
  redistribution. **Decision: bundle the XSD in the gem.** The first-run
43
45
  fetch-and-cache fallback contemplated by DESIGN.md §7.7 tier 2 is not needed.
44
46
 
47
+ **This reasoning does not generalise to every schema in the repository, and the
48
+ distinction is load-bearing.** It holds for the FA(3), auth and UPO schemas, which are the
49
+ Ministry's own work and therefore covered by its MIT licence. It does *not* hold for the
50
+ W3C, OASIS and ETSI schemas that upstream redistributes inside its PEF bundle — the
51
+ Ministry's licence cannot relicense someone else's document. Those are pinned to
52
+ `spec/fixtures/xades/` and deliberately **not** packaged; see §4.3. Two questions decide
53
+ where a pinned schema goes:
54
+
55
+ 1. **Is it needed at runtime, or only by the tests?** Test-only artifacts belong in
56
+ `spec/fixtures/` regardless of licence, because shipping them is dead weight.
57
+ 2. **Whose document is it?** If the answer is not "Ministerstwo Finansów", this section's
58
+ MIT reasoning does not apply and bundling needs its own justification.
59
+
60
+ Anything that would move a third-party schema into `lib/` — for instance offering runtime
61
+ signature validation — is a licensing decision, not a refactor, and belongs with the human
62
+ (DESIGN.md §12).
63
+
64
+ ### 1.3 Pinned prose documentation
65
+
66
+ `ksef-api` holds **77 files**; the four schemas and the OpenAPI document above were
67
+ originally the only ones pinned. That was a mistake: most of what §9 listed as
68
+ "unverified" was sitting in the same repository, in prose. The normative subset is now
69
+ mirrored under `docs/upstream/`, preserving upstream paths, at the **same commit
70
+ `1c34fe27`**. Verified byte-for-byte against the upstream tree listing on download.
71
+
72
+ | Local path (under `docs/upstream/`) | Feeds |
73
+ |---|---|
74
+ | `uwierzytelnianie.md` | §4, §4.3, §4.5 — the whole auth flow |
75
+ | `auth/podpis-xades.md` | §4.3, §4.4 — XAdES allow-list, certificate attributes |
76
+ | `auth/testowe-certyfikaty-i-podpisy-xades.md` | §4.6 — TEST bootstrap |
77
+ | `auth/sesje.md` | §4.7 — auth session revocation |
78
+ | `auth/context-identifier-*.md`, `auth/subject-identifier-type-*.md` | §4.1 — verbatim request examples |
79
+ | `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md` | §10.2 — key distribution and rotation |
80
+ | `sesja-interaktywna.md` | §11 — online session semantics |
81
+ | `faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md` | §12 — status and UPO retrieval |
82
+ | `faktury/numer-ksef.md` | §13 — KSeF number structure and CRC-8 |
83
+ | `faktury/weryfikacja-faktury.md` | §15 — invoice admission checks; the tier-1 specification |
84
+ | `limity/limity-api.md`, `limity/limity.md` | §6, §6.1 — rate and size limits |
85
+ | `srodowiska.md` | §2, §11.3 — environments, accepted schema versions |
86
+ | `dane-testowe-scenariusze.md` | §6a — TEST data provisioning |
87
+ | `tokeny-ksef.md` | §4.1, §6a.2, §6a.3 — KSeF token issuance, permitted contexts, confidentiality |
88
+
89
+ (The `tokeny-ksef.md` row was missing until 2026-08-23 though the file was pinned all
90
+ along. It is load-bearing: it is the sole source for "a token can only be issued in a `Nip`
91
+ or `InternalId` context", which is what restricts `Auth::Token::CONTEXT_TYPES` to two of
92
+ the contract's four.)
93
+
94
+ The UPO schema is pinned to `lib/ksef/upo/schema/upo-v4-3.xsd` (in `lib/`, following the
95
+ precedent set by the auth schemas: pinned ahead of the code that consumes it) and the six
96
+ worked UPO examples to `spec/fixtures/upo/`.
97
+
98
+ Two entries share a digest — `auth/context-identifier-nip.md` and
99
+ `auth/subject-identifier-type-certificate-subject.md` are byte-identical upstream, both
100
+ illustrating the same NIP-plus-`certificateSubject` request. That is not a download error.
101
+
102
+ **Completed 2026-08-26: every markdown file in the repository is now pinned**, 32 of 32.
103
+
104
+ The earlier position was that `uprawnienia.md`, `api-changelog.md`, `certyfikaty-KSeF.md`,
105
+ `kody-qr.md`, `offline/`, `pobieranie-faktur/`, `sesja-wsadowa.md` and
106
+ `przeglad-kluczowych-zmian-ksef-api-2-0.md` were **deliberately** out, on the reasoning that
107
+ prose churns on editorial fixes and a `verify:artifacts` failure should mean *a fact changed*
108
+ rather than that someone corrected a typo — so only documents a milestone actually derives
109
+ facts from belong in the manifest.
110
+
111
+ **The rule was right and its application was wrong**, for at least two files. `offlineMode`
112
+ and `hashOfCorrectedInvoice` are parameters of `Sessions::Online#send_invoice`, which shipped
113
+ in Phase 2; `tryby-offline.md` and `offline/korekta-techniczna.md` are the documents that
114
+ define them, and by the paragraph's own test they were always documents this milestone
115
+ derives facts from. Nobody noticed because the *OpenAPI* described both fields, and a
116
+ one-line field description reads like the whole story until you find the page that is not.
117
+ See §16, which is what reading them produced — including a rule that changes how KSeF
118
+ classifies an ordinary invoice this gem sends today.
119
+
120
+ | Local path (under `docs/upstream/`) | Feeds |
121
+ |---|---|
122
+ | `tryby-offline.md` | §16, §16.1 — the three offline regimes, and `offlineMode` |
123
+ | `offline/automatyczne-okreslanie-trybu-offline.md` | §16.1 — KSeF reclassifying an online invoice as offline |
124
+ | `offline/korekta-techniczna.md` | §16.2 — technical correction, and `hashOfCorrectedInvoice` |
125
+ | `sesja-wsadowa.md` | §16.3, §14.1 — batch packaging; a second witness to the IV error |
126
+ | `pobieranie-faktur/*.md` | §16.4 — invoice download, export packages, High Water Mark |
127
+ | `api-changelog.md` | §16.5 — API version history |
128
+ | `przeglad-kluczowych-zmian-ksef-api-2-0.md` | §16.5 — the 1.0 → 2.0 overview |
129
+ | `certyfikaty-KSeF.md`, `kody-qr.md`, `uprawnienia.md` | pinned, **not yet read** — 0.2/0.3 scope |
130
+ | `README.md` | the repository's own index of the above |
131
+
132
+ Three of those are pinned without being digested, and the table says so rather than implying
133
+ a reading that did not happen. The eight PNGs stay unpinned: two are session state diagrams
134
+ and would be worth reading, but a binary cannot be diffed into a fact.
135
+
136
+ Still unpinned, and genuinely out of scope: the **PEF** (`PEF(3)`, `PEF_KOR(3)` and fourteen
137
+ more UBL `bazowe` schemas — three of the seventeen are already pinned to `spec/fixtures/xades/`
138
+ for signature validation), **`FA_RR(1)`**, and **`FA(2)`**. Worth knowing what those are:
139
+ `FA_RR` is the flat-rate farmer invoice and `PEF` is the Peppol/UBL structure, both accepted
140
+ by KSeF and neither an eighth `RodzajFaktury`. So "all seven invoice types are modelled" is
141
+ true, and "this gem covers every invoice KSeF accepts" would not be.
142
+
143
+ ### 1.4 The FA(3) sample corpus comes from two *other* repositories
144
+
145
+ DESIGN.md §7.6 requires the parser's round-trip law to run against "Ministry-published
146
+ FA(3) sample files". **There are none in `ksef-api`** — its `faktury/` tree holds schemas,
147
+ prose and UPO examples, and not one example invoice. The samples exist, in two sibling
148
+ CIRFMF repositories, and were pinned 2026-08-24:
149
+
150
+ | Local path (under `spec/fixtures/fa3/`) | Source | Why this one |
151
+ |---|---|---|
152
+ | `ksef-pdf-generator/invoice.xml` | `CIRFMF/ksef-pdf-generator` @ `2b7c1da`, `assets/invoice.xml` | The primary sample: 13 KB, richly populated, and **the only candidate with no placeholders** |
153
+ | `ksef-client-csharp/invoice-template-fa-3.xml` | `CIRFMF/ksef-client-csharp` @ `406904d`, `KSeF.Client.Tests.Core/Templates/` | The minimal case |
154
+ | `ksef-client-csharp/invoice-template-fa-3-with-custom-Subject3.xml` | same | Carries `Podmiot3`, which our model cannot represent — the fixture that proves the parser does not silently drop it |
155
+ | `ksef-client-csharp/invoice-template-fa-3-with-disallowed-unicode-characters.xml` | same | XSD-valid and rejected by KSeF; the whole argument for tier 1 (§15.1) |
156
+
157
+ **All four validate against our pinned FA(3) schema** — measured 2026-08-24, zero errors,
158
+ **after the placeholder substitution described below**. That qualifier is not pedantry: the
159
+ three C# templates as pinned each fail on `#nip#` against `TNrNIP`, so anyone validating the
160
+ bytes on disk to check this ledger would get a non-empty error list and conclude it lies. The
161
+ pdf-generator sample needs no substitution and validates as it stands. What the result does
162
+ show is worth having: our XSD rewriting (§8.3) and our validator agree with upstream's own
163
+ test corpus.
164
+
165
+ Three practical notes:
166
+
167
+ - **The C# ones are templates, not documents.** They contain `#nip#`, `#nipOdbiorca#` and
168
+ `#invoice_number#` placeholders, and `#nip#` is not a legal `TNrNIP`. The pinned bytes
169
+ stay verbatim so their digests verify; substitution happens in the spec helper, which
170
+ keeps the two concerns apart. The pdf-generator sample needs no substitution.
171
+ - **They are test-only, so §1.2's first question settles the location**: `spec/fixtures/`,
172
+ never `lib/`, and the gemspec's `docs/*.md` + `lib/**` file list does not package them.
173
+ - **Licences differ, and one differs in substance.** `ksef-client-csharp` is MIT
174
+ © 2025 Ministerstwo Finansów; `ksef-pdf-generator` is MIT © 2025 **CIRF** — a different
175
+ copyright holder from every other artifact here. Both texts are retained beside the
176
+ fixtures as `LICENSE.ksef-client-csharp.txt` and `LICENSE.ksef-pdf-generator.txt`, as MIT
177
+ requires of a redistributor. Neither is in the digest manifest: a licence changing is a
178
+ legal event, not a changed fact about KSeF, and it should not read as an upstream
179
+ data change.
180
+
181
+ Being outside `ksef-api`, these four are **not** covered by §1's single-commit pin. Each row
182
+ above carries its own repository and commit.
183
+
184
+ **What a `verify:artifacts` failure actually means, here and everywhere:** the *local file
185
+ changed*. The task compares bytes on disk against recorded digests and never contacts
186
+ upstream, so an upstream repository moving is invisible to it — checking that is a manual job
187
+ against the commits recorded above. §1's "treat a mismatch as an upstream change" describes
188
+ the usual *cause* (someone re-downloaded), not the mechanism.
189
+
190
+ One practical note for a re-verifier: upstream's licence file in `ksef-client-csharp` is
191
+ `LICENCE.txt`, with the British spelling. There is no `LICENSE`, and a fetch of that name
192
+ 404s.
193
+
194
+ ### 1.5 The Ministry's own worked examples — and the first artifact from outside CIRFMF
195
+
196
+ Pinned 2026-08-24 to `spec/fixtures/fa3/mf-samples/`. **Twenty-six FA(3) invoices covering all
197
+ seven `RodzajFaktury` values** — 12 `VAT`, 5 `KOR`, 3 `KOR_ZAL`, 2 `ROZ`, 2 `UPR`, 1 `KOR_ROZ`,
198
+ 1 `ZAL` — plus a five-page descriptions PDF that is *not* redistributed.
199
+
200
+ | | |
201
+ |---|---|
202
+ | Package | *Przykładowe pliki dla struktury logicznej e-Faktury FA(3)* |
203
+ | URL | `https://ksef.podatki.gov.pl/media/e5cia0ey/przykladowe-pliki-dla-struktury-logicznej-e-faktury-fa-3.zip` |
204
+ | Archive SHA-256 | `41ebd3c57144951c65d68a36fbe433285b5791a86a8bd46cb059503e3f8b1e10` (200 512 bytes) |
205
+ | Retrieved | 2026-08-24 |
206
+
207
+ **This is the only corpus of non-`VAT` invoice types that exists.** Two independent sweeps of
208
+ every CIRFMF repository — all branches, the whole history of all six — found every FA(3) fixture
209
+ to be `RodzajFaktury=VAT`. The two files named "pef-correction" are UBL 2.1 `CreditNote`
210
+ documents, not FA(3) corrections. So DESIGN.md §7.4's remaining six types had no worked example
211
+ anywhere until this package was found, by following an issue in `CIRFMF/ksef-schematy` to the
212
+ Ministry's downloads page.
213
+
214
+ All twenty-six validate against the pinned FA(3) XSD with **zero errors** (measured 2026-08-24),
215
+ which is a second independent confirmation that §8.3's in-memory import rewriting is sound.
216
+
217
+ **They earned their keep the same day, and kept doing so.** `KOR` was built against the five
218
+ corrections here and nothing else — no upstream code models an FA(3) correction — and **all
219
+ five** parse, re-serialise and validate (four did until 2026-08-26; Przykład 7 was the fifth,
220
+ and §8.6 is what unblocked it). Every structural decision in §8.4 was taken by reading them, and two
221
+ were taken *against* what the schema documentation alone would have suggested: Przykład 2 gives
222
+ a before/after pair the same `NrWierszaFa` where the annotation says *"z odrębną numeracją"*,
223
+ and three of the five make it plain that a correction's summary cannot be derived from its rows.
224
+ §8.4a is a third finding they produced: their KSeF numbers do not satisfy §13's checksum. `ZAL`
225
+ and `ROZ` followed on 2026-08-25 (§8.5) and the last three types on 2026-08-26 (§8.6), each
226
+ built the same way. **Twenty-two of the twenty-six now parse, re-serialise, validate and
227
+ round-trip**; the four that do not are refused for a construct — two priced gross, two
228
+ identifying their buyer by something other than a NIP — rather than for their type.
229
+
230
+ #### The licence position, and the decision taken
231
+
232
+ **This is the first artifact pinned from outside the CIRFMF GitHub organisation, and §1.2's MIT
233
+ reasoning does not reach it.** That grant arrives through the repositories; these files are in
234
+ none of them.
235
+
236
+ `podatki.gov.pl` states site-wide that using its content *"niezależnie od celu i sposobu
237
+ korzystania, nie wymaga zgody Ministerstwa Finansów"* — regardless of purpose or manner, requires
238
+ no consent from the Ministry — with CC BY 3.0 PL for material marked as copyright. **But the files
239
+ are hosted on `ksef.podatki.gov.pl`**, a subdomain carrying no licence statement of its own, and
240
+ they bear no internal notice. Whether the parent statement reaches the subdomain is an
241
+ interpretive question nobody has answered in writing.
242
+
243
+ **Decision (human, 2026-08-24, DESIGN.md §12): redistribute the twenty-six XML files as test
244
+ fixtures, with attribution.** `spec/fixtures/fa3/mf-samples/NOTICE.md` carries the source, the
245
+ retrieval date, the archive checksum, the licence statement in Polish and English, and the caveat
246
+ above stated plainly. The descriptions PDF is *not* redistributed — it is referenced by URL and
247
+ checksum only, which needs no licence at all. Nothing here is packaged: the gemspec ships
248
+ `lib/**` and `docs/*.md`, and a built gem contains **zero fixtures** — the property that
249
+ matters here. (An earlier revision also gave a file count; it has moved since and is not worth
250
+ pinning.)
251
+
252
+ Filenames were normalised to `przyklad-01.xml` … `przyklad-26.xml`. Upstream's carry Polish
253
+ diacritics and an inconsistent `FA_3_`/`Fa_3_` prefix, which travel badly across case-insensitive
254
+ filesystems and Windows checkouts; the **bytes are untouched**, and the bytes are what
255
+ `docs/artifacts.sha256` pins. `NOTICE.md` holds the full mapping.
256
+
257
+ #### What the corpus establishes, beyond being a corpus
258
+
259
+ - **`Σ P_13_* + Σ P_14_* == P_15` holds exactly in 22 of the 26, and 24 count only if you let
260
+ `0 == 0` stand as a witness** — Przykład 5 and 13 state no buckets and a `P_15` of zero, so
261
+ they reconcile trivially and tier 3 skips them before comparing. Three samples state no
262
+ buckets at all (5, 13, 16), not one. Corrected 2026-08-26; the earlier "24 of 26" inflated
263
+ the evidence by two.
264
+ The two exceptions are the useful part, and they constrain any future tier 3: **Przykład 1 is
265
+ off by 0.01** (1666.66 + 383.33 + 0.95 + 0.05 = 2050.99 against a stated `P_15` of 2051).
266
+
267
+ **The cause, corrected twice.** An early revision called it "a gross-priced invoice"; it is
268
+ not, and could not be, since it states `P_9A`/`P_11` throughout. A later one called it
269
+ bucket-level tax rounding; **that is arithmetically false** — the exact taxes are 383.3318
270
+ and 0.0475, which round individually to 383.33 and 0.05 and sum to 383.38, exactly what
271
+ rounding their sum gives. Per-bucket tax rounding moves this invoice's total by 0.0007.
272
+
273
+ The real cause is in the rows: the nets are computed back *w stu* from **round gross prices**
274
+ of 2000, 50 and 1, and rounded **down** — `2000 / 1.23 = 1626.0163`, where two-place rounding
275
+ gives `1626.02` and the document states `1626.01`. Substitute the correctly-rounded net and
276
+ the invoice reconciles exactly, with no tolerance needed. `P_15` is 2000 + 50 + 1.
277
+
278
+ This matters for more than accuracy: under the tax-rounding story the error is bounded by the
279
+ five tax buckets, so a grosz of tolerance nearly covers it. Under the real mechanism it is
280
+ bounded by the **number of lines** and by the issuer's rounding convention — which is to say
281
+ not bounded at all. Two such lines and the gap is two grosze. That is why tier 3's rule is a
282
+ **warning** and not an error (§17.1). Both corrections made 2026-08-26.
283
+ And
284
+ **Przykład 16 (`UPR`) omits the buckets entirely**, the descriptions PDF stating that a
285
+ simplified invoice may carry `P_15` alone. **A zero-tolerance equality check would reject the
286
+ Ministry's own first example**, so that rule needs a one-grosz tolerance and a
287
+ bucket-presence guard — see §15.6.
288
+ - **A `KOR` carries deltas in the buckets**, confirming the XSD annotations: Przykład 2 uses
289
+ before/after rows (`StanPrzed=1` on the before row, same `NrWierszaFa`) with header
290
+ `P_13_1=-162.60`, `P_14_1=-37.40`, `P_15=-200`, reconciling exactly. Amounts are unpadded
291
+ (`P_15=2051`, `P_15=-200`), so any comparison must be numeric and never on strings.
292
+ - **Four of the twelve `VAT` samples are beyond this model**, which is a measured map of 0.1's
293
+ limits rather than a guess: `08` and `19` price rows **gross** (`P_9B`/`P_11A` under art. 106e
294
+ ust. 7-8) where the model carries net pricing only, and `22`/`23` identify the buyer by
295
+ `NrVatUE` and by `NrID` (§8.2a). Each is refused with a message naming the construct and saying
296
+ the document is fine; `spec/ksef/fa3/round_trip_spec.rb` asserts every one, and asserts that no
297
+ `VAT` sample is left unclassified.
298
+
45
299
  ---
46
300
 
47
301
  ## 2. Environments — resolves DESIGN.md §6.1 [VERIFY]
@@ -141,19 +395,482 @@ reference number or the KSeF number.
141
395
 
142
396
  ## 4. Authentication
143
397
 
398
+ Source: `uwierzytelnianie.md` (10.07.2025) plus the pinned spec. Retrieved 2026-08-22.
399
+
144
400
  - Security scheme: a single HTTP **`Bearer`** scheme, `bearerFormat: JWT`
145
401
  (`components.securitySchemes.Bearer`).
402
+ - **Exactly two authentication methods exist**, and both share the same challenge
403
+ prologue and the same token-redemption epilogue:
404
+ 1. **Qualified electronic signature (XAdES)** — an `AuthTokenRequest` XML document
405
+ signed with a certificate. The authenticating subject is read *from the signing
406
+ certificate*. `POST /auth/xades-signature`, `Content-Type: application/xml`.
407
+ 2. **KSeF token** — a JSON document carrying a previously issued token.
408
+ `POST /auth/ksef-token`.
146
409
  - `POST /auth/challenge` returns `AuthenticationChallengeResponse`, required fields:
147
410
  `challenge`, `timestamp` (date-time), `timestampMs` (int64, Unix ms), `clientIp`.
411
+ - **Challenge lifetime is 10 minutes** — now *verified* from `uwierzytelnianie.md`
412
+ ("Czas życia challenge'a wynosi 10 minut"), superseding the earlier note in this
413
+ section that it was documentation-hearsay and unconfirmed.
148
414
  - `clientIp` in the challenge response ties into the `ip-not-allowed` authorisation
149
415
  failure (§5.3): the API pins the session to the IP seen at authentication.
150
- - Challenge validity of 10 minutes is asserted by DESIGN.md §1 as verified; **not**
151
- re-confirmed from the pinned spec — the spec does not encode it. Treat as
152
- documentation-sourced, and do not build a hard timer on it without re-verification.
416
+ - The authenticating subject must already hold at least one active permission in the
417
+ requested context, or no access token is issued.
418
+
419
+ ### 4.1 The `AuthTokenRequest` document
420
+
421
+ Pinned at `lib/ksef/auth/schema/schemat_auth_v2-{0,1}.xsd` (§1).
422
+
423
+ **Send the 2.0 namespace.** An earlier revision of this section said "both versions are
424
+ accepted by the API; v2.1 is current" and this gem was built against 2.1 on that basis.
425
+ That was an inference, not a verified fact, and checking the reference implementations
426
+ (2026-08-22) contradicted it — see §14.4. Every piece of available evidence points at 2.0:
427
+
428
+ | Source | Namespace |
429
+ |---|---|
430
+ | `ksef-client-csharp`, `AuthenticationTokenRequest.cs` | `[XmlRoot(Namespace = "…/auth/token/2.0")]` |
431
+ | `ksef-client-java`, JAXB-generated `TContextIdentifier` etc. | generated against `…/2.0` |
432
+ | `ksef-client-java`, its own bundled `AuthTokenRequest.xsd` | `targetNamespace="…/2.0"` |
433
+ | all five worked examples in `CIRFMF/ksef-api` | `xmlns="…/2.0"` |
434
+
435
+ Nothing observed emits 2.1. **Whether the API accepts 2.1 at all is unverified** and needs
436
+ a live TEST call; until then 2.0 is the only defensible default.
437
+
438
+ Validation is a separate question from what to send, because **v2.0's file does not compile
439
+ as a schema** (§14.4). The rules therefore come from v2.1's file with its target namespace
440
+ rewritten in memory to match the document — the same technique `Ksef::FA3::Validator` uses
441
+ for its remote `schemaLocation`, and legitimate here because the two files are structurally
442
+ identical: diffing them shows only the namespace and the three IP patterns differ, and
443
+ v2.1's are the correct ones.
444
+
445
+ | Fact | Value |
446
+ |---|---|
447
+ | Target namespace (v2.1) | `http://ksef.mf.gov.pl/auth/token/2.1` |
448
+ | `elementFormDefault` | `qualified`; `attributeFormDefault` `unqualified` |
449
+ | Root element | `AuthTokenRequest` (no namespace prefix in upstream's examples — default namespace) |
450
+ | Required children, in order | `Challenge`, `ContextIdentifier`, `SubjectIdentifierType` |
451
+ | `SubjectIdentifierType` values | `certificateSubject`, `certificateFingerprint` |
452
+ | Optional | `AuthorizationPolicy` → `AllowedIps` → `Ip4Address` / `Ip4Range` / `Ip4Mask`, each 0–**10** |
453
+
454
+ `AllowedIps` is itself mandatory *inside* `AuthorizationPolicy`, so the policy element
455
+ cannot be present but empty.
456
+
457
+ **`Challenge` is strictly formatted** — `xsd:token`, length exactly **36**, pattern
458
+ `\d{8}-CR-[A-F0-9]{10}-[A-F0-9]{10}-[A-F0-9]{2}`. Verified 2026-08-22 that upstream's own
459
+ example `20250604-CR-461EA5B000-537A6BA15D-D7` satisfies it, that lowercase hex is
460
+ rejected, and that the literal `CR` is required. Worth validating client-side before
461
+ spending a signature on it. Note the shape matches §12's reference numbers, `CR` being the
462
+ kind tag for a challenge.
463
+
464
+ `ContextIdentifier` is a **choice of four** in v2.1 — not three:
465
+
466
+ | Element | Pattern | Usable? |
467
+ |---|---|---|
468
+ | `Nip` | `[1-9]((\d[1-9])\|([1-9]\d))\d{7}` | yes |
469
+ | `InternalId` | the NIP pattern, then `-\d{5}` | yes |
470
+ | `NipVatUe` | NIP, `-`, then an EU VAT number by member state | **no — see §14.4** |
471
+ | `PeppolId` | `^P[A-Z]{2}[0-9]{6}$` | **no — see §14.4** |
472
+
473
+ Note the NIP pattern here is *structural* (first digit non-zero, positions 2–3 not both
474
+ zero), not a checksum. It is weaker than `Ksef::FA3::NIP`'s check-digit validation, so
475
+ both are worth applying.
476
+
477
+ Per `tokeny-ksef.md`, a KSeF token can only be issued in a **`Nip` or `InternalId`**
478
+ context, which is fortunate given the state of the other two.
479
+
480
+ Signature form: enveloped or enveloping; **detached is rejected**.
481
+
482
+ Self-signed certificates are accepted on **TEST only**. `srodowiska.md` is explicit that
483
+ this is why TEST contexts are not isolated between integrators.
484
+
485
+ ### 4.2 Tokens
486
+
487
+ `/auth/token/redeem` exchanges a completed authentication for the token pair;
488
+ `/auth/token/refresh` renews. This confirms the [VERIFY] in DESIGN.md §6.3's shared
489
+ epilogue — the API does issue a refresh token alongside the access token. (That section's
490
+ numbered lists stop at 3; there is no step 4 to cite.)
491
+
492
+ | Token | Lifetime | Notes |
493
+ |---|---|---|
494
+ | `accessToken` | short (docs say "kilkanaście minut"). **Read expiry from the response's `validUntil`, not the JWT `exp`** — the contract's `TokenInfo` requires `token` + `validUntil` precisely so no decoding is needed, and DESIGN.md §4.3 excludes the `jwt` dependency | sent as `Authorization: Bearer` |
495
+ | `refreshToken` | **up to 7 days**, reusable | renews the access token without re-authenticating |
496
+
497
+ **Revocation is not immediate.** An `accessToken` stays valid until its `exp` even if the
498
+ user's permissions change in the meantime. Never treat possession of a live token as
499
+ proof of current authorisation.
500
+
501
+ **`/auth/token/redeem` is single-use.** `uwierzytelnianie.md` §4: the endpoint returns the
502
+ token pair *once* for a completed authentication, and every subsequent call with the same
503
+ `authenticationToken` returns **400**. A retry wrapper around redemption would turn a
504
+ transient network blip into a permanently unusable authentication — this endpoint must
505
+ surface its errors, which the POST-never-retried rule (DESIGN.md §6.7) already guarantees.
506
+
507
+ The full flow, verified end to end from `uwierzytelnianie.md` (retrieved 2026-08-22):
508
+
509
+ | Step | Call | Auth header | Notes |
510
+ |---|---|---|---|
511
+ | 1 | `POST /auth/challenge` | none | public endpoint, 60 req/s per IP; TTL 10 min |
512
+ | 2 | build + sign `AuthTokenRequest` | — | offline |
513
+ | 3 | `POST /auth/xades-signature` | none | returns `referenceNumber` + `authenticationToken` |
514
+ | 4 | `GET /auth/{referenceNumber}` | `Bearer {authenticationToken}` | poll to completion |
515
+ | 5 | `POST /auth/token/redeem` | `Bearer {authenticationToken}` | **once only** → `accessToken` + `refreshToken` |
516
+ | 6 | `POST /auth/token/refresh` | `Bearer {refreshToken}` | renews `accessToken` |
517
+
518
+ Note the header at steps 4–5 is the *temporary* `authenticationToken`, not an
519
+ `accessToken`. Conflating them is the obvious implementation error here.
520
+
521
+ **Step 4 must poll without a deadline on DEMO and PROD.** Those environments verify the
522
+ signing certificate's status with the issuer over OCSP/CRL, and the operation legitimately
523
+ reports "in progress" until the issuer answers — the docs state the duration depends on the
524
+ certificate provider. A client that gives up after a fixed timeout will report failure for
525
+ authentications that were about to succeed. (On TEST, self-signed certificates skip this.)
526
+
527
+ #### 4.2a Step 6 measured — `/auth/token/refresh`, recorded 2026-08-26
528
+
529
+ The last of the six to be exercised against the live service, and the only one whose response
530
+ had never been seen. Recorded into `spec/cassettes/the_token_refresh_flow_recorded/`, so these
531
+ are observations rather than readings of the contract.
532
+
533
+ **The response carries `accessToken` and nothing else:**
534
+
535
+ ```json
536
+ { "accessToken": { "token": "…", "validUntil": "2026-08-26T13:45:24.2432483+00:00" } }
537
+ ```
538
+
539
+ Three things follow, each of which the implementation had assumed:
153
540
 
154
- Token redemption and refresh exist as distinct endpoints (`/auth/token/redeem`,
155
- `/auth/token/refresh`), which confirms the DESIGN.md §6.3 step 4 [VERIFY]: the API does
156
- issue a refresh token alongside the access token.
541
+ | Assumption | Now |
542
+ |---|---|
543
+ | `Auth::Client#refresh` reads `body["accessToken"]` | **confirmed.** The envelope matches the redeem response's `accessToken` member, not a bare `TokenInfo` |
544
+ | `AccessToken#renew!` replaces the access token and keeps the refresh token | **confirmed.** No `refreshToken` is returned, so there is nothing to replace it with. A client that expected a rotated refresh token would have to re-authenticate every renewal |
545
+ | The refresh token's life is "up to 7 days" | **exactly 7 days on TEST**, to the second: redeemed `13:30:06.5720816`, valid until `2026-09-02T13:30:06.5720816` |
546
+
547
+ **A renewed access token gets a full fresh lifetime, not the remainder of the old one.** The
548
+ redeemed token was valid until `13:45:06`; refreshing eight seconds later returned one valid
549
+ until `13:45:24` — fifteen minutes from the refresh, not from the original issue. So a
550
+ long-running session renews indefinitely without re-authenticating, bounded only by the refresh
551
+ token's seven days.
552
+
553
+ **Fifteen minutes is what "kilkanaście minut" means here.** Four measurements across the tier's
554
+ three cassettes, as `validUntil` minus the moment the response was received: **900.2, 898.7,
555
+ 890.6, 900.2** seconds.
556
+
557
+ **Read those to the nearest second, not to the tenth.** `recorded_at` is an RFC 2822 timestamp
558
+ with whole-second granularity, so each figure carries about a second of error — which is also why
559
+ two of them read as 900.2 rather than 900.0, and why the claim that measuring from delivery "can
560
+ only under-estimate" is not something these four numbers demonstrate. The underlying grant *is*
561
+ exactly 900 s: the refresh response's `validUntil` is 900.0000000 s after the mint instant its own
562
+ body implies.
563
+
564
+ The spread is ours, not KSeF's. The 890.6 figure is a redeem whose authentication needed four
565
+ polls over nine seconds — KSeF dates the grant from when it minted the token, and we can only
566
+ measure from when we took delivery. That is why {Ksef::Auth::AccessToken} records `@acquired_at`
567
+ rather than trusting a constant: a lifetime measured from delivery is never longer than the real
568
+ one, so it refreshes slightly early rather than slightly late.
569
+
570
+ Nothing here is hard-coded. This is a TEST observation, not a documented guarantee, so the
571
+ threshold stays a proportion of the observed lifetime.
572
+
573
+ ### 4.3 XAdES signature requirements — resolves the §9 blocker
574
+
575
+ Source: `auth/podpis-xades.md`. **The specification is an allow-list, not a single
576
+ mandated combination**, which is the key finding: there is no exact byte-shape to
577
+ reverse-engineer, only a permitted set to choose from.
578
+
579
+ | Aspect | Permitted |
580
+ |---|---|
581
+ | Form | **enveloped** or **enveloping**. Detached is rejected. |
582
+ | Profiles | XAdES-BES, -EPES, -T, -LT, -C, -X, -XL, -A, -ERS, and BASELINE-B/-T/-LT/-LTA |
583
+ | Transforms | XPath `not(ancestor-or-self::ds:Signature)`, xmldsig-filter2, `#enveloped-signature`, `#base64`, c14n11 (±comments), exc-c14n (±comments), REC-xml-c14n-20010315 (±comments) |
584
+ | `SignatureMethod` | RSASSA-PKCS1-v1_5 or RSASSA-PSS (sha1/256/384/512, plus sha3-\* for PSS), min **2048-bit**; or ECDSA (sha1/256/384/512, sha3-\*), min **256-bit** curve |
585
+ | `DigestMethod` | sha1, sha256, sha384, sha512, sha3-256, sha3-384, sha3-512 — chosen **independently** of `SignatureMethod` |
586
+
587
+ `DigestMethod` governs both `SignedInfo/Reference/DigestMethod` and the qualifying
588
+ properties, notably `SigningCertificate/CertDigest/DigestMethod`.
589
+
590
+ **Implementation choice for this gem:** enveloped, XAdES-BES, `#enveloped-signature`
591
+ transform, exclusive c14n, `rsa-sha256`, `xmlenc#sha256` digest. Every one of those is
592
+ explicitly listed above, so the combination needs no further verification. Corroborated
593
+ 2026-08-22 against both clients: C# builds exactly this shape in `SignatureService`, and
594
+ Java asks DSS for `SignaturePackaging.ENVELOPED` with `DigestAlgorithm.SHA256` and
595
+ `SignatureAlgorithm.RSA_SHA256`.
596
+
597
+ Neither client states its `SignedInfo` `CanonicalizationMethod` — C# leaves .NET's
598
+ `SignedXml` default in place and Java leaves it to DSS — so exclusive c14n is *this gem's*
599
+ choice rather than a copied one. It is safe because the allow-list above permits both c14n
600
+ 1.0 and exclusive c14n, and exclusive avoids namespace-inheritance surprises when the
601
+ signature is lifted between documents.
602
+
603
+ #### Two facts the allow-list does not state
604
+
605
+ Both taken from `ksef-client-csharp`'s `KSeF.Client/Api/Services/SignatureService.cs`
606
+ (retrieved 2026-08-22) and recorded with that provenance rather than treated as common
607
+ knowledge:
608
+
609
+ | Fact | Value | Why it matters |
610
+ |---|---|---|
611
+ | `Type` on the `SignedProperties` reference | `http://uri.etsi.org/01903#SignedProperties` | Fixed by ETSI TS 101 903, absent from the Ministry's allow-list, and appears in no pinned artifact |
612
+ | `SigningTime` is **backdated one minute** | `CertificateTimeBuffer = TimeSpan.FromMinutes(-1)` | Unexplained upstream, but plainly a clock-skew guard: a signing time fractionally in the future relative to the server's clock invites rejection, and being a minute early costs nothing |
613
+
614
+ The remaining structural details, all mirrored: `Id="Signature"` and
615
+ `Id="SignedProperties"` as literal identifiers; the document reference is `URI=""` with the
616
+ enveloped transform *then* c14n; the `SignedProperties` reference carries only the c14n
617
+ transform; `KeyInfo` holds `X509Data/X509Certificate`; `IssuerSerial` uses the issuer name
618
+ in RFC 2253 order and the serial in decimal.
619
+
620
+ **One namespace subtlety worth stating explicitly**, because getting it wrong produces a
621
+ document that looks correct and verifies nowhere: inside `xades:QualifyingProperties` the
622
+ reference implementation declares `xmlns` = the **xmldsig** namespace, so `DigestMethod`,
623
+ `DigestValue`, `X509IssuerName` and `X509SerialNumber` are written *unprefixed* yet belong
624
+ to xmldsig, not to XAdES — even though they sit inside `xades:CertDigest` and
625
+ `xades:IssuerSerial`.
626
+
627
+ **Built directly on Nokogiri and stdlib `openssl`, adding no dependency.** Decided
628
+ 2026-08-22 after confirming every primitive the signature and §10 need is already
629
+ available (measured, both on 3.2.11 and 4.0.6):
630
+
631
+ | Need | Available as |
632
+ |---|---|
633
+ | Exclusive c14n | `Nokogiri::XML::XML_C14N_EXCLUSIVE_1_0` (also `_1_0` = 0 and `_1_1` = 2) via `Node#canonicalize` |
634
+ | `rsa-sha256` signature | `OpenSSL::PKey::RSA#sign("SHA256", …)` — 256-byte output for a 2048-bit key |
635
+ | RSA-OAEP SHA-256 + MGF1-SHA-256 | `#encrypt(data, rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256")` — the plain `#public_encrypt` will **not** do, it cannot set the MGF1 digest |
636
+ | AES-256-CBC / PKCS#7 | `OpenSSL::Cipher.new("aes-256-cbc")`, padding on by default; `#random_key` / `#random_iv` give the 32 and 16 bytes of §10.1 |
637
+
638
+ `openssl` is a **default gem** on both Rubies (3.1.0 on 3.2.11, 4.0.2 on 4.0.6), so
639
+ requiring it is not a new runtime dependency and needs no gemspec entry — the same status
640
+ as `date`, and unlike `bigdecimal`, which had to be declared because it became a *bundled*
641
+ gem in Ruby 3.4.
642
+
643
+ #### Signature namespaces, and where they are pinned
644
+
645
+ Read from pinned artifacts rather than recalled, per the never-invent-a-namespace-URI rule.
646
+ Upstream redistributes the W3C and ETSI schemas inside its PEF bundle, so they are
647
+ available at the same commit as everything else:
648
+
649
+ | Namespace | Pinned as |
650
+ |---|---|
651
+ | `http://www.w3.org/2000/09/xmldsig#` | `spec/fixtures/xades/UBL-xmldsig-core-schema-2.1.xsd` |
652
+ | `http://uri.etsi.org/01903/v1.3.2#` | `spec/fixtures/xades/UBL-XAdESv132-2.1.xsd` |
653
+ | `http://uri.etsi.org/01903/v1.4.1#` | `spec/fixtures/xades/UBL-XAdESv141-2.1.xsd` |
654
+
655
+ Measured 2026-08-22: all three **compile offline**. Their `xsd:import` locations are
656
+ *relative* (`UBL-xmldsig-core-schema-2.1.xsd`), so unlike the FA(3) schema they need no
657
+ in-memory `schemaLocation` rewrite, and `xmldsig-core` imports nothing at all. A minimal
658
+ enveloped `ds:Signature` using exclusive c14n, `rsa-sha256` and `xmlenc#sha256` validates
659
+ against the xmldsig schema, and the same document with `SignatureValue` removed is
660
+ rejected — so this gives the signer real structural validation, not a rubber stamp.
661
+
662
+ **Placed under `spec/fixtures/`, not `lib/`, on purpose.** Two reasons. Validating a
663
+ signature is a test-time concern — the client signs, and KSeF verifies — so nothing at
664
+ runtime needs these files. And they are W3C and ETSI documents redistributed by OASIS and
665
+ then by the Ministry; their terms are not the repository's MIT licence that §1.2 relied on
666
+ for bundling the FA(3) schemas. Keeping them out of the gem sidesteps a redistribution
667
+ question we do not need to answer.
668
+
669
+ `ksef-client-csharp`'s `CertTestApp` (§4.6) is held in reserve as a debugging aid, not a
670
+ build-time input: if TEST rejects a signature with a message that does not say why, its
671
+ `--output file` signed XML gives something concrete to diff against. Installing a .NET SDK
672
+ is therefore optional, and deliberately not a prerequisite for this work.
673
+
674
+ For ECDSA, `SignatureValue` is `R || S` fixed-field concatenation per XMLDSIG 1.1 /
675
+ RFC 4050 §3.3 — **not** DER, which is what OpenSSL emits by default. Only relevant if
676
+ ECDSA support is added; RSA avoids the issue.
677
+
678
+ ### 4.4 Certificate subject requirements
679
+
680
+ Source: `auth/podpis-xades.md`. Accepted certificate types: qualified personal (PESEL or
681
+ NIP), qualified organisation seal (NIP), Trusted Profile (ePUAP), KSeF-issued internal
682
+ certificate (not qualified, but honoured), and Peppol service-provider certificates.
683
+
684
+ | Certificate kind | Required subject attributes | Identifier pattern |
685
+ |---|---|---|
686
+ | Qualified personal signature | `givenName` (2.5.4.42), `surname` (2.5.4.4), `serialNumber` (2.5.4.5), `commonName` (2.5.4.3), `countryName` (2.5.4.6) | `(PNOPL\|PESEL).*?(\d{11})` or `(TINPL\|NIP).*?(\d{10})` |
687
+ | Qualified organisation seal | `organizationName` (2.5.4.10), `organizationIdentifier` (2.5.4.97), `commonName`, `countryName` | `(VATPL).*?(\d{10})` |
688
+
689
+ An organisation seal **must not** carry `givenName` or `surname`. The `.*?` in each
690
+ pattern accommodates the hyphenated form the reference clients emit — `TINPL-1234567890`,
691
+ `VATPL-1234567890`.
692
+
693
+ Where a qualified certificate lacks a usable identifier in 2.5.4.5, authentication is
694
+ still possible by pre-granting permissions against the certificate's **SHA-256
695
+ fingerprint** and using `SubjectIdentifierType` = `certificateFingerprint`.
696
+
697
+ ### 4.5 KSeF-token authentication — the encrypted payload
698
+
699
+ Source: `uwierzytelnianie.md` §2.2. The plaintext is:
700
+
701
+ ```
702
+ {ksefToken}|{timestampMs}
703
+ ```
704
+
705
+ UTF-8 encoded, where `timestampMs` is the `timestamp` from the `POST /auth/challenge`
706
+ response **as Unix milliseconds**. Encrypt with the KSeF public key using
707
+ **RSA-OAEP with SHA-256 and MGF1-SHA-256**, then Base64-encode into `encryptedToken`.
708
+
709
+ The timestamp is not decoration — the docs are explicit that it acts as a nonce, so that a
710
+ captured ciphertext cannot be replayed into a later session. Reusing a stale timestamp, or
711
+ sending a locally generated one, defeats that and will not match the challenge.
712
+
713
+ Corroborated by the pinned contract (checked 2026-08-23): the `POST /auth/ksef-token`
714
+ operation description names **RSA-OAEP with SHA-256**, the `token|timestamp` framing, and
715
+ the timestamp "jako liczba milisekund od 1 stycznia 1970 roku". So the encryption of this
716
+ payload is stated by the contract as well as by the prose, and both are asserted in
717
+ `spec/openapi_contract_spec.rb`.
718
+
719
+ `InitTokenAuthenticationRequest` requires `challenge`, `contextIdentifier` and
720
+ `encryptedToken`; `publicKeyId` and `authorizationPolicy` are optional. **Send
721
+ `publicKeyId` anyway** — see §10.3. The `authorizationPolicy` here carries the same three
722
+ IP lists as the XAdES document's, JSON-cased (`ip4Addresses`, `ip4Ranges`, `ip4Masks`) and
723
+ capped at ten entries each, which independently corroborates §4.1's cap.
724
+
725
+ The 400 table for this operation names **`21111`** — "Nieprawidłowe wyzwanie autoryzacyjne",
726
+ an invalid authorisation challenge — alongside `21470` (§10.2) and `21405`. That is the
727
+ cheapest of the three to avoid, so the challenge format of §4.1 is checked locally before
728
+ the request is built.
729
+
730
+ `ECDsa` exists as an alternative encryption method in both reference clients. Out of scope
731
+ for 0.1; RSA is the documented default.
732
+
733
+ ### 4.6 TEST bootstrap — resolves DESIGN.md §12 item 4's central unknown
734
+
735
+ Source: `auth/testowe-certyfikaty-i-podpisy-xades.md`.
736
+
737
+ **Self-signed certificates are permitted on TEST only**, and upstream ships a console app
738
+ that does the whole bootstrap: `KSeF.Client.Tests.CertTestApp`. It generates a test
739
+ certificate, builds and XAdES-signs `AuthTokenRequest`, submits it, polls to completion,
740
+ and returns the JWT pair. `--output file` writes both the certificate and the **signed
741
+ XML** to disk, which is exactly the reference artifact needed to check our own signature
742
+ against.
743
+
744
+ The document instructs installing .NET 10, but the project multi-targets
745
+ `net8.0;net9.0;net10.0` (`KSeF.Client.Tests.CertTestApp.csproj` @ `ksef-client-csharp`,
746
+ retrieved 2026-08-22), so any of those SDKs suffices.
747
+
748
+ ### 4.7 Auth session management
749
+
750
+ Source: `auth/sesje.md`. `GET /auth/sessions` lists active authentication sessions
751
+ (`continuationToken` paging). `DELETE /auth/sessions/current` and
752
+ `DELETE /auth/sessions/{referenceNumber}` revoke one.
753
+
754
+ Revocation invalidates the associated **`refreshToken` only** — already-issued
755
+ `accessToken`s stay valid to their `exp`. Consistent with §4.2: there is no way to kill a
756
+ live access token.
757
+
758
+ **`authenticationMethod` is deprecated** (noted 2026-08-23). The contract marks
759
+ `AuthenticationOperationStatusResponse.authenticationMethod` `deprecated: true` and adds
760
+ `authenticationMethodInfo` (`category` / `code` / `displayName`) beside it; both are
761
+ required, so nothing breaks today. `Ksef::Auth::OperationStatus` still reads the deprecated
762
+ field. Migrating is not urgent — the value is informational and this gem never branches on
763
+ it — but it should happen before upstream removes the field, and the successor is the one
764
+ to read for new work.
765
+
766
+
767
+ ### 4.8 Authentication status codes
768
+
769
+ `GET /auth/{referenceNumber}` returns **HTTP 200** carrying a `StatusInfo` whose `code` is
770
+ *not* an HTTP status — it describes the asynchronous operation. The Ministry's *prose* names
771
+ only "in progress" and "succeeded", and says the full list "will be available in the
772
+ endpoint's technical documentation".
773
+
774
+ **Source: the pinned OpenAPI contract** —
775
+ `components.schemas.AuthenticationOperationStatusResponse.properties.status.description`
776
+ carries the complete table. Re-sourced 2026-08-23, and the correction matters twice over.
777
+
778
+ An earlier revision of this section cited
779
+ `KSeF.Client.Core/Models/ApiResponses/AuthenticationStatusCodeResponse.cs` in
780
+ `ksef-client-csharp` and said this was "a reference-implementation constant, not something
781
+ the contract states". That was wrong, and it **understated our own confidence**: the prose
782
+ saying the list is not yet documented was taken at its word, and nobody looked in the
783
+ contract, which is a *first-tier* artifact. Reading it changed the table in three ways —
784
+ so the lesson generalises: when upstream prose says a fact is undocumented, check the
785
+ OpenAPI descriptions before reaching for a reference implementation.
786
+
787
+ | Code | Meaning | Terminal? |
788
+ |---|---|---|
789
+ | 100 | authentication in progress | no — the only code that means keep polling |
790
+ | 200 | succeeded | yes |
791
+ | 415 | failed — subject holds no permissions in this context | yes |
792
+ | 425 | authentication and its refresh tokens revoked by the user | yes |
793
+ | 450 | token problem — eight distinct causes, see below | yes |
794
+ | 460 | certificate invalid, chain error, untrusted, revoked, suspended or malformed | yes |
795
+ | 470 | authorisation methods of a deceased person | yes |
796
+ | **480** | **authentication blocked — suspected security incident** | yes, and **not** retryable |
797
+ | 500 | unknown error | yes |
798
+ | 550 | cancelled by the system; retry later | yes, but retryable |
799
+
800
+ The three corrections:
801
+
802
+ 1. **`480` exists and was missing entirely.** "Uwierzytelnienie zablokowane — podejrzenie
803
+ incydentu bezpieczeństwa. Skontaktuj się z Ministerstwem Finansów." It is absent from the
804
+ C# enum, which is why it was absent here and from `Ksef::Auth::Status` until 2026-08-23.
805
+ It is the one code whose correct response is neither a retry nor a fix on the client side:
806
+ the user must contact the Ministry. Retrying it is the worst available move, given §6
807
+ records that repeated suspicious behaviour lengthens a block.
808
+ 2. **`450` collapses eight causes, not four** — a malformed, mistimed, revoked or inactive
809
+ token, *plus* a bad authorisation challenge, bad token encryption, bad token encoding, and
810
+ a token not usable in the requested context. `460` collapses six certificate ones. The
811
+ distinction arrives only in `StatusInfo.description`, so surface the server's wording
812
+ rather than a code-to-string table of our own.
813
+ 3. **`400` and `401` are not contract codes.** They appear in the C# enum only. Kept as named
814
+ constants because the server may still send them, but their absence from the contract is
815
+ upstream's choice, not an omission here.
816
+
817
+ Asserted against the contract in `spec/openapi_contract_spec.rb`, so this provenance cannot
818
+ regress silently a second time.
819
+
820
+ **Treat any unrecognised code as terminal.** Assuming otherwise polls a dead operation for
821
+ ever, and the docs already warn that on DEMO and PROD a legitimate 100 can persist for as
822
+ long as the certificate issuer's OCSP/CRL response takes (§4.2) — so "still 100" cannot be
823
+ distinguished from "stuck" by elapsed time alone.
824
+
825
+ ---
826
+
827
+ ### 4.9 `json` 3.0.0 breaks Faraday 2.14.3 — a shim with a removal trigger
828
+
829
+ Not a KSeF fact; recorded here because it is the one place a dependency's behaviour reaches
830
+ shipped transport code, and because the shim must be deleted rather than forgotten.
831
+
832
+ **What happened.** `json 3.0.0` (released 2026-09) dropped the second **positional** argument to
833
+ `JSON.parse`; options are keyword-only now. `Faraday::Response::Json#parse` still calls
834
+ `decoder.public_send(method_name, body, @parser_options || {})`, so under json 3 every JSON
835
+ response raised `Faraday::ParsingError: wrong number of arguments (given 2, expected 1)`.
836
+ Measured in a clone of this repository bundled against json 3.0.0: **1580 examples,
837
+ 163 failures**, all one cause. With the shim: **0 failures**, coverage gates enforced.
838
+
839
+ **It reached users, not just CI.** The gemspec requires `faraday "~> 2.0"`, faraday declares
840
+ `json >= 0`, and **faraday 2.14.3 is the latest release** — so `gem install ksef_client` produced
841
+ a client that could not read any API response.
842
+
843
+ **Why it arrived with no commit.** `Gemfile.lock` is gitignored by library convention
844
+ (DESIGN.md §4.1), so CI resolves fresh — and **rubocop 1.90.0 relaxed its own `json ~> 2.3` pin
845
+ to `>= 2.3`**, which is what let json 3.0.0 into the resolve. A transitive development pin had
846
+ been shielding the build, invisibly and by accident.
847
+
848
+ **The fix.** Faraday's response middleware accepts a caller-supplied decoder, read from *inside*
849
+ `parser_options`. `Ksef::HTTP::JsonDecoder.call(body, _options = nil)` calls `JSON.parse(body)`,
850
+ and `HTTP::Connection.build` wires it. This keeps every other behaviour the middleware provides
851
+ — the content-type match with its `;` split (so `application/problem+json; charset=utf-8` still
852
+ parses), the `respond_to?(:to_str)` guard, blank body to `nil`, the `StandardError`/`SyntaxError`
853
+ rescue, and `Faraday::ParsingError` wrapping — and leaves the ordering spec that pins the
854
+ middleware by class working untouched.
855
+
856
+ Three measured traps, all recorded on the code:
857
+
858
+ | Trap | Behaviour |
859
+ |---|---|
860
+ | `parser_options` in a frozen constant | `FrozenError` on the first request — the middleware reads the decoder with a destructive `delete` |
861
+ | One `parser_options` hash shared by two connections | the first request empties it; the second silently reverts to `::JSON.parse` and fails again |
862
+ | `decoder: JSON` (Faraday's `respond_to?(:load)` branch) | `JSON.load(body, {})` returns **nil** for a valid body, no exception |
863
+
864
+ So the hash stays a fresh literal in `build`, and the decoder is given in array form.
865
+
866
+ **Removal trigger.** Faraday *merged* json 3 support in
867
+ [PR #1687](https://github.com/lostisland/faraday/pull/1687) on 2026-08-12 and has **not released
868
+ it** — 2.14.3 shipped 2026-06-16. When a release containing it exists, raise the gemspec floor to
869
+ that version and delete `JsonDecoder`, its wiring in `HTTP::Connection`, the copy in
870
+ `spec/ksef/http/retry_spec.rb`, and the guard example in `spec/ksef/http/connection_spec.rb`.
871
+ `JsonDecoder`'s optional second parameter is what makes the interim safe in both worlds: the
872
+ merged fix splats the options as keywords, and `(body, _options = nil)` satisfies that call shape
873
+ as well as today's.
157
874
 
158
875
  ---
159
876
 
@@ -256,9 +973,77 @@ Source: `limity/limity-api.md` (dated 22.11.2025), retrieved 2026-08-21.
256
973
  Live budgets are introspectable at runtime via `GET /rate-limits`, `GET /limits/context`
257
974
  and `GET /limits/subject`.
258
975
 
976
+ ### 6.1 Documented per-endpoint ceilings
977
+
978
+ Source: `limity/limity-api.md` (22.11.2025), retrieved 2026-08-22. Columns are
979
+ req/s · req/min · req/h. These are PROD defaults.
980
+
981
+ | Endpoint | s | min | h |
982
+ |---|---|---|---|
983
+ | `POST /sessions/online` | 10 | 30 | 120 |
984
+ | `POST /sessions/online/{ref}/invoices` | 10 | 30 | 180 |
985
+ | `POST /sessions/online/{ref}/close` | 10 | 30 | 120 |
986
+ | `POST /sessions/batch` | 10 | 20 | 60 |
987
+ | `POST /sessions/batch/{ref}/close` | 10 | 20 | 60 |
988
+ | `GET /sessions/{ref}/invoices/{invoiceRef}` | 30 | 120 | 1200 |
989
+ | `GET /sessions` | **5** | **10** | **60** |
990
+ | `GET /sessions/{ref}/invoices` | 10 | 20 | 200 |
991
+ | `GET /sessions/{ref}/invoices/failed` | 10 | 20 | 200 |
992
+ | `GET /sessions/*` (other) | 10 | 120 | 1200 |
993
+ | `POST /invoices/query/metadata` | 8 | 16 | 20 |
994
+ | `POST /invoices/exports` | 8 | 16 | 20 |
995
+ | `GET /invoices/exports/{ref}` | 10 | 60 | 600 |
996
+ | `GET /invoices/ksef/{ksefNumber}` | 8 | 16 | 64 |
997
+ | everything else | 10 | 30 | 120 |
998
+ | `POST /auth/challenge` (unauthenticated) | 60 per IP | — | — |
999
+ | `POST /auth/ksef-token` | 60 | — | — |
1000
+ | `GET /security/public-key-certificates` | 60 | — | — |
1001
+
1002
+ The last two are read from each operation's own `x-rate-limits` in the pinned spec
1003
+ (2026-08-23) rather than from `limity-api.md`, which lists neither. Both sit well above the
1004
+ "everything else" default, which is consistent with their being on the pre-authentication
1005
+ path — but it also means the per-endpoint values in the spec, not just the prose table,
1006
+ are the authority.
1007
+
1008
+ Three consequences for the client design:
1009
+
1010
+ - **`GET /sessions` is the tightest budget in the API** at 10/min. Session *polling* must
1011
+ use `GET /sessions/{ref}` or the per-invoice status endpoint (1200/h), never a list scan.
1012
+ - **Batch part uploads are exempt from rate limiting entirely**, and the docs recommend
1013
+ uploading parts in parallel. Relevant for 0.2.
1014
+ - The per-hour ceilings bite long before the per-second ones. `POST /invoices/exports` at
1015
+ 20/h is 1 per 3 minutes sustained — retry backoff has to be sized against req/h, not
1016
+ req/s.
1017
+
1018
+ Environment multipliers: **TEST is 10× the PROD defaults**; **DEMO replicates PROD
1019
+ exactly**. TEST can be pushed to PROD-like behaviour with
1020
+ `POST /testdata/rate-limits/production`, set arbitrarily with `POST /testdata/rate-limits`,
1021
+ and reset with `DELETE /testdata/rate-limits`.
1022
+
1023
+ Higher download limits apply **20:00–06:00**; the values are unpublished pending
1024
+ production tuning.
1025
+
1026
+ ### 6.2 Size and volume limits
1027
+
1028
+ Source: `limity/limity.md` (21.10.2025).
1029
+
1030
+ | Parameter | Default |
1031
+ |---|---|
1032
+ | Max invoice size, no attachment | **1 MB** |
1033
+ | Max invoice size, with attachment | **3 MB** |
1034
+ | Max invoices per session (online or batch) | **10 000** |
1035
+
1036
+ Per authenticated subject, KSeF certificate ceilings are 300 enrolments / 100 active for a
1037
+ NIP identifier, and 12 / 6 for PESEL or a certificate fingerprint.
1038
+
1039
+ Both are adjustable on TEST via `POST /testdata/limits/context/session` and
1040
+ `POST /testdata/limits/subject/certificate` (with matching `DELETE`s to restore defaults),
1041
+ which makes the 1 MB invoice-size rejection path testable without constructing a real 1 MB
1042
+ invoice.
1043
+
259
1044
  ---
260
1045
 
261
- ## 6a. Provisioning TEST credentials (DESIGN.md §12.4)
1046
+ ## 6a. Provisioning TEST credentials (DESIGN.md §12 item 4)
262
1047
 
263
1048
  Sources: `dane-testowe-scenariusze.md` (05.08.2025), `tokeny-ksef.md` (29.06.2025),
264
1049
  `srodowiska.md`, and the pinned spec. Retrieved 2026-08-22.
@@ -273,7 +1058,8 @@ A test NIP must still pass the standard checksum: digits 1–9 weighted by
273
1058
  `6,5,7,2,3,4,5,6,7`, summed, `mod 11`, which must equal digit 10 (and must not be 10).
274
1059
  Verified against every NIP appearing in the upstream docs — `7762811692`, `7980332920`,
275
1060
  `3755747347` — and against the two in DESIGN.md §8, `9999999999` and `1111111111`. All
276
- six are checksum-valid, which independently confirms the §7.2 algorithm.
1061
+ six are checksum-valid, which independently confirms **DESIGN.md** §7.2's NIP algorithm.
1062
+ (Named explicitly: inside this document a bare "§7.2" would read as §7's second item.)
277
1063
 
278
1064
  You then register the NIP on TEST:
279
1065
 
@@ -289,7 +1075,7 @@ needs no prior credentials. (They exist on TEST only; see §2.)
289
1075
  `createdDate` caveat: when re-creating test data under the same identifier, the date must
290
1076
  be **later** than the previous one — not equal, not earlier.
291
1077
 
292
- ### 6a.2 The token needs a one-time XAdES authentication — this blocks §12.4 for 0.1
1078
+ ### 6a.2 The token needs a one-time XAdES authentication — which is why XAdES is in 0.1
293
1079
 
294
1080
  `tokeny-ksef.md`: *"Wygenerowanie tokena KSeF jest możliwe wyłącznie po jednorazowym
295
1081
  uwierzytelnieniu się podpisem elektronicznym (XAdES)."* — a KSeF token can be generated
@@ -299,18 +1085,170 @@ The pinned spec corroborates it: `POST /tokens` declares `security: [{Bearer: []
299
1085
  needs an existing session, while `/auth/xades-signature` needs none. There is no
300
1086
  unauthenticated path to a first token.
301
1087
 
302
- **Consequence for this gem:** 0.1 implements KSeF-token auth only; XAdES is roadmapped for
303
- 0.3. So `KSEF_TEST_TOKEN` cannot be minted by this gem at its current stage. The token must
304
- be obtained out of band once — via the official `ksef-client-csharp`, which has a working
305
- XAdES flow, using a self-signed certificate (permitted on TEST; see
306
- `auth/testowe-certyfikaty-i-podpisy-xades.md`). After that one-time bootstrap the token is
307
- long-lived and this gem's token auth works normally.
1088
+ **Consequence for this gem — and why the roadmap changed.** This finding is what moved
1089
+ XAdES from 0.3 into 0.1 (DESIGN.md §6.3, decided 2026-08-22). A token-only client cannot
1090
+ issue its own first credential, which would have forced every user — and our own nightly
1091
+ CI — to bootstrap via somebody else's client.
1092
+
1093
+ **Retired 2026-08-22.** This section used to say the interim workaround was a one-time
1094
+ out-of-band mint via the official C# client. That is no longer necessary — the certificate
1095
+ flow has landed, and `rake auth:bootstrap` does the whole chain in this gem. See §6a.3.
1096
+
1097
+ ### 6a.3 `rake auth:bootstrap`
1098
+
1099
+ Implemented in `tasks/ksef_bootstrap.rb` — outside `lib/`, so never packaged, but covered
1100
+ by `spec/tasks/ksef_bootstrap_spec.rb` against stubs rather than left as an untested
1101
+ script. A checksum bug here would otherwise surface as an opaque rejection from a remote
1102
+ server.
1103
+
1104
+ What it does, in order:
1105
+
1106
+ 1. invents a NIP and a PESEL, both checksum-valid, the NIP also shaped to satisfy the auth
1107
+ schema's `TNIP` pattern (§4.1);
1108
+ 2. `POST /testdata/person` — unauthenticated, which is the only reason the chain is not
1109
+ circular;
1110
+ 3. generates a self-signed certificate carrying `serialNumber=PNOPL-<pesel>` (§4.4), or
1111
+ uses a real qualified certificate if one is supplied;
1112
+ 4. runs the full §4.2 flow: challenge → sign → submit → poll → redeem;
1113
+ 5. `POST /tokens` with the access token, requesting `InvoiceRead` and `InvoiceWrite`;
1114
+ 6. prints `KSEF_TEST_NIP` and `KSEF_TEST_TOKEN`, which are stored as **environment**
1115
+ secrets on `ksef-test` — not repository secrets. A repository secret is readable by
1116
+ every workflow in the repo, which for a live KSeF credential is more exposure than
1117
+ it needs; only a job declaring the environment can read an environment secret. The
1118
+ environment is also restricted to the `main` branch, so a pushed branch cannot
1119
+ claim it and read the token.
1120
+
1121
+ **PESEL checksum**, needed for step 1 and not previously ledgered: weights
1122
+ `1,3,7,9,1,3,7,9,1,3` across the first ten digits; the eleventh is `(10 - sum % 10) % 10`.
1123
+ Confirmed the way §6a.1 confirmed the NIP algorithm — it validates every PESEL the upstream
1124
+ documentation ships (`15062788702`, `30112206276`, `38092277125`, `88102341294`) and
1125
+ rejects those values with the check digit altered.
1126
+
1127
+ **PESEL is a structured identifier, and KSeF enforces it.** Learned from the API itself,
1128
+ 2026-08-23 — `POST /testdata/person` rejected a checksum-perfect PESEL with:
1129
+
1130
+ ```
1131
+ 400 [21405] Żądanie jest nieprawidłowe. Invalid PESEL format.
1132
+ ```
1133
+
1134
+ The first six digits encode a **birth date**, with the century folded into the month field:
1135
+
1136
+ | Month field | Century |
1137
+ |---|---|
1138
+ | 01–12 | 1900s |
1139
+ | 21–32 | 2000s |
1140
+ | 41–52 | 2100s |
1141
+ | 61–72 | 2200s |
1142
+ | 81–92 | 1800s |
1143
+
1144
+ Confirmed by decoding every PESEL the upstream docs ship: `15062788702` → 1915-06-27,
1145
+ `30112206276` → 1930-11-22, `38092277125` → 1938-09-22, `88102341294` → 1988-10-23. All
1146
+ four are plain 1900s dates.
1147
+
1148
+ Nothing upstream *states* this. `dane-testowe-scenariusze.md` shows PESELs in examples but
1149
+ never describes their structure, and the OpenAPI schema types the field as `string`. **This
1150
+ is the first fact in this ledger whose source is the API's own behaviour** rather than a
1151
+ document or a reference implementation — a strictly more reliable tier than anything above
1152
+ it, and the only one that cannot be obtained offline.
1153
+
1154
+ The generator draws births from 1950–1999; the validator accepts the full scheme range,
1155
+ because 1800s and 2000s PESELs are legitimate even if implausible for a test person.
1156
+
1157
+ **NIP checksum, stated precisely** because it is easy to get backwards: the check digit
1158
+ *is* the weighted sum `mod 11`, **not** `11 - (sum mod 11)`. Confirmed against four NIPs
1159
+ from the upstream docs. A sum of 10 is unrepresentable, so that draw is discarded.
1160
+
1161
+ The task refuses any environment whose `test_data_api?` capability is false, so DEMO is
1162
+ refused as well as PROD — the `/testdata/*` endpoints exist on TEST only, and the guard is
1163
+ on the capability rather than the name so a `custom` environment cannot slip past.
308
1164
 
309
1165
  Tokens are minted in a `Nip` or `InternalId` context with a fixed permission set chosen at
310
1166
  creation — changing permissions requires a new token. For this gem's integration suite,
311
1167
  `InvoiceRead` and `InvoiceWrite` are the relevant ones. Treat the token as a confidential
312
1168
  secret (`tokeny-ksef.md` says so explicitly).
313
1169
 
1170
+ ### 6a.4 Verified against live TEST, 2026-08-23
1171
+
1172
+ `rake auth:bootstrap` completed against the TEST environment and produced a usable KSeF
1173
+ token. Because a token can only be minted with an `accessToken`, which requires a redeemed
1174
+ authentication, which requires status `200`, that single outcome establishes several things
1175
+ that no amount of offline testing could:
1176
+
1177
+ - **KSeF accepts the XAdES-BES signature this gem produces.** The combination chosen in
1178
+ §4.3 — enveloped, exclusive c14n, `rsa-sha256`, `xmlenc#sha256`, with the ETSI `Type` URI
1179
+ on the `SignedProperties` reference — is accepted in practice, not merely permitted by
1180
+ the allow-list.
1181
+ - **The 2.0 namespace is correct** (§14.4). The document was sent with
1182
+ `xmlns="http://ksef.mf.gov.pl/auth/token/2.0"` and was not rejected. Whether 2.1 would
1183
+ also be accepted remains unverified; there is now no reason to find out.
1184
+ - **`/testdata/person` really is unauthenticated**, as §6a.1 read from the contract.
1185
+ - **A self-signed certificate is accepted on TEST** (§4.6), carrying the PESEL as
1186
+ `serialNumber=PNOPL-<pesel>` (§4.4).
1187
+ - **The §4.2 steps the bootstrap exercises work as ledgered** — challenge, submission,
1188
+ polling on `StatusInfo.code`, and single-use redemption. Scoped deliberately (corrected
1189
+ 2026-08-23): `POST /auth/token/refresh` is *not* among them, so nothing here vouches for
1190
+ refresh, and `POST /auth/ksef-token` did not exist at the time. **Refresh was closed
1191
+ separately on 2026-08-26** by the recorded tier — see §4.2a, which is what makes all six
1192
+ steps of §4.2 observed rather than five.
1193
+
1194
+ Two failures on the way there, both bugs on this side rather than upstream: a local OpenSSL
1195
+ trust store with no CA bundle (see §6a.5), and the PESEL structure above.
1196
+
1197
+ ### 6a.5 A missing CA bundle looks like a broken server certificate
1198
+
1199
+ Also learned the hard way, 2026-08-23. On a machine where Homebrew's `openssl@3` is
1200
+ installed but `/usr/local/etc/openssl@3/cert.pem` is absent, Ruby has **no trust store at
1201
+ all**, and the failure reads:
1202
+
1203
+ ```
1204
+ certificate verify failed (self-signed certificate in certificate chain)
1205
+ ```
1206
+
1207
+ which points at the wrong thing entirely. KSeF's chain is genuine —
1208
+ `*.ksef.mf.gov.pl` → `GeoTrust TLS RSA CA G1` → `DigiCert Global Root G2` — and the "self-signed
1209
+ certificate" being complained about is that DigiCert root, self-signed as every root is.
1210
+ With no store to chain to, OpenSSL reports the last certificate it saw.
1211
+
1212
+ `curl` succeeds throughout, because it uses Apple's store rather than OpenSSL's, so a
1213
+ reachability check proves nothing about what Ruby will do.
1214
+
1215
+ Fix without weakening anything: point OpenSSL at a real bundle,
1216
+ `SSL_CERT_FILE=/usr/local/etc/ca-certificates/cert.pem`, or reinstall Homebrew's
1217
+ `ca-certificates` to restore the symlink. **Never by disabling verification** — that is a
1218
+ hard rule, and the misleading error message makes it a tempting one to break.
1219
+
1220
+ ### 6a.6 `/testdata/person` grants permissions asynchronously
1221
+
1222
+ Learned from the API, 2026-08-23 — the second fact in this ledger whose source is
1223
+ observed behaviour rather than a document. `POST /testdata/person` returns **200**, but the
1224
+ Owner grant it describes is **not yet in effect**. Authenticating immediately afterwards
1225
+ fails with status **415**, "Brak przypisanych uprawnień" (no assigned permissions), returned
1226
+ on the *first* poll rather than after a `100`.
1227
+
1228
+ Measured, with a fresh NIP and PESEL each time:
1229
+
1230
+ | Wait after registering | Result |
1231
+ |---|---|
1232
+ | none, isolated call after an idle period | success |
1233
+ | none, three calls in quick succession | success, **415**, **415** |
1234
+ | ten seconds, three calls in quick succession | success, success, success |
1235
+
1236
+ So it is not a fixed latency but a queue: an isolated grant lands within the ~2 s the auth
1237
+ flow takes, and consecutive ones do not. That is why a single manual `rake auth:bootstrap`
1238
+ worked first time while a three-example integration suite failed every example.
1239
+
1240
+ **415 is genuinely ambiguous here**, which is the important part. It is both "the grant has
1241
+ not landed yet" and the legitimate permanent answer for a subject with no rights. So:
1242
+
1243
+ - the **integration suite** may retry it, and does, because it provisioned the identity
1244
+ moments earlier and knows which meaning applies;
1245
+ - **library code must never retry a 415.** There it is a final answer, and retrying would
1246
+ hammer the API over a permissions problem no retry can fix.
1247
+
1248
+ The suite now provisions **one** identity per run and settles for ten seconds, rather than
1249
+ one per example — fewer test people in a shared environment, and one wait instead of three.
1250
+
1251
+
314
1252
  ---
315
1253
 
316
1254
  ## 7. Divergences from DESIGN.md
@@ -360,7 +1298,225 @@ Source: pinned `schemat_FA(3)_v1-0E.xsd` (§1).
360
1298
  `elementFormDefault="qualified"` means **every** element must be namespace-qualified in
361
1299
  the instance document — the serializer cannot emit unprefixed children.
362
1300
 
363
- ### 8.1 Import chain and offline validation
1301
+ ### 8.1 Structural shape (measured 2026-08-22, drives the codegen)
1302
+
1303
+ The schema is not a flat set of named types, which is what a reader might reasonably
1304
+ expect. Counts below are from the pinned file and are asserted by
1305
+ `spec/ksef/fa3/generated_spec.rb`.
1306
+
1307
+ | Fact | Value |
1308
+ |---|---|
1309
+ | Global elements | 1 (`Faktura`) |
1310
+ | Named complexTypes | 7 |
1311
+ | Anonymous complexTypes reachable from the root | 51 |
1312
+ | Max nesting depth | 7 |
1313
+ | `xsd:sequence` | 86 |
1314
+ | `xsd:choice` | 19 |
1315
+ | `xsd:all` / `xsd:group` / `xsd:any` | 0 |
1316
+ | Named simpleTypes with enumerations | 21 across all pinned schemas |
1317
+ | `xsd:simpleContent` extensions | 2 |
1318
+
1319
+ Two consequences the generator has to honour:
1320
+
1321
+ 1. **Leaf element names are not unique.** `DaneKontaktowe` appears under all four subject
1322
+ elements, so generated metadata is keyed by element *path*
1323
+ (`Faktura/Podmiot1/DaneKontaktowe`), which also stays stable if upstream adds a fifth.
1324
+ 2. **Four types have a top-level `xsd:choice`** — `Zwolnienie`, `NoweSrodkiTransportu`,
1325
+ `PMarzy` and `FakturaZaliczkowa` (all reached via `Faktura/Fa/...`). Anything that
1326
+ flattens a type's root compositor converts their "exactly one of" into "all of these,
1327
+ in order", which a validator would then accept. Choice structure must be preserved,
1328
+ not flattened.
1329
+
1330
+ `TStawkaPodatku` has 14 values and **half of them are not numeric** — seven rates
1331
+ (`23`, `22`, `8`, `7`, `5`, `4`, `3`) alongside seven codes (`0 KR`, `0 WDT`, `0 EX`,
1332
+ `zw`, `oo`, `np I`, `np II`). Any numeric coercion in the VAT path corrupts the latter,
1333
+ so rate codes are carried as strings throughout and only the *amounts* are `BigDecimal`.
1334
+
1335
+ ### 8.1a Rate buckets — resolves DESIGN.md §7.3 [VERIFY]
1336
+
1337
+ Read from the pinned schema — but from **two** places in it, and one column is weaker than
1338
+ the others, which is worth stating because it is how two bugs survived here.
1339
+
1340
+ The `P_13_*`/`P_14_*` annotations give the **Covers** column, and they name percentages only
1341
+ for buckets 1–3; they name **no rate code anywhere**. The **Rate codes** column is therefore
1342
+ matched from the other side, against `TStawkaPodatku`'s own enumeration annotations — a
1343
+ different part of the schema, and the part that was not re-read when this table was first
1344
+ written. Where both sides name the same statutory article the match is exact and first-tier
1345
+ (that is how `np I`/`np II` are settled below). Where neither does — `4` and `3` sharing
1346
+ bucket 4 — the only evidence is `ksef-pdf-generator`'s UI labels *"4% lub 3%"* and *"OSS"*,
1347
+ and **that file is not pinned in this repository** (§1.4 pins only that repo's
1348
+ `assets/invoice.xml`). Treat that one row as second-tier. Getting this wrong misreports VAT.
1349
+
1350
+ | Net | Tax | Covers | Rate codes |
1351
+ |---|---|---|---|
1352
+ | `P_13_1` | `P_14_1` | Standard rate ("stawka podstawowa") | `23`, `22` |
1353
+ | `P_13_2` | `P_14_2` | First reduced rate | `8`, `7` |
1354
+ | `P_13_3` | `P_14_3` | Second reduced rate | `5` |
1355
+ | `P_13_4` | `P_14_4` | Flat rate for passenger taxis | `4`, `3` |
1356
+ | `P_13_5` | `P_14_5` | Special procedure, Act ch. XII s. 6a | — (see below) |
1357
+ | `P_13_6_1` | — | 0% excluding intra-EU supply and export | `0 KR` |
1358
+ | `P_13_6_2` | — | 0% intra-EU supply of goods (WDT) | `0 WDT` |
1359
+ | `P_13_6_3` | — | 0% export | `0 EX` |
1360
+ | `P_13_7` | — | Exempt from tax | `zw` |
1361
+ | `P_13_8` | — | Supply outside the country, **excluding** what `P_13_5` and `P_13_9` take | `np I` |
1362
+ | `P_13_9` | — | Services under Act art. 100(1)(4) | `np II` |
1363
+ | `P_13_10` | — | Reverse charge, buyer is the taxpayer (art. 17) | `oo` |
1364
+ | `P_13_11` | — | Margin scheme (art. 119, 120) | — (see below) |
1365
+ | `P_15` | | **Total amount due** (gross) | |
1366
+
1367
+ Four things that fall out of this and matter for the builder:
1368
+
1369
+ - **The zero-rated and exempt buckets have no `P_14_*` counterpart.** There is no tax
1370
+ amount to report, so a summary builder must not emit a paired tax field for them —
1371
+ the schema has no element to put it in.
1372
+ - **`np I` and `np II` are different buckets, and the schema says so twice.** `np II` is
1373
+ *"niepodlegajace opodatkowaniu na terytorium kraju, świadczenie usług o których mowa w art.
1374
+ 100 ust. 1 pkt 4 ustawy"*, and `P_13_9` is *"Suma wartości świadczenia usług, o których mowa
1375
+ w art. 100 ust. 1 pkt 4 ustawy"* — the same statutory scope, word for word. Meanwhile
1376
+ `P_13_8` reads *"z wyłączeniem kwot wykazanych w polach P_13_5 i **P_13_9**"*, and `np I`
1377
+ excludes those same transactions, so `P_13_8` is the one bucket `np II` may not go in.
1378
+ **Until 2026-08-26 both codes mapped to `P_13_8`**, misstating intra-EU services on an
1379
+ XSD-valid document that tier 1 passed. Found by audit. No corpus witness exists — no `np`
1380
+ code appears in any pinned sample — so the schema's own annotations are the whole evidence,
1381
+ and they are decisive. **Third instance of this class**, after the shared-bucket
1382
+ accumulation bug and rate code `3`.
1383
+
1384
+ - **Bucket 4 takes rate codes `4` *and* `3`; buckets 5 and 11 take none.** Each rate bucket
1385
+ pairs a current rate with the one it replaced — 23/22, 8/7, 4/3 — and bucket 5 is not a rate
1386
+ bucket at all: `P_13_5` is the special procedure of *"dziale XII w rozdziale 6a"* and
1387
+ `P_14_5` is **"kwota podatku od wartości dodanej"**, foreign VAT under OSS, whose per-line
1388
+ rate lives in `P_12_XII` (a percentage) rather than in `P_12`. `P_13_11` is unreachable for
1389
+ its own reason: the margin scheme is declared through `Adnotacje/PMarzy`, not a rate.
1390
+ **Until 2026-08-24 this table mapped code `3` to bucket 5**, so a domestic 3% sale was
1391
+ declared as OSS foreign VAT on an XSD-valid document. Found by comparing against
1392
+ `ksef-pdf-generator`, whose summary labels read *"4% lub 3%"* for bucket 4 and *"OSS"* for
1393
+ bucket 5 — the only official code that renders these buckets. **That table row was itself
1394
+ left uncorrected until 2026-08-26**, two commits after the code was fixed, so a reader
1395
+ trusting the table over this bullet would have reintroduced the bug.
1396
+ `VatRate.unreachable_elements` names buckets 5 and 11, and a spec now asserts every *other*
1397
+ net bucket is reachable — an incomplete list there is what let `P_13_9` look like a
1398
+ deliberate gap.
1399
+ - **The mapping is many-to-one, so summaries must *accumulate*.** `"23"` and `"22"` both
1400
+ report into `P_13_1`/`P_14_1`, `"8"` and `"7"` into bucket 2, `"4"` and `"3"` into
1401
+ `P_13_4`/`P_14_4`. Assigning per rate code rather than summing lets the last code win — which is
1402
+ exactly the bug found on 2026-08-24: an invoice with a 23% line of 100 and a 22% line of 200
1403
+ emitted `P_13_1=200.00` and `P_14_1=44.00` while `P_15` still carried the correct `367.00`.
1404
+ The tax base was understated by a third, `P_13_1 + P_14_1 ≠ P_15`, and **the document was
1405
+ XSD-valid** — the schema cannot see an arithmetic inconsistency, which is precisely the
1406
+ reconciliation class that validator tier 3 exists for (§15.6). Historical rates like 22%
1407
+ make this reachable on real invoices, not just in theory.
1408
+ - **`P_14_1W`, `P_14_2W`, `P_14_3W`, `P_14_4W`** are the foreign-currency variants,
1409
+ carrying the tax amount converted per the Act when the invoice is issued in a currency
1410
+ other than PLN. They sit alongside their base field in the same sequence, so a
1411
+ non-PLN invoice populates both.
1412
+
1413
+ `P_15Z` and `P_15ZK` relate to advance-payment invoices and their corrections, which are
1414
+ 0.1 scope but not Phase 1 (DESIGN.md §7.4 puts ZAL after VAT and KOR).
1415
+
1416
+ ### 8.2 Non-obvious mandatory elements
1417
+
1418
+ Discovered by validating against the schema rather than by reading it. `JST`/`GV` are asserted
1419
+ in `spec/ksef/fa3/validator_spec.rb`; the `Adnotacje` list is enforced by
1420
+ `Ksef::FA3::Invoice::DEFAULT_ANNOTATIONS` and by tier 1's key check rather than by an assertion
1421
+ of its own.
1422
+
1423
+ **`Podmiot2` (the buyer) requires both `JST` and `GV`** — `minOccurs="1"` on each, and typed
1424
+ by an **anonymous inline restriction of `xsd:integer`** enumerating `1` and `2`, so those are
1425
+ the only permitted values. (Not `etd:TWybor1_2`, as an earlier revision of this line said: the
1426
+ schema does use that type for `P_16`–`P_23`, but not here, and the generated metadata records
1427
+ `base: "xsd:integer"` for both. Corrected by audit 2026-08-26.)
1428
+
1429
+ | Element | Meaning | `"1"` |
1430
+ |---|---|---|
1431
+ | `JST` | Buyer is a subordinate unit of a local-government body | yes |
1432
+ | `GV` | Buyer is a member of a VAT group | yes |
1433
+
1434
+ Every invoice must therefore state these two facts about its buyer, even for an ordinary
1435
+ domestic B2B sale where both answers are "no" (`"2"`). Omitting either makes the document
1436
+ schema-invalid, and no prose in the integrator documentation flags it — the builder must
1437
+ default them rather than leave them to the caller to discover.
1438
+
1439
+ Also mandatory and easy to miss, all inside `Fa/Adnotacje`: `P_16`, `P_17`, `P_18`,
1440
+ `P_18A`, `P_23`, plus the three wrapper elements `Zwolnienie`, `NoweSrodkiTransportu` and
1441
+ `PMarzy` — each of which is one of the four types whose root compositor is a choice
1442
+ (§8.1), so exactly one branch of each must be present.
1443
+
1444
+ ### 8.2a The two subjects are not symmetric, and the buyer's name is optional
1445
+
1446
+ Read straight from the schema while writing the parser (2026-08-24), because upstream's own
1447
+ corpus contains a document that only makes sense if this is true.
1448
+
1449
+ `TPodmiot1` — the seller's identity — is a plain sequence of two mandatory elements:
1450
+
1451
+ ```
1452
+ NIP, Nazwa
1453
+ ```
1454
+
1455
+ `TPodmiot2` — the buyer's — is a **four-way choice** followed by an *optional* name:
1456
+
1457
+ ```
1458
+ choice( NIP | (KodUE, NrVatUE) | ([KodKraju], NrID) | BrakID )
1459
+ sequence minOccurs="0" ( Nazwa )
1460
+ ```
1461
+
1462
+ Three consequences, and each one bit:
1463
+
1464
+ - **A buyer may have no `Nazwa` at all.** `invoice-template-fa-3-with-disallowed-unicode-characters.xml`
1465
+ has exactly that, and a parser that treats the name as mandatory rejects a document
1466
+ upstream ships as valid. `Ksef::FA3::Subject` therefore allows a nil name, and refuses one
1467
+ only for a seller — enforced in `#to_fa3`, which is the only place that knows the role.
1468
+ - **An absent name must be omitted, not written empty.** `Nazwa` is `TZnakowy512`, so
1469
+ `<Nazwa/>` fails its minimum length: emitting an empty element would turn a legal buyer
1470
+ into an invalid document.
1471
+ - **A buyer need not have a NIP.** `NrVatUE` covers an EU counterparty, `NrID` a
1472
+ non-EU one, and `BrakID` an unidentified buyer. This model carries a NIP only, so the
1473
+ parser reports the other three as a **limitation of the model** rather than as a malformed
1474
+ document — the distinction matters, because the invoice is fine and it is us who cannot
1475
+ hold it.
1476
+
1477
+ ### 8.2b FA(3) has no structured address, and neither does this model
1478
+
1479
+ `TAdres` is `KodKraju` + `AdresL1` + optional `AdresL2` + optional `GLN`, where both address
1480
+ lines are free text of up to 512 characters. There is no street, no city, no postal code.
1481
+
1482
+ **Checked against the only official code that reads an FA(3) address**, per CLAUDE.md's
1483
+ Workflow rule ("Unsure how KSeF behaves? Read the official C#/Java clients before guessing"): `ksef-pdf-generator`'s `src/lib-public/generators/FA3/Adres.ts`
1484
+ renders exactly `AdresL1`, `AdresL2`, `KodKraju` and `GLN` — nothing else exists to render.
1485
+ Neither the C# nor the Java client models FA(3) at all; both take the document as bytes. So
1486
+ the flat shape is upstream's, not a simplification of it.
1487
+
1488
+ `Ksef::FA3::Address` still *accepts* `street:`, `city:` and `postal_code:`, because that is
1489
+ how an address arrives from a human or an ERP. It does not **retain** them: they compose into
1490
+ `line1` at construction and the parts are gone. That is a deliberate rule, and it now applies
1491
+ to three types:
1492
+
1493
+ | Type | Stores | Not stored |
1494
+ |---|---|---|
1495
+ | `Address` | `line1`, `line2`, `country` | `street`, `city`, `postal_code` — composed on the way in |
1496
+ | `Line` | `BigDecimal`, rounded to its element's scale | the `Integer`/`String`/finer value it arrived as |
1497
+ | `Invoice` | `issued_at` as the document's own string | the `Time` it may have been given |
1498
+ | `Invoice` | `issue_date` as a `Date` | the `String` it may have been given |
1499
+ | `Invoice` | `annotations` as the document states them | nothing — they used to be discarded entirely |
1500
+
1501
+ The `Line` and `issue_date` rows were added on 2026-08-24, after a review found the rule
1502
+ stated but not followed: `issue_date` kept whatever the caller passed, and `Line` canonicalised
1503
+ the *type* but not the *value*, holding `150.125` where the document will carry `150.13`
1504
+ (`TKwotowy` is `fractionDigits="2"`; `TIlosci` is `6`). Both broke the round-trip law for a
1505
+ field whose value never really changed, and the `Line` one also made a line's own arithmetic
1506
+ disagree with the invoice as printed — a net derived from a price finer than the one shown.
1507
+
1508
+ **The rule: the model stores the document's representation, not the caller's input.** Two
1509
+ things fall out of it. A `Float` or a malformed address is refused at construction, where the
1510
+ caller can see what it passed, instead of at serialisation. And an invoice built from
1511
+ structured parts is genuinely `==` to the same invoice parsed back from XML — which is what
1512
+ lets DESIGN.md §7.6's round-trip law be stated as an equality rather than as a vaguer
1513
+ "equivalence". Retaining the inputs would invent distinctions FA(3), KSeF and every reader of
1514
+ the invoice are all blind to.
1515
+
1516
+ `GLN` is not carried. It is optional, nothing needs it yet, and a parsed document that has
1517
+ one reports it through `Invoice#unmapped_elements` rather than losing it silently.
1518
+
1519
+ ### 8.3 Import chain and offline validation
364
1520
 
365
1521
  ```
366
1522
  schemat_FA(3)_v1-0E.xsd
@@ -379,25 +1535,1934 @@ memory**, parsing the XSD into a document, editing the attribute, and compiling
379
1535
  base URI pointing at the schema directory. The pinned file on disk must stay byte-for-byte
380
1536
  identical so the §1 digests keep verifying.
381
1537
 
1538
+ ### 8.4 The correction, `KOR` — read from the XSD and measured against the Ministry's five
1539
+
1540
+ Source: the pinned FA(3) XSD for the structure, and the five `KOR` samples of §1.5 for what
1541
+ a real one looks like. Recorded 2026-08-24, when `KOR` was built.
1542
+
1543
+ The correction elements are an **anonymous `<xsd:sequence minOccurs="0">`** sitting between
1544
+ `RodzajFaktury` and `ZaliczkaCzesciowa` in `Fa`:
1545
+
1546
+ | Element | Occurs | Carried as |
1547
+ |---|---|---|
1548
+ | `PrzyczynaKorekty` | 0–1, `TZnakowy` | `Correction#reason` |
1549
+ | `TypKorekty` | 0–1, `TTypKorekty` (1/2/3) | `Correction#effect` |
1550
+ | `DaneFaKorygowanej` | **1–50 000** | `Correction#corrected` |
1551
+ | `OkresFaKorygowanej` | 0–1, `TZnakowy` | `Correction#period` |
1552
+ | `NrFaKorygowany` | 0–1, `TZnakowy` | `Correction#corrected_number` |
1553
+ | `Podmiot1K` | 0–1 | `Correction#previous_seller` |
1554
+ | `Podmiot2K` | 0–101 | `Correction#previous_buyers` |
1555
+ | `P_15ZK` | 0–1, `TKwotowy` | `Correction#paid_before` — scoped to `KOR_ZAL`/`KOR_ROZ`, and it means a different thing on each; see §8.6 |
1556
+ | `KursWalutyZK` | 0–1, `TIlosci` | `Correction#exchange_rate_before`; shares an anonymous `<xsd:sequence minOccurs="0">` with `P_15ZK` and is itself `minOccurs="0"` **inside** it, so `P_15ZK` alone is valid — which is what all four samples do — and `KursWalutyZK` alone is not |
1557
+
1558
+ Four consequences that are easy to get wrong:
1559
+
1560
+ **The group is optional; `DaneFaKorygowanej` is not optional within it.** So a `KOR` with no
1561
+ correction elements at all is schema-valid, while one carrying `PrzyczynaKorekty` and nothing
1562
+ else is not. `Correction` refuses the latter at construction and the parser reads the former
1563
+ as simply having no correction — inventing one would change the document.
1564
+
1565
+ **`DaneFaKorygowanej` ends in a choice**, not two optional fields: either `NrKSeF` +
1566
+ `NrKSeFFaKorygowanej`, or `NrKSeFN` alone — *"znacznik faktury korygowanej wystawionej poza
1567
+ KSeF"*. The two *markers* — `NrKSeF` and `NrKSeFN` — are `etd:TWybor1`, which has exactly one
1568
+ member, `"1"`: they are present or absent, with no "no" to write. (The first branch is a
1569
+ sequence of the marker plus `NrKSeFFaKorygowanej`, which is a `TNumerKSeF`.) Modelled as one nil-able `ksef_number`, so
1570
+ emitting both branches or neither is unrepresentable.
1571
+
1572
+ **`IDNabywcy` is the link between `Podmiot2K` and the `Podmiot2` it corrects** — *"unikalny
1573
+ klucz powiązania danych nabywcy na fakturach korygujących"*. Both sides carry it, a correction
1574
+ may name up to 101 previous buyers, and nothing else pairs them. It is therefore a field of
1575
+ `Subject`, not an element to lose. `Podmiot2K` has **no `JST`/`GV`**; `Podmiot1K` makes `Adres`
1576
+ mandatory as `Podmiot1` does.
1577
+
1578
+ **`StanPrzed` marks a row as the state before correction** — *"w przypadku gdy korekta …
1579
+ jest dokonywana w sposób polegający na wykazaniu danych przed korektą i po korekcie jako
1580
+ osobnych wierszy"*. Przykład 2 gives such a pair **the same `NrWierszaFa`** despite the same
1581
+ sentence saying *"z odrębną numeracją"*, and pairs them by `UU_ID` too. Since `UU_ID` is not
1582
+ modelled, the shared row number is the only link left, so `Line#row_number` carries it — but
1583
+ only when it differs from the row's position, or a parsed ordinary invoice would stop equalling
1584
+ the built invoice it came from (DESIGN.md §7.6).
1585
+
1586
+ #### A correction's summaries are read, never computed
1587
+
1588
+ This is the substantive modelling decision. An ordinary `VAT` invoice's `P_13_*`/`P_14_*` are a
1589
+ function of its rows. A `KOR`'s are **deltas**, and FA(3) does not require its rows to determine
1590
+ them. Measured over the five samples:
1591
+
1592
+ | Sample | Rows | How the summary relates to them |
1593
+ |---|---|---|
1594
+ | Przykład 2 | before/after pair | after − before = the stated delta |
1595
+ | Przykład 3 | one row, already a delta | equals the stated delta |
1596
+ | Przykład 5 | **none** | `P_15` = 0, no buckets; a pure `Podmiot2K` data correction |
1597
+ | Przykład 6 | **none** | six corrected invoices, a period, buckets stated outright |
1598
+ | Przykład 7 | one row with **no amounts at all** | `P_7`, `CN`, unit and quantity only |
1599
+
1600
+ So three of the five cannot derive a summary from their rows at any price. `Invoice#totals`
1601
+ holds what the document states, and the parser fills it for every `KOR`. `Ksef::FA3::Totals`
1602
+ is keyed by **element name**, not rate code, because that mapping is not invertible — `"23"`
1603
+ and `"22"` share `P_13_1` (§8.1a) — and because it is the document's own representation
1604
+ (§8.2b). `Invoice#lines` may then be empty, which the constructor permits **only** when totals
1605
+ are stated.
1606
+
1607
+ The after − before rule that Przykład 2 satisfies is **deliberately not implemented**. One
1608
+ witness is not a rule, no first-tier source states it, and deriving a correction's tax base
1609
+ from an inference is the kind of thing §15.6 warns against. Callers state the delta.
1610
+
1611
+ #### The one cross-field rule tier 1 carries
1612
+
1613
+ **The XSD states this outright.** The anonymous sequence carries its own annotation:
1614
+
1615
+ > Dane dla przypadków, gdy pole RodzajFaktury przyjmuje wartości KOR, KOR_ZAL lub KOR_ROZ
1616
+
1617
+ — *data for the cases where the RodzajFaktury field takes the values KOR, KOR_ZAL or
1618
+ KOR_ROZ*. So the rule is a first-tier fact, not an inference from `PrzyczynaKorekty`'s
1619
+ *"dla faktur korygujących"*, as an earlier revision of this section said.
1620
+
1621
+ What the XSD **cannot do is enforce it**: the group sits in the same sequence whatever the
1622
+ type, so a `VAT` invoice carrying `DaneFaKorygowanej` validates clean (measured, by injecting
1623
+ the group into Przykład 1). That gap is exactly what tier 1 is for, and `ModelValidator`
1624
+ reports it. **The converse is not checked** — a `KOR` without the group is schema-valid, and
1625
+ complaining would be inventing a rule.
1626
+
1627
+ #### 8.4a The Ministry's KSeF numbers do not satisfy §13's checksum
1628
+
1629
+ Measured 2026-08-24: **all six distinct `NrKSeFFaKorygowanej` values in the five `KOR` samples
1630
+ fail the CRC-8 of §13**. The commonest, `9999999999-20230908-8BEF280C8D35-4D`, appears in
1631
+ **eleven of the twenty-six samples** — every `KOR`, every `KOR_ZAL`, the `KOR_ROZ` and both
1632
+ `ROZ`. It is the **only** KSeF-number reference in eight of those eleven; przyklad-06, -07 and
1633
+ -18 carry others alongside it. (Two earlier revisions were wrong here — first "three samples",
1634
+ then "the sole reference in three of them", which counted only the `KOR` samples where it
1635
+ stands alone. Corrected by audit 2026-08-26.) All six
1636
+ match `TNumerKSeF`'s pattern — every sample is XSD-valid — so they are well-formed
1637
+ placeholders rather than numbers KSeF ever issued.
1638
+
1639
+ Our CRC-8 is not the thing that is wrong: it reproduces the documented example of §13, and
1640
+ agreed with a real number assigned on live TEST (recorded in DESIGN.md §11's run record for
1641
+ `32692339217`, asserted by `spec/integration/session_flow_spec.rb`). So **tier 1 does not
1642
+ check the checksum of a referenced KSeF number.** Nothing upstream says KSeF verifies it, and
1643
+ rejecting on it would make tier 1 refuse the Ministry's own documents — the same failure as
1644
+ the whitespace-collapse bug, whose record is the header comment of
1645
+ `lib/ksef/fa3/field_checks.rb`. Do not add it.
1646
+
1647
+ #### 8.4b The five-lens audit of `KOR`, and the three facts it settled
1648
+
1649
+ Run 2026-08-24 against the merged commit, five independent read-only lenses. Three of the five
1650
+ found the same top defect without conferring, which is worth recording as much as the defect
1651
+ is: **a rule enforced on one side of a boundary is not enforced.**
1652
+
1653
+ **The derived summary.** §8.4 says a correction's summaries are *read, never computed*. That
1654
+ was true of {Parser} and false of the serializer: `DocumentMapping#summary` fell back to the
1655
+ line-derived buckets whenever no `Totals` was given, and nothing required a *built* correction
1656
+ to give one. Since a `StanPrzed` row is the position **as it was before the correction** — an
1657
+ amount already invoiced on the original document — summing it counts it twice with the wrong
1658
+ sign. The Ministry's Przykład 2, built through this gem's own DSL without `f.totals`, declared
1659
+ `P_15 = 3799.98` for a correction whose value is `-200.00`: a refund emitted as a charge,
1660
+ XSD-valid, tier 1 silent, `#unmapped_elements` empty. Tier 1a now requires stated totals
1661
+ whenever a line carries `state_before` — scoped to the marker rather than to the type, because
1662
+ a correction whose rows already *are* the deltas computes correctly and Przykład 3 is exactly
1663
+ that shape.
1664
+
1665
+ **`Integer()` honours radix prefixes.** `NrWierszaFa` is `TNaturalny`, whose lexical space
1666
+ permits leading zeros, and fixed-width row numbering is an ordinary ERP convention. `Integer`
1667
+ without an explicit base reads `"010"` as **octal eight**, so a schema-valid document stating
1668
+ row ten parsed as row eight and re-serialised as `8` — in the one field that pairs a
1669
+ correction's before/after rows once `UU_ID` is dropped. `"08"` failed the other way, raising
1670
+ on a document the schema accepts. Fixed with base 10 on the String branch. The spec that
1671
+ should have caught it used `"03"`, where octal and decimal agree.
1672
+
1673
+ **The two pinned artifacts disagree about a KSeF number, and each is right.** The OpenAPI
1674
+ contract's `KsefNumber` pattern admits the NIP issuer form alone:
1675
+
1676
+ ```
1677
+ ^([1-9](\d[1-9]|[1-9]\d)\d{7})-…
1678
+ ```
1679
+
1680
+ The FA(3) XSD's `TNumerKSeF` admits two more (XSD patterns are implicitly anchored, so it
1681
+ carries no leading `^`):
1682
+
1683
+ ```
1684
+ ^([1-9]((\d[1-9])|([1-9]\d))\d{7}|M\d{9}|[A-Z]{3}\d{7})-…
1685
+ ```
1686
+
1687
+ They are not in conflict — they govern different things. The contract says what a **lookup
1688
+ URL** may contain, and `Ksef::KsefNumber` serves lookups (`Invoices::Client#download`,
1689
+ `UPO::Client#for_ksef_number`), so its narrowness is correct there. The XSD says what a
1690
+ **document** may reference. Judging a document field by `KsefNumber::FORMAT` flagged an
1691
+ XSD-valid `M123456789-…` reference as malformed, so tier 1 no longer judges the format at all
1692
+ and tier 2 owns it. **Do not widen `KsefNumber::FORMAT` to "fix" this** — that would loosen
1693
+ the lookup path against its own contract. The layouts are the same length either way, so §13's
1694
+ CRC-8 input rule is unaffected.
1695
+
1696
+ A fourth, smaller finding of the same shape: {Serializer} compared `values.keys.map(&:to_s)`
1697
+ against the schema when rejecting unknown element names, but wrote with `values.key?(name)`
1698
+ against a String. A symbol key therefore passed the check and was then **silently dropped**.
1699
+ Keys are normalised once, at the top of `write_children`.
1700
+
1701
+ ### 8.5 The advance-payment pair, `ZAL` and `ROZ`
1702
+
1703
+ Source: the pinned FA(3) XSD for the structure, and the Ministry's `ZAL`, `ROZ`, `KOR_ZAL` and
1704
+ `KOR_ROZ` samples of §1.5 for what they contain. Recorded 2026-08-25.
1705
+
1706
+ A `ZAL` documents money received **before** the goods are delivered; a `ROZ` — art. 106f ust. 3
1707
+ — is the invoice issued once they are, settling what the advance invoices already covered. Two
1708
+ elements carry that, and both sit in `Fa` outside the correction group:
1709
+
1710
+ | Element | Occurs | Carried as |
1711
+ |---|---|---|
1712
+ | `Zamowienie` | 0–1 | `Invoice#order` |
1713
+ | `Zamowienie/WartoscZamowienia` | 1, `TKwotowy` | `Order#total` |
1714
+ | `Zamowienie/ZamowienieWiersz` | **1–10 000** | `Order#lines` |
1715
+ | `FakturaZaliczkowa` | 0–**100** | `Invoice#advances` |
1716
+ | `ZaliczkaCzesciowa` | 0–31 | **not modelled** — see below |
1717
+
1718
+ **A `ZAL` need not carry `FaWiersz`, and the Ministry's does not.** `Zamowienie` is
1719
+ *"zamówienie lub umowa, o których mowa w art. 106f ust. 1 pkt 4"* — the order or contract — and
1720
+ its positions stand in for invoice rows, in the currency the advance invoice was issued in.
1721
+ Przykład 10 carries two `ZamowienieWiersz` and no rows. **The schema permits rows even so**:
1722
+ `FaWiersz` is `minOccurs="0"`, described as *"węzeł opcjonalny dla faktury zaliczkowej"* —
1723
+ optional, not forbidden. Nothing here refuses a `ZAL` with rows, and this paragraph reports one
1724
+ witness rather than a rule. (An earlier revision stated it as a general fact; §8.4 declines to
1725
+ build rules on single witnesses and the same discipline applies here.)
1726
+
1727
+ **`WartoscZamowienia` is not `P_15`, and confusing them would be a large error.** It is
1728
+ *"wartość zamówienia lub umowy z uwzględnieniem kwoty podatku"* — the whole order **including
1729
+ tax**. In Przykład 10 it is `375 150` against a `P_15` of `20 000`: the order is the contract,
1730
+ `P_15` is the money received so far.
1731
+
1732
+ **An order position states its own tax.** `ZamowienieWiersz` carries both `P_11NettoZ`
1733
+ (*"wartość zamówionego towaru lub usługi bez kwoty podatku"*) and `P_11VatZ` (*"kwota podatku
1734
+ od zamówionego towaru lub usługi"*). `FaWiersz` has no per-row tax element and the tax is
1735
+ computed from `P_12`; here the document carries both, so `OrderLine` reads both and derives
1736
+ nothing. `StanPrzedZ` is `StanPrzed`'s twin, for a `KOR_ZAL` correcting an order position.
1737
+
1738
+ **`FakturaZaliczkowa`'s choice is inverted from `DaneFaKorygowanej`'s.** There, the marker
1739
+ pairs with the KSeF number. Here it pairs with the plain one:
1740
+
1741
+ - in KSeF → `NrKSeFFaZaliczkowej` **alone**;
1742
+ - outside KSeF → `NrKSeFZN` (*"znacznik faktury zaliczkowej wystawionej poza KSeF"*) followed
1743
+ by `NrFaZaliczkowej`.
1744
+
1745
+ So the two branches name *different fields*, and {AdvanceInvoice} carries both with exactly one
1746
+ required — unlike {CorrectedInvoice}, where one nil-able field was enough. Getting this
1747
+ backwards is easy and produces a schema-valid document asserting the wrong provenance.
1748
+
1749
+ #### Both types state their summary, measured rather than assumed
1750
+
1751
+ `Invoice::STATED_TOTALS_TYPES` gains `ZAL` and `ROZ`. Over every sample of each, the stated
1752
+ buckets **never** equal the row totals:
1753
+
1754
+ | Sample | Type | Σ row `P_11` | Σ stated `P_13_*` | `P_15` |
1755
+ |---|---|---|---|---|
1756
+ | Przykład 10 | `ZAL` | — (no rows) | 16 260.16 | 20 000 |
1757
+ | Przykład 14 | `ROZ` | 312 000.00 | 284 277.75 | 307 635 |
1758
+ | Przykład 17 | `ROZ` | 312 000.00 | 277 222.45 | 300 000 |
1759
+ | Przykład 11 | `KOR_ZAL` | — (no rows) | 2 221.34 | 0 |
1760
+ | Przykład 18 | `KOR_ROZ` | 624 000.00 | 7 055.30 | 7 635 |
1761
+
1762
+ A `ZAL` has nothing to derive from. A `ROZ` describes the goods in its rows and states the
1763
+ amount **remaining after the advance** in its buckets — and the advance is not in this
1764
+ document, so the model could not compute it even in principle. `ZaliczkaCzesciowa`'s own
1765
+ documentation confirms the shape: *"różnica kwoty w polu P_15 i sumy poszczególnych pól P_15Z
1766
+ stanowi kwotę pozostałą ponad płatności otrzymane przed wykonaniem"*.
1767
+
1768
+ Tier 1 therefore requires a stated summary whenever an order is present or an advance invoice
1769
+ is settled — the same rule, and the same module, as the `state_before` case of §8.4b. All three
1770
+ triggers are **structural**: an element the document either carries or does not. Whether the
1771
+ figures then reconcile is tier 3's, and tier 3 reports it as a warning (§17.1).
1772
+
1773
+ #### What is deliberately not modelled
1774
+
1775
+ - **`ZaliczkaCzesciowa`** — instalments of a partial advance. It appears in **none of the
1776
+ twenty-six samples**, so there is nothing to build against; it is a whole element and is
1777
+ therefore visible through `#unmapped_elements`.
1778
+ - ~~**`P_15ZK` / `KursWalutyZK`**~~ — **modelled 2026-08-26**, with `KOR_ZAL` and `KOR_ROZ`.
1779
+ See §8.6.
1780
+ - **`Platnosc`** and **`DodatkowyOpis`** — payment details and free-form key/value notes. Both
1781
+ appear across `VAT`, `ZAL` and `ROZ` alike and have never been modelled; both are
1782
+ path-visible. `Platnosc` on a `ZAL` carries *when* the advance was paid, which is worth
1783
+ having eventually, but it is not what makes a `ZAL` a `ZAL`.
1784
+
1785
+
1786
+ ### 8.6 The last three types, and the row that states no amount
1787
+
1788
+ Recorded 2026-08-26, when `UPR`, `KOR_ZAL` and `KOR_ROZ` landed. **All seven `RodzajFaktury`
1789
+ values are now modelled**, and twenty-two of the Ministry's twenty-six samples parse,
1790
+ re-serialise, validate and round-trip. The four that do not are refused for a *construct* —
1791
+ two priced gross, two identifying their buyer by something other than a NIP — not for a type.
1792
+
1793
+ Two of the three needed almost nothing new. `KOR_ZAL` is the correction group of §8.4 plus the
1794
+ `Zamowienie` of §8.5; `KOR_ROZ` is the correction group plus `FakturaZaliczkowa`. Between them
1795
+ they added one element:
1796
+
1797
+ **`P_15ZK` means two different things, and the invoice type decides which.** Its documentation
1798
+ reads *"W przypadku korekt faktur zaliczkowych - kwota zapłaty przed korektą. W przypadku
1799
+ korekt faktur, o których mowa w art. 106f ust. 3 ustawy - kwota pozostała do zapłaty przed
1800
+ korektą"* — the amount **paid** before the correction on a `KOR_ZAL`, the amount **left to
1801
+ pay** before it on a `KOR_ROZ`. One element, two readings. `Correction#paid_before` carries the
1802
+ figure and does not try to name it more precisely than the schema does. All four `KOR_ZAL` and
1803
+ `KOR_ROZ` samples carry it; `KursWalutyZK`, which shares its anonymous sequence, appears in
1804
+ none of the twenty-six — and since that sequence makes `KursWalutyZK` optional *within* it,
1805
+ those four are documents with `P_15ZK` and no rate, which is valid.
1806
+
1807
+ `KursWalutyZK` is `TIlosci`, so **six decimal places, as a ceiling rather than a preference**.
1808
+ Held unrounded, the model carried a rate FA(3) cannot express and `#to_fa3` rounded it away at
1809
+ emit, so the invoice stopped equalling itself through a round-trip — with tier 2 silent,
1810
+ because what reached the document was valid. Found by audit 2026-08-26; it is §8.2b's rule, and
1811
+ the only field in the model that was not following it.
1812
+
1813
+ #### `UPR` needed a row that states no amount at all
1814
+
1815
+ A simplified invoice under art. 106e ust. 5 pkt 3 — the XSD's own gloss on the `UPR`
1816
+ enumeration — may omit a great deal. **Both Ministry samples name the goods and stop**, which
1817
+ is a measurement over n=2 and not a rule about the class:
1818
+
1819
+ | Sample | The whole row | Summary |
1820
+ |---|---|---|
1821
+ | Przykład 15 | `NrWierszaFa`, `P_7` | `P_13_1`, `P_14_1`, `P_15` |
1822
+ | Przykład 16 | `NrWierszaFa`, `P_7`, `P_12` | `P_15` alone |
1823
+
1824
+ Every child of `FaWiersz` but `NrWierszaFa` is `minOccurs="0"`, so this is not a special case
1825
+ in the schema — it is the schema's default, and the model was the thing being strict. (`FaWiersz`
1826
+ is declared with an **anonymous** `<xsd:complexType>`; there is no `TFaWiersz` to name, and
1827
+ several documents said there was until an audit checked. FA(3) names only seven complexTypes.)
1828
+ `Line` now carries every field optionally except `name:`, which stays a required *keyword* —
1829
+ pass `name: nil` deliberately, as the parser does — and **`Line#net` answers `nil` rather than
1830
+ raising**: "the row states no amount" is a state the model has to hold, and it is not the same
1831
+ as zero.
1832
+
1833
+ `#gross` answers nil for the same row, and so does **`#vat`, as of the 2026-08-26 audit**. It
1834
+ returned `BigDecimal(0)`, which conflated two different absences: a rate code that carries no
1835
+ tax (`zw`, `oo`, `np I`) really is zero, while a row with no amount has tax that is *unknown*.
1836
+ A caller's `lines.sum(&:vat)` under-reported in silence while `sum(&:net)` raised.
1837
+
1838
+ Three predicates and one scope decision go with this. `Line#priced?` is "states an amount";
1839
+ `Line#summarised?` is "states an amount *and* a rate", which is what it takes to reach a
1840
+ bucket. And `Invoice::STATED_TOTALS_TYPES` gained all three new types, so the parser **reads**
1841
+ their summaries rather than deriving them — which the samples above force rather than merely
1842
+ suggest: Przykład 15's summary cannot be derived from a row carrying only `P_7`, and Przykład
1843
+ 16 states a `P_15` with no buckets at all. Same measurement as §8.5's for `ZAL`/`ROZ`.
1844
+
1845
+ **The same shape unblocked Przykład 7**, the one Ministry correction this model could not read
1846
+ — its single row names goods, a `CN` code and a quantity, with no amount anywhere. So all five
1847
+ corrections now go through, and the refusal that used to name it is gone.
1848
+
1849
+ #### What replaced the refusals, and why it had to
1850
+
1851
+ `RowReader` used to refuse two shapes: a row with no price, and a row with no `P_12`. Both are
1852
+ now read, because both are legal and the parser is documented not to validate. But an unpriced
1853
+ row on an invoice that *derives* its summary from its rows is a real hazard — the amount is
1854
+ simply absent from the tax base, and **neither tier can see it**: the XSD is blind to
1855
+ arithmetic, and `#unmapped_elements` to values. That is the §8.4b bug class again.
1856
+
1857
+ So tier 1 took it over, addressed to the line and scoped to where it costs something:
1858
+
1859
+ | Row states | On a deriving invoice | On one that states its summary |
1860
+ |---|---|---|
1861
+ | amount + rate | fine | fine |
1862
+ | amount, no rate | **reported** — no bucket to put it in | fine |
1863
+ | no amount | **reported** — absent from the tax base | fine, this is `UPR` |
1864
+
1865
+ `Invoice#net_by_rate` skips what it cannot place, and so does `#vat_rounded_per_line` — which
1866
+ is exactly why the rule is needed: without it the document would quietly understate.
1867
+
1868
+ **The guard class changed, and that is worth stating plainly.** These two shapes used to be
1869
+ *impossible* — `parse` refused them — and are now *reported if you ask*. `Ksef::Client`'s send
1870
+ path asks (`validate: true` by default), so the shipped route is unchanged; a caller who reaches
1871
+ for `#to_xml` directly is not covered, and a caller who parses a document, re-serialises it and
1872
+ archives the result will get a document whose tax base differs from the original's with nothing
1873
+ raised. Demonstrated on Przykład 1 with one `P_12` deleted — still XSD-valid — where the
1874
+ re-serialised `P_15` came back 50.01 lower, `#unmapped_elements` was silent (the element paths
1875
+ are identical), and even the round-trip law held, because a parsed invoice is already a fixed
1876
+ point. Only `#errors` catches it.
1877
+
1878
+ The one shape still refused at parse time is **gross pricing** (`P_9B`/`P_11A`), and the
1879
+ distinction is worth keeping: a gross-priced row carries a number this model has nowhere to
1880
+ put, so reading it would drop a real amount. An unpriced row carries no number to drop. Those
1881
+ two elements are the *only* ones in the row whose annotation cites art. 106e ust. 7-8, so the
1882
+ refusal is complete; `P_9B` alone went untested until 2026-08-26, because both Ministry samples
1883
+ carry `P_11A` as well and an `||` operand is not a branch either criterion can see.
1884
+
1885
+ #### A unit price is not a `TKwotowy`
1886
+
1887
+ Found by the same audit, and pre-dating this work. `P_9A` — and `P_9AZ` on an order position —
1888
+ is **`TKwotowy2`**: *"Wartość numeryczna 22 znaki max, w tym 8 znaków po przecinku"*, four
1889
+ times the precision of the `TKwotowy` amount it produces. The model rounded both to two places,
1890
+ so a row priced at `1626.0125` (schema-valid) was re-emitted as `1626.01` with `#errors` empty
1891
+ and `#unmapped_elements` empty — the §8.4b bug class applied to a number rather than a flag,
1892
+ and a **silent alteration of a stated amount**, which is the most serious kind of defect this
1893
+ model can have. `Formatting::UNIT_PRICE_SCALE` is 8; `Formatting.unit_price` pads to two places
1894
+ so `150` still reads `150.00`, and `TKwotowy2`'s pattern permits one to eight, so every
1895
+ existing document is byte-identical under the fix.
1896
+
1897
+ `P_10` (discount) and `P_9B` are `TKwotowy2` too; the model carries neither, and both are
1898
+ reported by `#unmapped_elements` as whole elements, so nothing is silently lost there.
1899
+
1900
+
382
1901
  ---
383
1902
 
1903
+ ### 8.7 `Zalacznik`, the attachment — measured 2026-08-26
1904
+
1905
+ FA(3)'s attachment is **not a file**. There is no MIME type, no encoding, no bytes: `Zalacznik`
1906
+ is a structured document of headings, key/value metadata, paragraphs and tables, sitting as a
1907
+ **sibling of `Fa`** rather than one of its children. That placement is the first thing worth
1908
+ knowing — it takes part in no summary and no arithmetic, so modelling it could not affect a
1909
+ single tax figure.
1910
+
1911
+ Two of the Ministry's samples carry one (Przykład 24 and 25, both energy bills): one block, eight
1912
+ metadata pairs, three tables.
1913
+
1914
+ **Four facts the schema states that a naive model would get wrong:**
1915
+
1916
+ | Fact | Where | Consequence |
1917
+ |---|---|---|
1918
+ | `MetaDane` is mandatory (`minOccurs` defaults to 1; the schema writes only `maxOccurs="1000"`) | inside `BlokDanych` | It is the *only* mandatory child. A block with a heading and a table but no metadata is schema-invalid, so {DataBlock} refuses one at construction |
1919
+ | `Kol` and `WKom` each repeat 1..20, **related nowhere** — no `xsd:key`, `keyref` or `unique` in any of the four schemas | `TNaglowek` / `Wiersz` | **Rows are ragged.** Measured widths across the corpus: `[1, 9]`, `[1, 6]` and `[1, 9, 1, 9, …]` — one-cell rows label what follows, alternating rather than heading a block of them, and one of the three tables is six columns wide. Storing rows as a rectangle would invent cells or drop them |
1920
+ | `TZnakowy2` has `minLength="0"` | `NKom`, `WKom`, `SKom` | An **empty cell is legal**, and distinct from an absent one. `Formatting.text("")` returns `""` rather than nil precisely so it survives; a dropped cell shifts every cell after it |
1921
+ | `Kol/@Typ` is `use="required"` with six inline values | `Kol` | FA(3)'s only inline *attribute* enumeration — `date`, `datetime`, `dec`, `int`, `time`, `txt`. {TableColumn} reads them from the generated metadata, which is why §18.2's codegen fix came first |
1922
+
1923
+ `Suma` is `minOccurs="0"`, so a table with no summary row must not re-serialise with an empty one
1924
+ — {AttachmentTable#totals} is nil rather than `[]` for that reason.
1925
+
1926
+ **One defect no tier catches, and it is undecidable rather than unimplemented.** A column
1927
+ declared `dec` whose cells hold `"nie liczba"` is XSD-clean, silent to `#errors`, `#warnings`
1928
+ and `#unmapped_elements` alike — every cell is `TZnakowy2` whatever its column says. It is not
1929
+ checked because **a cell cannot be reliably associated with a column at all**: rows are ragged,
1930
+ so a one-cell label row belongs to no column and the association has no general answer. Recorded
1931
+ so it is not mistaken for an oversight.
1932
+
1933
+ **The reader refuses eight structurally-invalid shapes** rather than reading them — no
1934
+ `BlokDanych`, no `MetaDane`, a half-empty `MetaDane`, no `TNaglowek`, no `Wiersz`, an empty
1935
+ `Wiersz`, a missing or illegal `Kol/@Typ`. That is consistent with the parser refusing a
1936
+ nameless seller, and the cost is that a malformed attachment makes the invoice's *tax* figures
1937
+ unreadable. `AttachmentReader`'s own comment weighs it and names the alternative.
1938
+
1939
+ **Operational constraints are out of 0.1 scope and stay there** (DESIGN.md §7.4). Sending an
1940
+ invoice that carries an attachment needs prior opt-in in `e-Urząd Skarbowy` and, per **§15.5**,
1941
+ a batch session — with the exception that section records, an offline *technical correction* may
1942
+ use an interactive one. The size ceiling rises from 1 MB to 3 MB (**§6.2**), and both are
1943
+ **defaults rather than ceilings of the format**: `limity.md` heads them *"Wartość domyślna"* and
1944
+ `GET /limits/context` reports the live values. None of that is modelled here: this is the
1945
+ document, not the submission — but the size figure does reach code, because tier 1b must not
1946
+ refuse a document the format permits ({DocumentValidator::MAX_BYTES_WITH_ATTACHMENT}).
1947
+
1948
+ An earlier version of this paragraph cited "§16", which says nothing about attachments.
1949
+
384
1950
  ## 9. Still unverified
385
1951
 
386
1952
  Carried forward; must be resolved before the code that depends on them is written
387
- (DESIGN.md §0.2).
388
-
389
- - **Crypto parameters** (DESIGN.md §6.4): symmetric cipher mode/padding/IV convention,
390
- RSA-OAEP digest and MGF1 parameters for both key wrapping and token encryption, and
391
- which published certificate serves which purpose. Sources to mine:
392
- `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md`, `tokeny-ksef.md`, and the
393
- `ksef-client-csharp` reference implementation for golden vectors.
394
- - **Session semantics**: whether one online session may carry multiple invoices, and
395
- session lifetime. Source: `sesja-interaktywna.md`.
396
- - **JWT lifetime and refresh mechanics**. Source: `uwierzytelnianie.md` plus the
397
- `/auth/token/refresh` response model.
398
- - **Challenge 10-minute validity** — asserted in DESIGN.md, not found in the spec (§4).
399
- - **P_13_x / P_14_x rate-bucket ↔ VAT-rate mapping** (DESIGN.md §7.3). Source: the pinned
400
- XSD plus `faktury/` guidance.
401
- - **Business-rule catalogue** for validation tier 3 (DESIGN.md §7.7).
402
- - **UPO document format** — schema pinned upstream at `faktury/upo/schemy/upo-v4-3.xsd`
403
- with worked examples under `faktury/upo/przyklady/v4-3/`; not yet pulled in.
1953
+ (DESIGN.md §0 rule 2). Reviewed 2026-08-23 (second pass, after the crypto module).
1954
+
1955
+ - **Business-rule catalogue** for validation tier 3 (DESIGN.md §7.7). Still the only
1956
+ genuinely open blocker of the original set, and now **known to be absent rather than
1957
+ merely unread**. This bullet used to say `faktury/weryfikacja-faktury.md` was the next
1958
+ place to look. It was pinned 2026-08-24 and it is not that document: it specifies
1959
+ *technical admission* checks, which turned out to be the missing first-tier source for
1960
+ tier **1**. See §15 for what it does settle, and §15.6 for the search that establishes no
1961
+ file at commit `1c34fe27` states a reconciliation rule — plus the three possible
1962
+ groundings for tier 3 and which of them is safe to build on.
1963
+ - **Error-code catalogue.** Still open, but **narrowed again**: the *authentication
1964
+ operation* status codes are recorded at §4.8 **from the pinned contract** — not, as this
1965
+ bullet used to say, from the reference implementation; see §4.8 for why that distinction
1966
+ cost us code 480 — and the
1967
+ per-endpoint `ExceptionResponse` codes now known — and `docs/errors.md` lists all four —
1968
+ are `21405` (input validation),
1969
+ `21470` (unknown or withdrawn key, §10.2), `21111` (invalid authorisation challenge,
1970
+ §4.5) and `21157` (invalid package part size). The rest must be collected from the spec
1971
+ per operation, or observed — **and the collection is mechanical**: each operation's `400`
1972
+ response carries a Markdown table of its own codes in the `description`, which is where
1973
+ `21111` came from. The C# client has sibling files (`CertificateStatusCodeResponse`,
1974
+ `InvoiceInSessionStatusCodeResponse`, `OperationStatusCodeResponse`,
1975
+ `InvoiceExportStatusCodeResponse`) that will likely close the corresponding areas the
1976
+ same way when those subsystems are built.
1977
+ - ~~**Whether the KSeF-token timestamp is really enforced as a replay nonce.**~~ **Resolved
1978
+ 2026-08-24: it is.** §4.5 recorded the claim from first-tier documentation and nothing
1979
+ offline could test it. `spec/integration/crypto_spec.rb` authenticates with a deliberately
1980
+ stale `timestampMs`, and live TEST refused it (run `32704511675`). The claim is now
1981
+ observed, not believed.
1982
+ - ~~**Whether `certificateId` and `publicKeyId` are derived as §10.2 states.**~~ **Resolved
1983
+ 2026-08-24: both are.** The library never computes either — it echoes the server's
1984
+ `publicKeyId` back — so no offline test could check the derivation.
1985
+ `spec/integration/crypto_spec.rb` recomputes both from the real certificates and they
1986
+ match: `publicKeyId` is the Base64 SHA-256 of the DER `SubjectPublicKeyInfo`,
1987
+ `certificateId` the Base64 SHA-256 of the DER certificate (same run).
1988
+ - **Nightly higher rate limits** (§6.1) — the 20:00–06:00 values are explicitly
1989
+ unpublished pending production tuning. Do not hard-code a nightly multiplier.
1990
+ - ~~**Whether `upo.pages[].downloadUrl` arrives absolute or host-relative**~~ — **resolved
1991
+ 2026-08-24: it arrives ABSOLUTE.** Observed on the first live nightly (run
1992
+ `32692339217`), which reported it explicitly. `Ksef::UPO::Client` handles both forms and
1993
+ will keep doing so — `srodowiska.md` promises only that a returned URL's *host* matches
1994
+ the environment called, not that the URL is absolute, so the relative branch stays as
1995
+ defence rather than dead code. (An earlier revision of this bullet said the field
1996
+ should simply be ignored; that was the superseded reading, corrected in §14.2 — the link
1997
+ is unmetered and hash-verified, so ignoring it costs something real.)
1998
+
1999
+ ### 9.1 Resolved
2000
+
2001
+ Kept as a record so they are not re-investigated. The 2026-08-22 pass over the newly
2002
+ pinned prose (§1.3) closed four of the five items that were open, including both that were
2003
+ marked as blocking Phase 2.
2004
+
2005
+ | Item | Where it now lives |
2006
+ |---|---|
2007
+ | Challenge 10-minute validity | §4 — verified from `uwierzytelnianie.md`, no longer hearsay |
2008
+ | JWT lifetime and refresh mechanics | §4.2 — access token to its `exp`, refresh token up to 7 days |
2009
+ | `P_13_x` / `P_14_x` rate-bucket mapping | §8.1a — read from the XSD's own documentation |
2010
+ | Base URLs for all three environments | §2 — read from each environment's own OpenAPI document |
2011
+ | Error model and `Retry-After` semantics | §5 |
2012
+ | XSD redistribution terms | §1.2 — MIT, so the schemas are bundled |
2013
+ | TEST credential provisioning | §6a |
2014
+ | **Crypto parameters** *(was: blocks all of Phase 2)* | §10 — from `sesja-interaktywna.md` and `uwierzytelnianie.md`, corroborated by both reference clients |
2015
+ | **XAdES signature specifics** *(was: blocks Phase 2's first step)* | §4.3 — an allow-list, so no single shape had to be reverse-engineered |
2016
+ | **Session semantics** | §11 — 12-hour lifetime, many invoices, concurrent sessions permitted |
2017
+ | **UPO document format** | §12 — schema pinned to `lib/ksef/upo/schema/`, six examples to `spec/fixtures/upo/` |
2018
+ | TEST bootstrap for a real credential | §4.6 — upstream ships a console app that does it |
2019
+
2020
+ ---
2021
+
2022
+ ## 10. Cryptography — resolves DESIGN.md §6.4 [VERIFY]
2023
+
2024
+ Primary source: `sesja-interaktywna.md` "Wymagania wstępne" and `uwierzytelnianie.md` §2.2
2025
+ (upstream's own numbering) — **both first-tier documentation, not inferred from client
2026
+ behaviour.**
2027
+ Independently corroborated against `KSeF.Client/Api/Services/CryptographyService.cs`
2028
+ (`ksef-client-csharp`) and `DefaultCryptographyService.java` (`ksef-client-java`), both
2029
+ retrieved 2026-08-22. Where a line number is cited below it is from the C# file.
2030
+
2031
+ ### 10.1 Parameters
2032
+
2033
+ | Purpose | Algorithm | Detail |
2034
+ |---|---|---|
2035
+ | Invoice payload | **AES-256-CBC**, **PKCS#7** padding | 256-bit key, 128-bit IV, 128-bit block (`CryptographyService.cs:486–490`) |
2036
+ | Symmetric key wrapping | **RSAES-OAEP**, SHA-256 digest + **MGF1-SHA-256** | `RSAEncryptionPadding.OaepSHA256` (`:133`, `:423`) |
2037
+ | KSeF token (§4.5) | **RSA-OAEP**, SHA-256 + MGF1-SHA-256 | over `{token}\|{timestampMs}` UTF-8 |
2038
+ | Key and IV generation | CSPRNG | 32 and 16 bytes respectively (`:498`, `:507`) |
2039
+
2040
+ A fresh symmetric key per session is **recommended** by the docs, not required.
2041
+
2042
+ `faktury/weryfikacja-faktury.md` states the same parameters as an admission requirement —
2043
+ a fifth witness, and the first from first-tier prose (§15.5). It writes the wrapping as
2044
+ "RSAES-OAEP (SHA-256/MGF1)", pairing the two digests in one breath, which is exactly the
2045
+ distinction the MGF1 row below exists to defend.
2046
+
2047
+ Java uses the JCE name `AES/CBC/PKCS5Padding`; for a 16-byte block PKCS#5 and PKCS#7 are
2048
+ the same padding, so this is not a divergence.
2049
+
2050
+ Ruby equivalents: `OpenSSL::Cipher.new("aes-256-cbc")` with `#random_key` / `#random_iv`,
2051
+ and `OpenSSL::PKey::RSA#public_encrypt` is **not** sufficient — OAEP with an explicit MGF1
2052
+ digest needs `OpenSSL::PKey::RSA#encrypt` with
2053
+ `rsa_padding_mode: "oaep", rsa_oaep_md: "sha256", rsa_mgf1_md: "sha256"`.
2054
+
2055
+ **No golden vectors exist upstream.** Neither client repository commits fixed
2056
+ plaintext/ciphertext pairs; `Compatibility/CryptoCompat*.cs` are polyfills for older .NET
2057
+ runtimes, not test vectors. This is acceptable: AES-256-CBC/PKCS#7 and RSA-OAEP-SHA256 are
2058
+ standard primitives that OpenSSL reproduces by construction, and NIST/RFC vectors validate
2059
+ them just as well as a C#-generated pair would. What is *not* standard, and therefore does
2060
+ need pinning down, is the framing — see §14.1.
2061
+
2062
+ **What is asserted instead** (implemented 2026-08-23, `spec/ksef/crypto_spec.rb` and
2063
+ `spec/ksef/crypto/encryptor_spec.rb`), replacing DESIGN.md §6.4's "port three vectors from
2064
+ the C# client":
2065
+
2066
+ | Parameter | How it is pinned |
2067
+ |---|---|
2068
+ | AES-256-CBC | **NIST SP 800-38A F.2.5** CBC-AES256.Encrypt, four blocks, byte for byte |
2069
+ | PKCS#7 | the padded output is one block longer than an exact multiple of 16 |
2070
+ | SHA-256 | **FIPS 180-4** vectors for `"abc"` and the empty string |
2071
+ | OAEP digest | the longest accepted plaintext is **190 bytes**, i.e. `k − 2·hLen − 2` with `hLen = 32`. With SHA-1 it would be 214, so this pins the digest without trusting the option name |
2072
+ | MGF1 digest | a ciphertext produced with MGF1-SHA-256 **fails** to decrypt under MGF1-SHA-1, so the two are genuinely distinguishable and the option is not being ignored |
2073
+
2074
+ The MGF1 row is the one that matters most. OpenSSL's MGF1 digest defaults to SHA-1, so
2075
+ setting `rsa_oaep_md` alone silently produces OAEP-SHA256-with-MGF1-SHA1 — a different
2076
+ scheme, which fails only at the far end. Verified on both 3.2.11 and 4.0.6.
2077
+
2078
+ ### 10.2 Public key distribution and rotation
2079
+
2080
+ Source: `bezpieczenstwo/klucze-publiczne-do-szyfrowania.md` (05.05.2026).
2081
+
2082
+ `GET /security/public-key-certificates` is **unauthenticated** — the contract declares no
2083
+ global `security` and none on the operation, the same reading that §6a.1 applied to
2084
+ `/testdata/*`. So keys can be fetched before a credential exists, which is what makes the
2085
+ KSeF-token flow possible from a cold start. Its ceiling is **60 req/s**, matching
2086
+ `/auth/challenge` rather than the 10/30/120 default of §6.1. It returns a list of:
2087
+
2088
+ | Field | Meaning |
2089
+ |---|---|
2090
+ | `certificate` | X.509 in **DER, Base64-encoded, without PEM BEGIN/END armour** |
2091
+ | `certificateId` | SHA-256 of the DER certificate, Base64 |
2092
+ | `publicKeyId` | SHA-256 of the DER `SubjectPublicKeyInfo`, Base64 — **the selector sent back to the API** |
2093
+ | `validFrom`, `validTo` | validity window |
2094
+ | `usage` | array; known values `KsefTokenEncryption`, `SymmetricKeyEncryption` |
2095
+
2096
+ Certificates are issued by a qualified CA with `CN = Ministerstwo Finansów`.
2097
+
2098
+ **Selection rule** (documented, so not a judgement call): pick by `usage`, require validity
2099
+ at the moment of use, and where several are valid prefer **the latest `validFrom`**.
2100
+
2101
+ `publicKeyId` must be sent on `POST /auth/ksef-token`, `POST /sessions/online`,
2102
+ `POST /sessions/batch` and `POST /invoices/exports`. It appears in the spec as
2103
+ `EncryptionInfo.publicKeyId`.
2104
+
2105
+ Two distinct rotation modes, which the client must not conflate:
2106
+
2107
+ - **Re-certification** — new certificate, *same* key pair. `publicKeyId` is unchanged.
2108
+ - **Key rotation** — new key pair, so `publicKeyId` changes. Planned rotations publish the
2109
+ new certificate early and both appear for the same `usage` during the overlap; emergency
2110
+ rotations revoke the old one and drop it from the list immediately.
2111
+
2112
+ Because emergency rotation is possible at any time, **the certificate list must not be
2113
+ cached indefinitely**. The documented recovery path is specific: on **HTTP 400 with code
2114
+ `21470`** ("the supplied key identifier is unknown or refers to a withdrawn key"), re-fetch
2115
+ the list, re-select, and repeat the operation. This is the one case where a failed POST
2116
+ should be retried after remediation — and it is remediation, not a blind retry, so it does
2117
+ not conflict with the never-auto-retry-POST rule.
2118
+
2119
+ ### 10.3 Decisions this gem made, that upstream does not state
2120
+
2121
+ Recorded here so they are not mistaken for ledgered facts, and so the reasoning survives.
2122
+ Implemented 2026-08-23 in `lib/ksef/crypto/`.
2123
+
2124
+ | Decision | Value | Why |
2125
+ |---|---|---|
2126
+ | Certificate cache TTL | **one hour**, `PublicKeys::DEFAULT_TTL` | §10.2 says "not indefinitely" and nothing more. An hour fetches once per process in practice while bounding how long a withdrawn key can linger; `refresh!` and `with_key_rotation` cover the rest |
2127
+ | `publicKeyId` | **always sent**, though the contract marks it nullable | It names *which* published key did the wrapping. Omitting it turns a key rotation from a `21470` you can remediate into an undecryptable payload with nothing to diagnose |
2128
+ | Unparseable `validFrom`/`validTo` | the certificate becomes **unselectable** | Fail closed. A nil window must not read as "no constraint" — that would wrap a payload under a key KSeF may already have withdrawn |
2129
+ | Unknown `usage` string | raises, rather than selecting nothing | A typo would otherwise present as "the Ministry publishes no such key", sending the reader to look for a rotation that never happened |
2130
+ | `Ksef::CryptoError` | a new branch of the DESIGN.md §6.7 hierarchy | Neither an `ApiError` (the request succeeded; the list has nothing usable) nor a `ConfigurationError` (documented as local and pre-request). The same reasoning that added `AuthorizationError` and `ResourceGoneError` (§7 item 4) |
2131
+ | Wrapping is done **by** the certificate | `Certificate#encrypt` | Keeps the public key and the OAEP parameters in one place, so no caller can wrap with the right key under the wrong padding |
2132
+
2133
+ One more, and it is the one worth stating: `Encryptor#seal` returns the ciphertext **and**
2134
+ the hash-and-size of both forms in a single object. §11.1 requires four integrity values
2135
+ per invoice and hashing the wrong artifact is silent — only the server can detect it — so
2136
+ the two digests are produced together rather than left to a caller to pair up.
2137
+
2138
+ ---
2139
+
2140
+ ## 11. Online session semantics — resolves the §9 session item
2141
+
2142
+ Source: `sesja-interaktywna.md` (10.07.2025), retrieved 2026-08-22.
2143
+
2144
+ | Fact | Value |
2145
+ |---|---|
2146
+ | Session lifetime | **12 hours** from creation; `validUntil` in the open response |
2147
+ | Invoices per session | many — up to the 10 000 cap of §6.2 |
2148
+ | Concurrent sessions | **permitted**, multiple per authentication |
2149
+ | Open cost | "lightweight and synchronous" |
2150
+ | Expiry behaviour | session closes automatically at `validUntil` |
2151
+
2152
+ Flow: `POST /sessions/online` → `POST /sessions/online/{ref}/invoices` (repeat) →
2153
+ `POST /sessions/online/{ref}/close`. Closing triggers **asynchronous** generation of the
2154
+ collective UPO; it is not available at the moment `close` returns.
2155
+
2156
+ ### 11.1 The send-invoice request carries four integrity values
2157
+
2158
+ `POST /sessions/online/{ref}/invoices` requires the hash **and** size of *both* the
2159
+ plaintext and the encrypted document, plus the Base64 ciphertext:
2160
+
2161
+ - `invoiceHash` + size — SHA-256 of the **plaintext** XML
2162
+ - `encryptedInvoiceHash` + size — SHA-256 of the **ciphertext**
2163
+ - `encryptedInvoiceContent` — Base64 of the ciphertext
2164
+
2165
+ Computing either hash over the wrong artifact is an easy and silent mistake; both must be
2166
+ covered by tests.
2167
+
2168
+ Independently confirmed by `faktury/weryfikacja-faktury.md`, which lists "computing and
2169
+ verifying the hash of the invoice together with the file size" and the same for the
2170
+ encrypted invoice among the checks KSeF performs (§15.5). All four values are the
2171
+ service's, not ours to interpret.
2172
+
2173
+ ### 11.2 Session open request
2174
+
2175
+ `OpenOnlineSessionRequest` = `formCode` + `encryption` (pinned spec, `components.schemas`).
2176
+ `formCode` is the triple `systemCode` / `schemaVersion` / `value` — for FA(3) that is
2177
+ `FA (3)` / `1-0E` / `FA`, consistent with §8's finding that `KodFormularza` is `FA` and
2178
+ `FA (3)` is the `kodSystemowy`. `encryption` is `EncryptionInfo` =
2179
+ `encryptedSymmetricKey` + `initializationVector` + `publicKeyId`.
2180
+
2181
+ **Send `X-KSeF-Feature: upo-v4-3` with it** — see §14.6. The header is not in the contract,
2182
+ but both reference clients send it, and it selects which UPO format the session will
2183
+ produce. Since this gem pins `upo-v4-3.xsd`, staying silent means accepting whatever the
2184
+ server defaults to, which may not be the version we can validate.
2185
+
2186
+ ### 11.2a Decisions this gem made about sessions, that upstream does not state
2187
+
2188
+ The counterpart to §10.3, kept beside the subsystem it governs. Recorded so they are not
2189
+ mistaken for ledgered facts, and so the reasoning survives the person who had it.
2190
+ Implemented 2026-08-23 in `lib/ksef/sessions/`.
2191
+
2192
+ | Decision | Value | Why |
2193
+ |---|---|---|
2194
+ | **The `Encryptor` is bound to the `Session`** | `Session` carries the encryptor that opened it; `#send_invoice` takes no key | The single most consequential one — see below |
2195
+ | Session lifetime in the composite | **a fresh session per `send_invoice`**, with `client.session { }` for batching | Decided by the human, 2026-08-23. Reasoning in DESIGN.md §6.5 |
2196
+ | Bearer fetched per request | `#bearer` is called on each call, not captured at construction | A 12-hour session outlives a "kilkanaście minut" access token, so a long run must pick up {Auth::AccessToken}'s proactive refresh mid-flight |
2197
+ | Reference numbers shape-checked | character-set match before path interpolation | Keeps a garbled or hostile value out of a URL. Deliberately *not* a §13 checksum check: §12 warns the non-KSeF-number forms are unverified against that algorithm |
2198
+ | `X-KSeF-Feature` opt-out | `upo_version: nil` omits the header | The header itself is contract-silent (§14.6), so a caller must be able to decline our guess about it |
2199
+ | Granular layer is stateless | `Sessions::Online` holds no session | Not a decision so much as a copied one: both official clients thread the reference through as a parameter, and it matches `Auth::Client` |
2200
+
2201
+ **Why the encryptor belongs to the session.** The symmetric key is agreed *once*, in the
2202
+ session-open request, and every invoice in that session is encrypted under it. So an invoice
2203
+ encrypted with any other key is undecryptable at the far end — and the only symptom is
2204
+ per-invoice status **435, "błąd odszyfrowania pliku"** (§12.1), which arrives
2205
+ **asynchronously**, long after the send returned `202`. There is no synchronous error and
2206
+ nothing in the response to inspect.
2207
+
2208
+ A `send_invoice(session, xml, encryptor:)` signature would make that mistake a plausible
2209
+ typo. Binding the encryptor to the `Session` at open time makes it unrepresentable instead:
2210
+ there is no way to name the wrong key, because the caller never names one. This is the same
2211
+ move as {Ksef::Crypto::Encryptor#seal} returning both digests together (§10.3) — where a
2212
+ mistake is silent and remote, prefer an API shape that cannot express it over a comment
2213
+ warning against it.
2214
+
2215
+ ### 11.3 Accepted schema versions differ by environment
2216
+
2217
+ Source: `srodowiska.md` (16.03.2026). **TEST accepts FA(2) as well as the current schemas;
2218
+ DEMO and PROD reject FA(2).** That is the trap worth remembering: an integration validated
2219
+ solely against TEST can be sending a schema production will refuse.
2220
+
2221
+ **The exact `formCode` triples come from the pinned contract, not from the prose**
2222
+ (corrected 2026-08-23 — `srodowiska.md` spells the PEF codes `FA_PEF(3)` / `FA_KOR_PEF(3)`,
2223
+ which are not the values the API accepts, and omits `FA_RR (1)` altogether).
2224
+ `OpenOnlineSessionRequest.formCode` declares exactly five:
2225
+
2226
+ | `systemCode` | `schemaVersion` | `value` |
2227
+ |---|---|---|
2228
+ | `FA (2)` | `1-0E` | `FA` |
2229
+ | **`FA (3)`** | **`1-0E`** | **`FA`** |
2230
+ | `PEF (3)` | `2-1` | `PEF` |
2231
+ | `PEF_KOR (3)` | `2-1` | `PEF` |
2232
+ | `FA_RR (1)` | `1-1E` | `FA_RR` |
2233
+
2234
+ FA(3) is the row this gem sends, and it is consistent with §8's finding that
2235
+ `KodFormularza` is `FA` while `FA (3)` is the `kodSystemowy` — note the space before the
2236
+ bracket in both. Take these three strings from the table above rather than assembling them,
2237
+ and never from `srodowiska.md`.
2238
+
2239
+ Also from the same document: test environments have a **maintenance window 16:00–18:00**
2240
+ (from 2025-10-01), which the nightly integration workflow should avoid.
2241
+
2242
+ ---
2243
+
2244
+ ## 12. Session status and UPO
2245
+
2246
+ Source: `faktury/sesje/sesja-sprawdzenie-stanu-i-pobranie-upo.md` (20.04.2026).
2247
+
2248
+ | Operation | Endpoint |
2249
+ |---|---|
2250
+ | List sessions | `GET /sessions` (filter by type/status; `continuationToken`) |
2251
+ | Session status | `GET /sessions/{ref}` |
2252
+ | Session invoices | `GET /sessions/{ref}/invoices` |
2253
+ | One invoice | `GET /sessions/{ref}/invoices/{invoiceRef}` |
2254
+ | Rejected only | `GET /sessions/{ref}/invoices/failed` |
2255
+ | UPO by invoice ref | `GET /sessions/{ref}/invoices/{invoiceRef}/upo` |
2256
+ | UPO by KSeF number | `GET /sessions/{ref}/invoices/ksef/{ksefNumber}/upo` |
2257
+ | Collective session UPO | `GET /sessions/{ref}/upo/{upoRef}` |
2258
+
2259
+ Session status carries `invoiceCount`, `successfulInvoiceCount`, `failedInvoiceCount`, and
2260
+ **after close** a `upo.pages[]` array of `{ referenceNumber, downloadUrl }`.
2261
+
2262
+ - The UPO is **XML, XAdES-signed by the Ministry of Finance**, conforming to the pinned
2263
+ `upo-v4-3.xsd`. It is the legal proof of receipt: archive the bytes **verbatim**.
2264
+ Re-serialising it — even losslessly by XML rules — risks invalidating the signature.
2265
+ - **A collective UPO holds at most 10 000 invoice entries**, which is why `pages[]` is an
2266
+ array. A client that reads only `pages[0]` silently loses proof of receipt for the rest.
2267
+ - Paging throughout this area is `continuationToken`-based, not offset-based.
2268
+ - `downloadUrl` has a path-prefix inconsistency — §14.2.
2269
+
2270
+ ### 12.1 Status codes — three separate tables, all from the pinned contract
2271
+
2272
+ Retrieved 2026-08-23 from the contract's `SessionInvoiceStatusResponse.status` and
2273
+ `SessionStatusResponse.status` descriptions. Sourced from the contract deliberately, and not
2274
+ from `InvoiceInSessionStatusCodeResponse.cs`, after §4.8's lesson — and the caution paid:
2275
+ the C# enum lists `400`, `401` and `403` for invoice status, which **the contract does not
2276
+ declare at all**.
2277
+
2278
+ **Per invoice, in a session.** **Both `100` and `150` mean keep polling** — corrected
2279
+ 2026-08-23, having first been written here as "`150` is the only one". That came from
2280
+ `OnlineSessionUtils.cs`, whose poller returns as soon as the code is anything but `150`, and
2281
+ it is wrong on the contract's own wording: `100` is *"przyjęta do dalszego przetwarzania"* —
2282
+ accepted for **further** processing — which is by definition not a final state, and an
2283
+ invoice sitting at `100` has no KSeF number yet. A poller that stops there reports a
2284
+ pending invoice as though it were decided.
2285
+
2286
+ So the rule this gem uses is **`code < 200` is in progress**, rather than a list of
2287
+ intermediate codes. That is deliberately the opposite of §4.8's
2288
+ treat-the-unknown-as-terminal rule for authentication, and the asymmetry is justified:
2289
+ authentication polls without a deadline, so an unrecognised code there must not loop for
2290
+ ever, whereas session polling is deadline-bounded, so an unknown intermediate code costs at
2291
+ worst one timeout — and "unresolved" is a more honest answer about an invoice than
2292
+ "prematurely final".
2293
+
2294
+ | Code | Meaning | Notes |
2295
+ |---|---|---|
2296
+ | **100** | **accepted for further processing** | **not terminal — keep polling** |
2297
+ | **150** | **processing** | **not terminal — keep polling** |
2298
+ | 200 | success | terminal, and the invoice has a KSeF number |
2299
+ | 405 | processing cancelled because the session failed | |
2300
+ | 410 | invalid permission scope | |
2301
+ | 415 | cannot send an invoice with an attachment | |
2302
+ | 430 | invoice file verification error | |
2303
+ | **435** | **file decryption error** | what a wrong key or IV produces — see below |
2304
+ | **440** | **duplicate invoice** | carries `originalSessionReferenceNumber` and `originalKsefNumber` |
2305
+ | 450 | invoice semantic verification error | the business-rule tier rejecting it |
2306
+ | 500 | unknown error | |
2307
+ | 550 | cancelled by the system; retry later | retryable |
2308
+
2309
+ Two of those earn their emphasis. **`435` is the code a §14.1 mistake produces** — prefix the
2310
+ IV to the ciphertext as upstream's prose instructs and this is what comes back, which makes
2311
+ it the single most useful code to surface verbatim. And **`440` is the only status carrying
2312
+ extensions**: on a duplicate the API hands back the session and KSeF number of the *original*
2313
+ submission, which is exactly what a caller needs to recover rather than guess. Do not discard
2314
+ those fields.
2315
+
2316
+ **Per session** — and the two session types have **different tables**, which is easy to miss.
2317
+
2318
+ | Code | Interactive | Batch |
2319
+ |---|---|---|
2320
+ | 100 | session open | batch session started |
2321
+ | 150 | — | processing |
2322
+ | **170** | **session closed** | — |
2323
+ | 200 | processed successfully | processed successfully |
2324
+ | 405 | — | package element verification error |
2325
+ | 415 | error decrypting the supplied key | error decrypting the supplied key |
2326
+ | 420 | — | invoice-per-session limit exceeded |
2327
+ | 430 | — | archive decompression error |
2328
+ | 435 | — | error decrypting archive parts |
2329
+ | 440 | cancelled — no invoices sent | cancelled — send window exceeded, or no invoices sent |
2330
+ | 445 | verification error, no valid invoices | verification error, no valid invoices |
2331
+ | 500 | — (batch only) | unknown error |
2332
+
2333
+ Three consequences for the online session layer:
2334
+
2335
+ - **`170` exists only for interactive sessions**, and interactive has no `150`. A poller
2336
+ written against the batch table would wait for a code that never arrives.
2337
+ - **`415` at session level is the RSA-OAEP wrap failing** — the server could not decrypt the
2338
+ symmetric key. Distinct from the per-invoice `435`, which is the AES payload failing. Those
2339
+ two codes localise a crypto fault to either the key or the payload, which is worth
2340
+ surfacing rather than collapsing.
2341
+ - **`440` "no invoices sent" means an opened-and-unused session is cancelled**, so opening
2342
+ one speculatively is not free.
2343
+
2344
+ ### 12.2 The reference clients' polling defaults, and why ours differ
2345
+
2346
+ Both clients poll on a **fixed 1-second interval up to 60 attempts** — a 60-second ceiling —
2347
+ and treat `150` as the sole continue condition (`OnlineSessionUtils.cs`:
2348
+ `DefaultSleepTimeMs = 1000`, `DefaultMaxAttempts = 60`, `ProcessingStatusCode = 150`).
2349
+
2350
+ DESIGN.md §6.5 specifies capped exponential backoff instead — 1s, 2s, 4s … 30s, with a
2351
+ five-minute default deadline. Keep ours, for two reasons the reference clients do not have to
2352
+ care about: a library shared across many callers should not spend a per-hour rate budget at a
2353
+ fixed 1/s (§6.1 caps `GET /sessions/{ref}` at 1200/h, which 60 polls per invoice would eat
2354
+ quickly), and a 60-second ceiling is too short for a large session. Their *terminal condition* needed
2355
+ correcting too — see §12.1: **`code < 200` is in progress**, because `100` means "accepted for
2356
+ further processing" and a poller stopping there reports a pending invoice as settled. This
2357
+ sentence said "poll while the code is `150`" until 2026-08-23, the fourth copy of that claim
2358
+ to survive a correction pass.
2359
+
2360
+ ### 12.3 Decisions this gem made about UPO retrieval, that upstream does not state
2361
+
2362
+ The third of these tables, after §10.3 (crypto) and §11.2a (sessions). Implemented
2363
+ 2026-08-23 in `lib/ksef/upo/`.
2364
+
2365
+ | Decision | Value | Why |
2366
+ |---|---|---|
2367
+ | **A separate, credential-free connection** | `HTTP::Connection.storage` — no bearer, no `base_url` | §14.2 forbids sending the access token to a `downloadUrl`. A rule nobody can break beats a rule everybody must remember — see below |
2368
+ | **No parsed form on `Document`** | holds the received `String`; no `#to_xml`, no re-encode, `#write` uses `binwrite` | The Ministry's XAdES signature covers **octets**. A lossless XML round-trip can still yield a document that no longer verifies, so the parsed form is simply not offered |
2369
+ | `Ksef::IntegrityError` | a new branch of DESIGN.md §6.7 | A hash mismatch means *fetch it again* — nothing is wrong with the request, the credentials or the document. Not a `ValidationError` (the caller's data is fine) nor an `ApiError` (the response was a success) |
2370
+ | `#verifiable?` separate from `#verified?` | two booleans, not one tri-state | "Nothing to check" and "the check failed" need different responses. **Corrected 2026-08-23:** this row used to justify the split by claiming the metered route publishes no hash. It does — §5.5 records the header on four `200` responses, all three UPO routes and the invoice download — and the client was discarding it. Both paths now verify; the split still earns its place for a response that genuinely omits the header |
2371
+ | `#fetch` prefers the link | falls back to the metered route on expiry or absence | §14.2's resolution, in one call: the link is unmetered and hash-verified, and `GET /sessions` already allows only 10/min |
2372
+ | **`#fetch` judges the expiry against an injected clock** | `UPO::Client.new(clock:)`, threaded from `Ksef::Client` | It read `Time.now` until 2026-09-03, which made it the only route decision here the recorded tier's pinned clock could not reach. The link lives three days (§14.2), so a cassette replayed correctly for three days and then began requesting a fallback URL nothing had recorded. Same class as reading a token's expiry off the wall clock, one layer down |
2373
+ | A relative `downloadUrl` is resolved | against the API host; an absolute one used untouched | §9 still carries which form the live API sends as unverified, so both are handled rather than one guessed at |
2374
+ | KSeF numbers parsed before use | `for_ksef_number` runs §13's CRC-8 first | A mistyped number fails locally instead of as an opaque 404 |
2375
+ | `Ksef::Client#upo` uses the **metered** route | against this section's own preference for the link | Deliberate, and only for a single invoice: obtaining the unmetered link costs a metered status call first, so the direct route is one request against two. `#collective_upo` and `UPO::Client#fetch` prefer the link, where it pays |
2376
+
2377
+ **Why the connection split is the mechanism and not just tidiness.** A `downloadUrl` is a
2378
+ pre-signed Azure Blob URI that carries its own authorisation in the query string. Sending
2379
+ the KSeF access token to it would hand a live credential to third-party storage, and the
2380
+ contract says so explicitly — *"nie należy wysyłać tokenu dostępowego"*. That is a rule
2381
+ about something absent, and absences are exactly what code review misses: nobody notices
2382
+ the header that was not removed. Putting those requests on a connection that has no bearer
2383
+ to attach turns "we remembered" into "we cannot", which is the same reasoning as binding the
2384
+ encryptor to the session (§11.2a) and having `Encryptor#seal` produce both digests at once
2385
+ (§10.3).
2386
+
2387
+ Reference numbers share a shape: `YYYYMMDD-XX-<hex>-<hex>-CC`, where `XX` is a kind tag
2388
+ (`CR` challenge, `SB` batch session, `EU` UPO) and `CC` looks like the same CRC-8 checksum
2389
+ as §13. Only the KSeF-number form is documented; **do not validate the others against §13's
2390
+ algorithm** without verifying it first.
2391
+
2392
+ ---
2393
+
2394
+ ## 13. KSeF number structure — verified with a working example
2395
+
2396
+ Source: `faktury/numer-ksef.md` for the structure, **corrected against the pinned contract
2397
+ 2026-08-23** for the length — see §13.1. The 2.0 format, 35 characters:
2398
+
2399
+ ```
2400
+ 9999999999-RRRRMMDD-FFFFFFFFFFFF-FF
2401
+ ```
2402
+
2403
+ Seller NIP (10) · `-` · acceptance date `YYYYMMDD` (8) · `-` · technical part (12 uppercase
2404
+ hex) · `-` · CRC-8 checksum (2 uppercase hex).
2405
+
2406
+ CRC-8 parameters: **polynomial `0x07`, initial value `0x00`**, no reflection, no final XOR,
2407
+ computed over the **first 32 characters** (everything before the final hyphen), rendered as
2408
+ two uppercase hex digits.
2409
+
2410
+ Verified locally 2026-08-22 against the documented example — CRC-8 of
2411
+ `5265877635-20250826-0100001AF629` is `0xAF`, matching the published
2412
+ `5265877635-20250826-0100001AF629-AF`. This doubles as the golden vector for the
2413
+ implementation.
2414
+
2415
+ ### 13.1 A 36-character legacy form exists, and the prose does not mention it
2416
+
2417
+ Found 2026-08-23, by a documentation review checking the code against the contract rather
2418
+ than against this section. **`components.schemas.KsefNumber` declares
2419
+ `minLength: 35, maxLength: 36`**, with the pattern
2420
+
2421
+ ```
2422
+ …-([0-9A-F]{6})-?([0-9A-F]{6})-([0-9A-F]{2})$
2423
+ ```
2424
+
2425
+ — an **optional hyphen splitting the technical part 6-6** — and says outright: *"Numer KSeF
2426
+ o długości 36 znaków jest akceptowany, by zachować kompatybilność wsteczną z KSeF 1.0. W
2427
+ KSeF 2.0 numery są generowane wyłącznie w formacie 35-znakowym."*
2428
+
2429
+ So both forms are accepted on input; only the 35-character form is ever *generated*. The
2430
+ prose this section was built from says "always exactly 35", and the contract outranks it
2431
+ (§0 rule 2). This was a real defect, not just drift: `KsefNumber.parse` enforced 35, and
2432
+ both `Invoices::Client#download` and `UPO::Client#for_ksef_number` parse before use — so a
2433
+ KSeF 1.0-era number could not be looked up at all, failing locally where the API would have
2434
+ answered 200.
2435
+
2436
+ **The CRC input rule for the 36-character form is unverified.** §13's rule is "the first 32
2437
+ characters, everything before the final hyphen", which is specific to the 35-character
2438
+ layout; for the longer form that span is 33 characters and includes an extra hyphen, and
2439
+ nothing upstream says whether the checksum covers the hyphenated or the de-hyphenated text.
2440
+ Two consequences, both implemented:
2441
+
2442
+ - `KsefNumber.parse` accepts both forms, so a lookup is possible either way.
2443
+ - It **verifies the checksum only on the 35-character form**, where the rule is known, and
2444
+ reports `#checksum_verified?` so a caller can tell a checked number from an accepted one.
2445
+ Guessing the legacy rule and rejecting on a mismatch would turn an unverified assumption
2446
+ into a hard failure on numbers the API accepts.
2447
+
2448
+ The pattern also carries facts §13 never recorded: the NIP part is *structural*
2449
+ (`[1-9](\d[1-9]|[1-9]\d)\d{7}` — first digit non-zero, positions 2–3 not both zero) and
2450
+ the date part constrains year, month and day ranges. Both are stricter than this gem's
2451
+ `\d{10}` and `\d{8}`, though `Date.strptime` already rejects impossible dates.
2452
+
2453
+ **Fourth time prose has lost to the contract in this project** — after the `480` auth status
2454
+ code (§4.8), the `formCode` table (§11.3) and the `100`/`150` polling rule (§12.1). The
2455
+ standing lesson, now stated once here rather than rediscovered a fifth time: **when a fact
2456
+ comes from upstream prose, check the OpenAPI schema for the same field before ledgering it.**
2457
+ The prose is a summary; the contract is the interface.
2458
+
2459
+ One business fact worth surfacing in the API: per `limity/limity-api.md`, **the invoice's
2460
+ official receipt date is the date its KSeF number was assigned**, not the date the client
2461
+ downloaded it.
2462
+
2463
+ ---
2464
+
2465
+ ## 14. Contradictions within upstream's own sources
2466
+
2467
+ Distinct from §7, which records divergences from *our* design document. These are places
2468
+ where upstream's prose, its OpenAPI contract and its reference clients disagree. Precedence
2469
+ per DESIGN.md §2: the pinned artifacts win.
2470
+
2471
+ ### 14.1 The IV is *not* prefixed to the ciphertext
2472
+
2473
+ `sesja-interaktywna.md` describes the 128-bit IV as "dołączanego jako prefiks do
2474
+ szyfrogramu" — appended as a prefix to the ciphertext. **This is wrong for the online
2475
+ session invoice path**, and following the prose produces a payload KSeF cannot decrypt.
2476
+
2477
+ **`sesja-wsadowa.md` says it too, word for word** (pinned 2026-08-26, §16.3). So this is not
2478
+ one document's slip but a sentence copied across the session documentation, which makes the
2479
+ resolution below more valuable rather than less: it now covers both session types. Batch and
2480
+ online share a single `EncryptionInfo` schema whose `initializationVector` is a **required
2481
+ discrete field**, so the contract argument applies to batch unchanged — but the byte-count
2482
+ measurement in witness four is an interactive example, and batch has no measured equivalent
2483
+ until it is built.
2484
+
2485
+ Four sources agree against it:
2486
+
2487
+ 1. **The pinned OpenAPI spec** — `EncryptionInfo` carries `initializationVector` as a
2488
+ discrete field of the session-open request, alongside `encryptedSymmetricKey` and
2489
+ `publicKeyId`. Highest precedence. (2026-08-22)
2490
+ 2. **C#** — `EncryptBytesWithAES256` returns the encryptor's output directly, with no
2491
+ prepended bytes (`CryptographyService.cs:149–161`). (2026-08-22)
2492
+ 3. **Java** — `cipher.doFinal(content)`, likewise bare
2493
+ (`DefaultCryptographyService.java`). (2026-08-22)
2494
+ 4. **The contract's own worked example, arithmetically** (found 2026-08-23). The
2495
+ `POST /sessions/online/{ref}/invoices` example pairs `invoiceSize: 6480` with
2496
+ `encryptedInvoiceSize: 6496`. 6480 is 405 whole AES blocks, so PKCS#7 adds **exactly one
2497
+ block** of padding and lands on 6496. A prefixed 16-byte IV would have made it 6512.
2498
+ This is the sharpest of the four: it is a number upstream published about its own
2499
+ encryption, and it leaves no room for the IV. Asserted in
2500
+ `spec/openapi_contract_spec.rb`, and reproduced by `Encryptor` in its own spec.
2501
+
2502
+ **Resolution: send the IV once in the session-open request; the per-invoice ciphertext is
2503
+ bare.** The prose's "prefix" phrasing may describe the separate ECDH/AES-GCM path, which
2504
+ *does* concatenate — `subjectPublicKeyInfo || nonce || tag || ciphertext`
2505
+ (`CryptographyService.cs:446`) — but that path is not used for online-session invoices.
2506
+
2507
+ ### 14.2 `downloadUrl` is a pre-signed link, not a path to join
2508
+
2509
+ **Corrected 2026-08-22.** An earlier revision of this section said the field carried a
2510
+ stray `/api/v2` prefix and concluded "treat `downloadUrl` as advisory and construct the
2511
+ path yourself". That conclusion was drawn from the *prose example* without checking the
2512
+ OpenAPI contract, which is the higher-precedence artifact and describes the field
2513
+ explicitly. The conclusion was wrong, and following it would have discarded something
2514
+ worth having.
2515
+
2516
+ `UpoPageResponse.downloadUrl` is `format: uri`, and the contract's own description says:
2517
+
2518
+ - the link is **generated on every status query**, so it is not a stable identifier;
2519
+ - access is by `HTTP GET` and the access token **must not be sent** ("*nie należy* wysyłać
2520
+ tokenu dostępowego");
2521
+ - it is **not subject to API rate limits**, and expires at `downloadUrlExpirationDate`;
2522
+ - the response carries **`x-ms-meta-hash`** — the SHA-256 of the UPO document, Base64.
2523
+
2524
+ The `x-ms-meta-hash` header is Azure Blob Storage's, so this is a pre-signed storage URL
2525
+ rather than an API route. That also explains the prefix that prompted the original
2526
+ misreading: the field is not meant to be concatenated with the base URL at all, so whether
2527
+ the documented example looks host-relative is beside the point.
2528
+
2529
+ Both reference clients implement it that way. `ksef-client-csharp` exposes two distinct
2530
+ paths — `GetSessionUpoAsync(sessionRef, upoRef, accessToken)` for the metered API route,
2531
+ and `GetUpoAsync(Uri)` / `GetUpoWithHashAsync(restClient, uri)` for the link, the latter
2532
+ passing **`token: null`** explicitly.
2533
+
2534
+ **Resolution: follow `downloadUrl` as an opaque absolute URI, without the bearer token,
2535
+ and verify `x-ms-meta-hash` against the bytes received.** Given how tight the session
2536
+ budgets are (§6.1 — `GET /sessions` allows 10/min), an unmetered path with a built-in
2537
+ integrity check is the better default; `GET /sessions/{ref}/upo/{upoRef}` remains the
2538
+ fallback when the link has expired.
2539
+
2540
+ Two consequences for the client design. The URL must never be logged or persisted as a
2541
+ durable reference — it expires, and it is credential-bearing. And the token-suppression is
2542
+ not optional politeness: sending a bearer token to third-party storage leaks it.
2543
+
2544
+ **How long it lives: exactly three days.** Measured 2026-09-03 from the committed cassettes —
2545
+ five links across three cassettes and two separate recording sessions, on both
2546
+ `downloadUrlExpirationDate` (session pages) and `upoDownloadUrlExpirationDate` (invoice status),
2547
+ each `recorded_at` + 72 h to within two seconds. The contract states the field but no duration,
2548
+ so this is observed rather than documented, and it is the kind of value that should be read from
2549
+ the response rather than assumed — `UpoPage#expires_at` does exactly that.
2550
+
2551
+ It is recorded because the *magnitude* matters to anything replaying a recorded response: three
2552
+ days is long enough for a cassette recorded and verified today to keep replaying correctly for
2553
+ days and then diverge, which is precisely what happened between 2026-08-26 and 2026-08-29
2554
+ (DESIGN.md §9.1). A credential that expires in fifteen minutes announces itself; one that expires
2555
+ in three days does not.
2556
+
2557
+ **Unverified:** whether the live API returns this field absolute or host-relative.
2558
+ `srodowiska.md` says only that a returned URL's *host* matches the environment called. Code
2559
+ should therefore resolve it against the environment's base host if it arrives relative, and
2560
+ use it as-is if absolute.
2561
+
2562
+ ### 14.3 Every upstream UPO example fails upstream's own UPO schema
2563
+
2564
+ Measured locally 2026-08-22, with both artifacts pinned at `1c34fe27`: all **six** worked
2565
+ examples under `faktury/upo/przyklady/v4-3/` fail XSD validation against
2566
+ `upo-v4-3.xsd` — each with exactly one error, the same one.
2567
+
2568
+ `upo-v4-3.xsd:13` constrains the receiving party's name to a fixed value:
2569
+
2570
+ ```xml
2571
+ <xsd:element name="NazwaPodmiotuPrzyjmujacego" fixed="Ministerstwo Finansów">
2572
+ ```
2573
+
2574
+ while every example — all captured on TEST — carries:
2575
+
2576
+ ```xml
2577
+ <NazwaPodmiotuPrzyjmujacego>Ministerstwo Finansów - środowisko testowe (TE)</NazwaPodmiotuPrzyjmujacego>
2578
+ ```
2579
+
2580
+ Verified that this is the *only* discrepancy: with the `fixed` attribute removed, all six
2581
+ validate clean. So the schema is otherwise accurate, and the examples are otherwise
2582
+ well-formed UPOs.
2583
+
2584
+ **Consequence, and it is a real one:** the `fixed` value evidently describes PROD, while
2585
+ the non-production environments append an environment marker. A client that strictly
2586
+ XSD-validates a received UPO **will reject every UPO issued by TEST and, presumably,
2587
+ DEMO.** Given this gem already does offline XSD validation for FA(3), that is a trap it
2588
+ would otherwise have walked straight into.
2589
+
2590
+ **Resolution: do not hard-fail UPO validation on this element.** Either validate with the
2591
+ constraint relaxed, or treat a `NazwaPodmiotuPrzyjmujacego` mismatch as a warning carrying
2592
+ the observed value. Whichever is chosen, the UPO bytes are still archived verbatim (§12) —
2593
+ validation is a diagnostic here, never a gate on storing legal proof of receipt.
2594
+
2595
+ The pinned examples double as regression fixtures for exactly this behaviour: a correct
2596
+ implementation accepts all six.
2597
+
2598
+ Not treated as a §9 open item: the facts are measured and unambiguous. What is *not*
2599
+ verified is whether DEMO uses a third spelling and whether PROD matches the `fixed` value
2600
+ exactly — neither can be checked without access to those environments, and neither changes
2601
+ the resolution above.
2602
+
2603
+ ### 14.4 Both auth schemas carry broken regular expressions
2604
+
2605
+ Measured 2026-08-22 against the pinned `schemat_auth_v2-{0,1}.xsd`. The root cause is the
2606
+ same in both: **XML Schema regular expressions are implicitly anchored, and `^` / `$` are
2607
+ *literal characters*, not anchors** (XSD Part 2, Appendix F — the metacharacters are
2608
+ `. \ ? * + { } ( ) [ ] |`). Patterns written as if they were Perl regexes therefore mean
2609
+ something quite different from what their author intended.
2610
+
2611
+ #### v2.0 does not compile at all
2612
+
2613
+ Its three IP patterns use `\b`, which XSD regex has no concept of. libxml2 rejects the
2614
+ whole file rather than just those facets:
2615
+
2616
+ ```
2617
+ FATAL: failed to compile: Wrong escape sequence, misuse of character '\'
2618
+ ERROR: Element 'pattern': The value '^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$'
2619
+ of the facet 'pattern' is not a valid regular expression.
2620
+ ```
2621
+
2622
+ So `schemat_auth_v2-0.xsd` cannot be used to validate anything — you get a schema
2623
+ compilation failure, not a validation result. (The same patterns also make dots optional
2624
+ via `\.?`, so even parsed loosely they would match `1111`.) **Resolution: validate against
2625
+ v2.1 only.** v2.1 rewrote all three patterns correctly.
2626
+
2627
+ #### Neither reference client validates locally, and both send 2.0
2628
+
2629
+ Checked 2026-08-22, and this is what settles how to respond to the defects below.
2630
+
2631
+ - **C#** serialises via `XmlSerializer` in `AuthenticationTokenRequestSerializer` with no
2632
+ schema attached, and writes the context value straight through
2633
+ (`writer.WriteString(Value)`).
2634
+ - **Java** marshals via JAXB without ever calling `marshaller.setSchema(...)`.
2635
+
2636
+ So the bundled XSD is a **codegen input, not a runtime check** in both clients, and the
2637
+ broken patterns below never fire for them: they emit the natural identifier and let the
2638
+ server decide. Notably the Java client ships its *own* edited copy of the 2.0 schema
2639
+ (`ksef-client/src/main/resources/xsd/AuthTokenRequest.xsd`) in which the IP patterns are
2640
+ repaired — just loosely, `([0-9]{1,3}\.){3}[0-9]{1,3}` would admit `999.999.999.999` —
2641
+ **but `TNipVatUE` and `TPeppolId` are left broken**, which is independent confirmation that
2642
+ those two are genuinely defective rather than misread.
2643
+
2644
+ This gem does the same thing for those two types (emit the real value, treat local
2645
+ validation as advisory) and additionally sends the 2.0 namespace both clients use.
2646
+
2647
+ #### Two of v2.1's four context identifiers cannot hold their real values
2648
+
2649
+ Because `^` and `$` are literal, `TPeppolId`'s pattern `^P[A-Z]{2}[0-9]{6}$` matches only
2650
+ a value that literally begins with `^` and ends with `$`; and `TNipVatUE`'s pattern ends
2651
+ with a stray `$`, so it demands a trailing dollar sign. Measured:
2652
+
2653
+ | Element | Value | Against v2.1 |
2654
+ |---|---|---|
2655
+ | `Nip` | `5265877635` | valid |
2656
+ | `InternalId` | `5265877635-12345` | valid |
2657
+ | `NipVatUe` | `5265877635-ATU12345678` — **upstream's own documented example** | **invalid** |
2658
+ | `NipVatUe` | `5265877635-ATU12345678$` | valid |
2659
+ | `PeppolId` | `PPL123456` | **invalid** |
2660
+ | `PeppolId` | `^PPL123456$` | valid |
2661
+
2662
+ That upstream's own example value for `NipVatUe` fails the schema that defines it is the
2663
+ clearest evidence this is an upstream defect and not a misreading.
2664
+
2665
+ **Resolution: emit the natural value and treat offline validation of those two context
2666
+ types as advisory.** Emitting `^PPL123456$` to satisfy a broken facet would be absurd and
2667
+ would certainly be rejected server-side, where the real identifier is what gets looked up.
2668
+ Nothing is lost in practice: a KSeF token can only be issued in a `Nip` or `InternalId`
2669
+ context anyway (§4.1), and both of those validate cleanly.
2670
+
2671
+ Whether the API enforces this XSD server-side is **unverified** and needs a live TEST call
2672
+ to settle. If it does, `NipVatUe` and `PeppolId` authentication are simply unusable until
2673
+ upstream fixes the patterns — which would be their bug to fix, not something a client can
2674
+ work around.
2675
+
2676
+ ### 14.5 A signed `AuthTokenRequest` can never be schema-valid
2677
+
2678
+ Measured 2026-08-22. The API **requires** an enveloped XAdES signature on this document
2679
+ (§4.3: detached is rejected). The schema that defines the document declares a closed
2680
+ sequence — `Challenge`, `ContextIdentifier`, `SubjectIdentifierType`, optional
2681
+ `AuthorizationPolicy` — with **no `xsd:any`**. So the signature the API demands is, to the
2682
+ schema, an unexpected element:
2683
+
2684
+ ```
2685
+ ERROR: Element '{http://www.w3.org/2000/09/xmldsig#}Signature': This element is not
2686
+ expected. Expected is ( {http://ksef.mf.gov.pl/auth/token/2.0}AuthorizationPolicy ).
2687
+ ```
2688
+
2689
+ The same document validates cleanly before signing. This is not a defect a client can work
2690
+ around — both requirements come from upstream and they contradict each other.
2691
+
2692
+ **Resolution: validate before signing, never after.** `Signer#sign` does this by default,
2693
+ which also means a malformed document is caught before a signature is spent on it. Anyone
2694
+ reaching for "validate the thing we are about to send" will find it always fails; that is
2695
+ this, not a bug in the document.
2696
+
2697
+ Consistent with §14.4's finding that neither reference client validates locally at all —
2698
+ had they tried, they would have hit this immediately.
2699
+
2700
+ ### 14.6 A session-open header both clients send and the contract never mentions
2701
+
2702
+ Found 2026-08-23, by reading the reference clients before designing the session layer.
2703
+
2704
+ `X-KSeF-Feature: upo-v4-3` is sent on `POST /sessions/online` (and on `POST /sessions/batch`)
2705
+ by **both** official clients. It selects the format of the UPO the session will eventually
2706
+ produce.
2707
+
2708
+ | Source | Evidence |
2709
+ |---|---|
2710
+ | `ksef-client-java` | `DefaultKsefClient.openOnlineSession` sets `headers.put(X_KSEF_FEATURE, upoVersion.value())`; the `UpoVersion` enum holds `upo-v4-2` and `upo-v4-3`, and `fromValue` falls back to `upo-v4-3` |
2711
+ | `ksef-client-csharp` | `OpenOnlineSessionAsync(..., string upoVersion = null)` adds `{ "X-KSeF-Feature", upoVersion }`; `IOnlineSessionClient` documents it as "Opcjonalna wersja formatu UPO. Dostępne wartości: `upo-v4-3`" |
2712
+ | **Pinned OpenAPI contract** | **`X-KSeF-Feature` does not appear anywhere in the document.** `POST /sessions/online` declares no `parameters` at all, and the string `upo-v4-` occurs zero times |
2713
+
2714
+ This is the inverse of §14.4's situation: not upstream contradicting itself, but the contract
2715
+ being *silent* about something both of its own reference implementations consider necessary.
2716
+ Measured, not inferred — grep counts of zero in the pinned spec.
2717
+
2718
+ **Resolution: send `X-KSeF-Feature: upo-v4-3` explicitly on session open.** Three reasons.
2719
+ This gem pins `upo-v4-3.xsd` and nothing else, so a session producing 4.2 would yield a
2720
+ document we cannot validate. Java's enum proves the server still understands `upo-v4-2`,
2721
+ so a default exists and is not ours to guess. And §14.3 already establishes that UPO
2722
+ validation is delicate enough — TEST's own examples fail upstream's own schema — without
2723
+ adding version drift to it.
2724
+
2725
+ Being contract-silent, this could not be checked offline, only observed — and it now has
2726
+ been. **Verified against live TEST on 2026-08-24** (nightly run `32692339217`): a session
2727
+ opened with the header returned a UPO in the `upo-v4-3` namespace, which is the one whose
2728
+ schema this gem bundles. The header does what both reference clients assume, and this section
2729
+ is no longer the least certain fact in it.
2730
+
2731
+ ### 14.7 A real UPO fails upstream's UPO schema on its own signature
2732
+
2733
+ Found on the **first live nightly**, 2026-08-24 (run `32692339217`), and invisible to every
2734
+ offline test before it.
2735
+
2736
+ A UPO is *"an XML document XAdES-signed by the Ministry of Finance"* (§12) — the signature is
2737
+ what makes it proof of receipt. `upo-v4-3.xsd` **declares no `ds:Signature` element anywhere**,
2738
+ so a genuine UPO validated against it reports:
2739
+
2740
+ ```
2741
+ Element '{http://www.w3.org/2000/09/xmldsig#}Signature': This element is not expected.
2742
+ ```
2743
+
2744
+ Every UPO KSeF has ever issued fails upstream's own schema, for the same reason §14.3's six
2745
+ examples do — except this one is worse, because it applies to the real artifact rather than to
2746
+ the published samples.
2747
+
2748
+ **And the samples are exactly why nobody noticed.** All six pinned worked examples are
2749
+ **unsigned** — measured: zero `Signature` elements between them. Upstream publishes the
2750
+ business content without the envelope, so the offline corpus cannot exhibit the defect that
2751
+ every live document has. `spec/ksef/upo/validator_spec.rb` now signs one of them to close that
2752
+ gap, so the next occurrence is caught without a live run.
2753
+
2754
+ **Resolution: `UPO::Validator` removes the enveloped signature from a *copy* before validating.**
2755
+ Not leniency — an enveloped signature wraps the business document, and the schema describes the
2756
+ business document, so setting it aside is what makes the remaining errors mean anything. §14.3's
2757
+ environment-marker warning and every other violation still surface unchanged, and the caller's
2758
+ bytes are never touched, which §12 requires. `UPO::Validator.signed?` exposes whether the
2759
+ signature is there, since that distinguishes a real UPO from a published example.
2760
+
2761
+ **What this does *not* do is verify the signature.** Checking the Ministry's XAdES would need
2762
+ its certificate chain and the W3C/ETSI schemas, which §1.2 keeps out of `lib/` on licensing
2763
+ grounds. Validation here stays a diagnostic on content, exactly as the rest of §14.3 says.
2764
+
2765
+ ---
2766
+
2767
+ ## 15. Invoice verification — what KSeF checks on submission
2768
+
2769
+ Source: `faktury/weryfikacja-faktury.md` (dated 09.04.2026), pinned 2026-08-24 at the same
2770
+ `ksef-api` commit `1c34fe27` as everything else in §1.3.
2771
+
2772
+ **Read this first: it is not the document §9 said it was.** §9 named this file as the place
2773
+ to look for the **tier-3 business-rule catalogue** (DESIGN.md §7.7 — line sums against rate
2774
+ summaries against `P_15`, correction references for `KOR`). It contains none of that. It is
2775
+ a list of *technical admission* checks: XML shape, uniqueness, size, crypto, permissions.
2776
+ §9 has been corrected, and §15.6 records where tier 3's catalogue actually is and is not.
2777
+
2778
+ What it does deliver is unexpectedly valuable in a different place: **tier 1 was the tier
2779
+ with no first-tier source at all**, assembled from the schema plus judgement, and this
2780
+ document specifies it exactly.
2781
+
2782
+ ### 15.1 XML admission rules — and not one of them is a schema check
2783
+
2784
+ An invoice must satisfy all six. The right-hand column is the point of the table:
2785
+
2786
+ | Rule | Can tier 2 catch it? |
2787
+ |---|---|
2788
+ | Well-formed XML per XML 1.0 | **Only since 2026-08-24** — see the note below |
2789
+ | UTF-8 **without BOM** (no leading `EF BB BF`) | **No** — a BOM parses fine |
2790
+ | Conforms to the schema declared at session open | Yes — this *is* tier 2 |
2791
+ | An XML prolog is optional, but if present must not declare a non-UTF-8 encoding | **No** |
2792
+ | **No processing instructions** | **No** — PIs are legal XML |
2793
+ | No discouraged Unicode characters: `[#x7F-#x84]`, `[#x86-#x9F]`, `[#xFDD0-#xFDEF]`, and `[#xNFFFE-#xNFFFF]` for every plane `N` = 1…10₁₆ | **No** |
2794
+
2795
+ Failing any one of them rejects the invoice.
2796
+
2797
+ **A correction to the first row, and it was a real defect.** The rule is not free: libxml2
2798
+ parses in *recovery* mode by default, so a document with an unclosed root or trailing text
2799
+ after it comes back as a usable tree — and that recovered tree validates clean against the
2800
+ schema. `Ksef::FA3::Validator` never consulted `document.errors`, so `valid?` returned **true
2801
+ for XML that is not XML**. Fixed 2026-08-24 by checking well-formedness first and reporting it
2802
+ separately; before that fix, *five* of the six rules were invisible to this gem's tier 2, and
2803
+ a tier-1 implementer reading this table would have skipped the one check the table promised was
2804
+ already covered.
2805
+
2806
+ **Four of the six are invisible to tier 2 even now**, which is the argument for tier 1
2807
+ existing at all — and it is not hypothetical. The C# client's own test corpus ships
2808
+ `invoice-template-fa-3-with-disallowed-unicode-characters.xml`, now pinned to
2809
+ `spec/fixtures/fa3/` (§1.4). Measured 2026-08-24:
2810
+
2811
+ - it is **XSD-valid** against our pinned FA(3) schema once its `#nip#` placeholder is
2812
+ substituted (§1.4) — `Ksef::FA3::Validator` returns zero errors, so tier 2 passes it;
2813
+ - it carries **U+0087** and **U+009B**, both inside the forbidden `[#x86-#x9F]` range.
2814
+
2815
+ **Implemented 2026-08-24** as `Ksef::FA3::DocumentValidator` (validator tier 1b, DESIGN.md
2816
+ §7.7). It runs on `#to_xml`'s output, before those bytes are hashed and encrypted — after that
2817
+ point a rejection costs a round trip and a session. All four otherwise-invisible rules are
2818
+ covered, plus the 1 000 000-byte ceiling of §15.5.
2819
+
2820
+ A schema-only validator ships that invoice, and the pinned rule above says KSeF rejects it.
2821
+ **That rejection is derived, not observed** — it follows from *"Niespełnienie któregokolwiek z
2822
+ powyższych wymagań spowoduje odrzucenie faktury"*, and nobody has submitted this fixture. (An
2823
+ earlier version of this sentence justified it with "no session has ever reached live KSeF",
2824
+ which §14.6 in this same document contradicts as of 2026-08-24: sessions reach it nightly. The
2825
+ conclusion stands on the narrower ground.) One fixture, and it settles whether tier 1 is worth building.
2826
+
2827
+ **Where those characters come from matters more than the rule.** The offending text reads
2828
+ `ilość` — that is `ilość` encoded as UTF-8 and then decoded as Latin-1. **Double-encoded
2829
+ UTF-8 is the practical source of forbidden C1 characters**, not deliberate abuse. Any ERP
2830
+ export that has been through one bad encoding conversion hits this, which makes it a
2831
+ likely real-world rejection rather than an exotic one.
2832
+
2833
+ ### 15.2 Duplicate detection is a three-field key, held for ten years
2834
+
2835
+ KSeF detects duplicates **globally**, not per session, on the combination:
2836
+
2837
+ 1. seller NIP — `Podmiot1/DaneIdentyfikacyjne/NIP`
2838
+ 2. invoice kind — `RodzajFaktury`
2839
+ 3. invoice number — `P_2`
2840
+
2841
+ A duplicate returns per-invoice code **440** (§12.1's table, confirmed here from prose).
2842
+ Uniqueness is retained for **ten full calendar years** counted from the end of the year of
2843
+ issue.
2844
+
2845
+ Two consequences worth stating, because both are easy to get wrong:
2846
+
2847
+ - **`RodzajFaktury` is part of the key.** The same `P_2` under `VAT` and under `KOR` is not
2848
+ a duplicate. A correction therefore does not need a distinct number from the invoice it
2849
+ corrects.
2850
+ - **The key is the seller's, not the issuer's.** Where branches, local-government units or
2851
+ authorised third parties invoice on behalf of one NIP, upstream says explicitly they must
2852
+ agree a numbering scheme between themselves. Nothing in the API prevents a collision;
2853
+ it surfaces as a 440 after submission.
2854
+
2855
+ ### 15.3 NIP checksums are validated **in production only**
2856
+
2857
+ The sharpest fact in the document, and it is stated twice — once for
2858
+ `Podmiot1`/`Podmiot2`/`Podmiot3`/`PodmiotUpowazniony`, and once for the NIP embedded in
2859
+ `Podmiot3`'s `InternalId`. Both say *"Dotyczy tylko środowiska produkcyjnego"* — applies to
2860
+ the production environment only.
2861
+
2862
+ Three consequences:
2863
+
2864
+ - **It explains `rake auth:bootstrap`.** §6a.1 invents a NIP and the flow works on TEST.
2865
+ That is not tolerance of a malformed identifier — checksum validation simply is not
2866
+ running there. (The bootstrap generates checksum-valid values anyway, which is why this
2867
+ never surfaced as a puzzle.)
2868
+ - **A green TEST run proves nothing about NIP validity.** No integration spec can cover
2869
+ this rule, in either direction: TEST will not reject a bad NIP, and PROD is off limits
2870
+ from any test (a hard rule). It is verifiable by reasoning about the document and by unit
2871
+ tests of our own checksum, and by nothing else.
2872
+ - **Keep `Ksef::FA3::NIP.validate!` running in every environment.** It is deliberately
2873
+ *stricter* than TEST. Do not add an environment check to relax it: production is where
2874
+ the rejection would land, and a gem that lets a bad NIP through on TEST has simply moved
2875
+ the failure to the worst possible moment.
2876
+
2877
+ ### 15.4 The issue date may not be in the future
2878
+
2879
+ `P_1` must not be later than the date KSeF accepts the document.
2880
+
2881
+ Offline this can only be approximated: the comparison is against *KSeF's* acceptance date,
2882
+ which is not knowable at build time, and a document issued today and submitted today sits
2883
+ exactly on the boundary. So tier 1's rule is the safe half — **reject an issue date in the
2884
+ future** — and the same-day boundary is left to the service. Do not implement this as an
2885
+ equality against `Date.today` in the local zone; a client in a western timezone would
2886
+ reject perfectly good invoices around midnight in Warsaw.
2887
+
2888
+ ### 15.5 Facts this document corroborates — and one it establishes
2889
+
2890
+ Three of the four rows below were already in this ledger, resting on the contract or the
2891
+ reference clients alone, so an independent witness is worth having. **The fourth is new**: the
2892
+ batch-only attachment rule appears here for the first time, and the row says so rather than
2893
+ claiming a confirmation it cannot make.
2894
+
2895
+ | Fact | Where it lives | What this document adds |
2896
+ |---|---|---|
2897
+ | AES-256-CBC, 256-bit key, 128-bit IV, PKCS#7; symmetric key under RSAES-OAEP **SHA-256/MGF1** | §10.1 | A **fifth** witness, and the first from first-tier prose. Note it writes "SHA-256/MGF1" as one unit, pairing the two digests exactly as §10.1 requires |
2898
+ | Hash **and size** of both the plaintext and the encrypted invoice | §11.1 | Confirms all four values, and that the check is KSeF's, not decoration. Note upstream heads this rule *"Zgodność metadanych faktury **w sesji interaktywnej**"* — it is scoped to the interactive session; the batch flow of 0.2 will need its own reading |
2899
+ | 1 MB (1 000 000 bytes) without attachments, 3 MB with; 10 000 invoices per session | §6.2 | Identical, plus batch: 50 ZIP files, 100 MB each before encryption, 5 GB per package. **Three of these carry an asterisk and are defaults, not ceilings of the format:** *"Jeżeli w scenariuszach biznesowych organizacji dostępne limity są niewystarczające, prosimy o kontakt z działem wsparcia KSeF"* — and `limity.md` heads the same numbers *"Wartość domyślna"*, with `GET /limits/context` reporting the live values. The asterisk attaches to 1 MB, 3 MB and 10 000; **not** to the batch ZIP figures. `DocumentValidator` takes `max_bytes:` for that reason: hard-coding the default as absolute rejected invoices KSeF would accept |
2900
+ | Attachments are **batch-only** | *nothing — this is the first source* | DESIGN.md §7.4 only puts *operational* attachment constraints out of scope for 0.1; it never said batch-only, and no document here did. So this row is **new**, not a confirmation, and the section heading above overstates it. With one exception — an offline *technical correction* may use an interactive session — plus a requirement of prior opt-in in `e-Urząd Skarbowy`. Both are 0.2/0.3 concerns; recorded so the exception is not rediscovered |
2901
+
2902
+ ### 15.6 Tier 3's catalogue is in none of the 77 files
2903
+
2904
+ Searched at commit `1c34fe27`: no document in `ksef-api` states a reconciliation rule.
2905
+ Nothing says `P_15` equals the sum of the rate buckets, nothing constrains a `KOR`
2906
+ document's references to the invoice it corrects, nothing gives an error code for an
2907
+ arithmetic mismatch. The closest thing to a rule catalogue is this section, and it is
2908
+ technical rather than semantic.
2909
+
2910
+ The FA(3) XSD does carry **683 `xsd:documentation` annotations** — every field described in
2911
+ Polish, with citations to the VAT Act. They are a genuine first-tier source for what a
2912
+ field *means* (that is where §8.1a's rate buckets came from). They state **almost** no
2913
+ equalities: `P_13_1` is documented as "the sum of net sales values under the basic rate" and
2914
+ `P_15` as "the total amount due", and the arithmetic relating those two is left to the reader.
2915
+
2916
+ **The one exception, found by audit 2026-08-26 — this sentence read "They state no equalities"
2917
+ and that was wrong.** `Rozliczenie/DoZaplaty` is annotated *"Kwota należności do zapłaty **równa
2918
+ polu P_15 powiększonemu o Obciazenia i pomniejszonemu o Odliczenia**"* — a literal arithmetic
2919
+ identity, and precisely the grounding-1 shape tier 3 wants. Measured over the corpus it holds
2920
+ **exactly**, with no tolerance and no guard, in all four samples that carry a `Rozliczenie`:
2921
+ Przykład 4 (`64279.92 + 0.00 − 1000.00 = 63279.92`), 24 and 25 (`53.63 + 0 − 0`), 26
2922
+ (`31.50 + 5.00 − 0.00 = 36.50`).
2923
+
2924
+ It is **not implemented**, because the model does not carry `Rozliczenie` at all — the whole
2925
+ group is in `#unmapped_elements` for those four samples. That makes it the best-grounded
2926
+ candidate rule this project knows of, and the argument for carrying `Rozliczenie` when tier 3
2927
+ is next extended (§17.4).
2928
+
2929
+ So tier 3 has three possible groundings, and they are not equal:
2930
+
2931
+ 1. **Arithmetic that follows from the field definitions.** That `P_13_1` is a *sum* of the
2932
+ lines taxed at the basic rate is what the annotation says; checking it is not policy.
2933
+ This is the part that can be built now, and it needs no external catalogue.
2934
+ 2. **The Ministry's FA(3) brochure** (`broszura informacyjna`), published on
2935
+ `podatki.gov.pl` and **not** in any CIRFMF repository. This is where the published
2936
+ business-rule list DESIGN.md §7.7 asks for would come from. Pinning it means taking an
2937
+ artifact from outside the repositories §1 covers, under a licence nobody here has read.
2938
+ 3. **Observation against TEST.** Reliable, slow, and it only ever finds rules we thought to
2939
+ probe for.
2940
+
2941
+ **Why it does not exist yet, and where it will appear.** `CIRFMF/ksef-api` issue **#837**
2942
+ (author `kw-cirf`, a collaborator) announced the **first business validation KSeF ever proposed**:
2943
+ for `KodWaluty != PLN`, at least one `P_14_x` + `P_14_xW` pair must be filled, rejection status
2944
+ **`425` "Faktura nie spełnia wymagań dotyczących danych"**, scheduled TEST 2026-07-21 / PROD
2945
+ 2026-07-28, *"to be added to `faktury/weryfikacja-faktury.md`"*. It was **withdrawn back to
2946
+ analysis** after the community showed it rejects legal foreign-currency invoices carrying only
2947
+ `np`/`zw`/`0%`/`oo`/margin rates. The live `weryfikacja-faktury.md` was diffed against our pinned
2948
+ copy on 2026-08-24: **identical** — the section never landed. Two things follow. The catalogue's
2949
+ future address is known (that file, plus status `425`), and the Ministry's own remark in the
2950
+ thread — that not one production invoice violated the proposed rule — is evidence **KSeF today
2951
+ enforces nothing beyond the schema**.
2952
+
2953
+ **And the error codes are not the catalogue either.** A natural hope is that a rejection code list
2954
+ is the rule list seen from the failure side. It is not: the reference clients' catalogues
2955
+ (`InvoiceInSessionStatusCodeResponse` and siblings) funnel *every* content violation into one
2956
+ code — **`450` "Błąd weryfikacji semantyki dokumentu faktury"** — with free-text details. There is
2957
+ no code for "totals do not reconcile" and none for a malformed correction reference.
2958
+
2959
+ **What the Ministry's samples add.** §1.5 records the measurement: `Σ P_13_* + Σ P_14_* == P_15`
2960
+ holds exactly in **22** of the 26, Przykład 1 misses by a grosz, and **three** state no buckets
2961
+ at all (5, 13 and 16). That is *empirical* grounding — grounding 3 — for a rule the text does not
2962
+ state, and it fixes the rule's shape: a tolerance of a grosz and a guard for absent buckets, or
2963
+ it rejects the Ministry's own first example.
2964
+
2965
+ **Corrected 2026-08-26, twice over.** This paragraph said "24 of 26" — which counts Przykład 5
2966
+ and 13, whose `0 == 0` the guard skips before comparing, so they witness nothing — and it named
2967
+ the cause of Przykład 1's grosz as bucket-level tax rounding, which §1.5 shows is arithmetically
2968
+ false: that mechanism moves the invoice by 0.0007. The real cause is nets computed back *w stu*
2969
+ from round gross prices and rounded down. Both errors were fixed in §1.5 and §17 and left
2970
+ standing here, in the higher-precedence document, which is the drift this ledger exists to
2971
+ prevent.
2972
+
2973
+ **Superseded 2026-08-26 — tier 3 shipped, and not on this recommendation.** This section
2974
+ advised building on **grounding 1**, where the rule is the field definition. That turned out
2975
+ not to be implementable here: `P_15`'s annotation says nothing about the buckets, and the
2976
+ definitional rule one would check instead — `P_13_1` against the rows — is falsified by ten of
2977
+ the fourteen modelled stated-summary samples, because a correction's buckets are deltas and an
2978
+ advance's are pre-payments. **Grounding 1 yields no rule at all on this model.** What shipped is
2979
+ grounding 3, empirical, advisory, and recorded in §17. The one genuine grounding-1 rule anyone
2980
+ has found is `Rozliczenie/DoZaplaty`, above — blocked only by the model not carrying it.
2981
+ Do not synthesise rules from Polish VAT law and record them as verified facts; that is
2982
+ inference wearing a citation. Grounding 2 is a licensing and scope question, not a coding
2983
+ one.
2984
+
2985
+ **§9's characterisation of this as "the next place to look" was reasonable and wrong.** The
2986
+ lesson is the same one §1.3 already carries, pointed the other way: listing the upstream
2987
+ tree found 73 unread files, and reading them resolved most of §9 — but it also means a
2988
+ remaining gap is now much more likely to be genuinely absent than merely unread.
2989
+
2990
+ ---
2991
+
2992
+ ## 16. Offline modes, technical corrections, batch and download
2993
+
2994
+ Recorded 2026-08-26, from the thirteen documents pinned that day (§1.3). Two of them define
2995
+ parameters this gem already accepts; the rest cover work that is unbuilt, and are recorded
2996
+ here so the next milestone starts from evidence rather than from the OpenAPI's one-line
2997
+ field descriptions.
2998
+
2999
+ ### 16.1 KSeF can classify an invoice as offline **that the sender declared online**
3000
+
3001
+ The rule, from `offline/automatyczne-okreslanie-trybu-offline.md` (dated 04.10.2025):
3002
+
3003
+ > Dla faktur wysyłanych jako `offlineMode: false` system porównuje **datę wystawienia**
3004
+ > faktury (`issueDate`, np. `P_1` dla faktury zgodnej z FA(3)) [i] **datę przyjęcia**
3005
+ > faktury w systemie KSeF do dalszego przetwarzania (`invoicingDate`).
3006
+ >
3007
+ > - Jeśli dzień kalendarzowy z `issueDate` jest wcześniejszy niż dzień kalendarzowy z
3008
+ > `invoicingDate` (porównanie po dacie, nie po godzinie), system automatycznie oznacza
3009
+ > fakturę jako **offline**, nawet jeśli nie była tak zadeklarowana.
3010
+
3011
+ `invoicingDate` differs by session type, and this is the part with a consequence:
3012
+
3013
+ | Session | `invoicingDate` is |
3014
+ |---|---|
3015
+ | interactive | the moment the **invoice** was submitted |
3016
+ | batch | the moment the **session was opened** (the `dateCreated` of `GET /sessions/{ref}`) |
3017
+
3018
+ **This reaches ordinary use of this gem.** `Ksef::Client#send_invoice` opens a session and
3019
+ submits immediately, so `invoicingDate` is now. An invoice whose `P_1` is *yesterday* — an
3020
+ entirely normal thing, and something the gem neither prevents nor warns about — is marked
3021
+ offline by KSeF regardless of what was declared. The comparison is by **calendar day, not by
3022
+ elapsed time**: upstream's own example is an invoice issued 2025-10-03 and sent at 00:00:01
3023
+ on 2025-10-04, which is offline after one second.
3024
+
3025
+ Note the asymmetry this creates with batch, where the clock stops at session open: a batch
3026
+ opened at 23:59:59 keeps its invoices online no matter when the parts arrive.
3027
+
3028
+ Nothing is implemented for this and nothing should be — it is the service's classification,
3029
+ not a client-side rule, and `P_1` is the caller's to choose. It is documented because a
3030
+ caller who reads "offlineMode: false" as "this invoice is online" is wrong, and neither the
3031
+ OpenAPI's field description nor anything else in this gem would tell them.
3032
+
3033
+ ### 16.2 A technical correction is two parameters, and only in an interactive session
3034
+
3035
+ From `offline/korekta-techniczna.md`. It applies to an invoice **issued offline and then
3036
+ rejected by KSeF for a technical reason** — schema mismatch, oversized file, duplicate, or
3037
+ any other validation failure that prevented a KSeF number being assigned. The corrected file
3038
+ has different bytes and therefore a different SHA-256, and `hashOfCorrectedInvoice` carries
3039
+ the hash of the **original rejected** one, so KSeF can link the two and redirect the first
3040
+ invoice's QR code to the accepted document.
3041
+
3042
+ Four constraints, none of them in the OpenAPI:
3043
+
3044
+ 1. It is **not** for content: *"nie jest dozwolona korygowanie treści faktury"*. Technical
3045
+ problems only.
3046
+ 2. It is **not** for permission failures — self-billing, JST or VAT-group relationship
3047
+ validation.
3048
+ 3. It may be sent **only in an interactive session**, though it may concern an invoice
3049
+ rejected in either an interactive or a batch one.
3050
+ 4. It is not allowed for an offline invoice that has already had a proper correction accepted.
3051
+
3052
+ And the shape: the document sends `offlineMode: true` **together with**
3053
+ `hashOfCorrectedInvoice`, in both the C# and Java examples it links.
3054
+
3055
+ **This gem does not enforce the pairing, deliberately.** `Sessions::Online#send_invoice`
3056
+ takes `offline_mode:` and `corrected_invoice_hash:` independently, because the contract makes
3057
+ both optional and says only *"Wymagany przy wysyłaniu korekty technicznej faktury"* — it does
3058
+ not state that one requires the other. Enforcing a constraint upstream describes in a
3059
+ procedure but does not impose in its schema would be inventing a rule, which is the same
3060
+ reasoning §8.4 uses for not requiring `PrzyczynaKorekty` on a `KOR`. The documentation on the
3061
+ method now says what a technical correction is, so a caller sending one knows to set both.
3062
+
3063
+ One related ordering rule, from `tryby-offline.md`: *"Fakturę korygującą przesyła się dopiero
3064
+ po nadaniu numeru KSeF dokumentowi pierwotnemu"* — a correction goes only after the invoice
3065
+ it corrects has been assigned a KSeF number. That is a sequencing rule for the caller, not a
3066
+ tier-1 check: `KOR` carries `NrKSeFFaKorygowanej`, and whether that number exists yet is not
3067
+ something the document can be asked.
3068
+
3069
+ ### 16.3 Batch, for when it is built — and a second witness to the §14.1 error
3070
+
3071
+ `sesja-wsadowa.md` (10.07.2025), plus `OpenBatchSessionRequest` and `BatchFilePartInfo` in
3072
+ the contract:
3073
+
3074
+ - One ZIP holding every invoice, **split into parts no larger than 100 MB before
3075
+ encryption**; each part is encrypted and uploaded separately, and described in `fileParts`.
3076
+ - `BatchFilePartInfo` is `ordinalNumber` + `fileSize` + `fileHash`, and both measurements are
3077
+ of the **encrypted** part, not the plaintext.
3078
+ - `offlineMode` sits on the **session open** request, not per invoice — the opposite of the
3079
+ interactive session, where it is per `SendInvoiceRequest`.
3080
+ - Upstream recommends hashing each plaintext XML before packing and keeping a local map,
3081
+ because per-invoice status comes back keyed by `invoiceHash` and there is otherwise nothing
3082
+ to correlate it to.
3083
+ - `TarGz` was added as an alternative to `Zip` in API 2.6.0 and is *recommended* for packages
3084
+ of many similar XML documents; `Zip` remains the default for compatibility (§16.5).
3085
+
3086
+ **And it repeats the IV claim §14.1 refutes**: *"wektora inicjującego o długości 128 bitów
3087
+ (IV), **dołączanego jako prefiks do szyfrogramu**"*. This is the same sentence as
3088
+ `sesja-interaktywna.md`'s, and it is wrong for the same structural reason — batch and online
3089
+ share one `EncryptionInfo` schema, whose `initializationVector` is a **required discrete
3090
+ field**. An IV that is a mandatory request field is not also a ciphertext prefix.
3091
+
3092
+ Be precise about the strength of that, because §14.1's four witnesses are interactive ones:
3093
+ the *contract* argument covers batch exactly, since it is literally the same schema; the
3094
+ *measured* argument (6480 plaintext bytes against 6496 encrypted — one PKCS#7 block, no room
3095
+ for a 16-byte IV) was taken from an interactive example and has no batch equivalent yet.
3096
+ Treat batch as settled by the schema and confirm it on the first live run, the way the
3097
+ interactive path was confirmed on 2026-08-24.
3098
+
3099
+ ### 16.4 Invoice download, and the High Water Mark
3100
+
3101
+ `pobieranie-faktur/`. Only the first of these is built (`Invoices::Client`).
3102
+
3103
+ | Endpoint | What it does |
3104
+ |---|---|
3105
+ | `GET /invoices/ksef/{ksefNumber}` | one invoice by KSeF number — **built** |
3106
+ | `POST /invoices/query/metadata` | paged `InvoiceMetadata` search, by date range and subject role |
3107
+ | `POST /invoices/exports` | asynchronous export to encrypted packages; `encryption` is **required** |
3108
+
3109
+ An export package carries a `_metadata.json` holding the same `InvoiceMetadata` array the
3110
+ metadata query returns, and invoices in the package are sorted ascending by whichever date
3111
+ type the `DateRange` named at initialisation.
3112
+
3113
+ **High Water Mark** (`hwm.md`, 25.11.2025) is the completeness guarantee, and it is the part
3114
+ worth knowing before designing anything incremental. KSeF publishes a moment `HWM` up to
3115
+ which every invoice is durably stored:
3116
+
3117
+ - for `PermanentStorage` ≤ `HWM`, the set is **closed and complete** — no new invoice will
3118
+ ever appear with a date in it;
3119
+ - for `(HWM, now]`, results are **potentially incomplete**, because storage is asynchronous
3120
+ and multi-threaded, so invoices can still appear in a window already queried.
3121
+
3122
+ Which gives two sync strategies, and upstream states the trade-off rather than picking for
3123
+ you: query only up to `HWM` — definitive, minimal duplicates, freshest invoices invisible
3124
+ until `HWM` advances, and *recommended for automatic incremental sync*; or query to `now` —
3125
+ freshest data immediately, but the `(HWM, now]` window must be re-queried next time and the
3126
+ caller must deduplicate, by KSeF number.
3127
+
3128
+ ### 16.5 The API changelog exists, and it is version-by-version
3129
+
3130
+ `api-changelog.md` covers 2.0 through **2.7.0** with per-environment deployment dates for
3131
+ TEST, DEMO and PRD. It is the document to consult when the service behaves unlike the pinned
3132
+ contract, since the contract is a snapshot and this is the history.
3133
+
3134
+ Items in it that touch code already written, all of which this gem already handles — recorded
3135
+ so the next reader does not re-derive them:
3136
+
3137
+ | Version | Change | Status here |
3138
+ |---|---|---|
3139
+ | 2.5.0 | `publicKeyId` selector for key rotation | implemented, §10.2 |
3140
+ | 2.6.0 | `X-System-Warning` advisory response header | implemented, `HTTP::SystemWarning` |
3141
+ | 2.6.0 | `TarGz` compression for batch and export | batch is unbuilt |
3142
+ | — | `X-Error-Format: problem-details` for 400/429 | implemented, `HTTP::Connection` |
3143
+ | — | `downloadUrlExpirationDate` on UPO pages | implemented, `UpoPage#expires_at` |
3144
+ | — | UPO v4-3 default from 2025-12-22; `X-KSeF-Feature: upo-v4-3` before that | implemented, §14.6 |
3145
+ | — | NIP checksum verification **on production only** | implemented, §15.3 |
3146
+ | — | `429` documented with `Retry-After` and `TooManyRequestsResponse` | implemented, §6 |
3147
+
3148
+ That every one of these was already correct is the useful result: the 2026-08-22 pass caught
3149
+ the API-behaviour documents, and what it missed was the *procedural* ones — offline modes and
3150
+ technical corrections — which describe not how an endpoint responds but when a taxpayer is
3151
+ supposed to call it. Those have no OpenAPI counterpart to catch them.
3152
+
3153
+ One oddity, flagged rather than resolved: the 2.7.0 row is dated `21.07.2027` for TEST, a
3154
+ year after the commit that introduces it (authored 2026-07-21). Every other row is
3155
+ chronologically consistent. Most likely an upstream typo for 2026; do not build anything on
3156
+ that date.
3157
+
3158
+ ---
3159
+
3160
+ ## 17. Validator tier 3 — one advisory rule, and why it cannot be more
3161
+
3162
+ Built 2026-08-26, then **substantially redesigned the same day** after a five-lens audit found
3163
+ that the first version refused legal invoices. `Ksef::FA3::BusinessValidator`, reached through
3164
+ `Invoice#warnings` — deliberately **not** `#errors`, and so it can never block a send.
3165
+
3166
+ ### 17.1 The summary-reconciliation rule
3167
+
3168
+ `Σ P_13_* + Σ P_14_* ≈ P_15`, over **figures the document states**, within one grosz, skipped
3169
+ when no buckets are stated.
3170
+
3171
+ **Its grounding is empirical — §15.6's grounding 3 — and not definitional.** The first version
3172
+ of this section claimed otherwise ("`P_13_1` is annotated as a sum; checking a sum is not
3173
+ policy") and that was wrong twice over. `P_15` is annotated *"Kwota należności ogółem"* and says
3174
+ nothing about the buckets; §15.6's own sentence is "Nothing says `P_15` equals the sum of the
3175
+ rate buckets." And the definitional rule one might reach for instead — check `P_13_1` against
3176
+ the rows — **is not implementable on this model**: ten of the fourteen modelled stated-summary
3177
+ samples falsify it, because a correction's buckets are deltas, an advance's are pre-payments
3178
+ and a settlement's are remainders (§8.4, §8.5). Grounding 1 yields no rule here at all.
3179
+
3180
+ The measurement, over the 26 pinned samples:
3181
+
3182
+ | | Samples | Note |
3183
+ |---|---|---|
3184
+ | buckets stated, reconcile exactly | 22 | including all six non-`VAT` types |
3185
+ | buckets stated, out by one grosz | 1 | Przykład 1 |
3186
+ | **no buckets stated** | **3** | Przykład 5 and 13 (`P_15` 0), Przykład 16 (`P_15` 450) |
3187
+
3188
+ So the rule evaluates 23 of 26, not 25. Przykład 5 and 13 are skipped by the guard, not passed
3189
+ by the rule, and counting them as witnesses inflates the evidence.
3190
+
3191
+ **Why one grosz, and why it is honestly arbitrary.** Przykład 1 is the only witness, and §1.5
3192
+ records what actually causes its gap: nets computed back *w stu* from round gross prices and
3193
+ rounded down, not per-bucket tax rounding, which moves that invoice by 0.0007. The error
3194
+ therefore scales with the **number of lines** and with the issuer's rounding convention — two
3195
+ such lines and it is two grosze, fifty and it is 38. **No tolerance sized from this corpus can
3196
+ be sound**, because the corpus never varies the dimension the error scales with. One grosz is
3197
+ what the single witness shows, and the rule warns rather than refuses precisely because that
3198
+ number cannot be defended as a bound.
3199
+
3200
+ **Why it is a warning.** An invoice priced from round gross prices — the commonest shape in
3201
+ Polish retail — fails this check while being entirely legal. Making it an error would repeat
3202
+ the mistake that got KSeF's own proposed business rule withdrawn (§15.6, issue #837: *it
3203
+ rejects legal invoices*). §14.3 is the precedent: the UPO receiving-party mismatch is a warning
3204
+ so that a schema opinion cannot stand between a legal document and its being filed.
3205
+
3206
+ **Why it reads the document rather than the model.** Both sides must come from the same source
3207
+ or the comparison measures this gem instead of the invoice. The first version compared the
3208
+ model's *derived* buckets against the document's total, and produced two false positives that
3209
+ `Ksef::Client#send_invoice` turned into refusals:
3210
+
3211
+ - **A rounding-regime artefact.** `summary_buckets` rounds each bucket; `gross_total`'s derived
3212
+ arm did not. Three ordinary lines priced the way DESIGN.md §8's snippet prices them gave a
3213
+ two-grosz "error" on an invoice this gem had just built. It also made the verdict depend on
3214
+ whether you had round-tripped: built accused, parsed clean.
3215
+ - **The model's own incompleteness.** A document using `P_13_5`/`P_14_5` (OSS) or `P_13_11`
3216
+ (margin scheme) states buckets no rate code reaches ({VatRate.unreachable_elements}), so the
3217
+ model's derivation is necessarily short and the invoice was accused of an arithmetic error
3218
+ that was really a gap in this gem.
3219
+
3220
+ Reading both sides from `raw_document` removes both. A built invoice states nothing
3221
+ independently and is therefore never compared — which is the honest answer, not a limitation.
3222
+
3223
+ **The `W` twins are excluded, and this is now the only place it is decisive.** `P_14_1W` is the
3224
+ PLN equivalent of `P_14_1` on a foreign-currency invoice, not a second tax; Przykład 20 states
3225
+ 13560 + 3118.80 against a `P_15` of 16678.80, and counting its 14036.16 twin gives 30714.96.
3226
+ `Totals::ELEMENTS` is the single definition of "a bucket" and the rule reads it. Note that the
3227
+ earlier version cited Przykład 20 for this and could not have been testing it: 20 has a
3228
+ *derived* summary, where `VatRate::BUCKETS` governs and no `W` element can appear whatever
3229
+ `Totals::ELEMENTS` says. No Ministry sample pairs a stated summary with a `W` twin, so the
3230
+ guard is exercised by an injected one.
3231
+
3232
+ ### 17.2 The defect grounding the rule uncovered: `P_15` was derived, not read
3233
+
3234
+ **A `VAT` invoice re-serialised the Ministry's Przykład 1 a grosz cheaper, and nothing said so.**
3235
+
3236
+ `P_15` is mandatory in `Fa`, so every document states one. The six types in
3237
+ `Invoice::STATED_TOTALS_TYPES` read it into `Totals#gross`; `VAT` derived it from its rows. For
3238
+ 21 of the 22 modelled samples the two agree. For Przykład 1 they do not — 2051 stated, 2050.99
3239
+ derived — so parsing and re-serialising it produced an invoice asking a grosz less.
3240
+
3241
+ Invisible to everything: tier 2 passes, `#unmapped_elements` is silent (`P_15` is present either
3242
+ way — a path-difference diagnostic cannot see a changed *value*), and even the round-trip law
3243
+ held, because a parsed invoice is already a fixed point. The `P_9A` class from §8.6, found the
3244
+ same way, by measuring the corpus rather than reading the code.
3245
+
3246
+ `Invoice#stated_gross` carries the document's `P_15` **only when it differs** from what the rows
3247
+ derive — the `Line#row_number` idiom (§8.4). **Canonicalised in the constructor**, where
3248
+ `Invoice.positioned` does the equivalent job for `row_number`; the first version canonicalised
3249
+ in the parser only, so `Invoice.new(stated_gross:)` and `#with(stated_gross:)` — both public —
3250
+ bypassed it and broke DESIGN.md §7.6 with no diagnostic. It is nil when `totals` is present,
3251
+ because a stated summary already carries its own gross.
3252
+
3253
+ Measured after the fix: **`P_15` is numerically unchanged on re-serialisation for all 22
3254
+ modelled samples**, asserted by a spec. *Numerically*, not byte-for-byte — 17 of the 22 change
3255
+ their formatting (`2051` → `2051.00`, `31.5` → `31.50`), which §1.5 already warns about
3256
+ ("amounts are unpadded, so any comparison must be numeric and never on strings"). An earlier
3257
+ draft of this section claimed byte-identity and was wrong.
3258
+
3259
+ The parser reads `P_15` **tolerantly**: an empty or unparseable one yields nil rather than
3260
+ raising, because the parser does not validate and a document being read to find out why KSeF
3261
+ rejected it may well be one whose `P_15` is the problem.
3262
+
3263
+ ### 17.3 Two asymmetries of the same shape, still open
3264
+
3265
+ Both predate this work and neither is fixed here; recorded so they are not rediscovered.
3266
+
3267
+ - **A line built with a quantity and a price and no `net_amount`** serialises a derived `P_11`
3268
+ that parses back *into* `net_amount`, so the built invoice and the parsed one differ in a
3269
+ field the caller never set. DESIGN.md §8's own snippet does this.
3270
+ - **An invoice with no `issued_at`** gets one written at serialisation and reads it back.
3271
+
3272
+ Both are the same shape as §17.2 — a value the document must carry that the model derives
3273
+ rather than holds — and the same remedy would apply.
3274
+
3275
+ A third, bounding what tier 3 can ever detect: **`P_13_*`/`P_14_*` are still recomputed** on a
3276
+ derived invoice rather than carried. Rewrite Przykład 1's `P_13_1` to 1700.00 and the
3277
+ re-serialised document silently restores 1666.66, with `#unmapped_elements` empty and tier 3
3278
+ silent — the rule compares the document against itself, and the model's derivation is not part
3279
+ of that comparison. Catching it needs a value-level provenance diagnostic, which is the
3280
+ generalisation of §8.6's "a path-difference diagnostic cannot see a changed value".
3281
+
3282
+ ### 17.4 What would ground the next rule
3283
+
3284
+ In order of strength:
3285
+
3286
+ 1. **`Rozliczenie/DoZaplaty`** — the one arithmetic identity the XSD actually states
3287
+ (§15.6), holding exactly in all four corpus samples that carry it. Blocked only by the model
3288
+ not carrying `Rozliczenie`.
3289
+ 2. **`P_15` less the sum of `P_15Z`** — the amount beyond pre-payments; stated in the Ministry's
3290
+ brochure, which is not a pinned artifact (§15.6 grounding 2).
3291
+ 3. **Observation against TEST** — reliable, slow, and it only ever finds rules we thought to
3292
+ probe for.
3293
+
3294
+ **Do not synthesise rules from Polish VAT law and record them as verified facts** (§15.6). Every
3295
+ rule added to `RULES` needs a ledger entry saying what grounds it.
3296
+
3297
+ ---
3298
+
3299
+ ## 18. Schema shapes the codegen relies on
3300
+
3301
+ Recorded 2026-08-26. The first two were measured while generating `docs/field_mapping.md`
3302
+ (DESIGN.md §7.2); §18.2 was found while extending the extractor for `Zalacznik`. All are
3303
+ properties of the pinned FA(3) XSD that a generator relies on, so they belong here rather
3304
+ than only in its comments.
3305
+
3306
+ **FA(3) declares no unbounded element.** `maxOccurs="unbounded"` appears **zero** times; every
3307
+ repeat is capped — `FaWiersz` at 10 000, `DaneFaKorygowanej` at 50 000, `FakturaZaliczkowa` at
3308
+ 100, `Podmiot2K` at 101. `Generated::Types` writes `max: nil` for an unbounded element and
3309
+ never has occasion to. The generator raises rather than rendering half a bound if that ever
3310
+ changes, because a dangling `0–` reads as a formatting slip rather than as a fact nobody has
3311
+ checked.
3312
+
3313
+ **Eleven element names carry different `xsd:documentation` in different places**, out of 273
3314
+ that carry any: `DaneIdentyfikacyjne` (5 variants), `Adres` (4), `NrEORI` (4), `AdresKoresp`
3315
+ (4), `DaneKontaktowe` (4), `Email` (3), `Telefon` (3), `KodKraju` (2), `NrKlienta` (2), `Kwota`
3316
+ (2), `Powod` (2). A description looked up by bare name is therefore ambiguous for those, and
3317
+ the first version of the field mapping dropped them — leaving `Adres` and `KodKraju` blank,
3318
+ which is where the Ministry states *when a buyer's address may be omitted*. Resolving the
3319
+ element by **path** removes the ambiguity entirely, and is what the generator does now.
3320
+
3321
+ ### 18.1 `Generated::Types` is flattened, and cardinality must not be read from it
3322
+
3323
+ The generated metadata hoists the children of an `xsd:choice` and of a
3324
+ `<xsd:sequence minOccurs="0">` up to their parent, because the serializer needs element
3325
+ *order* and nothing else. Occurrence counts survive that hoisting unchanged, so an element
3326
+ inside an optional group reports `min: 1` and a choice branch reports `min: 1` — both
3327
+ "mandatory", both wrong.
3328
+
3329
+ Measured consequences, each of which the first field mapping printed as fact: the buyer's
3330
+ `NIP` is one branch of a four-way choice; the buyer's `Nazwa` sits in an optional sequence;
3331
+ `P_15ZK` sits in two nested optional sequences; `NrFaZaliczkowej` and `NrKSeFFaZaliczkowej`
3332
+ are the two branches of one choice and were both rendered "occurs exactly 1", which is not a
3333
+ document any issuer can produce.
3334
+
3335
+ **The buyer's name being optional is §8.2a's three-time bug**, and printing it as mandatory in
3336
+ a table aimed at auditors is the same error in a new medium. Anything that needs *effective*
3337
+ cardinality must walk the XSD; `Generated::Types` answers a different question.
3338
+
3339
+ ### 18.2 An attribute belongs to the type that declares it, and the extractor said otherwise
3340
+
3341
+ The extractor read attributes down a **descendant** axis, `.//xsd:attribute[@name]`, so every
3342
+ complexType inherited the attributes of everything nested beneath it. Seven types claimed an
3343
+ attribute; two declare one. Measured by running the pre-fix extractor: `TNaglowek` claimed
3344
+ `kodSystemowy` and `wersjaSchemy`, which are declared on `KodFormularza`; and `Faktura`,
3345
+ `Zalacznik`, `BlokDanych`, `Tabela`, the attachment's own `TNaglowek` **and `Kol` itself** each
3346
+ reported `Kol`'s `Typ` — five inheriting it and one declaring it.
3347
+
3348
+ An earlier version of this paragraph listed `KodFormularza` among the claimants, which cannot be
3349
+ true and is contradicted by the next paragraph: `KodFormularza` had no key at all. It also
3350
+ omitted `Kol`, the one type that really did declare what it reported. Corrected 2026-08-26 after
3351
+ an audit re-ran the old extractor rather than reading the description of it.
3352
+
3353
+ **It survived because it produced a correct document.** `DocumentMapping#header` reads the two
3354
+ fixed attributes and writes them onto `KodFormularza` — the right element, found at the wrong
3355
+ level, because the leak surfaced them one step up. Nothing downstream could tell: generated
3356
+ metadata that reads plausibly and is wrong is worse than metadata that is missing.
3357
+
3358
+ A second defect hid the first. Anonymous complexTypes were collected only by descending from
3359
+ the `Faktura` element, and that descent stops at any element declared with a named `type`. FA(3)
3360
+ has exactly one anonymous type nested inside a named one — `KodFormularza`, inside `TNaglowek` —
3361
+ so the correct key did not exist, and reading `TNaglowek` was the only lookup available. Both are
3362
+ fixed: anonymous types nested in a named type are keyed from the type name
3363
+ (`"TNaglowek/KodFormularza"`), and attributes are read from the type's own children plus its
3364
+ `xsd:simpleContent`/`xsd:complexContent` extension, which are wrappers around its own definition
3365
+ rather than a descent.
3366
+
3367
+ **Attributes may also carry an inline enumeration, and `Enums` cannot see it.** `Enums` keys on
3368
+ `xsd:simpleType[@name]`; `Kol/@Typ` restricts an anonymous one. It is FA(3)'s only such
3369
+ attribute, and its six values — `date`, `datetime`, `dec`, `int`, `time`, `txt` — are the column
3370
+ types of an attachment table. They are now carried as `values:` on the attribute, because the
3371
+ alternative is to restate them in Ruby, which DESIGN.md §7.1 forbids.
3372
+
3373
+ **Two more the same audit found, both making the metadata a false statement about the schema.**
3374
+
3375
+ `compositor_of` looked for `xsd:sequence`/`xsd:choice` among a type's direct children, so a type
3376
+ whose model arrives through `xsd:complexContent/xsd:extension` reported **no content model at
3377
+ all**. FA(3) has exactly one, `Faktura/Podmiot1/AdresKoresp` (`<xsd:extension base="tns:TAdres"/>`),
3378
+ and the XSD really does permit `KodKraju`/`AdresL1`/`AdresL2`/`GLN` there — so `Serializer`
3379
+ refused those elements with a message naming an **empty** list of permitted ones. Latent only
3380
+ because no model carries `AdresKoresp`. The general form is worse: a non-empty extension also
3381
+ loses every anonymous type declared beneath it.
3382
+
3383
+ And `element_particle` kept an inline restriction's `base` while discarding its enumerations and
3384
+ any `fixed`. Three elements carry an inline enumeration — `WariantFormularza`, `JST`, `GV` — and
3385
+ one carries `fixed` (`PrefiksPodatnika`, `fixed="PL"`, which rendered identically to the
3386
+ declaration without it). The cost was immediate and visible: `DocumentMapping#header` hand-wrote
3387
+ `"WariantFormularza" => 3` six lines below a comment saying the fixed values are read from this
3388
+ metadata. They were not, because the metadata did not carry them. It does now, and the line reads
3389
+ the enumeration.
3390
+
3391
+ One more, latent: `Renderer::KEY_ORDER` did not list `use` or `fixed`, and `sorted_keys` maps
3392
+ every unlisted key to one shared rank. `sort_by` is not stable, so those two tied on every
3393
+ rendered attribute — the same determinism trap that reached CI from `tasks/field_mapping.rb`.
3394
+ Adding `values` would have made three. Every key a rendered Hash can hold is now listed.
3395
+
3396
+ ## 19. Defects the 2026-08-26 audit round found
3397
+
3398
+ Numbered as their own chapter because they were appended to §17 while it was being written and
3399
+ ended up **inside chapter 18**, numbered as though they belonged to §17. One of them, `§17.4`,
3400
+ was a true duplicate colliding with the tier-3 section that DESIGN.md and CLAUDE.md both cite;
3401
+ `§17.5` and `§17.6` were merely misfiled, with no counterpart to collide with. Renumbered
3402
+ 2026-08-26; `CHANGELOG.md` and `spec/ksef/fa3/correction_spec.rb` point here now.
3403
+
3404
+ ### 19.1 `StanPrzed` rows are not summed
3405
+
3406
+ Recorded 2026-08-26. `Summaries#net_by_rate` and `#vat_by_rate` skip a line marked
3407
+ `state_before`, and that is a correctness fix rather than a tidy.
3408
+
3409
+ A `StanPrzed` row states a position **as it was** before the correction; a `KOR` shows it beside
3410
+ its replacement so a reader can see both. Adding the two together answers a question nobody
3411
+ asked. Measured on Przykład 2:
3412
+
3413
+ | | before the fix | after |
3414
+ |---|---|---|
3415
+ | `net_by_rate` | `{"23" => 3089.42}` — 1626.01 *plus* 1463.41 | `{"23" => 1463.41}` |
3416
+ | `net_total` | −162.60 | −162.60 |
3417
+
3418
+ A caller building a per-rate VAT report over downloaded invoices got a figure nineteen times
3419
+ the truth, with no error and a passing `#valid?`.
3420
+
3421
+ **Nothing that derives a summary is affected**, which is what makes the change safe: tier 1's
3422
+ `SummaryChecks::DERIVATION_BLOCKERS` already refuses a `state_before` row on an invoice that
3423
+ derives, so these rows only ever appear where the summary is stated and `net_by_rate` is not
3424
+ what produces the document.
3425
+
3426
+ Note what the fix does *not* claim. `net_by_rate` now answers the **after** state, not the
3427
+ delta — the delta is what `Totals` states and what `#net_total` returns. Two different
3428
+ questions, and §8.4 is why: a correction's buckets are deltas that its rows need not determine.
3429
+
3430
+ ### 19.2 The parser's own document was never consulted
3431
+
3432
+ `Invoice#errors` runs tier 2 over `#to_xml` — bytes this gem has just produced, well-formed by
3433
+ construction — so **tier 2 is structurally incapable of seeing the input**. libxml2 recovers
3434
+ from broken XML by default, so a document with a mismatched closing tag parsed into a
3435
+ good-looking invoice and `#valid?` answered **true** for XML that is not XML.
3436
+
3437
+ That is the same defect §15.1 records as fixed on 2026-08-24, one level up: the fix then was to
3438
+ teach `Validator` to consult `document.errors`, and the parser's own retained document was never
3439
+ wired the same way. `Provenance#source_errors` now reads it, and `#errors` reports it first.
3440
+
3441
+ Recovery also substitutes silently — an invalid UTF-8 byte becomes U+FFFD — and this is the only
3442
+ record that it happened.
3443
+
3444
+ **It bounds a wider family.** These all parse, and none is legal FA(3): `+1500.00`, `0001500.00`,
3445
+ `1.5e3`, a duplicated `<P_11>`, and `<P_11>1500.4567</P_11>` stored and re-emitted as `1500.46`.
3446
+ The last is §8.6's `P_9A` class at a different element. Catching those needs the input validated
3447
+ against the schema rather than the output — *"validate the bytes you were given, not the bytes
3448
+ you would write"* — which is a larger change than this one and is not made here.
3449
+
3450
+ ### 19.3 Two encodings, one predicate
3451
+
3452
+ `String#valid_encoding?` answers **true** for a string that is validly encoded in something that
3453
+ is not UTF-8. Every guard in this gem tested it, so a `Windows-1250` or `ISO-8859-2` name — what
3454
+ a Polish ERP emits — passed tier 1 and then raised `Encoding::CompatibilityError` out of
3455
+ `#errors`, `#to_xml` and `Ksef::Client#send_invoice`. DESIGN.md §7.7 promises `#errors` reports rather than
3456
+ raises "including for text that is tagged UTF-8 but is not"; that held for invalid *bytes* and
3457
+ failed for a valid non-UTF-8 tag.
3458
+
3459
+ `FieldChecks.utf8?` is now the one place that decides, and it distinguishes three cases:
3460
+
3461
+ | Encoding | Rule | Why |
3462
+ |---|---|---|
3463
+ | `UTF-8` | `valid_encoding?` | as before |
3464
+ | `ASCII-8BIT` | valid when the bytes *are* UTF-8 | binary asserts nothing, so reading unambiguous bytes is not a guess — this is what `File.binread` produces |
3465
+ | anything else | refused, naming the encoding | a declared encoding is a statement, and overriding it would be guessing |
3466
+
3467
+ `Windows-1250` "Łódź" begins `A3`, a UTF-8 continuation byte, so it fails the binary test too and
3468
+ could not slip through even if mislabelled.