command_tower 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/app/deserializers/command_tower/deserializers/messaging/preferences/update_deserializer.rb +10 -1
  3. data/app/errors/command_tower/errors/account/push_test_destination_error.rb +21 -0
  4. data/app/models/command_tower/messaging/inbox_item.rb +6 -0
  5. data/app/serializers/command_tower/serializers/messaging/inbox.rb +1 -1
  6. data/app/serializers/command_tower/serializers/messaging/preferences/notification_serializer.rb +2 -0
  7. data/app/services/command_tower/messaging/execution/adapter_result.rb +11 -1
  8. data/app/services/command_tower/messaging/execution/adapters/expo/adapter.rb +146 -25
  9. data/app/services/command_tower/messaging/execution/adapters/expo/configuration.rb +5 -0
  10. data/app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb +86 -0
  11. data/app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb +41 -0
  12. data/app/services/command_tower/messaging/execution/attempt_ledger.rb +3 -0
  13. data/app/services/command_tower/messaging/execution/expo_receipt_collection.rb +224 -0
  14. data/app/services/command_tower/messaging/execution/operation_logger.rb +55 -5
  15. data/app/services/command_tower/messaging/execution/recovery.rb +8 -0
  16. data/app/services/command_tower/messaging/inbox/reader.rb +15 -2
  17. data/app/services/command_tower/messaging/inbox/unread_count_result.rb +3 -2
  18. data/app/services/command_tower/messaging/notification_types/declaration.rb +19 -1
  19. data/app/services/command_tower/messaging/notification_types/declaration_validator.rb +62 -0
  20. data/app/services/command_tower/messaging/notification_types/permitted_choice.rb +55 -0
  21. data/app/services/command_tower/messaging/notification_types/registry.rb +25 -0
  22. data/app/services/command_tower/messaging/notification_types.rb +3 -0
  23. data/app/services/command_tower/messaging/preferences/catalog.rb +7 -0
  24. data/app/services/command_tower/messaging/preferences/catalog_notification_result.rb +26 -1
  25. data/app/services/command_tower/messaging/preferences/effective_choice.rb +92 -0
  26. data/app/services/command_tower/messaging/preferences/preference_state.rb +24 -4
  27. data/app/services/command_tower/messaging/preferences/store.rb +10 -0
  28. data/app/services/command_tower/messaging/preferences/update.rb +36 -4
  29. data/app/services/command_tower/messaging/rendering/channel_renderer.rb +63 -3
  30. data/app/services/command_tower/messaging/rendering/inbox_document.rb +27 -0
  31. data/app/services/command_tower/messaging/rendering/push_presentation.rb +90 -0
  32. data/app/services/command_tower/messaging/rendering/rendered_push_payload.rb +54 -6
  33. data/app/services/command_tower/messaging/sound_catalog.rb +85 -0
  34. data/app/services/command_tower/services/account/push/send_self_test.rb +7 -1
  35. data/app/services/command_tower/services/messaging/communications/produce.rb +27 -0
  36. data/app/services/command_tower/services/messaging/inbox.rb +5 -1
  37. data/app/workflows/command_tower/workflows/me/error_mapping.rb +1 -0
  38. data/app/workflows/command_tower/workflows/messaging/execution/deliver_workflow.rb +5 -1
  39. data/db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb +7 -0
  40. data/db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb +8 -0
  41. data/docs/upgrades/0.19.0.md +40 -0
  42. data/docs/upgrades/README.md +1 -0
  43. data/lib/command_tower/configuration/messaging/config.rb +10 -0
  44. data/lib/command_tower/configuration/messaging/expo.rb +61 -0
  45. data/lib/command_tower/install/baseline.rb +2 -0
  46. data/lib/command_tower/version.rb +1 -1
  47. metadata +13 -2
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CommandTower
4
+ module Messaging
5
+ # Registered push sounds. The engine owns System Default, a logical id that needs no
6
+ # bundled native asset. Hosts append custom entries through
7
+ # `config.messaging.sounds` (`{ id:, label:, asset: }`); `asset` is the native file
8
+ # reference. Whether the file is physically bundled is verified later (IOS), not here.
9
+ module SoundCatalog
10
+ module_function
11
+
12
+ ID_FORMAT = /\A[a-z][a-z0-9_]*\z/
13
+ # iOS plays a custom push sound only for a bundled file name with one of these extensions.
14
+ ASSET_EXTENSIONS = %w[.wav .aiff .caf].freeze
15
+ SYSTEM_DEFAULT = { id: "default", label: "System Default", asset: nil }.freeze
16
+
17
+ def entries
18
+ [SYSTEM_DEFAULT] + host_entries.grep(Hash).map { |entry| entry.transform_keys(&:to_sym).freeze }
19
+ end
20
+
21
+ def ids
22
+ entries.pluck(:id)
23
+ end
24
+
25
+ def fetch(id)
26
+ entries.find { |entry| entry[:id] == id.to_s }
27
+ end
28
+
29
+ # Messages for an invalid host catalog; empty when valid. Registry seal raises on them.
30
+ def validation_errors
31
+ errors = []
32
+ seen = [SYSTEM_DEFAULT[:id]]
33
+
34
+ host_entries.each_with_index do |entry, index|
35
+ label = "sounds[#{index}]"
36
+ unless entry.is_a?(Hash)
37
+ errors << "#{label} must be a hash"
38
+ next
39
+ end
40
+
41
+ entry = entry.transform_keys(&:to_sym)
42
+ errors.concat(entry_errors(entry, label:, seen:))
43
+ seen << entry[:id]
44
+ end
45
+
46
+ errors
47
+ end
48
+
49
+ def host_entries
50
+ Array(CommandTower.config.messaging.sounds)
51
+ end
52
+ private_class_method :host_entries
53
+
54
+ def entry_errors(entry, label:, seen:)
55
+ id = entry[:id]
56
+ errors = []
57
+
58
+ if !id.is_a?(String) || !id.match?(ID_FORMAT)
59
+ errors << "#{label} id must match #{ID_FORMAT.inspect}"
60
+ elsif seen.include?(id)
61
+ reason = id == SYSTEM_DEFAULT[:id] ? "reserved for System Default" : "duplicated"
62
+ errors << "#{label} id #{id.inspect} is #{reason}"
63
+ end
64
+
65
+ errors << "#{label} label must be present" unless present_string?(entry[:label])
66
+ errors.concat(asset_errors(entry[:asset], label:))
67
+ errors
68
+ end
69
+ private_class_method :entry_errors
70
+
71
+ def asset_errors(asset, label:)
72
+ return ["#{label} asset must be present"] unless present_string?(asset)
73
+ return [] if ASSET_EXTENSIONS.include?(File.extname(asset).downcase)
74
+
75
+ ["#{label} asset must end in #{ASSET_EXTENSIONS.join(', ')}"]
76
+ end
77
+ private_class_method :asset_errors
78
+
79
+ def present_string?(value)
80
+ value.is_a?(String) && !value.strip.empty?
81
+ end
82
+ private_class_method :present_string?
83
+ end
84
+ end
85
+ end
@@ -31,13 +31,19 @@ module CommandTower
31
31
  return
32
32
  end
33
33
 
34
+ deep_link = CommandTower.config.messaging.expo.resolve_self_test_deep_link
35
+ if deep_link.nil?
36
+ context.fail!(application_error: CommandTower::Errors::Account::PushTestDestinationError.new)
37
+ return
38
+ end
39
+
34
40
  produce_result = CommandTower::Services::Messaging::Communications::Produce.call(
35
41
  user:,
36
42
  notification_type_key: NOTIFICATION_TYPE_KEY,
37
43
  host_event_identity: "push_delivery_test/#{user.id}/#{SecureRandom.uuid}",
38
44
  title: TITLE,
39
45
  body: BODY,
40
- metadata: {},
46
+ metadata: { "deep_link" => deep_link },
41
47
  platform_enabled_channels: CommandTower::Services::Messaging::Preferences::PlatformEnabledChannels.call,
42
48
  )
43
49
  unless produce_result.success?
@@ -25,6 +25,15 @@ module CommandTower
25
25
  return
26
26
  end
27
27
 
28
+ if missing_required_deep_link?
29
+ context.fail!(
30
+ application_error: CommandTower::Errors::ValidationError.new(
31
+ details: { messaging: "deep_link is required" },
32
+ ),
33
+ )
34
+ return
35
+ end
36
+
28
37
  accept_result = CommandTower::Messaging.accept(
29
38
  recipient_id: recipient_result.data[:recipient_id],
30
39
  notification_type_key:,
@@ -65,6 +74,24 @@ module CommandTower
65
74
  rescue CommandTower::Messaging::Accept::Error
66
75
  context.fail!(application_error: CommandTower::Errors::InternalError.new)
67
76
  end
77
+
78
+ private
79
+
80
+ def missing_required_deep_link?
81
+ return false unless CommandTower::Messaging::NotificationTypes.registered?(notification_type_key)
82
+
83
+ declaration = CommandTower::Messaging::NotificationTypes.lookup(notification_type_key)
84
+ return false unless declaration.deep_link_required
85
+
86
+ !metadata_deep_link_present?
87
+ end
88
+
89
+ def metadata_deep_link_present?
90
+ return false unless metadata.is_a?(Hash)
91
+
92
+ value = metadata["deep_link"] || metadata[:deep_link]
93
+ value.is_a?(String) && !value.strip.empty?
94
+ end
68
95
  end
69
96
  end
70
97
  end
@@ -116,7 +116,11 @@ module CommandTower
116
116
  validate :user, is_a: User, required: true
117
117
 
118
118
  def call
119
- with_recipient { |recipient_id| context.count = CommandTower::Messaging::Inbox.unread_count(recipient_id:).count }
119
+ with_recipient do |recipient_id|
120
+ result = CommandTower::Messaging::Inbox.unread_count(recipient_id:)
121
+ context.count = result.count
122
+ context.badge_count = result.badge_count
123
+ end
120
124
  end
121
125
  end
122
126
 
@@ -26,6 +26,7 @@ module CommandTower
26
26
  CommandTower::Errors::Account::PushoverVerificationFailedError,
27
27
  CommandTower::Errors::Account::PushEndpointNotFoundError,
28
28
  CommandTower::Errors::Account::PushTestNoEndpointError,
29
+ CommandTower::Errors::Account::PushTestDestinationError,
29
30
  CommandTower::Errors::ValidationError
30
31
  :unprocessable_entity
31
32
  when CommandTower::Errors::Account::PhoneVerificationThrottledError,
@@ -154,7 +154,7 @@ module CommandTower
154
154
 
155
155
  def load_delivery(channel_delivery_id)
156
156
  CommandTower::Messaging::ChannelDelivery.includes(
157
- communication: %i[user destination_plan],
157
+ communication: %i[user destination_plan inbox_item],
158
158
  ).find_by(id: channel_delivery_id)
159
159
  end
160
160
 
@@ -172,6 +172,7 @@ module CommandTower
172
172
  communication:,
173
173
  channel_key: delivery.channel_key,
174
174
  recipient_address:,
175
+ channel_delivery: delivery,
175
176
  )
176
177
 
177
178
  CommandTower::Messaging::Execution::OperationLogger.render_succeeded(
@@ -204,18 +205,21 @@ module CommandTower
204
205
  CommandTower::Messaging::Execution::OperationLogger.adapter_accepted(
205
206
  channel_delivery: delivery,
206
207
  delivery_attempt: attempt,
208
+ provider_metadata: result.provider_metadata,
207
209
  )
208
210
  elsif result.retryable_failure?
209
211
  CommandTower::Messaging::Execution::OperationLogger.adapter_retryable(
210
212
  channel_delivery: delivery,
211
213
  delivery_attempt: attempt,
212
214
  error_code: result.error_code,
215
+ provider_metadata: result.provider_metadata,
213
216
  )
214
217
  else
215
218
  CommandTower::Messaging::Execution::OperationLogger.adapter_terminal(
216
219
  channel_delivery: delivery,
217
220
  delivery_attempt: attempt,
218
221
  error_code: result.error_code,
222
+ provider_metadata: result.provider_metadata,
219
223
  )
220
224
  end
221
225
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddProviderMetadataToMessagingDeliveryAttempts < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_column :messaging_delivery_attempts, :provider_metadata, :json
6
+ end
7
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ class AddFinishedAtIndexToMessagingDeliveryAttempts < ActiveRecord::Migration[7.2]
4
+ def change
5
+ add_index :messaging_delivery_attempts, :finished_at,
6
+ name: "index_messaging_delivery_attempts_on_finished_at"
7
+ end
8
+ end
@@ -0,0 +1,40 @@
1
+ # Upgrade: CommandTower 0.19.0
2
+
3
+ **From:** `0.18.0`
4
+ **To:** `0.19.0`
5
+
6
+ Minor release: how a push presents itself, and Expo ticket and receipt facts. Notification types can declare badge eligibility, urgency, sound, and a required deep link. Preferences can store urgency and sound. Expo send records provider metadata, and the existing recovery job collects receipts afterward. Two new migrations.
7
+
8
+ ## Host-visible changes
9
+
10
+ | Change | Host impact |
11
+ |--------|-------------|
12
+ | Notification type declaration | Optional `badge_eligible` (default false), `urgency`, `sound`, and `deep_link_required` (default false). Omitted urgency and sound stay fixed at `normal` and `default`. |
13
+ | `config.messaging.sounds` | Host entries `{ id:, label:, asset: }` appended after System Default (`id: "default"`, no asset). Asset names end in `.wav`, `.aiff`, or `.caf`. Invalid catalogs fail registry seal. |
14
+ | `config.messaging.expo.home_deep_link` | String or zero-argument Proc. Required when a push-capable type is registered and the Expo adapter is not `disabled`. Used when a push has no metadata `deep_link` and no live inbox item. |
15
+ | `config.messaging.expo.self_test_deep_link` | String or zero-argument Proc. Resolved when `POST /me/push/test` runs. Blank fails the test before Produce. |
16
+ | `config.messaging.expo.interruption_levels` | Subset of `normal`, `time_sensitive`, `critical`. Default is `["normal"]`. A declared urgency above this set degrades `critical` → `time_sensitive` → `normal`. |
17
+ | Produce `metadata.deep_link` | Required when the type sets `deep_link_required` and the value is missing. Push-capable types that are not inbox-backed should set that flag. |
18
+ | Preferences | `GET` catalog entries include `urgency` and `sound`. `PATCH` accepts `preferences.urgency` and `preferences.sound` alongside inbox and channels. |
19
+ | Inbox unread | `Messaging::Inbox.unread_count` returns `count` and `badge_count`. `badge_count` is unread items whose type is badge-eligible. Expo `badge` is sent only for those types, including `0`. |
20
+ | Push thread | A communication with `conversation_key` sends that key as the push thread id. |
21
+ | Delivery attempts | Nullable `provider_metadata` JSON stores Expo ticket and receipt facts. `finished_at` is indexed for receipt collection. |
22
+ | Recovery | After the existing redrive, receipt collection runs. A collection error is logged and does not fail the redrive. |
23
+
24
+ ## Host actions
25
+
26
+ 1. Bump gem to `0.19.0` (or `>= 0.19.0`).
27
+ 2. Install and migrate:
28
+ - `bundle exec rails command_tower:install:migrations`
29
+ - `bundle exec rails db:migrate`
30
+ - New files: `20261012000001_add_provider_metadata_to_messaging_delivery_attempts`, `20261012000002_add_finished_at_index_to_messaging_delivery_attempts`.
31
+ 3. If Expo is not `disabled` and any type allows push, set `config.messaging.expo.home_deep_link`. Registry seal raises without it.
32
+ 4. Set `self_test_deep_link` before members use push self-test.
33
+ 5. Add `time_sensitive` to `interruption_levels` only after the shipped iOS binary has the Time Sensitive capability. Leave `critical` off unless Apple granted Critical Alerts.
34
+ 6. Existing declarations that omit the new fields keep today's behavior: no badge, fixed normal urgency, System Default sound, deep link not required.
35
+
36
+ ## Not in this release
37
+
38
+ - No host notification types. Hosts still register their own keys.
39
+ - No bundled custom sound files. The catalog stores the asset name. The app binary supplies the file.
40
+ - No backfill of `provider_metadata`. Existing attempts stay null.
@@ -4,6 +4,7 @@ Host-facing upgrade / change summaries for CommandTower releases.
4
4
 
5
5
  | Version | Summary |
6
6
  |---------|---------|
7
+ | [0.19.0](0.19.0.md) | Push presentation: badge, urgency, sound, deep links; Expo ticket and receipt metadata; preference urgency and sound |
7
8
  | [0.18.0](0.18.0.md) | Inbox conversations: optional Produce identity, grouped list entries, `GET /me/inbox/conversation` window of at most 5, accept-time presentation snapshot |
8
9
  | [0.17.0](0.17.0.md) | Client-version-compatibility gating (`observe`/`enforce`); Rich Messaging Inbox `content` documents; host-overridable rendering templates; shared email theming |
9
10
  | [0.16.0](0.16.0.md) | Me experience-states (`GET`/`POST complete`); `host_key`; `user_experience_states` migration; `me_experience_states` RBAC |
@@ -46,6 +46,16 @@ module CommandTower
46
46
  default: ->(_selection) { [] },
47
47
  default_shown: "->(_selection) { [] }"
48
48
 
49
+ add_composer :sounds,
50
+ desc: "Host-registered push sounds appended to the engine's System Default " \
51
+ "(id \"default\", no bundled asset). Array of { id:, label:, asset: } hashes; " \
52
+ "`asset` is the native file reference. Ids are lowercase snake_case and unique. " \
53
+ "Physical asset availability is verified by the iOS app, not here. " \
54
+ "Validated at notification-type registry seal.",
55
+ allowed: Array,
56
+ default: [],
57
+ default_shown: "[]"
58
+
49
59
  add_composer :sms,
50
60
  desc: "Messaging notification SMS delivery configuration (not Identity OTP)",
51
61
  allowed: Sms,
@@ -9,6 +9,9 @@ module CommandTower
9
9
  include ClassComposer::Generator
10
10
 
11
11
  ADAPTERS = %w[disabled fake log http].freeze
12
+ # Mirrors Messaging::NotificationTypes::URGENCY_LEVELS. Kept literal because this file
13
+ # loads before the app's autoloaded constants; a spec keeps the two in step.
14
+ INTERRUPTION_LEVELS = %w[normal time_sensitive critical].freeze
12
15
 
13
16
  add_composer :adapter,
14
17
  desc: "Expo Push delivery: disabled | fake | log | http. Default disabled (fail closed).",
@@ -45,6 +48,64 @@ module CommandTower
45
48
  "(default 3600 = one hour).",
46
49
  allowed: Integer,
47
50
  default: 3600
51
+
52
+ add_composer :home_deep_link,
53
+ desc: "Host Home deep link used when a push has no metadata deep_link and no live inbox item. " \
54
+ "String or zero-argument Proc. Seal checks presence and does not call a Proc. " \
55
+ "Required when a push-capable type is registered and the Expo adapter is not disabled.",
56
+ allowed: [String, Proc, NilClass],
57
+ default: nil
58
+
59
+ add_composer :self_test_deep_link,
60
+ desc: "Deep link for the account push self-test. String or zero-argument Proc. " \
61
+ "Resolved only when SendSelfTest runs. Blank fails the test before Produce.",
62
+ allowed: [String, Proc, NilClass],
63
+ default: nil
64
+
65
+ add_composer :interruption_levels,
66
+ desc: "Urgency levels this host's shipped iOS binary can honor, as Expo `interruptionLevel`. " \
67
+ "Subset of normal, time_sensitive, critical. `normal` is always supported. A declared " \
68
+ "urgency above this set degrades critical -> time_sensitive -> normal. Add `time_sensitive` " \
69
+ "only after the binary carries the Time Sensitive capability, and `critical` only with " \
70
+ "Apple's Critical Alerts entitlement. Default normal only.",
71
+ allowed: Array,
72
+ default: ["normal"],
73
+ default_shown: '["normal"]',
74
+ validator: ->(val) { val.all? { |level| INTERRUPTION_LEVELS.include?(level) } },
75
+ invalid_message: ->(val) { "Provided #{val.inspect}. Allowed levels: #{INTERRUPTION_LEVELS.join(', ')}" }
76
+
77
+ def home_deep_link_configured?
78
+ link_configured?(home_deep_link)
79
+ end
80
+
81
+ def resolve_home_deep_link
82
+ resolve_link(home_deep_link)
83
+ end
84
+
85
+ def self_test_deep_link_configured?
86
+ link_configured?(self_test_deep_link)
87
+ end
88
+
89
+ def resolve_self_test_deep_link
90
+ resolve_link(self_test_deep_link)
91
+ end
92
+
93
+ private
94
+
95
+ def link_configured?(value)
96
+ return true if value.respond_to?(:call)
97
+ return false unless value.is_a?(String)
98
+
99
+ !value.strip.empty?
100
+ end
101
+
102
+ def resolve_link(value)
103
+ resolved = value.respond_to?(:call) ? value.call : value
104
+ return nil unless resolved.is_a?(String)
105
+
106
+ stripped = resolved.strip
107
+ stripped.empty? ? nil : stripped
108
+ end
48
109
  end
49
110
  end
50
111
  end
@@ -18,6 +18,8 @@ module CommandTower
18
18
  20260906180000_create_user_experience_states.rb
19
19
  20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb
20
20
  20261010000002_add_conversation_identity_to_messaging_communications.rb
21
+ 20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb
22
+ 20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb
21
23
  ].freeze
22
24
 
23
25
  module_function
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module CommandTower
4
- VERSION = "0.18.0"
4
+ VERSION = "0.19.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: command_tower
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.18.0
4
+ version: 0.19.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - matt-taylor
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-11 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: phonelib
@@ -275,6 +275,7 @@ files:
275
275
  - app/errors/command_tower/errors/account/phone_verification_throttled_error.rb
276
276
  - app/errors/command_tower/errors/account/push_capability_unavailable_error.rb
277
277
  - app/errors/command_tower/errors/account/push_endpoint_not_found_error.rb
278
+ - app/errors/command_tower/errors/account/push_test_destination_error.rb
278
279
  - app/errors/command_tower/errors/account/push_test_no_endpoint_error.rb
279
280
  - app/errors/command_tower/errors/account/push_test_rate_limit_error.rb
280
281
  - app/errors/command_tower/errors/account/pushover_already_configured_error.rb
@@ -503,7 +504,9 @@ files:
503
504
  - app/services/command_tower/messaging/execution/adapters/email/configuration.rb
504
505
  - app/services/command_tower/messaging/execution/adapters/expo/adapter.rb
505
506
  - app/services/command_tower/messaging/execution/adapters/expo/configuration.rb
507
+ - app/services/command_tower/messaging/execution/adapters/expo/endpoint_ticket_history.rb
506
508
  - app/services/command_tower/messaging/execution/adapters/expo/http_client.rb
509
+ - app/services/command_tower/messaging/execution/adapters/expo/interruption_level.rb
507
510
  - app/services/command_tower/messaging/execution/adapters/fake_adapter.rb
508
511
  - app/services/command_tower/messaging/execution/adapters/pushover/adapter.rb
509
512
  - app/services/command_tower/messaging/execution/adapters/pushover/configuration.rb
@@ -514,6 +517,7 @@ files:
514
517
  - app/services/command_tower/messaging/execution/adapters/unconfigured_adapter.rb
515
518
  - app/services/command_tower/messaging/execution/attempt_ledger.rb
516
519
  - app/services/command_tower/messaging/execution/claim.rb
520
+ - app/services/command_tower/messaging/execution/expo_receipt_collection.rb
517
521
  - app/services/command_tower/messaging/execution/invalid_adapter_contract_error.rb
518
522
  - app/services/command_tower/messaging/execution/operation_logger.rb
519
523
  - app/services/command_tower/messaging/execution/readiness_terminal_mapping.rb
@@ -549,6 +553,7 @@ files:
549
553
  - app/services/command_tower/messaging/notification_types/error.rb
550
554
  - app/services/command_tower/messaging/notification_types/invalid_declaration_error.rb
551
555
  - app/services/command_tower/messaging/notification_types/not_found_error.rb
556
+ - app/services/command_tower/messaging/notification_types/permitted_choice.rb
552
557
  - app/services/command_tower/messaging/notification_types/registry.rb
553
558
  - app/services/command_tower/messaging/notification_types/sealed_registry_error.rb
554
559
  - app/services/command_tower/messaging/planner.rb
@@ -567,6 +572,7 @@ files:
567
572
  - app/services/command_tower/messaging/preferences/catalog_category_result.rb
568
573
  - app/services/command_tower/messaging/preferences/catalog_notification_result.rb
569
574
  - app/services/command_tower/messaging/preferences/catalog_result.rb
575
+ - app/services/command_tower/messaging/preferences/effective_choice.rb
570
576
  - app/services/command_tower/messaging/preferences/error.rb
571
577
  - app/services/command_tower/messaging/preferences/evaluation_result.rb
572
578
  - app/services/command_tower/messaging/preferences/evaluator.rb
@@ -601,12 +607,14 @@ files:
601
607
  - app/services/command_tower/messaging/rendering/inbox_document_renderer.rb
602
608
  - app/services/command_tower/messaging/rendering/inbox_presentation_resolver.rb
603
609
  - app/services/command_tower/messaging/rendering/inbox_presentation_snapshot.rb
610
+ - app/services/command_tower/messaging/rendering/push_presentation.rb
604
611
  - app/services/command_tower/messaging/rendering/render_error.rb
605
612
  - app/services/command_tower/messaging/rendering/rendered_payload.rb
606
613
  - app/services/command_tower/messaging/rendering/rendered_push_payload.rb
607
614
  - app/services/command_tower/messaging/rendering/rendered_pushover_payload.rb
608
615
  - app/services/command_tower/messaging/rendering/rendered_sms_payload.rb
609
616
  - app/services/command_tower/messaging/rendering/template_resolver.rb
617
+ - app/services/command_tower/messaging/sound_catalog.rb
610
618
  - app/services/command_tower/secrets.rb
611
619
  - app/services/command_tower/secrets/cleanse.rb
612
620
  - app/services/command_tower/secrets/generate.rb
@@ -800,6 +808,8 @@ files:
800
808
  - db/migrate/20260906180000_create_user_experience_states.rb
801
809
  - db/migrate/20261010000001_add_inbox_presentation_snapshot_to_messaging_communications.rb
802
810
  - db/migrate/20261010000002_add_conversation_identity_to_messaging_communications.rb
811
+ - db/migrate/20261012000001_add_provider_metadata_to_messaging_delivery_attempts.rb
812
+ - db/migrate/20261012000002_add_finished_at_index_to_messaging_delivery_attempts.rb
803
813
  - docs/admin_workspace.md
804
814
  - docs/api_reference.md
805
815
  - docs/architecture.md
@@ -849,6 +859,7 @@ files:
849
859
  - docs/upgrades/0.16.0.md
850
860
  - docs/upgrades/0.17.0.md
851
861
  - docs/upgrades/0.18.0.md
862
+ - docs/upgrades/0.19.0.md
852
863
  - docs/upgrades/README.md
853
864
  - lib/command_tower.rb
854
865
  - lib/command_tower/admin_scope.rb