tina4ruby 3.13.94 → 3.13.96

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +883 -0
  3. data/README.md +1 -1
  4. data/lib/tina4/auth.rb +166 -87
  5. data/lib/tina4/auto_crud.rb +29 -32
  6. data/lib/tina4/cache_backends/base_backend.rb +19 -0
  7. data/lib/tina4/cache_backends/database_backend.rb +29 -0
  8. data/lib/tina4/cache_backends/memcached_backend.rb +124 -13
  9. data/lib/tina4/cache_backends/memory_backend.rb +15 -0
  10. data/lib/tina4/cache_backends/redis_backend.rb +173 -52
  11. data/lib/tina4/cache_backends.rb +10 -1
  12. data/lib/tina4/cli.rb +23 -39
  13. data/lib/tina4/cors.rb +186 -30
  14. data/lib/tina4/database/sqlite3_adapter.rb +4 -1
  15. data/lib/tina4/database.rb +322 -22
  16. data/lib/tina4/database_adapter.rb +178 -0
  17. data/lib/tina4/database_result.rb +63 -17
  18. data/lib/tina4/database_url.rb +363 -0
  19. data/lib/tina4/dev.rb +0 -1
  20. data/lib/tina4/dev_admin.rb +118 -20
  21. data/lib/tina4/dispatch_pipeline.rb +605 -0
  22. data/lib/tina4/docstore.rb +274 -60
  23. data/lib/tina4/drivers/firebird_driver.rb +118 -4
  24. data/lib/tina4/drivers/mongodb_driver.rb +19 -4
  25. data/lib/tina4/drivers/mssql_driver.rb +73 -10
  26. data/lib/tina4/drivers/mysql_driver.rb +71 -4
  27. data/lib/tina4/drivers/odbc_driver.rb +40 -4
  28. data/lib/tina4/drivers/postgres_driver.rb +97 -10
  29. data/lib/tina4/drivers/sqlite_driver.rb +21 -2
  30. data/lib/tina4/env.rb +176 -34
  31. data/lib/tina4/field_types.rb +12 -0
  32. data/lib/tina4/health.rb +30 -14
  33. data/lib/tina4/job.rb +15 -5
  34. data/lib/tina4/log.rb +236 -32
  35. data/lib/tina4/mcp.rb +11 -5
  36. data/lib/tina4/messenger.rb +248 -36
  37. data/lib/tina4/metrics.rb +179 -891
  38. data/lib/tina4/middleware.rb +191 -56
  39. data/lib/tina4/migration.rb +17 -1
  40. data/lib/tina4/orm.rb +114 -17
  41. data/lib/tina4/public/css/tina4.min.css +1 -1
  42. data/lib/tina4/queue.rb +154 -9
  43. data/lib/tina4/queue_backends/kafka_backend.rb +191 -2
  44. data/lib/tina4/queue_backends/lite_backend.rb +121 -25
  45. data/lib/tina4/queue_backends/mongo_backend.rb +146 -10
  46. data/lib/tina4/queue_backends/rabbitmq_backend.rb +194 -1
  47. data/lib/tina4/rack_app.rb +94 -316
  48. data/lib/tina4/request.rb +48 -8
  49. data/lib/tina4/response.rb +42 -1
  50. data/lib/tina4/response_cache.rb +142 -24
  51. data/lib/tina4/router.rb +141 -12
  52. data/lib/tina4/session.rb +243 -29
  53. data/lib/tina4/session_handlers/database_handler.rb +185 -20
  54. data/lib/tina4/session_handlers/file_handler.rb +113 -21
  55. data/lib/tina4/session_handlers/memcached_handler.rb +183 -0
  56. data/lib/tina4/session_handlers/mongo_handler.rb +232 -15
  57. data/lib/tina4/session_handlers/mongo_wire_client.rb +300 -0
  58. data/lib/tina4/session_handlers/redis_handler.rb +20 -6
  59. data/lib/tina4/session_handlers/valkey_handler.rb +18 -4
  60. data/lib/tina4/shutdown.rb +180 -30
  61. data/lib/tina4/sql_translator.rb +110 -0
  62. data/lib/tina4/swagger.rb +50 -18
  63. data/lib/tina4/version.rb +1 -1
  64. data/lib/tina4/webserver.rb +28 -6
  65. data/lib/tina4.rb +289 -37
  66. metadata +35 -17
  67. data/lib/tina4/scss_compiler.rb +0 -349
data/CHANGELOG.md CHANGED
@@ -6,14 +6,897 @@ number means the same thing everywhere.
6
6
  **The authoritative release notes for every shipped version live in the documentation:**
7
7
  https://tina4.com/ruby/36-releases
8
8
 
9
+ ### Breaking: Messenger `inbox()` / `read()` item shapes (3.13.96 parity)
10
+
11
+ The IMAP read path is aligned to the settled cross-framework shape (Python is the
12
+ reference). Measured against live GreenMail.
13
+
14
+ **`inbox()` item is EXACTLY `{uid, subject, from, to, date, snippet, seen}`.**
15
+ - `from` and `to` are header STRINGS (`"Name <email>"`), not arrays of
16
+ `{name, email}`.
17
+ - the `read` key is renamed `seen` (Boolean).
18
+ - `date` is ISO-8601.
19
+ - `flags` and `size` are dropped.
20
+ - a new `snippet` field carries decoded, transfer-decoded, tag-stripped plain
21
+ text, truncated to 200 chars (no more raw base64 / no more absent field).
22
+
23
+ **`read()` item uses `body_text` / `body_html`** (renamed from `body` / `html`),
24
+ returns `from`/`to`/`cc` as STRINGS, an `attachments` array of
25
+ `{filename, content_type, size}`, and a `headers` Hash (Message-ID lives there).
26
+ The old `flags` / `read` / `raw` / `message_id` top-level keys are dropped
27
+ (`headers["Message-ID"]` replaces `message_id`).
28
+
29
+ **`inbox` and `read` are callable POSITIONALLY** — `inbox("INBOX", 10, 0)`,
30
+ `read(uid, "INBOX")` — as well as by keyword (both forms work).
31
+
32
+ **New methods:** `mark_unread`, `send_template` (renders a Frond template string
33
+ to HTML and sends), and `delete` (flags `\Deleted` + expunge; fails loud like the
34
+ other reads).
35
+
36
+ **`TINA4_MAIL_IMAP_USERNAME` / `_PASSWORD` are honoured**, falling back to
37
+ `TINA4_MAIL_USERNAME` / `_PASSWORD`. Ruby authenticated IMAP to the SMTP account,
38
+ so an app whose reading mailbox differed from its SMTP relay account read the
39
+ wrong mailbox. There are matching `imap_username:` / `imap_password:` constructor
40
+ args (explicit beats env, ADR-0041).
41
+
42
+ **Migration.** Code reading `item[:from].first[:email]` becomes
43
+ `item[:from]` (a string); `item[:read]` becomes `item[:seen]`; `msg[:body]` /
44
+ `msg[:html]` become `msg[:body_text]` / `msg[:body_html]`; `msg[:message_id]`
45
+ becomes `msg[:headers]["Message-ID"]`.
46
+
47
+ ### Breaking: Swagger defaults, response codes, and operationId (3.13.96 parity)
48
+
49
+ Four measured cross-framework divergences in the OpenAPI generator, settled to
50
+ match the Python/PHP reference.
51
+
52
+ **`info.version` defaults to `1.0.0`, `info.description` to `""`.** Ruby defaulted
53
+ `info.version` to the FRAMEWORK version (`Tina4::VERSION`), so an undocumented app
54
+ claimed API `v3.13.x`. `info.version` is the APPLICATION's API version; it now
55
+ defaults to `1.0.0`. `info.description` defaults to empty instead of the canned
56
+ "Auto-generated API documentation". `TINA4_SWAGGER_VERSION` /
57
+ `TINA4_SWAGGER_DESCRIPTION` still override.
58
+
59
+ **An undecorated route emits only `200`; `401` appears only on a secured route.**
60
+ Ruby stamped `200/400/401/404/500` on EVERY operation, including a public GET,
61
+ advertising status codes nothing in the framework produces. Now an undecorated
62
+ route carries only `200`, and a route documented as secured also carries `401`.
63
+
64
+ **`operationId` preserves the path's underscores.** `/__health` and `/health`
65
+ both collapsed to `get_health` and then one got a `_2` suffix by registration
66
+ order. They now produce distinct `get___health` / `get_health` — an
67
+ operationId is a generated client's method name and must be stable and unique.
68
+
69
+ **AutoCrud emits `components.schemas`.** Generated write routes referenced a bare
70
+ `{"type":"object"}` request body. They now `$ref` a `components.schemas` entry
71
+ keyed by the model CLASS name, with a `required` array derived from column
72
+ nullability.
73
+
74
+ **Migration.** A generated client that hard-coded the app version from
75
+ `info.version`, or generated error handlers from the phantom `400/404/500`
76
+ responses, or a method name from the collapsed `get_health`, will regenerate to
77
+ the corrected shape. No runtime behaviour of a Tina4 server changes.
78
+
79
+ ### Breaking: Messenger `uid` is a String, and `inbox()` pages newest first
80
+
81
+ Two cross-framework divergences, both MEASURED 2026-08-06 against live GreenMail
82
+ with all four frameworks asked the same question about the same mailbox.
83
+
84
+ uid type python str php string node string ruby Integer
85
+ page order python P3,P2 php P3,P2 node P3,P2 ruby P2,P3
86
+
87
+ Ruby was the outlier on both.
88
+
89
+ **`uid` is now a String.** The documented contract says String in all four, so a
90
+ caller comparing `uid == "3"` got false in Ruby alone. `read()` / `mark_read()` /
91
+ `delete()` still accept a String or an Integer, so passing an id back in is
92
+ unchanged.
93
+
94
+ **`inbox()` and `search()` now page newest first.** `uid_search` was already
95
+ reversed and the page sliced correctly, but `uid_fetch` returns rows in SERVER
96
+ (ascending) order however the uids were asked for, silently re-sorting the page.
97
+ So `inbox(limit: 1)` returned the OLDEST message in Ruby and the NEWEST in the
98
+ other three - the most common inbox call there is.
99
+
100
+ **Migration.** Code doing `uid == 3` or `uid.to_i` keeps working; code relying on
101
+ `uid.is_a?(Integer)` does not. Code that took `inbox(limit: n).first` as the
102
+ oldest message in that page now gets the newest, which is what the other three
103
+ frameworks always returned.
104
+
105
+
9
106
  This file is deliberately NOT a copy of those notes. Duplicating them is exactly how a
10
107
  changelog rots into claiming a version that was never cut, so this file records only
11
108
  UNRELEASED work. When a version ships, its notes go to the release notes above.
12
109
 
110
+ ### Breaking (the queue store moved to the canonical cross-framework layout)
111
+
112
+ **Breaking: the file-backed queue's default store moved from
113
+ `<cwd>/.queue/<topic>/<id>.json` to
114
+ `<TINA4_QUEUE_PATH|data/queue>/<topic>/<id>.queue-data`. The DIRECTORY and the
115
+ FILE EXTENSION both changed.** No job is lost - see the migration note below.
116
+
117
+ Ruby was the odd one out. Python, PHP and Node all store jobs at
118
+ `<TINA4_QUEUE_PATH|data/queue>/<topic>/*.queue-data`. Ruby wrote
119
+ `<cwd>/.queue/<topic>/<id>.json` and read `TINA4_QUEUE_PATH` **nowhere**, so the
120
+ variable `.env.example` has documented all along ("Queue storage path (file
121
+ backend)") did nothing whatsoever: an operator who pointed the store at a
122
+ mounted volume kept writing to the container's ephemeral filesystem and lost
123
+ every queued job on restart, with no error and no warning.
124
+
125
+ The store's layout - root, topic segment, job extension, dead-letter directory -
126
+ is now defined once on `Tina4::Queue` (`base_path`, `topic_dirname`,
127
+ `topic_path`, `job_files`, `job_file`, `JOB_EXTENSION`,
128
+ `DEAD_LETTER_DIRNAME`) and is the single answer both the backend and the
129
+ dev-admin queue panel ask. The topic is sanitised to one path segment there,
130
+ in the one place that resolves it, because the panel takes its topic straight
131
+ off a query string.
132
+
133
+ **MIGRATION NOTE.** Nothing is required of you, and no job is stranded.
134
+
135
+ - The first time the file backend resolves its own store, it **moves** every
136
+ `*.json` under `<cwd>/.queue/` into the new store, renaming to
137
+ `*.queue-data` and preserving the `<topic>/`, `<topic>/reserved/` and
138
+ `dead_letter/` structure. It logs `Queue: moved N job(s) ...` at INFO.
139
+ - It is a MOVE, never a copy, so a job cannot be delivered twice; it never
140
+ overwrites a file already at the destination, so concurrent processes cannot
141
+ clobber each other; and emptied legacy directories are removed, so the check
142
+ costs one `Dir.exist?` afterwards.
143
+ - **If you had `TINA4_QUEUE_PATH` set:** it previously did nothing in Ruby, so
144
+ your jobs are in `<cwd>/.queue` too. They move into the path you configured,
145
+ which now works as it does in the other three frameworks.
146
+ - **If you did not:** your jobs move to `<cwd>/data/queue`.
147
+ - **If you construct `LiteBackend.new(dir: ...)` yourself:** that store is yours
148
+ and is never touched.
149
+ - Add `data/queue/` to your `.gitignore`. Newly scaffolded projects get it.
150
+ - The rescue triggers on the legacy directory EXISTING, not on the new store
151
+ being absent, so a rolling upgrade in which an old instance keeps writing to
152
+ `.queue` still has those jobs collected.
153
+
154
+ Pending jobs and reservations now interoperate with a store written by Python,
155
+ PHP or Node. Dead letters still do not: Ruby keeps them in a shared
156
+ `<base>/dead_letter/` tagged by topic, where Python and Node use a per-topic
157
+ `<base>/<topic>/failed/`. That divergence is unchanged here.
158
+
159
+ ### Fixed (the dev-admin queue panel listed a different store from the one it counted)
160
+
161
+ The panel's job list and its stats read different sources, so the two disagreed.
162
+ `GET /__dev/api/queue/topics` scanned a hardcoded `<cwd>/data/queue` that no
163
+ Ruby app ever wrote to, so it could not name a real topic. `GET /__dev/api/queue`
164
+ never listed pending or reserved jobs at all - only `queue.failed()` and
165
+ `queue.dead_letters()` - while its stats counted all four buckets, and it ignored
166
+ the `?status=` filter the panel's own badges have always sent.
167
+
168
+ Four defects, all the same shape - the list describes a different set from the
169
+ counts:
170
+
171
+ 1. **The directory.** Both handlers re-derived the path instead of asking where
172
+ the store is.
173
+ 2. **The set.** Pending and reserved jobs were counted and never listed. A
174
+ failed-but-retryable job - which lives in the PENDING directory - was counted
175
+ by `stats.pending` and listed as `"failed"`: one job, two contradictory
176
+ answers.
177
+ 3. **max_retries.** Dead letters were listed via `queue.dead_letters()`, which
178
+ filters on the max_retries of the Queue the dev admin itself constructed (3).
179
+ An app configured `max_retries: 1` dead-letters at ONE attempt, and every one
180
+ of those jobs was counted by `stats.failed` and never shown. The list now
181
+ uses `max_retries: 0` - the same "no attempt-count filter" spelling
182
+ `tina4ruby queue retry` already used.
183
+ 4. **Duplicate JSON keys.** `job.merge(status: "...")` added a SYMBOL key beside
184
+ the record's existing STRING one, so every job went out as
185
+ `{"status":"dead", ..., "status":"dead_letter"}` - two `status` names in one
186
+ object.
187
+
188
+ Every job now appears exactly once, in the bucket its own stat counts it in, so
189
+ `sum(pending, completed, failed, reserved) == jobs.length` by construction on a
190
+ quiescent store, and every `?status=` filter returns exactly what its stat
191
+ counts.
192
+
193
+ ### Fixed (the documented sort spelling raised on real MongoDB, ADR-0036)
194
+
195
+ `cursor.sort("total", -1)` - the spelling this file documents - raised
196
+ `ArgumentError: wrong number of arguments (given 2, expected 0..1)` on a real
197
+ MongoDB, because `Mongo::Collection::View#sort` takes ONE spec document. The
198
+ list-of-pairs form was worse: it reached the server as an ARRAY and came back
199
+
200
+ ```
201
+ [14:TypeMismatch]: Expected field sort to be of type object
202
+ ```
203
+
204
+ so of the three spellings the fallback accepted, only the hash form survived the
205
+ swap - and it was not the documented one.
206
+
207
+ `sort` now normalises all three to the driver's single spec document, so
208
+ `sort("total", -1)`, `sort({ "total" => -1 })` and `sort([["total", -1]])` are
209
+ equivalent on both providers. Ruby's fallback `Cursor` already accepted all
210
+ three; it is the DRIVER side that was narrow, and `MongoView` now bridges it.
211
+
212
+ NOT affected: Ruby's chain was already lazy on both providers, because a
213
+ `Mongo::Collection::View` is lazy and immutable. PHP's was not, and ADR-0036
214
+ fixes that separately.
215
+
216
+ MEASURED 2026-08-04 against a real MongoDB 7.0.39: 4 chain cases x 2 providers
217
+ x 4 frameworks = 32 combinations, of which **10 failed** before this change and
218
+ 0 fail after. Pinned by the substitutability suite in all four frameworks,
219
+ which asserts every spelling on BOTH providers, that `skip` composes, that an
220
+ ASCENDING sort actually ascends (a direction ignored outright would pass a
221
+ descending-only test), and that the chain is LAZY - a document inserted after
222
+ the chain is built but before it is iterated must appear.
223
+
224
+ ### Fixed (the uniform DocStore spellings now work on the real provider, ADR-0035)
225
+
226
+ - `collection.find_one(filter)` and `cursor.to_list` worked on the SQLite
227
+ fallback and did not exist on `Mongo::Collection`, so code that used them
228
+ raised `NoMethodError` the moment `TINA4_MONGO_URI` was set. They now work on
229
+ BOTH providers.
230
+
231
+ `get_collection` returns `Tina4::DocStore::MongoCollection` on the Mongo path -
232
+ a `SimpleDelegator` that adds the two methods and forwards the entire driver
233
+ surface untouched. `aggregate`, `bulk_write`, `indexes`, `watch`, sessions and
234
+ transactions are all still reachable; measured 2026-08-04 against a real
235
+ MongoDB 7.0.39, with 0 fallback-only collection methods and 0 fallback-only
236
+ cursor methods.
237
+
238
+ ADDITIVE, not a replacement. `find(filter).first` and `to_a` are the driver's
239
+ spellings, they are unchanged, and both forms return the same answer.
240
+
241
+ ADR-0025 corollary 1 said to DELETE a method the driver lacks. ADR-0035
242
+ supersedes that corollary only: a method may exist on the fallback when it
243
+ also exists on what `get_collection` RETURNS, and Tina4 may supply it on both
244
+ sides. The core rule and corollaries 2, 3 and 4 stand.
245
+
246
+ Pinned by `spec/docstore_substitutability_spec.rb`, which reads a document
247
+ back through every spelling on BOTH providers and measures the fallback's
248
+ public methods against the wrapped driver rather than a hand-kept list.
249
+
250
+ **Breaking: on the Mongo path `get_collection` now returns a delegator, so a
251
+ CLASS check answers differently.** Every method call, `==` against the raw
252
+ collection, and the whole driver surface behave exactly as before, but
253
+
254
+ Tina4::DocStore.get_collection("x").is_a?(Mongo::Collection) # was true, now false
255
+ Tina4::DocStore.get_collection("x").class # was Mongo::Collection
256
+
257
+ **Migration:** stop type-checking the return, or reach the real object with
258
+ `__getobj__`:
259
+
260
+ collection.__getobj__.is_a?(Mongo::Collection) # true
261
+
262
+ Nothing in the framework type-checks it; this is stated because a user
263
+ application might.
264
+
265
+ KNOWN and NOT fixed here: the fallback `Cursor#sort(key, direction)` takes two
266
+ arguments and `Mongo::Collection::View#sort` takes one. Use the hash spelling
267
+ `sort({ "total" => -1 })`, which both providers accept.
268
+
269
+ ### Breaking (DocStore: a missing MongoDB driver now raises)
270
+
271
+ `TINA4_MONGO_URI` set with the `mongo` gem NOT installed used to return the local SQLite collection. It now
272
+ raises `Tina4::DocStore::DocStoreDriverMissing`, naming the provider and what is missing (ADR-0033,
273
+ applying ADR-0024 rule 3).
274
+
275
+ Re-measured 2026-08-04 at `v3` HEAD in a REAL driverless environment - no mock, no
276
+ faked import - one env produced two shapes and four messages across the family:
277
+ Python, PHP and Ruby silently returned the local SQLite store, Node threw a bare
278
+ `ERR_MODULE_NOT_FOUND`. Silent degradation here means production writes landing in a
279
+ container-local file nobody reads, which vanishes on the next deploy, with no error at
280
+ any point.
281
+
282
+ **Migration - one of two lines:**
283
+
284
+ ```
285
+ gem install mongo # use the real provider
286
+ unset TINA4_MONGO_URI # or use the local SQLite store, explicitly
287
+ ```
288
+
289
+ Also changed: `serverless?` is now CONFIGURATION ONLY. It used to also return true when the gem was absent, which is
290
+ what routed the call into the local branch; without this an app branching on it would
291
+ take the local path and never reach the raise. The error message names the env var that
292
+ supplied the URI and never its VALUE, because a Mongo URI routinely carries
293
+ `user:password@` and an error string is the most-logged text a framework emits.
294
+
295
+ ### Breaking (the query-cache key carried no database identity)
296
+
297
+ **Breaking: every existing persistent query-cache entry becomes a miss on upgrade.**
298
+
299
+ `Database#cache_key` was `sha256(sql + params)` with nothing naming the connection, so
300
+ on ANY shared cache backend two databases cross-served each other's rows. Two apps
301
+ pointed at one Redis, or one app with a primary and an analytics connection, silently
302
+ read each other's data. Identical SQL text across tenants is the COMMON case in a
303
+ multi-tenant deployment, not an edge case, so the collision was the normal outcome.
304
+ This is a data-isolation failure, not a caching inefficiency.
305
+
306
+ The key is now `sha256(engine://host:port/database \0 sql \0 params)`. Credentials are
307
+ deliberately excluded: a password in the key would cold-start the cache on every
308
+ rotation, and a shared backend's key namespace is readable by every tenant of that
309
+ backend. Nothing per-process is included either (no pid, no object_id, no salt) - that
310
+ would isolate the databases by accident and destroy the point of a shared cache, since
311
+ no instance would ever hit another instance's entry.
312
+
313
+ **Migration:** the key format changed, so entries written by an earlier version are
314
+ unreachable and simply miss. A cold cache is safe - it costs one repopulating read per
315
+ key. Cross-served rows are not safe, which is why this ships as a break rather than a
316
+ compatibility shim. Nothing needs to be run: no config change, no manual flush. If you
317
+ would rather not carry the dead entries until their TTL expires, call `db.cache_clear`
318
+ once after deploying.
319
+
320
+ ### Fixed (queue operations acted on the local file store, not the configured backend)
321
+
322
+ Every operation must act on the CONFIGURED backend. These calls appeared to succeed
323
+ while operating on the wrong data, which is the worst failure class because nothing
324
+ surfaces it. `pop_by_id` was broken in ALL FOUR frameworks.
325
+
326
+ - `clear()` and `pop_by_id()` returned `0`/`nil` because `MongoBackend` had NEITHER
327
+ method and `Queue` guarded on `respond_to?`. Clearing a mongo-backed queue was a
328
+ no-op that looked exactly like an already-empty queue. Both are implemented on
329
+ mongodb now, and the guards raise a named refusal instead of a silent 0/nil.
330
+
331
+ ### Fixed (a queue method could be a fatal error instead of resolving)
332
+
333
+ Every public `Queue` method must RESOLVE on every backend the framework offers. A
334
+ method that does not exist cannot even reach a refusal, so the upgrade path is
335
+ severed rather than degraded.
336
+
337
+ - `queue.size` raised `NoMethodError` on the kafka backend, which simply had no
338
+ `size` method. It now answers `0` - the value ADR-0022 decision 5 already records,
339
+ and the one Python and PHP already gave. A log has no queue depth, and computing
340
+ one means an admin round-trip per call.
341
+
342
+ ### Fixed (queue priority was ignored on every backend but file)
343
+
344
+ - `push(..., priority)` is now honoured on the `mongodb` backend: priority is stored
345
+ top-level and the dequeue sorts highest-first, ties oldest-first — the same policy
346
+ the file backend already applied. An urgent job queued behind a backlog used to wait
347
+ for all of it in production while prioritising correctly in development. Here the Mongo backend never WROTE the field its own `dequeue` read back
348
+ (`doc["priority"] || 0`), so every job scored 0, and it sorted on `created_at`
349
+ alone.
350
+ - **Breaking:** pushing with a priority to `rabbitmq` or `kafka` now RAISES, naming the
351
+ backend and the operation, instead of silently discarding it. A RabbitMQ queue is
352
+ FIFO: native priority needs the queue DECLARED with `x-max-priority`, and an existing
353
+ queue cannot be redeclared with one (the broker answers PRECONDITION_FAILED), so
354
+ switching it on would break every queue already in service. Kafka has no priority
355
+ concept at all. Migration: use the `file` or `mongodb` backend for prioritised jobs.
356
+ A push with priority 0 (the default) is unaffected.
357
+
358
+ ### Fixed (a queue delay was silently dropped on every non-file backend)
359
+
360
+ - `push(..., delay)` is now honoured on the `mongodb` backend. It was silently DROPPED
361
+ on every non-file backend in ALL FOUR frameworks, so a scheduled job fired immediately
362
+ in production and on time in development. Here the Mongo backend never WROTE the `available_at` that `Queue#push` had already
363
+ computed, AND its `dequeue` never FILTERED on it. Writing the field alone would
364
+ have changed nothing.
365
+ - **Breaking:** pushing with a delay to `rabbitmq` or `kafka` now RAISES, naming the
366
+ backend and the operation, instead of silently discarding the delay. Neither broker
367
+ has a per-message delay: RabbitMQ's delayed-message-exchange is a non-core plugin and
368
+ the TTL + dead-letter workaround head-of-line blocks, and Kafka reads a partition in
369
+ offset order. Migration: use the `file` or `mongodb` backend for delayed jobs, or
370
+ schedule the push itself. A push with no delay is unaffected.
371
+
372
+ ### Fixed (an unknown queue backend name silently used the file store)
373
+
374
+ - An unrecognised `TINA4_QUEUE_BACKEND` now RAISES, naming the bad value and the
375
+ valid set, instead of falling through to the local file store. The name is also
376
+ normalised (trimmed + lowercased), so ` RabbitMQ ` resolves.
377
+
378
+ WHY: MEASURED 2026-08-03. A typo in `TINA4_QUEUE_BACKEND` produced a RUNNING app
379
+ writing every job to local disk while the operator believed they were in
380
+ RabbitMQ - jobs nothing consumes, on a container filesystem that vanishes on
381
+ the next deploy, with no error at any point.
382
+
383
+ python raised, named the valid set <- already correct
384
+ ruby raised, named the valid set <- already correct
385
+ php SILENT FALLBACK to file
386
+ nodejs SILENT FALLBACK to file
387
+
388
+ This is the same rule the SESSION backend already adopted, for the same
389
+ reason, so two of four were simply behind.
390
+
391
+ Ruby-specific, found while proving the shared case: `.downcase.strip` bound
392
+ only to the `ENV.fetch` branch, so an explicit `Queue.new(backend: "FILE")`
393
+ raised while the identical spelling in `TINA4_QUEUE_BACKEND` resolved - and
394
+ while python, php and nodejs all accepted it. Both sources are normalised now.
395
+ Python is master on internal API design.
396
+
397
+ Pinned by `spec/queue_backend_validation_spec.rb`, with a negative case asserting the guard still accepts
398
+ every documented name - without it, "make everything raise" would pass.
399
+ Mutation-proved in both directions (guard disabled, normalisation removed).
400
+
401
+ ### Fixed (array queries diverged from MongoDB, ADR-0025 clause 4)
402
+
403
+ - A query against an ARRAY field now behaves the way MongoDB behaves. The rule is
404
+ one sentence: a condition on an array-valued field matches when ANY ELEMENT
405
+ matches it (or the whole array equals the operand), and a negation matches when
406
+ NO element does. Implemented over SQLite's `json_each`.
407
+
408
+ WHY: MEASURED 2026-08-03 against a real MongoDB with an 18-case matrix. EIGHT
409
+ behaviours diverged IDENTICALLY in all four frameworks, which is the signature
410
+ of a contract nobody had written down:
411
+
412
+ tags = "x" against ["x","y"] mongo 1, fallback 0 (containment)
413
+ tags $in ["x"] mongo 1, fallback 0
414
+ nums = 1 against [1,2,3] mongo 1, fallback 0
415
+ nums $lt 2 against [1,2,3] mongo 1, fallback 0
416
+ tags $regex "^x$" mongo 1, fallback 0
417
+ tags $nin ["x"] mongo 0, fallback 1 <- FALSE POSITIVE
418
+ tags $ne "x" mongo 0, fallback 1 <- FALSE POSITIVE
419
+ nums $gt 9 against [1,2,3] mongo 0, fallback 1 <- FALSE POSITIVE
420
+
421
+ The three false positives are the worst of it: the fallback returned documents
422
+ Mongo EXCLUDES. `nums $gt 9` matched [1,2,3] because json_extract of an array
423
+ returns its JSON TEXT and SQLite sorts any text above any number - a wrong
424
+ answer, not a missing feature.
425
+
426
+ Also fixed in the same pass: an object field is no longer matched by one of its
427
+ values, and IS matched by the whole object.
428
+
429
+ Pinned by `spec/docstore_substitutability_spec.rb`, which runs a 20-case matrix against BOTH providers and
430
+ asserts they return the SAME counts - not a hard-coded number, so the test
431
+ cannot drift towards whatever the fallback happens to do. Mutation-proved by
432
+ removing the array branch from equality.
433
+
434
+ ### Fixed (DocStore leaked a Mongo client per call, ADR-0025)
435
+
436
+ - `get_collection` cached the connected client instead of building a new one on every
437
+ call. Added `Tina4::DocStore.close_doc_store` to close every Mongo client and the SQLite store.
438
+
439
+ WHY: MEASURED 2026-08-03 against a real MongoDB - 20 calls left 60 server
440
+ connections open, growing LINEARLY and without bound. It was invisible in
441
+ development, because the SQLite fallback opens no connections at all: a
442
+ resource leak that existed ONLY after the swap to the real provider, and that
443
+ exhausts the server rather than erroring.
444
+
445
+ The cache is keyed per (uri, database) so a reconfigure gets its own client, and it is
446
+ guarded against the check-then-act race in which two concurrent first-callers
447
+ both build a client and one is orphaned - the same leak, just rarer.
448
+
449
+ Pinned by `spec/docstore_substitutability_spec.rb`, which drives three identical rounds plus 100 further
450
+ calls and asserts the growth PLATEAUS. That is the distinction that matters: a
451
+ pool legitimately opens several connections and then flattens; a leak keeps
452
+ climbing. Mutation-proved by restoring one-client-per-call.
453
+
454
+ NOT affected: PHP. Its ext-mongodb driver pools at the libmongoc level, so
455
+ many Client objects sharing a URI share one pool - measured 0 growth over 60
456
+ calls. It gets the same named test anyway, because correct-for-a-reason-we-did-
457
+ not-choose is exactly what regresses silently.
458
+
13
459
  ## Unreleased
14
460
 
461
+ ### Breaking: the rate limiter keys on the socket peer, not X-Forwarded-For
462
+
463
+ `X-Forwarded-For` is written by whoever sends it. Reading it unconditionally let
464
+ any client pick its own rate-limit bucket, and - worse - pick SOMEONE ELSE'S,
465
+ exhausting a third party's quota. Measured with `TINA4_RATE_LIMIT=3`: a rotating
466
+ `X-Forwarded-For` scored 200,200,200,200,200,200 where a fixed one correctly
467
+ scored 200,200,200,429,429,429.
468
+
469
+ `X-Forwarded-For` and `X-Real-IP` are now read ONLY when the raw socket peer is
470
+ listed in the new `TINA4_TRUSTED_PROXIES`. Within the chain the RIGHTMOST hop
471
+ that is not itself a trusted proxy wins, matching Rack and Express (a client can
472
+ prepend its own hop, so the leftmost entry is attacker-controlled even behind a
473
+ real proxy).
474
+
475
+ **Migration.** If your app runs behind a proxy, load balancer or ingress, set
476
+ `TINA4_TRUSTED_PROXIES` to that proxy's address or range. It accepts a
477
+ comma-separated mix of exact addresses and CIDR ranges, IPv4 and IPv6:
478
+
479
+ ```
480
+ TINA4_TRUSTED_PROXIES=10.0.0.0/8
481
+ TINA4_TRUSTED_PROXIES=192.168.1.5, ::1, fd00::/8
482
+ ```
483
+
484
+ It is EMPTY by default, which means trust nothing. If you leave it unset behind a
485
+ proxy, every client is bucketed under the proxy's address and you will
486
+ over-limit. That is deliberate: over-limiting is a degraded service, while the
487
+ previous behaviour was an open door. Direct-to-internet apps need no change.
488
+
489
+ See ADR-0019.
490
+ ### Auth: the `jwt` gem is gone, RS256 is opt-in stdlib OpenSSL
491
+
492
+ `tina4ruby` no longer depends on the `jwt` gem. Runtime dependencies drop from 12 to 11.
493
+ Every JWT is now signed and verified with stdlib OpenSSL: `OpenSSL::HMAC` for the standard
494
+ HS256/HS384/HS512 family, and `OpenSSL::PKey::RSA#sign`/`#verify` for the opt-in RS256. The
495
+ gem was only ever wrapping the base64url `header.payload.signature` envelope that
496
+ `lib/tina4/auth.rb` already builds for its HMAC path, so it bought nothing and cost a
497
+ dependency. Tokens are unchanged on the wire: an RS256 token minted by the new code verifies
498
+ under PHP `openssl_verify` and Node `crypto.createVerify`, and a tampered payload is rejected
499
+ by both.
500
+
501
+ HMAC (HS256/HS384/HS512) is the standard algorithm family across all four frameworks and is
502
+ zero-dependency in each. RS256 stays available in Ruby, opt-in via key presence
503
+ (`.keys/private.pem` + `.keys/public.pem` with a blank `TINA4_SECRET`), and needs no library.
504
+ If a Ruby build somehow lacks `OpenSSL::PKey::RSA`, the RS256 path raises
505
+ `NotImplementedError` naming what is missing and the remedy, instead of failing silently.
506
+
507
+ Note that HMAC is symmetric: every service that VERIFIES a token holds the secret that SIGNS
508
+ it, so any verifier can also mint tokens. That is fine for one app or a trusted fleet, and
509
+ wrong when handing tokens to a third party you do not control. Use RS256 there, so a verifier
510
+ can hold only the public key.
511
+
512
+ Algorithm pinning is unchanged and now explicitly covered: under an HMAC configuration a
513
+ token whose header claims `RS256` is rejected, and `alg: "none"` is rejected, even when the
514
+ accompanying signature is a genuinely valid HMAC. See `spec/auth_rs256_optin_spec.rb`.
515
+
516
+ **Breaking:** `Tina4::Auth.valid_token_detail` (alias `validate_token`) previously reported
517
+ the `jwt` gem's own wording on the RS256 path - `{ valid: false, error: "Token expired" }`
518
+ for an expired token, and the gem's raw decode message for anything else. Both paths now
519
+ report the single HMAC-path wording, `{ valid: false, error: "Invalid or expired token" }`.
520
+ *Migration:* do not branch on the `error` STRING. Test `result[:valid]`, which is unchanged,
521
+ and read `result[:payload]` on success. Applications that matched `error == "Token expired"`
522
+ to distinguish expiry from a bad signature must instead inspect the `exp` claim themselves
523
+ via `Tina4::Auth.get_payload(token)`. This aligns the RS256 detail shape with the HMAC one,
524
+ so the response no longer depends on which algorithm signed.
525
+
526
+ ### Security: the auth + session contract (ADR-0021)
527
+
528
+ **Breaking:** the API-key auth result is now `{ "_auth" => "api_key" }` instead of
529
+ `{ "api_key" => true }`, in `Tina4::Auth.authenticate_request`, the `env["tina4.auth"]`
530
+ set by `Tina4::Auth.bearer_auth`, and the `env["tina4.auth_payload"]` set by the
531
+ write-route gate. *Migration:* replace `payload["api_key"]` with
532
+ `payload["_auth"] == "api_key"`. PHP and Node already used `_auth`; Python moved to it
533
+ in the same change, so the same successful auth no longer reads three different ways
534
+ across the four frameworks.
535
+
536
+ Also in this change:
537
+
538
+ - **Breaking: strict session mode.** A session id is now adopted only when it is BOTH a well-formed opaque id
539
+ (`Tina4::Session.valid_session_id?`, `/\A[A-Za-z0-9_-]{1,128}\z/` — the constraint is
540
+ the alphabet, there is deliberately no entropy floor) AND an id the backend already
541
+ holds a session under. Anything else is DISCARDED and a fresh `SecureRandom.hex(32)`
542
+ minted. This is OWASP strict mode / PHP's `session.use_strict_mode=1`, and it closes
543
+ session fixation: Ruby previously adopted any cookie id, so an attacker could plant
544
+ one, wait for the victim to log in under it, and already hold the authenticated
545
+ session id. A backend OUTAGE is deliberately not treated as "unknown" — it logs and
546
+ still adopts, so one Redis blip cannot rotate every id at once.
547
+
548
+ Strict mode on its own logs NOBODY out: an id the backend already holds is still
549
+ adopted, on every backend. (An earlier draft of this bullet claimed a one-time
550
+ logout for everyone. That was wrong. The one-time logout is caused by the filename
551
+ change in the next bullet, and it reaches the FILE backend only.)
552
+ - **Breaking: session filenames are a SHA-256 of the id.** `FileHandler#session_path`
553
+ did `gsub(/[^a-zA-Z0-9_-]/, "")` — traversal-safe but LOSSY, so `a/b` and `ab` both
554
+ became `sess_ab.json` and one user's session data surfaced under another user's id.
555
+ It is now `sess_<sha256(id)>.json`, parity with Python's `FileSessionHandler._file`.
556
+
557
+ **This invalidates live sessions on the FILE backend ONLY.** An existing
558
+ `sess_<id>.json` is not found under the new name, so those users are handed a fresh
559
+ session and sign in again once. Do not tell operators on the other backends that
560
+ everyone is logged out - it is not true for them. Verified backend by backend
561
+ against this release:
562
+
563
+ | session backend | live sessions after upgrade | why |
564
+ | --- | --- | --- |
565
+ | file | LOST, sign in again once | filename moved from `sess_<stripped-id>.json` to `sess_<sha256(id)>.json` |
566
+ | redis | survive | key is still `tina4:session:<id>`, the raw id |
567
+ | valkey | survive | key is still `tina4:session:<id>`, the raw id |
568
+ | mongodb | survive | `_id` is still the raw id |
569
+ | database | survive | `tina4_session.session_id` is still the raw id |
570
+ | memcached | not applicable | new session backend in this release, no prior sessions to lose |
571
+
572
+ The memcached handler hashes a key only when the composed key would exceed
573
+ memcached's 250-byte limit or carry a control character, so a normal id is stored
574
+ under the raw value there too.
575
+
576
+ *Migration:* nothing to run. On the file backend, expect one round of sign-ins on
577
+ the deploy, so avoid shipping it in the middle of a checkout flow or alongside a
578
+ change that assumes a warm session. Delete stale `sessions/sess_*.json` at your
579
+ leisure; they are unreadable, not dangerous.
580
+ - **A malformed `exp`/`nbf` no longer reads as "no constraint".** RFC 7519 s2 defines
581
+ them as NumericDate; the check was `payload["exp"] && ...`, so `exp: null` or
582
+ `exp: false` skipped it entirely and the token never expired. A present-but-non-numeric
583
+ claim now rejects the token. A token with no `exp`/`nbf` key at all stays
584
+ unconstrained (non-breaking).
585
+ - **`authenticate_request` / `bearer_auth` check the JWT BEFORE the API key**, matching
586
+ Python, PHP and Node.
587
+ - **The write-route gate compares the API key timing-safely.** `RackApp.enforce_route_auth`
588
+ used a plain `token == api_key`, which returns as soon as two bytes differ and leaks
589
+ the key prefix through response timing; it now routes through
590
+ `Tina4::Auth.validate_api_key` (`OpenSSL.fixed_length_secure_compare`).
591
+
592
+ Locked in by `spec/auth_session_contract_spec.rb`, whose example names are identical in
593
+ all four frameworks.
594
+
595
+ ### Breaking: `Session#get` returns a STORED false instead of the default
596
+
597
+ `Tina4::Session#get` was `@data[key.to_s] || default`, and `||` hands back the
598
+ caller's default for ANY falsy stored value. A legitimately stored `false` read back
599
+ as `true` whenever the caller passed `true` as the default - so
600
+ `session.get("marketing_opt_in", true)` reported opted-IN for a user who had
601
+ explicitly opted OUT. It is now `value.nil? ? default : value`, which keys off
602
+ PRESENCE, matching Python's `dict.get(key, default)`.
603
+
604
+ The `nil?` form is deliberate rather than `@data.key?(k) ? @data[k] : default`: both
605
+ fix the `false` case, but the `key?` form would also make a stored `nil` win over the
606
+ default, which is a second behaviour change nobody asked for.
607
+
608
+ **Migration.** Read the call sites where you pass a TRUTHY default and can store a
609
+ `false` under that key - typically feature flags, consent and opt-out flags. Those
610
+ now return the stored `false` where they used to return your default. A key that was
611
+ never stored still returns the default, unchanged. Python, PHP and Node were already
612
+ correct here; only Ruby moves.
613
+
614
+ Locked by the cross-framework example `session get returns a stored false instead of
615
+ the default`, whose name is identical in all four repos.
616
+
617
+ ### Breaking: the response cache obeys RFC 9111 (Authorization and Vary)
618
+
619
+ The response cache keyed entries on method plus URL, with NO request header in
620
+ the key. It is a shared, server-side store, so on a secured GET route the first
621
+ caller's body was served to every later caller of the same URL. Measured
622
+ end-to-end on a real secured route: a valid token for `bob` returned alice's
623
+ private body with `X-Cache: HIT`. In Node, where route middleware runs before
624
+ the auth gate, an ANONYMOUS request returned 200 with alice's body.
625
+
626
+ Two RFC 9111 rules now apply, as they do in Varnish, nginx and Rails:
627
+
628
+ - Section 3 / 3.5: a response to a request carrying `Authorization` is NOT
629
+ stored unless the response carries `Cache-Control: public`, `s-maxage` or
630
+ `must-revalidate`.
631
+ - Section 4.1: `Vary` is honoured. The nominated request headers are recorded
632
+ with the entry and must match on lookup; an absent field matches only an
633
+ absent field. `Vary: *` is never stored.
634
+
635
+ **Migration.** Authenticated GETs are no longer cached by default. If a
636
+ response body is genuinely identical for every caller, opt back in per
637
+ response:
638
+
639
+ ```ruby
640
+ response.headers["Cache-Control"] = "public"
641
+ ```
642
+
643
+ Only add it where the body carries nothing user-specific. Public GET caching is
644
+ unchanged. See ADR-0020 and `plan/v3/features/043-caching.md`.
645
+
646
+ ### Breaking: an unknown TINA4_CACHE_BACKEND raises instead of falling back to memory
647
+
648
+ An unrecognised name silently became an in-process memory cache, so a typo
649
+ (`TINA4_CACHE_BACKEND=redsi`) produced a running app that shared nothing while the
650
+ operator believed it was Redis. It now raises, naming the bad value and the valid
651
+ set - the contract `TINA4_SESSION_BACKEND` already uses.
652
+
653
+ **Migration.** Fix the spelling. Valid: `memory`, `file`, `redis`, `valkey`,
654
+ `memcached`, `mongodb`, `database` (plus the aliases `memcache`, `mongo`, `db`).
655
+
656
+ ### Security: a before hook that refuses without returning the pair no longer runs the handler
657
+
658
+ A `before_*` middleware hook that set a 4xx status and returned `nil` did NOT
659
+ short-circuit - the response carried the 403 but the route handler ran anyway.
660
+ The `status_code >= 400` check was nested INSIDE the "did the hook return a
661
+ 2-element Array" branch, so refusal was only honoured for hooks that also
662
+ returned `[request, response]`. Any auth/guard middleware written as
663
+
664
+ ```ruby
665
+ def self.before_auth(request, response)
666
+ response.json({ error: "denied" }, 403) unless authorised?(request)
667
+ end
668
+ ```
669
+
670
+ executed its protected handler. Reproduced with a real `Tina4::Request` /
671
+ `Tina4::Response`: the pair-returning form returned `false` (correct), the
672
+ nil-returning form returned `true`. The check is now unconditional after EVERY
673
+ hook call, matching Python, PHP and Node (Rails short-circuits on the response
674
+ STATE, not on what the filter returned).
675
+
676
+ Regression test: `a before hook that sets 4xx and returns nothing skips the
677
+ handler` in `spec/middleware_pipeline_characterisation_spec.rb`.
678
+
679
+ ### Fixed: per-route class middleware now runs its before_*/after_* hooks
680
+
681
+ `Route#run_middleware` called `mw.call(request, response)` on every attached
682
+ middleware. A class declaring `def self.before_auth` does not respond to
683
+ `.call`, so per-route class middleware raised `NoMethodError` and the
684
+ dispatcher turned every such request into a clean 500. **In Ruby this is
685
+ broken-to-working, not inert-to-active**: the documented per-route
686
+ `before_*`/`after_*` mechanism did not silently do nothing, it 500'd. (In PHP
687
+ and Node the equivalent fix makes previously-INERT middleware start running -
688
+ that difference matters when reading those changelogs.)
689
+
690
+ Per-route class middleware now goes through `Tina4::Middleware.run_before` /
691
+ `run_after` - the SAME orchestrator, hook discovery and return-value table as
692
+ global middleware, not a second parallel runner. 2-arg callable ("filter")
693
+ middleware and 3-arg function middleware are unchanged.
694
+
695
+ **Migration:** if you attached a class with `before_*`/`after_*` hooks to a
696
+ route, those hooks now RUN. Routes that were returning 500 will start serving,
697
+ and a hook that refuses will now actually refuse. Audit any such middleware
698
+ before upgrading - it has never executed in production.
699
+
700
+ **Also breaking:** a halting per-route middleware used to answer with a
701
+ hardcoded `[403, text/html, "403 Forbidden"]`, discarding whatever the
702
+ middleware had set. The response the middleware SET is now sent - a 401 with
703
+ `WWW-Authenticate`, a 302 to `/login`, or a JSON error body all survive. A
704
+ middleware that halts having set nothing still gets a 403 (now
705
+ `{"error":"Forbidden","status":403}`, the same shape as the middleware 500).
706
+
707
+ ### Middleware hook return values are one documented table
708
+
709
+ Applied to EVERY `before_*`/`after_*` hook at EVERY scope (global and
710
+ per-route):
711
+
712
+ | return value | meaning |
713
+ | --- | --- |
714
+ | a `Tina4::Response` | SHORT-CIRCUIT; that object IS the response, at ANY status |
715
+ | `[request, response]` | rebind both, continue |
716
+ | `false` | SHORT-CIRCUIT; send the response as set, 403 only if still default/empty |
717
+ | `nil` | continue |
718
+
719
+ The `Tina4::Response` row is the PRIMARY rule and is new: it is the only one
720
+ that can express a 302 redirect from middleware. The `status >= 400`
721
+ short-circuit is retained as a documented LEGACY COMPATIBILITY PATH so
722
+ middleware written before it keeps working.
723
+
724
+ Note for Ruby specifically: Ruby has implicit returns, so a hook whose last
725
+ expression is a chainable response call (`response.add_header(...)` returns
726
+ `self`) now short-circuits. End such hooks with `[request, response]` or `nil`.
727
+ Block-based handlers registered with `Tina4::Middleware.before(pattern) { }`
728
+ are deliberately NOT covered by the table - a block's value is its last
729
+ expression, so reading a returned Response as a refusal there would fire
730
+ constantly. They keep their "`false` halts" contract.
731
+
732
+ ### String-form route middleware (Python/PHP/Node parity)
733
+
734
+ `middleware: ["ResponseCache"]` and `middleware: ["ResponseCache:300"]` now
735
+ resolve, via `Tina4::Router.resolve_string_middleware`. Ruby was the only one
736
+ of the four frameworks without the mechanism - a String reached
737
+ `mw.call(request, response)` and raised `NoMethodError`. One instance is
738
+ memoised per spec so the cache can actually hit across requests (PHP does the
739
+ same; Python gets it by resolving at registration). An unknown name raises
740
+ `ArgumentError` naming the known set - never a silent skip, which for an auth
741
+ middleware would mean serving the route unprotected. The registry holds
742
+ `ResponseCache` only, matching PHP and Node; unifying it with Python's larger
743
+ name list is scheduled separately.
744
+ ### CORS denies by default, and never pairs the wildcard with credentials
745
+
746
+ **Breaking:** `TINA4_CORS_ORIGINS` defaulted to `*`, which allowed every origin
747
+ on a fresh install. It now defaults to UNSET, which denies every cross-origin
748
+ request: no `Access-Control-Allow-Origin` is sent, and the browser's own CORS
749
+ check blocks the request. Django, Rails and ASP.NET all require an explicit
750
+ policy before emitting any CORS header, and now so does Tina4.
751
+
752
+ **Migration:** name the origins your frontend runs on.
753
+
754
+ ```
755
+ TINA4_CORS_ORIGINS=https://app.example.com
756
+ ```
757
+
758
+ Comma-separate several. `TINA4_CORS_ORIGINS=*` restores the old allow-any
759
+ behaviour for anyone who wants it: only the DEFAULT changed, not the capability.
760
+ Non-browser clients (curl, server-to-server) never consult CORS and are
761
+ unaffected. The status code of a denied preflight is unchanged at 204.
762
+
763
+ Also in this change:
764
+
765
+ - `Access-Control-Allow-Origin: *` is never sent alongside
766
+ `Access-Control-Allow-Credentials: true`. The Fetch Standard's CORS check
767
+ treats `*` as a literal once the request carries credentials, so every browser
768
+ rejects the pair. When both are configured the wildcard wins, credentials are
769
+ dropped, and a warning names the fix.
770
+ - `Vary: Origin` is now sent whenever the allowed origin is computed from the
771
+ request's `Origin` header, including when the origin is REJECTED. Without it a
772
+ shared cache can store one origin's response and serve it to another
773
+ (RFC 9110 s12.5.5). It is not sent for a constant `*`, which does not vary.
774
+ - Every rejected cross-origin request logs an actionable warning naming the
775
+ origin, the environment variable, and the fix. Silence was the common thread
776
+ in every defect this audit found.
777
+
778
+ See ADR-0018.
779
+
780
+ ### Fixed: CORS headers now reach the actual response, not only the preflight
781
+
782
+ `Tina4::CorsMiddleware.apply_headers` was never called from the dispatch path.
783
+ Only the preflight was answered. A browser sent its preflight, got a 204 saying
784
+ yes, sent the real request, and received no `Access-Control-Allow-Origin`, so it
785
+ blocked the response. Cross-origin browser access did not work in ANY
786
+ configuration. `apply_cors` now runs as an `ALWAYS_STAGE`, so the headers also
787
+ survive a short-circuited 401 and the early-returning swagger and static paths.
788
+
789
+ Ruby was also the only framework of the four that emitted
790
+ `Access-Control-Allow-Origin: *` together with
791
+ `Access-Control-Allow-Credentials: true`. It no longer does.
792
+
793
+ `Tina4::CorsClassMiddleware` is now a thin adapter over `Tina4::CorsMiddleware`
794
+ instead of a second copy of the rules. The copy had already drifted three ways:
795
+ no wildcard guard, a `Referer` fallback (a `Referer` is a URL, not an origin),
796
+ and an allow-list miss that returned the FIRST allowed origin, stamping another
797
+ site's origin onto a rejected caller's response. The `Referer` fallback is gone.
798
+
799
+ ### CORS preflight responses now carry `Allow`
800
+
801
+ A CORS preflight (`OPTIONS` with an `Origin`) returned 204 with the
802
+ `Access-Control-*` headers but no `Allow`, while a bare `OPTIONS` to the same
803
+ path returned `Allow`. A preflight IS an OPTIONS response, so it now carries
804
+ `Allow` too, derived from the router's real method set (RFC 9110 s9.3.7).
805
+
806
+ This is conformance, not a deviation - see ADR-0013. The frameworks' own
807
+ OPTIONS handlers already emit `Allow` (Django's `View.options()`, Express's
808
+ router). The add-on CORS libraries omit it only because they short-circuit
809
+ ahead of the framework and skip its OPTIONS handler. Tina4 owns both paths in
810
+ one dispatcher.
811
+
812
+ `Allow` and `Access-Control-Allow-Methods` are NOT interchangeable: `Allow` is
813
+ what the RESOURCE supports, `Access-Control-Allow-Methods` is what the CORS
814
+ POLICY permits cross-origin (`TINA4_CORS_METHODS`, a static list as in every
815
+ mainstream library). A policy naming DELETE on a GET-only route is still a 405.
816
+
817
+ Non-breaking: one added response header on a 204; no existing header changes.
818
+
819
+
820
+ ### Breaking: global middleware now runs before the auth gate
821
+
822
+ Dispatch order is now identical in all four frameworks:
823
+
824
+ ```
825
+ pre-match globals -> match -> post-match globals -> auth gate -> route middleware -> handler
826
+ ```
827
+
828
+ Ruby (and Python) previously ran the auth gate FIRST, so a global middleware
829
+ never saw a rejected request. That made a global rate limiter unable to throttle
830
+ a brute-force login, and dropped every 401 from an access log. Node and PHP
831
+ already ran the globals first; every mainstream framework does the same (Django
832
+ ships `CsrfViewMiddleware` ahead of `AuthenticationMiddleware` and enforces auth
833
+ in a view decorator after all `MIDDLEWARE`; Laravel runs the `web` group before
834
+ the `auth` route middleware; ASP.NET puts `UseAuthorization` last before the
835
+ endpoint). See ADR-0012.
836
+
837
+ **Migration:** a global middleware (registered via `Tina4::Middleware.use` /
838
+ `Router.use`) now runs on requests that are about to be rejected, including
839
+ 401s. If yours assumes an authenticated request, check for it - `request.user`
840
+ is only populated after the gate. A middleware that must NOT see rejected
841
+ requests should be attached to the route instead of registered globally; route
842
+ middleware still runs after the gate.
843
+
844
+
15
845
  ### Changed
16
846
 
847
+ - **Breaking: the metrics payload is now the native engine's shape.** `full_analysis` no
848
+ longer returns a `violations` key. The ranked `offenders` list replaces it and
849
+ `--fail-on` reads that same list, so one concept has one name instead of two.
850
+ Verified before removal: zero consumers outside the tests.
851
+
852
+ - **Breaking: `file_detail` returns the engine's per-file shape.** It no longer returns
853
+ `total_lines`, `classes`, `imports` or `warnings`, and `functions` is now a COUNT rather
854
+ than a list. Anything reading those keys must move to the engine's fields, or call
855
+ `full_analysis` and read `most_complex_functions` for per-function detail.
856
+
857
+ - **Breaking: the empty-class warning is gone and is not coming back.** The old
858
+ hand-rolled analyzer flagged `class Foo {}` with no members. An empty class is usually
859
+ CORRECT rather than a defect: marker classes, base exception types, DTO placeholders.
860
+ Tina4 itself ships `MetricsEngineError` as exactly that, so the check flagged the
861
+ framework's own correct code. A check that fires on correct code is noise, and noise is
862
+ why the offenders list went unread for months. The engine's vocabulary stays the four
863
+ things that are actionable: complexity, large file, low maintainability, untested.
864
+
865
+ - **Breaking: the column-metadata primary-key flag is `primary_key`.** Ruby and Python use `primary_key`; PHP and Node use `primaryKey`. Each follows its own
866
+ language's paradigm because this is framework API surface, not data. A dead `:primary`
867
+ fallback that nothing ever set was deleted.
868
+
869
+ - **Breaking: metrics REQUIRE the `tina4` CLI on PATH, with no fallback.** All four
870
+ frameworks deleted their own hand-rolled analyzer, so `full_analysis`, `offenders` and
871
+ `file_detail` now shell out to `tina4 metrics --json` (ADR-0002: one engine, so a number
872
+ measured in one language is comparable with the same number measured in another). A
873
+ missing or stale CLI raises and names the install command instead of quietly returning
874
+ worse numbers; the dev-admin endpoints answer 503, or 404 for an unknown file path.
875
+ Previously a failure fell back to the local analyzer, which is exactly how four
876
+ frameworks came to disagree about the same file. The file census behind the dashboard
877
+ (`quick_metrics`) stays in-process and needs no CLI: it is a glob-and-count, and the
878
+ engine is 8x to 37x slower on that path.
879
+
880
+ - **Breaking: every ORM read path that takes a `limit:` now defaults to 100 rows, and
881
+ three of them were returning EVERY ROW.** `where`, `all` and `select` defaulted to
882
+ `limit: nil`, and `Database#fetch` skips `apply_limit` entirely when the limit is nil,
883
+ so all three read the whole table. `with_trashed` and a `scope`-generated method
884
+ defaulted to 20. All five now default to 100.
885
+
886
+ Migration: this one can change results in both directions. A caller relying (knowingly
887
+ or not) on an unbounded read must now ask for it: `Model.all(limit: 10_000)`. A caller
888
+ relying on the old 20 gets 100. Code that already passes a limit is unaffected.
889
+
890
+ `QueryBuilder#get` and `fetch_all` are deliberately UNCHANGED and stay uncapped.
891
+ Neither takes a `limit:`, so a cap there can only ever be silent, and that silent
892
+ `LIMIT 100` was the data-loss-on-read footgun removed in 3.13.39 (ruby#4). The rule: a
893
+ path that advertises `limit:` caps at 100, a path without one never caps.
894
+
895
+ - **Fixed** a `scope`-generated method accepted `limit:` and `offset:` and then discarded
896
+ both. It called `where(filter_sql, params)` without passing either, so
897
+ `User.active(limit: 5)` returned the whole table: 150 rows came back from a 5-row
898
+ request against a 150-row table. Both arguments now reach `where`.
899
+
17
900
  - Internal: the SQL dialect-translation file is renamed
18
901
  `lib/tina4/sql_translation.rb` -> `lib/tina4/sql_translator.rb`, so the filename matches the
19
902
  `Tina4::SQLTranslator` class it defines (and the sibling frameworks). The class name, its