rubyn-code 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (235) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +620 -0
  4. data/db/migrations/000_create_schema_migrations.sql +4 -0
  5. data/db/migrations/001_create_sessions.sql +16 -0
  6. data/db/migrations/002_create_messages.sql +16 -0
  7. data/db/migrations/003_create_tasks.sql +17 -0
  8. data/db/migrations/004_create_task_dependencies.sql +8 -0
  9. data/db/migrations/005_create_memories.sql +44 -0
  10. data/db/migrations/006_create_cost_records.sql +16 -0
  11. data/db/migrations/007_create_hooks.sql +12 -0
  12. data/db/migrations/008_create_skills_cache.sql +8 -0
  13. data/db/migrations/009_create_teams.sql +27 -0
  14. data/db/migrations/010_create_instincts.sql +15 -0
  15. data/exe/rubyn-code +6 -0
  16. data/lib/rubyn_code/agent/conversation.rb +193 -0
  17. data/lib/rubyn_code/agent/loop.rb +517 -0
  18. data/lib/rubyn_code/agent/loop_detector.rb +78 -0
  19. data/lib/rubyn_code/auth/oauth.rb +174 -0
  20. data/lib/rubyn_code/auth/server.rb +126 -0
  21. data/lib/rubyn_code/auth/token_store.rb +153 -0
  22. data/lib/rubyn_code/autonomous/daemon.rb +233 -0
  23. data/lib/rubyn_code/autonomous/idle_poller.rb +111 -0
  24. data/lib/rubyn_code/autonomous/task_claimer.rb +100 -0
  25. data/lib/rubyn_code/background/job.rb +19 -0
  26. data/lib/rubyn_code/background/notifier.rb +44 -0
  27. data/lib/rubyn_code/background/worker.rb +146 -0
  28. data/lib/rubyn_code/cli/app.rb +118 -0
  29. data/lib/rubyn_code/cli/input_handler.rb +79 -0
  30. data/lib/rubyn_code/cli/renderer.rb +205 -0
  31. data/lib/rubyn_code/cli/repl.rb +519 -0
  32. data/lib/rubyn_code/cli/spinner.rb +100 -0
  33. data/lib/rubyn_code/cli/stream_formatter.rb +149 -0
  34. data/lib/rubyn_code/config/defaults.rb +43 -0
  35. data/lib/rubyn_code/config/project_config.rb +120 -0
  36. data/lib/rubyn_code/config/settings.rb +127 -0
  37. data/lib/rubyn_code/context/auto_compact.rb +81 -0
  38. data/lib/rubyn_code/context/compactor.rb +89 -0
  39. data/lib/rubyn_code/context/manager.rb +91 -0
  40. data/lib/rubyn_code/context/manual_compact.rb +87 -0
  41. data/lib/rubyn_code/context/micro_compact.rb +135 -0
  42. data/lib/rubyn_code/db/connection.rb +176 -0
  43. data/lib/rubyn_code/db/migrator.rb +146 -0
  44. data/lib/rubyn_code/db/schema.rb +106 -0
  45. data/lib/rubyn_code/hooks/built_in.rb +124 -0
  46. data/lib/rubyn_code/hooks/registry.rb +99 -0
  47. data/lib/rubyn_code/hooks/runner.rb +88 -0
  48. data/lib/rubyn_code/hooks/user_hooks.rb +90 -0
  49. data/lib/rubyn_code/learning/extractor.rb +191 -0
  50. data/lib/rubyn_code/learning/injector.rb +138 -0
  51. data/lib/rubyn_code/learning/instinct.rb +172 -0
  52. data/lib/rubyn_code/llm/client.rb +218 -0
  53. data/lib/rubyn_code/llm/message_builder.rb +116 -0
  54. data/lib/rubyn_code/llm/streaming.rb +203 -0
  55. data/lib/rubyn_code/mcp/client.rb +139 -0
  56. data/lib/rubyn_code/mcp/config.rb +83 -0
  57. data/lib/rubyn_code/mcp/sse_transport.rb +225 -0
  58. data/lib/rubyn_code/mcp/stdio_transport.rb +196 -0
  59. data/lib/rubyn_code/mcp/tool_bridge.rb +164 -0
  60. data/lib/rubyn_code/memory/models.rb +62 -0
  61. data/lib/rubyn_code/memory/search.rb +181 -0
  62. data/lib/rubyn_code/memory/session_persistence.rb +194 -0
  63. data/lib/rubyn_code/memory/store.rb +199 -0
  64. data/lib/rubyn_code/observability/budget_enforcer.rb +159 -0
  65. data/lib/rubyn_code/observability/cost_calculator.rb +61 -0
  66. data/lib/rubyn_code/observability/models.rb +29 -0
  67. data/lib/rubyn_code/observability/token_counter.rb +42 -0
  68. data/lib/rubyn_code/observability/usage_reporter.rb +140 -0
  69. data/lib/rubyn_code/output/diff_renderer.rb +212 -0
  70. data/lib/rubyn_code/output/formatter.rb +120 -0
  71. data/lib/rubyn_code/permissions/deny_list.rb +49 -0
  72. data/lib/rubyn_code/permissions/policy.rb +59 -0
  73. data/lib/rubyn_code/permissions/prompter.rb +80 -0
  74. data/lib/rubyn_code/permissions/tier.rb +22 -0
  75. data/lib/rubyn_code/protocols/interrupt_handler.rb +95 -0
  76. data/lib/rubyn_code/protocols/plan_approval.rb +67 -0
  77. data/lib/rubyn_code/protocols/shutdown_handshake.rb +109 -0
  78. data/lib/rubyn_code/skills/catalog.rb +70 -0
  79. data/lib/rubyn_code/skills/document.rb +80 -0
  80. data/lib/rubyn_code/skills/loader.rb +57 -0
  81. data/lib/rubyn_code/sub_agents/runner.rb +168 -0
  82. data/lib/rubyn_code/sub_agents/summarizer.rb +57 -0
  83. data/lib/rubyn_code/tasks/dag.rb +208 -0
  84. data/lib/rubyn_code/tasks/manager.rb +212 -0
  85. data/lib/rubyn_code/tasks/models.rb +31 -0
  86. data/lib/rubyn_code/teams/mailbox.rb +128 -0
  87. data/lib/rubyn_code/teams/manager.rb +175 -0
  88. data/lib/rubyn_code/teams/teammate.rb +38 -0
  89. data/lib/rubyn_code/tools/background_run.rb +41 -0
  90. data/lib/rubyn_code/tools/base.rb +84 -0
  91. data/lib/rubyn_code/tools/bash.rb +81 -0
  92. data/lib/rubyn_code/tools/bundle_add.rb +53 -0
  93. data/lib/rubyn_code/tools/bundle_install.rb +41 -0
  94. data/lib/rubyn_code/tools/compact.rb +57 -0
  95. data/lib/rubyn_code/tools/db_migrate.rb +52 -0
  96. data/lib/rubyn_code/tools/edit_file.rb +49 -0
  97. data/lib/rubyn_code/tools/executor.rb +62 -0
  98. data/lib/rubyn_code/tools/git_commit.rb +97 -0
  99. data/lib/rubyn_code/tools/git_diff.rb +61 -0
  100. data/lib/rubyn_code/tools/git_log.rb +59 -0
  101. data/lib/rubyn_code/tools/git_status.rb +59 -0
  102. data/lib/rubyn_code/tools/glob.rb +44 -0
  103. data/lib/rubyn_code/tools/grep.rb +81 -0
  104. data/lib/rubyn_code/tools/load_skill.rb +41 -0
  105. data/lib/rubyn_code/tools/memory_search.rb +77 -0
  106. data/lib/rubyn_code/tools/memory_write.rb +52 -0
  107. data/lib/rubyn_code/tools/rails_generate.rb +54 -0
  108. data/lib/rubyn_code/tools/read_file.rb +38 -0
  109. data/lib/rubyn_code/tools/read_inbox.rb +64 -0
  110. data/lib/rubyn_code/tools/registry.rb +48 -0
  111. data/lib/rubyn_code/tools/review_pr.rb +145 -0
  112. data/lib/rubyn_code/tools/run_specs.rb +75 -0
  113. data/lib/rubyn_code/tools/schema.rb +59 -0
  114. data/lib/rubyn_code/tools/send_message.rb +53 -0
  115. data/lib/rubyn_code/tools/spawn_agent.rb +154 -0
  116. data/lib/rubyn_code/tools/spawn_teammate.rb +168 -0
  117. data/lib/rubyn_code/tools/task.rb +148 -0
  118. data/lib/rubyn_code/tools/web_fetch.rb +108 -0
  119. data/lib/rubyn_code/tools/web_search.rb +196 -0
  120. data/lib/rubyn_code/tools/write_file.rb +30 -0
  121. data/lib/rubyn_code/version.rb +5 -0
  122. data/lib/rubyn_code.rb +203 -0
  123. data/skills/code_quality/fits_in_your_head.md +189 -0
  124. data/skills/code_quality/naming_conventions.md +213 -0
  125. data/skills/code_quality/null_object.md +205 -0
  126. data/skills/code_quality/technical_debt.md +135 -0
  127. data/skills/code_quality/value_objects.md +216 -0
  128. data/skills/code_quality/yagni.md +176 -0
  129. data/skills/design_patterns/adapter.md +191 -0
  130. data/skills/design_patterns/bridge_memento_visitor.md +254 -0
  131. data/skills/design_patterns/builder.md +158 -0
  132. data/skills/design_patterns/command.md +126 -0
  133. data/skills/design_patterns/composite.md +147 -0
  134. data/skills/design_patterns/decorator.md +204 -0
  135. data/skills/design_patterns/facade.md +133 -0
  136. data/skills/design_patterns/factory_method.md +169 -0
  137. data/skills/design_patterns/iterator.md +116 -0
  138. data/skills/design_patterns/mediator.md +133 -0
  139. data/skills/design_patterns/observer.md +177 -0
  140. data/skills/design_patterns/proxy.md +140 -0
  141. data/skills/design_patterns/singleton.md +124 -0
  142. data/skills/design_patterns/state.md +207 -0
  143. data/skills/design_patterns/strategy.md +127 -0
  144. data/skills/design_patterns/template_method.md +173 -0
  145. data/skills/gems/devise.md +365 -0
  146. data/skills/gems/dry_rb.md +186 -0
  147. data/skills/gems/factory_bot.md +268 -0
  148. data/skills/gems/faraday.md +263 -0
  149. data/skills/gems/graphql_ruby.md +514 -0
  150. data/skills/gems/pundit.md +446 -0
  151. data/skills/gems/redis.md +219 -0
  152. data/skills/gems/rubocop.md +257 -0
  153. data/skills/gems/sidekiq.md +360 -0
  154. data/skills/gems/stripe.md +224 -0
  155. data/skills/minitest/assertions.md +185 -0
  156. data/skills/minitest/fixtures.md +238 -0
  157. data/skills/minitest/integration_tests.md +210 -0
  158. data/skills/minitest/mailers_and_jobs.md +218 -0
  159. data/skills/minitest/mocking_stubbing.md +202 -0
  160. data/skills/minitest/service_tests_and_performance.md +246 -0
  161. data/skills/minitest/structure_and_conventions.md +169 -0
  162. data/skills/minitest/system_tests.md +237 -0
  163. data/skills/rails/action_cable.md +160 -0
  164. data/skills/rails/active_record_basics.md +174 -0
  165. data/skills/rails/active_storage.md +242 -0
  166. data/skills/rails/api_design.md +212 -0
  167. data/skills/rails/associations.md +182 -0
  168. data/skills/rails/background_jobs.md +212 -0
  169. data/skills/rails/caching.md +158 -0
  170. data/skills/rails/callbacks.md +135 -0
  171. data/skills/rails/concerns_controllers.md +218 -0
  172. data/skills/rails/concerns_models.md +280 -0
  173. data/skills/rails/controllers.md +190 -0
  174. data/skills/rails/engines.md +201 -0
  175. data/skills/rails/form_objects.md +168 -0
  176. data/skills/rails/hotwire.md +229 -0
  177. data/skills/rails/internationalization.md +192 -0
  178. data/skills/rails/logging.md +198 -0
  179. data/skills/rails/mailers.md +180 -0
  180. data/skills/rails/migrations.md +200 -0
  181. data/skills/rails/multitenancy.md +207 -0
  182. data/skills/rails/n_plus_one.md +151 -0
  183. data/skills/rails/presenters.md +244 -0
  184. data/skills/rails/query_objects.md +177 -0
  185. data/skills/rails/routing.md +194 -0
  186. data/skills/rails/scopes.md +187 -0
  187. data/skills/rails/security.md +233 -0
  188. data/skills/rails/serializers.md +243 -0
  189. data/skills/rails/service_objects.md +184 -0
  190. data/skills/rails/testing_strategy.md +258 -0
  191. data/skills/rails/validations.md +206 -0
  192. data/skills/refactoring/code_smells.md +251 -0
  193. data/skills/refactoring/command_query_separation.md +166 -0
  194. data/skills/refactoring/encapsulate_collection.md +125 -0
  195. data/skills/refactoring/extract_class.md +138 -0
  196. data/skills/refactoring/extract_method.md +185 -0
  197. data/skills/refactoring/replace_conditional.md +211 -0
  198. data/skills/refactoring/value_objects.md +246 -0
  199. data/skills/rspec/build_stubbed.md +199 -0
  200. data/skills/rspec/factory_design.md +206 -0
  201. data/skills/rspec/let_vs_let_bang.md +161 -0
  202. data/skills/rspec/mocking_stubbing.md +209 -0
  203. data/skills/rspec/request_specs.md +212 -0
  204. data/skills/rspec/service_specs.md +262 -0
  205. data/skills/rspec/shared_examples.md +244 -0
  206. data/skills/rspec/system_specs.md +286 -0
  207. data/skills/rspec/test_performance.md +215 -0
  208. data/skills/ruby/blocks_procs_lambdas.md +204 -0
  209. data/skills/ruby/classes.md +155 -0
  210. data/skills/ruby/concurrency.md +194 -0
  211. data/skills/ruby/data_struct_openstruct.md +158 -0
  212. data/skills/ruby/debugging_profiling.md +204 -0
  213. data/skills/ruby/enumerable_patterns.md +168 -0
  214. data/skills/ruby/exception_handling.md +199 -0
  215. data/skills/ruby/file_io.md +217 -0
  216. data/skills/ruby/hashes.md +195 -0
  217. data/skills/ruby/metaprogramming.md +170 -0
  218. data/skills/ruby/modules.md +210 -0
  219. data/skills/ruby/pattern_matching.md +177 -0
  220. data/skills/ruby/regular_expressions.md +166 -0
  221. data/skills/ruby/result_objects.md +200 -0
  222. data/skills/ruby/strings.md +177 -0
  223. data/skills/ruby_project/bundler_dependencies.md +181 -0
  224. data/skills/ruby_project/cli_tools.md +224 -0
  225. data/skills/ruby_project/rake_tasks.md +146 -0
  226. data/skills/ruby_project/structure.md +261 -0
  227. data/skills/sinatra/application_structure.md +241 -0
  228. data/skills/sinatra/middleware_and_deployment.md +221 -0
  229. data/skills/sinatra/testing.md +233 -0
  230. data/skills/solid/dependency_inversion.md +195 -0
  231. data/skills/solid/interface_segregation.md +237 -0
  232. data/skills/solid/liskov_substitution.md +263 -0
  233. data/skills/solid/open_closed.md +212 -0
  234. data/skills/solid/single_responsibility.md +183 -0
  235. metadata +397 -0
@@ -0,0 +1,446 @@
1
+ # Gem: Pundit
2
+
3
+ ## What It Is
4
+
5
+ Pundit provides authorization through plain Ruby policy classes. Each model gets a policy class that defines who can do what. It's intentionally simple — no DSL, no roles table, no configuration. Just Ruby classes with methods that return true/false.
6
+
7
+ ## Setup Done Right
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem 'pundit'
12
+
13
+ # app/controllers/application_controller.rb
14
+ class ApplicationController < ActionController::Base
15
+ include Pundit::Authorization
16
+
17
+ # CRITICAL: Ensure every action is authorized
18
+ after_action :verify_authorized, except: :index
19
+ after_action :verify_policy_scoped, only: :index
20
+
21
+ # Handle unauthorized access gracefully
22
+ rescue_from Pundit::NotAuthorizedError, with: :user_not_authorized
23
+
24
+ private
25
+
26
+ def user_not_authorized
27
+ flash[:alert] = "You are not authorized to perform this action."
28
+ redirect_back(fallback_location: root_path)
29
+ end
30
+ end
31
+ ```
32
+
33
+ ```ruby
34
+ # app/policies/application_policy.rb
35
+ class ApplicationPolicy
36
+ attr_reader :user, :record
37
+
38
+ def initialize(user, record)
39
+ @user = user
40
+ @record = record
41
+ end
42
+
43
+ # Default: deny everything. Policies opt IN to permissions.
44
+ def index? = false
45
+ def show? = false
46
+ def create? = false
47
+ def new? = create?
48
+ def update? = false
49
+ def edit? = update?
50
+ def destroy? = false
51
+
52
+ class Scope
53
+ def initialize(user, scope)
54
+ @user = user
55
+ @scope = scope
56
+ end
57
+
58
+ def resolve
59
+ raise NotImplementedError, "#{self.class} must implement #resolve"
60
+ end
61
+
62
+ private
63
+
64
+ attr_reader :user, :scope
65
+ end
66
+ end
67
+ ```
68
+
69
+ ```ruby
70
+ # app/policies/order_policy.rb
71
+ class OrderPolicy < ApplicationPolicy
72
+ def show?
73
+ owner? || admin?
74
+ end
75
+
76
+ def create?
77
+ user.present? && user.credit_balance > 0
78
+ end
79
+
80
+ def update?
81
+ owner? && record.editable?
82
+ end
83
+
84
+ def destroy?
85
+ owner? && record.pending?
86
+ end
87
+
88
+ class Scope < ApplicationPolicy::Scope
89
+ def resolve
90
+ if user.admin?
91
+ scope.all
92
+ else
93
+ scope.where(user: user)
94
+ end
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def owner?
101
+ record.user == user
102
+ end
103
+
104
+ def admin?
105
+ user&.admin?
106
+ end
107
+ end
108
+ ```
109
+
110
+ ```ruby
111
+ # Controller usage
112
+ class OrdersController < ApplicationController
113
+ def index
114
+ @orders = policy_scope(Order).recent.page(params[:page])
115
+ end
116
+
117
+ def show
118
+ @order = Order.find(params[:id])
119
+ authorize @order
120
+ end
121
+
122
+ def create
123
+ @order = current_user.orders.build(order_params)
124
+ authorize @order
125
+
126
+ if @order.save
127
+ redirect_to @order
128
+ else
129
+ render :new, status: :unprocessable_entity
130
+ end
131
+ end
132
+
133
+ def update
134
+ @order = Order.find(params[:id])
135
+ authorize @order
136
+
137
+ if @order.update(order_params)
138
+ redirect_to @order
139
+ else
140
+ render :edit, status: :unprocessable_entity
141
+ end
142
+ end
143
+
144
+ def destroy
145
+ @order = Order.find(params[:id])
146
+ authorize @order
147
+ @order.destroy
148
+ redirect_to orders_path
149
+ end
150
+ end
151
+ ```
152
+
153
+ ## Gotcha #1: Forgetting to Authorize
154
+
155
+ The #1 Pundit bug: you add a new action and forget to call `authorize`. Without `verify_authorized`, the action silently works for everyone — including users who shouldn't have access.
156
+
157
+ ```ruby
158
+ # WRONG: No authorization — anyone can export
159
+ def export
160
+ @orders = Order.all # SECURITY HOLE: No policy check
161
+ send_data generate_csv(@orders)
162
+ end
163
+
164
+ # RIGHT: Authorize explicitly
165
+ def export
166
+ @orders = policy_scope(Order)
167
+ authorize Order, :export? # Checks OrderPolicy#export?
168
+ send_data generate_csv(@orders)
169
+ end
170
+ ```
171
+
172
+ **The trap:** `verify_authorized` in `after_action` catches this in development — you'll get `Pundit::AuthorizationNotPerformedError`. But only if you set it up. Without the `after_action`, forgotten authorization is a silent security hole.
173
+
174
+ **Skipping verification for specific actions:**
175
+
176
+ ```ruby
177
+ # When an action legitimately doesn't need authorization
178
+ def health_check
179
+ skip_authorization # Explicitly marks this action as not needing auth
180
+ render json: { status: "ok" }
181
+ end
182
+
183
+ def index
184
+ @orders = policy_scope(Order) # policy_scope satisfies verify_policy_scoped
185
+ # No need for authorize — verify_policy_scoped is separate from verify_authorized
186
+ end
187
+ ```
188
+
189
+ ## Gotcha #2: `authorize` Must Be Called on the Right Object
190
+
191
+ ```ruby
192
+ # WRONG: Authorizing the class when you should authorize the instance
193
+ def update
194
+ @order = Order.find(params[:id])
195
+ authorize Order # Checks if user can update ANY order, not THIS order
196
+ # OrderPolicy#update? receives the Order CLASS, not the instance
197
+ # record.user == user will fail because Class doesn't have .user
198
+ end
199
+
200
+ # RIGHT: Authorize the specific record
201
+ def update
202
+ @order = Order.find(params[:id])
203
+ authorize @order # Checks if user can update THIS specific order
204
+ end
205
+
206
+ # RIGHT: Authorize the class for collection actions
207
+ def create
208
+ @order = current_user.orders.build(order_params)
209
+ authorize @order # Instance is fine here — Pundit infers the policy
210
+ end
211
+
212
+ # RIGHT: Authorize with explicit policy action
213
+ def publish
214
+ @order = Order.find(params[:id])
215
+ authorize @order, :publish? # Calls OrderPolicy#publish?, not #update?
216
+ end
217
+ ```
218
+
219
+ **The trap:** The action name maps to the policy method automatically (`create` action → `create?` policy method). If your action has a non-standard name (like `publish`, `export`, `approve`), you MUST pass the policy method explicitly: `authorize @order, :publish?`.
220
+
221
+ ## Gotcha #3: Policy Scope vs Authorize
222
+
223
+ They're different things for different purposes:
224
+
225
+ ```ruby
226
+ # policy_scope: Filters a COLLECTION. Returns only records the user can see.
227
+ # Used in index actions. Satisfies verify_policy_scoped.
228
+ def index
229
+ @orders = policy_scope(Order) # Calls OrderPolicy::Scope#resolve
230
+ end
231
+
232
+ # authorize: Checks permission on a SINGLE record. Returns the record or raises.
233
+ # Used in show/create/update/destroy. Satisfies verify_authorized.
234
+ def show
235
+ @order = Order.find(params[:id])
236
+ authorize @order # Calls OrderPolicy#show?
237
+ end
238
+ ```
239
+
240
+ **The trap:** Using `authorize` in an index action doesn't filter records — it just checks if the user can access the index page. You still need `policy_scope` to filter WHICH records they see.
241
+
242
+ ```ruby
243
+ # WRONG: Authorizes index access but shows ALL orders to everyone
244
+ def index
245
+ authorize Order, :index?
246
+ @orders = Order.all # Everyone sees everything!
247
+ end
248
+
249
+ # RIGHT: policy_scope filters to only the user's orders
250
+ def index
251
+ @orders = policy_scope(Order).page(params[:page])
252
+ end
253
+ ```
254
+
255
+ ## Gotcha #4: The User Can Be Nil
256
+
257
+ Pundit passes `current_user` as the first argument to the policy. If the user isn't signed in and you don't handle nil, you get `NoMethodError` inside the policy.
258
+
259
+ ```ruby
260
+ # WRONG: Assumes user is always present
261
+ class OrderPolicy < ApplicationPolicy
262
+ def show?
263
+ record.user == user || user.admin? # NoMethodError if user is nil
264
+ end
265
+ end
266
+
267
+ # RIGHT: Handle nil user
268
+ class OrderPolicy < ApplicationPolicy
269
+ def show?
270
+ return false unless user # Guest users can't see anything
271
+
272
+ owner? || admin?
273
+ end
274
+
275
+ def index?
276
+ user.present? # Must be signed in to list orders
277
+ end
278
+
279
+ private
280
+
281
+ def admin?
282
+ user&.admin? # Safe navigation
283
+ end
284
+ end
285
+ ```
286
+
287
+ If you use the Null Object pattern (`GuestUser` instead of nil), this is handled automatically — but make sure `GuestUser` responds correctly to all methods the policy calls.
288
+
289
+ ## Gotcha #5: Permitted Attributes Per Role
290
+
291
+ Pundit can control WHICH fields a user can update, not just WHETHER they can update:
292
+
293
+ ```ruby
294
+ # app/policies/order_policy.rb
295
+ class OrderPolicy < ApplicationPolicy
296
+ def permitted_attributes
297
+ if user.admin?
298
+ [:shipping_address, :notes, :status, :total, :assigned_to]
299
+ else
300
+ [:shipping_address, :notes]
301
+ end
302
+ end
303
+
304
+ # Or per-action permitted attributes
305
+ def permitted_attributes_for_create
306
+ [:shipping_address, :notes, :line_items_attributes]
307
+ end
308
+
309
+ def permitted_attributes_for_update
310
+ if record.pending?
311
+ [:shipping_address, :notes]
312
+ else
313
+ [:notes] # Can only edit notes after confirmation
314
+ end
315
+ end
316
+ end
317
+
318
+ # Controller
319
+ class OrdersController < ApplicationController
320
+ def update
321
+ @order = Order.find(params[:id])
322
+ authorize @order
323
+
324
+ if @order.update(permitted_attributes(@order))
325
+ redirect_to @order
326
+ else
327
+ render :edit, status: :unprocessable_entity
328
+ end
329
+ end
330
+
331
+ private
332
+
333
+ # This uses Pundit's permitted_attributes — NOT params.require().permit()
334
+ # Don't mix the two approaches
335
+ end
336
+ ```
337
+
338
+ **The trap:** Using `params.require(:order).permit(:status)` in the controller bypasses Pundit's attribute control. If you use Pundit for permitted attributes, use `permitted_attributes(@order)` everywhere — don't mix approaches.
339
+
340
+ ## Gotcha #6: Testing Policies
341
+
342
+ Policies are plain Ruby — test them directly without HTTP:
343
+
344
+ ```ruby
345
+ # spec/policies/order_policy_spec.rb
346
+ RSpec.describe OrderPolicy do
347
+ subject { described_class.new(user, order) }
348
+
349
+ let(:order) { build_stubbed(:order, user: owner) }
350
+ let(:owner) { build_stubbed(:user) }
351
+
352
+ context "when user is the owner" do
353
+ let(:user) { owner }
354
+
355
+ it { is_expected.to permit_action(:show) }
356
+ it { is_expected.to permit_action(:update) }
357
+ it { is_expected.to permit_action(:destroy) }
358
+ end
359
+
360
+ context "when user is an admin" do
361
+ let(:user) { build_stubbed(:user, role: :admin) }
362
+
363
+ it { is_expected.to permit_action(:show) }
364
+ it { is_expected.to permit_action(:update) }
365
+ it { is_expected.to permit_action(:destroy) }
366
+ end
367
+
368
+ context "when user is a stranger" do
369
+ let(:user) { build_stubbed(:user) }
370
+
371
+ it { is_expected.not_to permit_action(:show) }
372
+ it { is_expected.not_to permit_action(:update) }
373
+ it { is_expected.not_to permit_action(:destroy) }
374
+ end
375
+
376
+ context "when user is nil (guest)" do
377
+ let(:user) { nil }
378
+
379
+ it { is_expected.not_to permit_action(:show) }
380
+ it { is_expected.not_to permit_action(:create) }
381
+ end
382
+
383
+ # Testing scopes
384
+ describe "Scope" do
385
+ let!(:own_order) { create(:order, user: user) }
386
+ let!(:other_order) { create(:order) }
387
+ let(:user) { create(:user) }
388
+
389
+ it "returns only the user's orders" do
390
+ scope = described_class::Scope.new(user, Order).resolve
391
+ expect(scope).to include(own_order)
392
+ expect(scope).not_to include(other_order)
393
+ end
394
+ end
395
+ end
396
+ ```
397
+
398
+ Add `pundit-matchers` gem for the `permit_action` syntax:
399
+
400
+ ```ruby
401
+ # Gemfile (test group)
402
+ gem 'pundit-matchers'
403
+ ```
404
+
405
+ ## Gotcha #7: Views — Checking Permissions
406
+
407
+ ```ruby
408
+ # In views, use policy() to check permissions
409
+ <% if policy(@order).update? %>
410
+ <%= link_to "Edit", edit_order_path(@order) %>
411
+ <% end %>
412
+
413
+ <% if policy(@order).destroy? %>
414
+ <%= button_to "Delete", order_path(@order), method: :delete %>
415
+ <% end %>
416
+
417
+ # For collection-level checks
418
+ <% if policy(Order).create? %>
419
+ <%= link_to "New Order", new_order_path %>
420
+ <% end %>
421
+
422
+ # DON'T check roles directly in views
423
+ # WRONG:
424
+ <% if current_user.admin? %>
425
+ <%= link_to "Edit", edit_order_path(@order) %>
426
+ <% end %>
427
+ # This duplicates policy logic. If admin rules change, you update the policy AND the view.
428
+ ```
429
+
430
+ ## Do's and Don'ts Summary
431
+
432
+ **DO:**
433
+ - Add `verify_authorized` and `verify_policy_scoped` after_actions immediately
434
+ - Default all permissions to `false` in `ApplicationPolicy`
435
+ - Handle nil user in every policy method
436
+ - Use `policy_scope` for collections, `authorize` for single records
437
+ - Test policies directly — they're plain Ruby, no HTTP needed
438
+ - Use `policy()` in views instead of role checks
439
+
440
+ **DON'T:**
441
+ - Don't forget to `authorize` in every controller action (or `skip_authorization` explicitly)
442
+ - Don't authorize the class when you mean the instance
443
+ - Don't mix `params.permit()` with Pundit's `permitted_attributes`
444
+ - Don't put authorization logic in controllers or views — keep it in policies
445
+ - Don't check `current_user.admin?` in views — use `policy(@record).action?`
446
+ - Don't assume user is present in policy methods — always guard for nil
@@ -0,0 +1,219 @@
1
+ # Gems: Redis
2
+
3
+ ## Pattern
4
+
5
+ Use Redis for caching, rate limiting, sessions, job queues, and real-time features. Use `connection_pool` for thread-safe access. Keep data ephemeral — Redis is a cache, not a database.
6
+
7
+ ### Setup
8
+
9
+ ```ruby
10
+ # Gemfile
11
+ gem "redis", "~> 5.0"
12
+ gem "connection_pool", "~> 2.4"
13
+ gem "hiredis-client" # C extension for faster Redis — optional but recommended
14
+
15
+ # config/initializers/redis.rb
16
+ REDIS_POOL = ConnectionPool.new(size: ENV.fetch("REDIS_POOL_SIZE", 10).to_i, timeout: 5) do
17
+ Redis.new(
18
+ url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"),
19
+ timeout: 2,
20
+ reconnect_attempts: 3
21
+ )
22
+ end
23
+
24
+ # Usage — always check out from pool, never hold a connection
25
+ REDIS_POOL.with do |redis|
26
+ redis.set("key", "value", ex: 3600) # Expires in 1 hour
27
+ value = redis.get("key")
28
+ end
29
+ ```
30
+
31
+ ### Caching
32
+
33
+ ```ruby
34
+ # Rails cache store
35
+ # config/environments/production.rb
36
+ config.cache_store = :redis_cache_store, {
37
+ url: ENV.fetch("REDIS_URL"),
38
+ expires_in: 1.hour,
39
+ namespace: "rubyn:cache",
40
+ pool_size: ENV.fetch("REDIS_POOL_SIZE", 10).to_i,
41
+ error_handler: ->(method:, returning:, exception:) {
42
+ Rails.logger.error("Redis cache error: #{method} #{exception.message}")
43
+ Sentry.capture_exception(exception) if defined?(Sentry)
44
+ }
45
+ }
46
+
47
+ # Usage via Rails.cache
48
+ Rails.cache.fetch("user:#{user.id}:credits", expires_in: 5.minutes) do
49
+ user.credit_ledger_entries.sum(:amount) # Only computed on cache miss
50
+ end
51
+
52
+ Rails.cache.delete("user:#{user.id}:credits") # Invalidate
53
+ Rails.cache.delete_matched("user:#{user.id}:*") # Invalidate all user caches
54
+ ```
55
+
56
+ ### Rate Limiting
57
+
58
+ ```ruby
59
+ # Simple sliding window rate limiter
60
+ class RateLimiter
61
+ def initialize(pool: REDIS_POOL)
62
+ @pool = pool
63
+ end
64
+
65
+ def allowed?(key, limit:, period:)
66
+ @pool.with do |redis|
67
+ current = redis.get(key).to_i
68
+ return true if current < limit
69
+
70
+ false
71
+ end
72
+ end
73
+
74
+ def increment(key, period:)
75
+ @pool.with do |redis|
76
+ count = redis.incr(key)
77
+ redis.expire(key, period) if count == 1 # Set TTL on first increment
78
+ count
79
+ end
80
+ end
81
+
82
+ def remaining(key, limit:)
83
+ @pool.with do |redis|
84
+ current = redis.get(key).to_i
85
+ [limit - current, 0].max
86
+ end
87
+ end
88
+ end
89
+
90
+ # Usage in middleware or controller
91
+ limiter = RateLimiter.new
92
+ key = "rate:#{current_user.id}:#{Time.current.beginning_of_minute.to_i}"
93
+
94
+ unless limiter.allowed?(key, limit: 60, period: 60)
95
+ render json: { error: "Rate limited" }, status: :too_many_requests
96
+ return
97
+ end
98
+
99
+ limiter.increment(key, period: 60)
100
+ ```
101
+
102
+ ### Distributed Locks
103
+
104
+ ```ruby
105
+ # Prevent concurrent execution of the same job
106
+ class DistributedLock
107
+ def initialize(pool: REDIS_POOL)
108
+ @pool = pool
109
+ end
110
+
111
+ def with_lock(key, ttl: 30, &block)
112
+ token = SecureRandom.hex(16)
113
+
114
+ @pool.with do |redis|
115
+ acquired = redis.set("lock:#{key}", token, nx: true, ex: ttl)
116
+ raise LockNotAcquired, "Could not acquire lock: #{key}" unless acquired
117
+
118
+ begin
119
+ yield
120
+ ensure
121
+ # Only release if we still own the lock (compare token)
122
+ release_script = <<~LUA
123
+ if redis.call("get", KEYS[1]) == ARGV[1] then
124
+ return redis.call("del", KEYS[1])
125
+ else
126
+ return 0
127
+ end
128
+ LUA
129
+ redis.eval(release_script, keys: ["lock:#{key}"], argv: [token])
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ # Usage
136
+ lock = DistributedLock.new
137
+ lock.with_lock("index:project:#{project.id}", ttl: 60) do
138
+ Embeddings::CodebaseIndexer.call(project)
139
+ end
140
+ ```
141
+
142
+ ### Pub/Sub for Real-Time
143
+
144
+ ```ruby
145
+ # Publishing events
146
+ REDIS_POOL.with do |redis|
147
+ redis.publish("order:updates", { order_id: order.id, status: "shipped" }.to_json)
148
+ end
149
+
150
+ # Subscribing (in a dedicated thread or process)
151
+ Thread.new do
152
+ Redis.new(url: ENV["REDIS_URL"]).subscribe("order:updates") do |on|
153
+ on.message do |channel, message|
154
+ data = JSON.parse(message)
155
+ ActionCable.server.broadcast("order_#{data['order_id']}", data)
156
+ end
157
+ end
158
+ end
159
+ ```
160
+
161
+ ### Key Design
162
+
163
+ ```ruby
164
+ # Use namespaced, structured keys
165
+ "rubyn:cache:user:42:credits" # Cache key
166
+ "rubyn:rate:user:42:1710892800" # Rate limit (epoch minute)
167
+ "rubyn:lock:index:project:17" # Distributed lock
168
+ "rubyn:session:abc123" # Session data
169
+
170
+ # GOOD: Include version for cache invalidation
171
+ "rubyn:v2:user:42:dashboard" # Bump v2→v3 to invalidate all dashboard caches
172
+
173
+ # GOOD: Include TTL in the key name for debugging
174
+ # Not in the key itself — use Redis TTL — but document expected TTLs:
175
+ # credits cache: 5 min
176
+ # dashboard: 15 min
177
+ # session: 24 hours
178
+ # rate limit: 60 seconds
179
+ ```
180
+
181
+ ## Why This Is Good
182
+
183
+ - **Connection pool prevents thread contention.** Without a pool, threads fight over a single Redis connection. `ConnectionPool` manages N connections and hands them out safely.
184
+ - **Namespaced keys prevent collisions.** `rubyn:cache:` vs `rubyn:rate:` vs `rubyn:lock:` — you can flush caches without losing rate limits.
185
+ - **TTLs prevent unbounded growth.** Every key should expire. Redis is memory-bound — keys without TTLs leak memory until OOM.
186
+ - **Lua scripts for atomic operations.** The distributed lock release uses a Lua script to atomically check-and-delete. Two separate commands would have a race condition.
187
+ - **Error handler on cache store.** If Redis goes down, the app degrades gracefully (cache misses) instead of crashing.
188
+
189
+ ## Anti-Pattern
190
+
191
+ ```ruby
192
+ # BAD: Global Redis connection shared across threads
193
+ $redis = Redis.new # NOT thread-safe under load
194
+ $redis.get("key") # Race conditions in multi-threaded Puma
195
+
196
+ # BAD: No TTL — keys live forever
197
+ redis.set("data", value) # Never expires — memory leak
198
+ redis.set("data", value, ex: 3600) # GOOD: expires in 1 hour
199
+
200
+ # BAD: No error handling — Redis down crashes the app
201
+ value = redis.get("key") # Redis::ConnectionError crashes the request
202
+ # GOOD: Rescue and degrade
203
+ value = redis.get("key") rescue nil
204
+ ```
205
+
206
+ ## When To Apply
207
+
208
+ - **Caching** — Rails.cache with Redis store. Most common use case.
209
+ - **Rate limiting** — API endpoints, login attempts, credit usage.
210
+ - **Sidekiq** — already uses Redis for job queues.
211
+ - **ActionCable** — WebSocket pub/sub backend.
212
+ - **Distributed locks** — prevent duplicate job execution across workers.
213
+ - **Session store** — faster than database sessions for high-traffic apps.
214
+
215
+ ## When NOT To Apply
216
+
217
+ - **Persistent data.** Redis can lose data on restart (unless using AOF/RDB). Don't store data you can't recompute.
218
+ - **Large values.** Redis is optimized for small values (<1KB). Don't store 10MB JSON blobs.
219
+ - **Complex queries.** Redis is a key-value store, not a database. No JOINs, no WHERE clauses, no full-text search.