support_desk 0.3.0 → 0.3.2

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d7e90a546ededcbf2f0f13d30a2929655e0d6bb343f11d114b0e539b8985bfb
4
- data.tar.gz: 502d37cedab338e5f42db97b021ed01dd8ee46844600fc31d3240e9a1a09597c
3
+ metadata.gz: 66ba1a32f86acca1b4068bbca4091fcb16c4e6840a9d57ae5dc44bea25572ea2
4
+ data.tar.gz: 2433e492636e42eeffde1652a2c7d21915267c6f0a9180bea8bb010d902cdfad
5
5
  SHA512:
6
- metadata.gz: fae5879d3023eed0f45c24e275bf63fdaf5ac2f8e982a7d6b9ea6c9db4afe12036025c6ef20a1280031456978908414849719fb9ea28a6d53d5dd47a3c6995aa
7
- data.tar.gz: 9e8e5df4a7e7f836c297a3509d4c2d23111a6f9365fdd34208e410db11e249aa0445c515fe19421efa4f883b43503cf65bc32efc207007ff46a55d9102b8a470
6
+ metadata.gz: ac1246120f4c2aa2763f81866006f054e0dfd14901d42120ddc25d754b08dad350b1627552faaa9e4fd62d37a50d785eeae9abe6dab1c5dc9adbb9f9bb44acd1
7
+ data.tar.gz: 66fca7c4ddad8c9b41358448ec24beda2a3dcd9bade8bd373fe7433a565700d500c212f863c1755048faa7dd803c79c1fa92d3fb0b029a072322d1eff6672994
data/CHANGELOG.md CHANGED
@@ -4,6 +4,135 @@ All notable changes to this project are documented here.
4
4
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
5
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.3.2] - Unreleased
8
+
9
+ ### Fixed
10
+
11
+ - Persist message-registration receipts atomically with clocks and turn revisions.
12
+ A new message with an earlier timestamp or smaller UUID is no longer dropped
13
+ as a replay. It invalidates the turn and triggers requester notifications and
14
+ handoff detection once, without rewinding clocks. This also fixes human-only
15
+ desks; no assistant configuration is needed to encounter the old defect.
16
+ - Recovery discovers unregistered replies on closed cases and honors the desk's
17
+ reopen/locked policy. Repair also runs when the assistant is disabled; only
18
+ actual assistant dispatch requires one to be configured.
19
+ - Public assistant assignment, release and reply refuse `turn: :current`.
20
+ Internal outreach and seat-taking use private paths and actual tokens.
21
+ - Default Chats signatures render assistant messages using per-message
22
+ disclosure snapshots, including old 0.3.0 messages with provenance but no
23
+ assistant-row snapshot. Rename, mode change and flag-off do not erase old
24
+ disclosure. Human replies and approved drafts keep human signatures, and host
25
+ signature overrides remain authoritative. Transcript names are snapshotted
26
+ for new messages; older ones use their stored display name.
27
+ - Correct the README signature override to preserve the human fallback.
28
+
29
+ ### Upgrade
30
+
31
+ Run `rails generate support_desk:upgrade` and migrate **with support writes paused
32
+ and all old web/jobs drained**. The new receipt table follows the existing ticket
33
+ and message ID types and seeds historical messages up to the old role clocks.
34
+ Old clocks cannot reveal lost callbacks behind that baseline: review suspect
35
+ historical cases before resuming. Only 0.3.2 processes may write after cutover.
36
+ See README for recovery, rollback limitations and the unchanged SQLite caveat.
37
+ No gem publication or deployment is performed by this change.
38
+
39
+ ## [0.3.1] - 2026-09-19
40
+
41
+ **Hardening the assistant, after an adversarial review of 0.3.0.** Nine
42
+ defects, every one of them in the concurrency, recovery and kill-switch
43
+ guarantees the release advertised rather than in what a green suite
44
+ covered. Nothing here changes what an assistant may do; it changes what
45
+ can happen to a case while she is doing it. `assign!` and `release!` take
46
+ a `turn:` when `by:` is an assistant — the one API change — and a host
47
+ with no assistant is untouched.
48
+
49
+ ### Fixed
50
+
51
+ - **A customer's next question can no longer be answered around (R1).**
52
+ Reconciliation is a `SELECT`, and a requester never takes the case's row
53
+ lock, so a question could commit between the answer reading the case and
54
+ the answer writing to it: she replied to the previous one, and a human
55
+ approved a proposal from a page that predated the new one — `seen_turn`
56
+ was not evidence that the reviewer had seen the last committed message.
57
+ Every speaking path now takes the ticket's row lock and then the
58
+ conversation row chats updates inside every message insert, before
59
+ reconciling. The lock order is ticket → conversation everywhere, and it
60
+ is written down. **The guarantee is a row lock, so it holds on PostgreSQL
61
+ and MySQL and not on SQLite**, which has no row locks and whose WAL
62
+ snapshot reads straight through an open write transaction: a desk with an
63
+ assistant belongs on PostgreSQL or MySQL, and `doctor` now warns when it
64
+ is not.
65
+ - **A seat is decided under the lock (R2).** `assign!` asked whether
66
+ anybody held the case, and whether she could hold it, *before* the
67
+ transition acquired the row. No threads were needed to break it: load an
68
+ unassigned case into one instance, give it to a person through another,
69
+ and the first instance took the seat from them. A topic cap or a pause
70
+ committed while a take waited for the lock was read too late in the same
71
+ way.
72
+ - **A lost registration is repairable (R3).** A requester message that
73
+ committed while the process that should have registered it died left a
74
+ turn nothing could ever consume: reconciliation happened inside the
75
+ candidate answer's savepoint, the bumped revision made that very answer
76
+ stale, and the `StaleTurn` rolled the repair back with it — for ever,
77
+ while the clocks said `awaiting_requester` so no idle query could see the
78
+ case. The fold is now its own committed step, and
79
+ `redispatch_assistant_turns!` looks for unregistered messages before it
80
+ looks at clocks.
81
+ - **Messages that share an instant no longer deadlock the turn (R4).** The
82
+ unseen-message test was "its id is not the one the clock was set from",
83
+ so every other message on that timestamp read as new: reconciliation
84
+ folded an already-registered one in again, bumped the revision, and made
85
+ the answer that discovered it stale. The retry did the same thing. "Seen"
86
+ is now the pair `(created_at, id)` compared in chats' own transcript
87
+ order, by the same rule in Ruby and in SQL.
88
+ - **The silent sweep rechecks before it escalates (R5).** Its predicates
89
+ were true when it SELECTED its candidates. A person who answered, took
90
+ the case or reset the clock in between lost anyway: the case was marked
91
+ human-required, its priority went up, and the customer was told a person
92
+ was coming — on a case that already had one.
93
+ - **Stranded seats come back (R6).** `deactivate!` promised the sweep would
94
+ release her cases; the sweep only ever visited configured assistants with
95
+ a `responds_within`, and only cases already overdue. An assistant with no
96
+ promise kept her seat for ever, and one removed by the documented kill
97
+ switch — which takes her configuration away — was not visited at all,
98
+ while the doctor's assistant checks returned early on exactly that state.
99
+ `SupportDesk.reclaim_assistant_seats!` (and `rake
100
+ support_desk:reclaim_assistant_seats`) reads the seats that exist.
101
+ - **Running out of turns tells the host (R7).** The budget branch wrote the
102
+ `escalated` event, released her seat and posted the public hand-off line,
103
+ but only the outer `escalate!` published `:ticket_escalated` — so a
104
+ host's "a person is needed here" notifier never heard about a
105
+ conversation that ran out of turns. One private method now publishes it
106
+ for every hand-off.
107
+ - **Disclosure on old messages stops moving (R8).** The requester-facing
108
+ signature resolved live configuration, so taking her out of the
109
+ initializer rewrote "— Rose · asistente virtual" into "— Rose" on
110
+ messages nobody had touched. Her name and mode are snapshotted onto her
111
+ own row and read back from it when nothing declares her any more. A
112
+ rename still renames history, deliberately; the README shows the
113
+ `Chats.config.message_signature` lambda for hosts that want each bubble
114
+ frozen.
115
+ - **The kill switch is read after the wait (R9).** Eligibility was checked
116
+ before the case's row lock, and the policy then read `active?` off that
117
+ same stale copy, so an already-seated assistant whose job queued behind a
118
+ busy case could still speak after `deactivate!` had committed. Every verb
119
+ resolves her under the lock now, from the row. The ordinary in-flight
120
+ window — a switch that commits after that read — is documented rather
121
+ than claimed away.
122
+
123
+ ### Changed
124
+
125
+ - `Ticket#assign!` and `Ticket#release!` take `turn:`. It is REQUIRED when
126
+ `by:` is an assistant (`ArgumentError` when omitted, `StaleTurn` when
127
+ stale) and ignored for a person, whose calls are unchanged.
128
+ - `SupportDesk.release_silent_assistants!` runs
129
+ `reclaim_assistant_seats!` first, and its count includes what that
130
+ reclaimed.
131
+ - The doctor's assistant invariants run whenever an assistant ROW exists,
132
+ not only while one is configured.
133
+ - `Ticket.with_unregistered_requester_messages` — the scope behind the
134
+ repair half of redispatch.
135
+
7
136
  ## [0.3.0] - 2026-09-19
8
137
 
9
138
  **A machine can answer, and a person still owns every word it sends.** An
data/README.md CHANGED
@@ -942,6 +942,13 @@ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
942
942
  also why this gem has no idempotency keys, no claim rows and no leases —
943
943
  "is this still the case you read?" is already answered.
944
944
 
945
+ Every verb of hers takes it, and that includes taking and giving back the
946
+ seat: `assign!(to: rose, by: rose, turn:)` and `release!(by: rose, turn:)`
947
+ require it when `by:` is an assistant (an omitted turn is an
948
+ `ArgumentError`, a stale one a `StaleTurn`), because a run that finished
949
+ after the case moved on must not release the seat a newer one took. A
950
+ **person's** calls are unchanged and take no turn.
951
+
945
952
  Two consequences worth knowing:
946
953
 
947
954
  - Check `ticket.assistant_turn == turn` in your job **before you spend
@@ -949,6 +956,34 @@ Two consequences worth knowing:
949
956
  - A retry after a committed action is a `StaleTurn`, and that is correct:
950
957
  the work was done. Generated jobs `discard_on` it.
951
958
 
959
+ #### Ticket, then conversation
960
+
961
+ A turn is only as good as the reconciliation behind it, and reconciliation
962
+ is a `SELECT`: on its own it cannot exclude a message that commits a
963
+ millisecond later. Your customer never takes the case's row lock — they
964
+ press send, and chats writes a message.
965
+
966
+ What chats *does* take is the **conversation row**: every message insert
967
+ updates `chats_conversations` inside its own transaction. So every path
968
+ that speaks takes the ticket's row lock and then that row, before
969
+ reconciling. A question already in flight holds it, so we wait for it and
970
+ the turn goes stale; a question that starts after we hold it waits for us
971
+ and raises a turn of its own. **The order is always ticket → conversation**,
972
+ in this gem and in anything you add to it.
973
+
974
+ What that does not cover: a message whose `INSERT` was already stamped when
975
+ we won the row is still stamped earlier than the answer, so a transcript can
976
+ show a question above an answer that did not address it. That is a
977
+ genuinely simultaneous send, and its own turn follows.
978
+
979
+ > [!IMPORTANT]
980
+ > **This is a row lock, so it is PostgreSQL and MySQL.** SQLite has none:
981
+ > it serializes writes, and in WAL mode a reconciliation `SELECT` reads the
982
+ > last committed snapshot straight through a requester's open write
983
+ > transaction, so a question committing behind an answer is still missed
984
+ > there. Run PostgreSQL or MySQL for a production desk with an assistant —
985
+ > `SupportDesk.doctor` warns when you haven't.
986
+
952
987
  ### Proposals in the console
953
988
 
954
989
  A pending proposal renders above the composer with its confidence, its
@@ -1020,6 +1055,32 @@ the turn and the whole policy that allowed it, `ticket.export` labels it
1020
1055
  marks it for staff. What a customer is told is a product decision; what your
1021
1056
  records say a machine wrote is not.
1022
1057
 
1058
+ **Historical disclosure is captured per message.** The default Chats signature
1059
+ renderer uses the assistant name and disclosure stored when the message was
1060
+ sent. Renaming, changing mode or removing configuration does not rewrite old
1061
+ signed bubbles. Human replies and human-approved drafts retain their normal
1062
+ human signatures. The assistant row snapshot is a fallback for legacy messages
1063
+ without provenance; their original disclosure cannot be reconstructed if it
1064
+ was never stored.
1065
+
1066
+ A host's `Chats.config.message_signature` override remains authoritative. If
1067
+ you customize it, preserve the human fallback as well as the assistant snapshot:
1068
+
1069
+ ```ruby
1070
+ Chats.configure do |config|
1071
+ config.message_signature = lambda do |message|
1072
+ stamp = message.metadata["support_desk"]
1073
+ name = if stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true &&
1074
+ stamp["display_name"].is_a?(String) && stamp["display_name"].present?
1075
+ stamp["display_name"]
1076
+ else
1077
+ Chats.display_name_for(message.author)
1078
+ end
1079
+ I18n.t("chats.message.signature", name: name)
1080
+ end
1081
+ end
1082
+ ```
1083
+
1023
1084
  ### Humans outrank
1024
1085
 
1025
1086
  - A person may answer a case she holds under **every** `reply_policy`.
@@ -1071,8 +1132,27 @@ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
1071
1132
  for cases nobody acted on, which is safe precisely because the turn is
1072
1133
  consumed by the first action and every later one is a `StaleTurn`.
1073
1134
 
1074
- `rake support_desk:assistant_status` reads and writes nothing, and is the
1075
- line to put in a deploy check. `SupportDesk.doctor` covers the same ground
1135
+ `release_silent_assistants` runs `SupportDesk.reclaim_assistant_seats!`
1136
+ first, and you can run that on its own
1137
+ (`rake support_desk:reclaim_assistant_seats`). It is a different question
1138
+ from silence: it reads the **seats that exist** rather than the assistants
1139
+ this process happens to have configured, and gives back every one whose
1140
+ holder is switched off, no longer declared, or no longer allowed to hold a
1141
+ case — with no `responds_within` and no overdue clock anywhere in it. That
1142
+ is what makes `deactivate!` and a flag flipped off actually release her
1143
+ cases, and it is why the doctor's seat checks keep running when the
1144
+ configuration is gone.
1145
+
1146
+ `redispatch_assistant_turns` also repairs before it decides: a requester
1147
+ message whose registration was lost after its commit leaves the clocks
1148
+ describing a case that no longer exists, so the task looks for unregistered
1149
+ **messages** and not only for idle clocks. The repair commits on its own,
1150
+ which is what makes a dead process followed by nothing but this task end in
1151
+ an actionable turn.
1152
+
1153
+ `rake support_desk:assistant_status` reports current counts without changing
1154
+ tickets. Resolving configured assistants may create their identity rows or
1155
+ refresh their name/disclosure snapshots. `SupportDesk.doctor` covers the same ground
1076
1156
  with verdicts.
1077
1157
 
1078
1158
  ### What the model sees
@@ -1166,7 +1246,8 @@ acts_as_support_agent kind: :ai # validated; see
1166
1246
  SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1167
1247
  SupportDesk.reset_assistants!
1168
1248
  SupportDesk.ai_actor?(record)
1169
- SupportDesk.release_silent_assistants! # → Integer
1249
+ SupportDesk.release_silent_assistants! # → Integer (runs reclaim_assistant_seats! first)
1250
+ SupportDesk.reclaim_assistant_seats! # → Integer; seats she may no longer sit in
1170
1251
  SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1171
1252
  ```
1172
1253
 
@@ -1176,7 +1257,7 @@ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1176
1257
  |---|---|
1177
1258
  | `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
1178
1259
  | `config` / `configured?` | her slice of the configuration; whether anything still declares her |
1179
- | `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration, with safe answers when it is gone |
1260
+ | `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration; `name` and `disclosure` fall back to the snapshot on her row, the rest to the safe answer |
1180
1261
  | `disclosed?` `signs?` `notice?` | the mode, as predicates |
1181
1262
  | `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
1182
1263
  | `on_duty?` `support_capacity` | the agent contract |
@@ -1201,6 +1282,8 @@ assistant" it was).
1201
1282
  respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
1202
1283
  draft!(body, by:, turn:, …) # → Draft
1203
1284
  escalate!(by:, reason:, summary:, turn:, request:)
1285
+ assign!(to:, by:, reason:, note:, request:, turn:) # turn: required when by: is an assistant
1286
+ release!(by:, reason: :released, request:, turn:) # idem
1204
1287
  request_human!(by:, request:) # by: must be the requester
1205
1288
  pause_assistant!(by:, reason:) / resume_assistant!(by:)
1206
1289
 
@@ -1213,6 +1296,7 @@ drafts pending_draft notes
1213
1296
  # scopes
1214
1297
  held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
1215
1298
  resolved_by_assistant with_pending_draft assistant_idle_since(time)
1299
+ with_unregistered_requester_messages
1216
1300
  ```
1217
1301
 
1218
1302
  Columns: `assistant_revision`, `last_requester_message_id`,
@@ -1288,12 +1372,13 @@ the count is non-zero.
1288
1372
  `support_human_door(ticket)` helper.
1289
1373
 
1290
1374
  **Rake** — `support_desk:release_silent_assistants` ·
1375
+ `support_desk:reclaim_assistant_seats` ·
1291
1376
  `support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
1292
1377
  `support_desk:assistant_status`.
1293
1378
 
1294
1379
  **Doctor** — `assistants (config)` · `assistant turn subscriber` ·
1295
1380
  `assistant authorship` · `assistant silence` · `assistant seats` ·
1296
- `assistant idle turns` · `drafts` · `ai agents without policy`.
1381
+ `assistant idle turns` · `drafts` · `ai agents without policy` · `message registrations`.
1297
1382
 
1298
1383
  **Test helpers**
1299
1384
 
@@ -1310,10 +1395,54 @@ the count is non-zero.
1310
1395
  | `with_assistant_config(key = nil, **overrides) { … }` | |
1311
1396
  | `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
1312
1397
 
1313
- ### Upgrading to 0.3
1398
+ ### Upgrading to 0.3.2 (including hosts without assistants)
1399
+
1400
+ 0.3.2 adds `support_desk_message_registrations`, an internal receipt per text
1401
+ message. A receipt, written with the ticket clocks and revision in one
1402
+ transaction, distinguishes a replay from an unseen message. Timestamp and UUID
1403
+ order are not evidence of delivery. Every unseen requester message invalidates
1404
+ the turn, notifies the host and runs handoff detection once, even if its timestamp
1405
+ is older; the SLA timestamps themselves never move backwards.
1406
+
1407
+ **This upgrade requires a drained cutover; it is not rolling-safe.** The table
1408
+ is additive, but old processes cannot write receipts.
1409
+
1410
+ 1. Pause support writes and drain old web requests and jobs. Keep the assistant
1411
+ disabled throughout the cutover.
1412
+ 2. Run `rails generate support_desk:upgrade`, inspect the new migration, then
1413
+ `rails db:migrate` while writes remain paused. Fresh installs get the same
1414
+ migration from `support_desk:install`.
1415
+ 3. The migration seeds existing text messages at or before each role's old
1416
+ clock as the historical baseline. Messages beyond those clocks remain
1417
+ discoverable by recovery. **Old data cannot prove whether a message behind
1418
+ the clock lost its callback.** Review suspect historical cases explicitly;
1419
+ the migration does not replay old notifications or reopen history en masse.
1420
+ 4. Start only 0.3.2 processes. Run `SupportDesk.doctor` and
1421
+ `SupportDesk.redispatch_assistant_turns!` before resuming support traffic.
1422
+ Recovery checks closed cases as well: `:reopen_on_reply` reopens them, while
1423
+ `:locked` preserves closure and dispatches no model work. Registration repair
1424
+ also runs on desks with no assistant configured.
1425
+ 5. Resume writes. An assistant rollout still requires its own staging checks
1426
+ and disclosure decision. The serialization guarantee still requires
1427
+ PostgreSQL/MySQL; SQLite does not acquire row locks.
1428
+
1429
+ Do not roll back to an old writer while serving traffic: it would leave missing
1430
+ receipts. If a rollback is necessary, pause and drain first, keep the receipt
1431
+ table, and re-establish the historical baseline before a later upgrade. Removing
1432
+ a receipt deliberately permits processing that message again; it is an internal
1433
+ recovery action, not a normal host API.
1434
+
1435
+ Public assistant `turn:` arguments must be the observed opaque token. Symbols
1436
+ such as `:current` are refused; private outreach and automatic seat-taking do
1437
+ not expose a public bypass. No new host-facing registration API is required.
1438
+
1439
+ ### Historical upgrade from 0.2 to 0.3.0
1440
+
1441
+ The following describes the original assistants migration. When installing the
1442
+ current release, also follow the 0.3.2 cutover above.
1314
1443
 
1315
1444
  ```bash
1316
- rails generate support_desk:upgrade # copies the additive assistants migration
1445
+ rails generate support_desk:upgrade # current releases also copy the receipt migration
1317
1446
  rails db:migrate
1318
1447
  ```
1319
1448
 
@@ -1464,10 +1593,13 @@ supportable), `find_requester` (callable, one argument), `engine mount`,
1464
1593
  (no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
1465
1594
  task), `awaiting` (agrees with the transcript), `references` (unique).
1466
1595
  Assistants (only where one is configured): `assistants (config)`,
1467
- `assistant turn subscriber`, `assistant authorship`, `assistant silence`,
1468
- `assistant seats`, `assistant idle turns`, `drafts`, and `ai agents without
1469
- policy` — which warns about any host class declared `kind: :ai`, since every
1470
- support write by it is refused.
1596
+ `assistant turn subscriber`, `assistant serialization` — which warns when
1597
+ the adapter takes no row locks, because the turn rests on them —
1598
+ `assistant authorship`, `assistant silence`, `assistant seats`, `assistant
1599
+ idle turns`, `drafts`, and `ai agents without policy`, which warns about any
1600
+ host class declared `kind: :ai`, since every support write by it is refused.
1601
+ The seat and draft checks also run once she is no longer configured, since
1602
+ that is exactly when a seat gets stranded.
1471
1603
 
1472
1604
  ## Compatibility
1473
1605
 
@@ -1536,6 +1668,7 @@ SupportDesk.desk(key = :default) # the Desk record, found or created, memo
1536
1668
  SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1537
1669
  SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
1538
1670
  SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
1671
+ SupportDesk.reclaim_assistant_seats! # seats an assistant may no longer sit in
1539
1672
  SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
1540
1673
  SupportDesk.find_topic("billing/invoice")
1541
1674
  SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
@@ -44,6 +44,12 @@ module SupportDesk
44
44
  File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
45
45
  end
46
46
 
47
+ def create_message_registrations_migration
48
+ migration_template "create_support_desk_message_registrations.rb.erb",
49
+ File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
50
+ end
51
+
52
+
47
53
  # The annotated initializer — every setting the gem has, with what it
48
54
  # means and what it defaults to.
49
55
  def create_initializer
@@ -90,9 +96,9 @@ module SupportDesk
90
96
  say " # routing(/^support@/i => :support_desk)"
91
97
 
92
98
  say "\n Already installed and bumping the version? 'rails g support_desk:upgrade' copies"
93
- say " only the migrations the new version needs (0.2.0: who opened the case), and"
94
- say " nothing you own. Follow the CHANGELOG's drained cutover: migrate, pause"
95
- say " support traffic, drain old web/workers, backfill, then serve only 0.2."
99
+ say " only the migrations the new version needs (including 0.3.2 receipts), and"
100
+ say " nothing you own. Follow the README drained cutover: pause support writes,"
101
+ say " drain old web/workers, migrate, then start only the new version."
96
102
 
97
103
  say "\n Assistants (0.3): when a machine should answer first, 'rails g support_desk:assistant"
98
104
  say " Rose --disclosure signature' writes the harness and prints the stanza. Read the"
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stop support writes and drain old web/jobs before migrating. Old processes
4
+ # cannot write receipts. Existing messages at/before the legacy clocks form
5
+ # the historical baseline; messages ahead of those clocks remain repairable.
6
+ # A pre-upgrade lost callback behind a clock cannot be inferred from old data:
7
+ # review historical suspect cases before resuming writes (see README).
8
+ class CreateSupportDeskMessageRegistrations < ActiveRecord::Migration<%= migration_version %>
9
+ def up
10
+ return if table_exists?(:support_desk_message_registrations)
11
+
12
+ create_table :support_desk_message_registrations, id: false do |t|
13
+ t.column :message_id, column_type(:chats_messages, "id"), null: false
14
+ t.column :ticket_id, column_type(:support_desk_tickets, "id"), null: false
15
+ end
16
+ add_index :support_desk_message_registrations, :message_id, unique: true, name: "index_sd_registrations_message"
17
+ add_index :support_desk_message_registrations, :ticket_id, name: "index_sd_registrations_ticket"
18
+ add_foreign_key :support_desk_message_registrations, :support_desk_tickets, column: :ticket_id, on_delete: :cascade
19
+ add_foreign_key :support_desk_message_registrations, :chats_messages, column: :message_id, on_delete: :cascade
20
+
21
+ execute <<~SQL
22
+ INSERT INTO support_desk_message_registrations (message_id, ticket_id)
23
+ SELECT m.id, t.id FROM chats_messages m
24
+ JOIN support_desk_tickets t ON t.conversation_id = m.conversation_id
25
+ WHERE m.kind = 'text' AND (
26
+ (m.sender_type = t.requester_type AND m.sender_id = t.requester_id
27
+ AND m.created_at <= t.last_requester_message_at)
28
+ OR (m.sender_type = 'SupportDesk::Desk' AND m.sender_id = t.desk_id
29
+ AND m.created_at <= t.last_agent_message_at))
30
+ SQL
31
+ end
32
+
33
+ def down
34
+ drop_table :support_desk_message_registrations, if_exists: true
35
+ end
36
+
37
+ private
38
+
39
+ def column_type(table, name)
40
+ connection.columns(table).find { |column| column.name == name }.sql_type
41
+ end
42
+ end
@@ -9,16 +9,14 @@ module SupportDesk
9
9
  # bump needs into an EXISTING install. Nothing else: the initializer, the
10
10
  # views, the console and the routes you already own stay untouched.
11
11
  #
12
- # Currently writes the 0.2.0 migration (who opened the case), which is the
13
- # SAME template a fresh install runs — one file owns those columns, so a
14
- # fresh install and an upgraded one end up with the same schema and the
15
- # same rollback. Running this twice writes nothing the second time: the
16
- # migration already sitting in db/migrate is identical, and Rails skips it.
12
+ # Install and upgrade use the same templates for opened_by, assistants,
13
+ # and message receipts. See the README for the drained receipt cutover;
14
+ # old writers do not know how to record message identities.
17
15
  class UpgradeGenerator < Rails::Generators::Base
18
16
  include ActiveRecord::Generators::Migration
19
17
 
20
18
  source_root File.expand_path("templates", __dir__)
21
- desc "Add the migrations a support_desk version bump needs (0.2.0: opened_by, 0.3.0: assistants)"
19
+ desc "Add the migrations a support_desk version bump needs (opened_by, assistants, message registration receipts)"
22
20
 
23
21
  # Rails' migration numbering, borrowed from ActiveRecord's generators.
24
22
  def self.next_migration_number(dir)
@@ -35,6 +33,12 @@ module SupportDesk
35
33
  File.join(db_migrate_path, "add_assistants_to_support_desk.rb")
36
34
  end
37
35
 
36
+ def create_message_registrations_migration
37
+ migration_template "create_support_desk_message_registrations.rb.erb",
38
+ File.join(db_migrate_path, "create_support_desk_message_registrations.rb")
39
+ end
40
+
41
+
38
42
  def display_post_upgrade_message
39
43
  say "\n🎫 support_desk upgrade migrations copied.", :green
40
44
  say "\n 1. Run 'rails db:migrate'. It adds `opened_by` and points every existing"
@@ -53,7 +57,11 @@ module SupportDesk
53
57
  say " rails g support_desk:assistant Rose --disclosure signature"
54
58
  say " writes the harness and prints the stanza, the subscription and the two"
55
59
  say " scheduled tasks. It never edits your initializer."
56
- say " 5. See the CHANGELOG for the full list.\n", :green
60
+ say " 5. 0.3.2 receipts require a drained cutover: pause support writes and stop"
61
+ say " old web/jobs, migrate, then start only the new version. Review historical"
62
+ say " suspect callbacks before resuming; old clocks cannot prove delivery."
63
+ say " Do not roll code back to a writer that cannot record receipts."
64
+ say " 6. See the CHANGELOG for the full list.\n", :green
57
65
  end
58
66
 
59
67
  private
@@ -162,6 +162,14 @@ module SupportDesk
162
162
  ok_with("#{SupportDesk.config.assistants.size} assistant(s) configured")
163
163
  end
164
164
 
165
+ checks << check("assistant serialization") do
166
+ next ok_with("no assistant on any desk") if assistant_desks.empty?
167
+ next warn_with("the turn cannot exclude a requester message committing behind an answer on " \
168
+ "SQLite — use PostgreSQL or MySQL") if sqlite?
169
+
170
+ ok_with("#{ActiveRecord::Base.connection.adapter_name} takes the row locks the turn rests on")
171
+ end
172
+
165
173
  checks << check("assistant turn subscriber") do
166
174
  next ok_with("no assistant on any desk") if assistant_desks.empty?
167
175
  next warn_with("nothing subscribes to :assistant_turn — no harness will ever answer. See the " \
@@ -245,6 +253,12 @@ module SupportDesk
245
253
  tables?
246
254
 
247
255
  checks = [
256
+ check("message registrations") do
257
+ next fail_with("run rails g support_desk:upgrade and rails db:migrate before serving support writes") unless
258
+ MessageRegistration.table_exists?
259
+
260
+ ok_with("message registration receipts available")
261
+ end,
248
262
  check("conversations") do
249
263
  orphans = Ticket.where(conversation_id: nil).count
250
264
  next fail_with("#{orphans} ticket(s) without a conversation") if orphans.positive?
@@ -317,10 +331,13 @@ module SupportDesk
317
331
  # and never of the policy's own verdict. A policy cannot page anybody
318
332
  # about its own bug.
319
333
  def assistant_invariant_checks
320
- # Nothing about a feature nobody turned on: a host with no assistant
321
- # configured runs not one extra query (I1).
322
- return [] if SupportDesk.config.assistants.empty?
323
334
  return [] unless assistants_migrated?
335
+ # Nothing about a feature nobody turned on (I1) — but "turned on" is
336
+ # not "configured right now". A host that removed her configuration
337
+ # still has the seats she is sitting on and the proposals she wrote,
338
+ # and the checks about THOSE are the ones that matter most on the way
339
+ # down (R6).
340
+ return [] if SupportDesk.config.assistants.empty? && !SupportDesk::Assistant.exists?
324
341
 
325
342
  checks = []
326
343
 
@@ -376,6 +393,16 @@ module SupportDesk
376
393
  checks
377
394
  end
378
395
 
396
+ # Whether this app is on SQLite, which has no row locks: it serializes
397
+ # writes, and a WAL snapshot reads straight through an open write
398
+ # transaction. Everything the turn rests on — the ticket's row lock and
399
+ # the conversation's — buys nothing there, because nothing waits on it.
400
+ def sqlite?
401
+ ActiveRecord::Base.connection.adapter_name.match?(/sqlite/i)
402
+ rescue StandardError
403
+ false
404
+ end
405
+
379
406
  # The desks that actually have an assistant, as Desk records.
380
407
  def assistant_desks
381
408
  SupportDesk.config.desks.each_key.filter_map do |key|
@@ -33,7 +33,7 @@ module SupportDesk
33
33
  SUPPORT_DESK_LIB = File.expand_path("support_desk", LIB_ROOT)
34
34
 
35
35
  ZEITWERK_IGNORED = %w[
36
- version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
36
+ message_signatures.rb version.rb errors.rb events.rb topic.rb topic_tree.rb configuration.rb current.rb macros.rb engine.rb
37
37
  console.rb console_routes.rb console_engine.rb
38
38
  ].freeze
39
39
 
@@ -111,6 +111,11 @@ module SupportDesk
111
111
  SupportDesk.subscribe_to_chats!
112
112
  end
113
113
 
114
+ initializer "support_desk.message_signatures" do
115
+ require_relative "message_signatures"
116
+ Chats.singleton_class.prepend(SupportDesk::MessageSignatures)
117
+ end
118
+
114
119
  # The checks that need the host's own classes loaded — the requester
115
120
  # class, and every `about:` class named in a topic tree. In to_prepare
116
121
  # (not an initializer) so they re-run after every code reload, which is
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SupportDesk
4
+ # The default Chats renderer uses the name/disclosure captured when an
5
+ # assistant spoke. Host signature overrides remain authoritative. Human
6
+ # replies (including approved drafts) still use Chats' normal signature.
7
+ module MessageSignatures
8
+ def message_signature_for(message)
9
+ return super if config.message_signature || !message&.signed?
10
+
11
+ stamp = message.metadata.is_a?(Hash) && message.metadata["support_desk"]
12
+ return super unless stamp.is_a?(Hash) && stamp["kind"] == "ai" && stamp["signed"] == true
13
+
14
+ name = stamp["display_name"]
15
+ return super unless name.is_a?(String) && name.present?
16
+
17
+ I18n.t("chats.message.signature", name: name)
18
+ end
19
+ end
20
+ end