permission_forge 0.14.1 → 0.15.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 (347) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2372 -0
  3. data/README.md +76 -25
  4. data/app/controllers/concerns/permission_forge/api/crud_actions.rb +3 -2
  5. data/app/controllers/concerns/permission_forge/api/input_shapes.rb +200 -0
  6. data/app/controllers/concerns/permission_forge/api/pagination.rb +12 -5
  7. data/app/controllers/concerns/permission_forge/api/response_renderer.rb +37 -1
  8. data/app/controllers/concerns/permission_forge/api_authentication.rb +13 -2
  9. data/app/controllers/concerns/permission_forge/cache_confirmation.rb +42 -11
  10. data/app/controllers/concerns/permission_forge/change_review.rb +42 -0
  11. data/app/controllers/concerns/permission_forge/copy_refusal.rb +39 -0
  12. data/app/controllers/concerns/permission_forge/param_shapes.rb +207 -7
  13. data/app/controllers/concerns/permission_forge/save_notice.rb +69 -0
  14. data/app/controllers/permission_forge/api/audit_events_controller.rb +1 -1
  15. data/app/controllers/permission_forge/api/base_controller.rb +163 -12
  16. data/app/controllers/permission_forge/api/dashboard_controller.rb +29 -4
  17. data/app/controllers/permission_forge/api/permission_set_group_users_controller.rb +5 -2
  18. data/app/controllers/permission_forge/api/permission_set_groups_controller.rb +5 -4
  19. data/app/controllers/permission_forge/api/permission_set_rules_controller.rb +7 -5
  20. data/app/controllers/permission_forge/api/permission_set_users_controller.rb +80 -19
  21. data/app/controllers/permission_forge/api/permission_sets_controller.rb +12 -6
  22. data/app/controllers/permission_forge/api/resource_actions_controller.rb +1 -1
  23. data/app/controllers/permission_forge/api/resource_fields_controller.rb +3 -4
  24. data/app/controllers/permission_forge/api/resources_controller.rb +26 -12
  25. data/app/controllers/permission_forge/api/unmatched_routes_controller.rb +20 -0
  26. data/app/controllers/permission_forge/application_controller.rb +39 -5
  27. data/app/controllers/permission_forge/assignments_controller.rb +36 -34
  28. data/app/controllers/permission_forge/bulk_assignments_controller.rb +8 -4
  29. data/app/controllers/permission_forge/field_lookups_controller.rb +1 -1
  30. data/app/controllers/permission_forge/maintenance_controller.rb +6 -2
  31. data/app/controllers/permission_forge/people_controller.rb +14 -14
  32. data/app/controllers/permission_forge/permission_set_editor.rb +20 -6
  33. data/app/controllers/permission_forge/permission_set_group_users_controller.rb +3 -1
  34. data/app/controllers/permission_forge/permission_set_groups_controller.rb +23 -9
  35. data/app/controllers/permission_forge/permission_set_rules_controller.rb +20 -10
  36. data/app/controllers/permission_forge/permission_sets_controller.rb +23 -24
  37. data/app/controllers/permission_forge/resource_actions_controller.rb +11 -6
  38. data/app/controllers/permission_forge/resource_fields_controller.rb +37 -19
  39. data/app/controllers/permission_forge/resources_controller.rb +12 -11
  40. data/app/frontend/permission_forge/pf_admin.css +99 -17
  41. data/app/frontend/permission_forge/pf_admin.js +36 -0
  42. data/app/helpers/permission_forge/application_helper.rb +22 -72
  43. data/app/helpers/permission_forge/audit_attribution_helper.rb +77 -0
  44. data/app/helpers/permission_forge/audit_helper.rb +57 -119
  45. data/app/helpers/permission_forge/audit_records_helper.rb +103 -0
  46. data/app/helpers/permission_forge/audit_sentences_helper.rb +56 -2
  47. data/app/helpers/permission_forge/badges_helper.rb +74 -17
  48. data/app/helpers/permission_forge/changes_helper.rb +24 -6
  49. data/app/helpers/permission_forge/conditions_helper.rb +119 -0
  50. data/app/helpers/permission_forge/labels_helper.rb +134 -0
  51. data/app/helpers/permission_forge/people_helper.rb +11 -19
  52. data/app/helpers/permission_forge/rule_problems_helper.rb +73 -0
  53. data/app/helpers/permission_forge/sentences_helper.rb +119 -98
  54. data/app/helpers/permission_forge/settings_helper.rb +2 -2
  55. data/app/helpers/permission_forge/ui_helper.rb +65 -4
  56. data/app/models/permission_forge/audit_event.rb +5 -1
  57. data/app/models/permission_forge/permission_set.rb +208 -18
  58. data/app/models/permission_forge/permission_set_group.rb +10 -4
  59. data/app/models/permission_forge/permission_set_group_user.rb +3 -1
  60. data/app/models/permission_forge/permission_set_rule.rb +254 -17
  61. data/app/models/permission_forge/permission_set_user.rb +38 -3
  62. data/app/models/permission_forge/resource.rb +18 -8
  63. data/app/models/permission_forge/resource_action.rb +15 -6
  64. data/app/models/permission_forge/resource_field.rb +97 -11
  65. data/app/views/permission_forge/assignments/confirm_destroy.html.erb +4 -4
  66. data/app/views/permission_forge/assignments/new.html.erb +10 -6
  67. data/app/views/permission_forge/audit_events/index.html.erb +1 -1
  68. data/app/views/permission_forge/audit_events/show.html.erb +16 -9
  69. data/app/views/permission_forge/bulk_assignments/confirm_revoke.html.erb +3 -3
  70. data/app/views/permission_forge/bulk_assignments/new.html.erb +3 -3
  71. data/app/views/permission_forge/field_lookups/show.html.erb +2 -2
  72. data/app/views/permission_forge/home/_broken_rules_card.html.erb +35 -0
  73. data/app/views/permission_forge/home/_conditions_card.html.erb +28 -0
  74. data/app/views/permission_forge/home/_ending_card.html.erb +11 -5
  75. data/app/views/permission_forge/home/_expired_card.html.erb +1 -1
  76. data/app/views/permission_forge/home/_mask_strategies_card.html.erb +27 -0
  77. data/app/views/permission_forge/home/_privacy_card.html.erb +21 -7
  78. data/app/views/permission_forge/home/_rules_card.html.erb +1 -1
  79. data/app/views/permission_forge/home/_secret_rules_card.html.erb +33 -0
  80. data/app/views/permission_forge/home/_stale_card.html.erb +7 -5
  81. data/app/views/permission_forge/home/_tidy_card.html.erb +1 -1
  82. data/app/views/permission_forge/home/index.html.erb +5 -6
  83. data/app/views/permission_forge/people/_access_tab.html.erb +26 -19
  84. data/app/views/permission_forge/people/_audit_tab.html.erb +8 -8
  85. data/app/views/permission_forge/people/_effective_field_source.html.erb +13 -7
  86. data/app/views/permission_forge/people/_effective_tab.html.erb +16 -15
  87. data/app/views/permission_forge/people/_effective_why.html.erb +20 -16
  88. data/app/views/permission_forge/people/_why_item.html.erb +1 -1
  89. data/app/views/permission_forge/people/confirm_remove_all.html.erb +3 -3
  90. data/app/views/permission_forge/people/erasure_preview.html.erb +1 -1
  91. data/app/views/permission_forge/permission_set_groups/confirm_destroy.html.erb +3 -3
  92. data/app/views/permission_forge/permission_set_groups/index.html.erb +3 -3
  93. data/app/views/permission_forge/permission_set_groups/show.html.erb +28 -19
  94. data/app/views/permission_forge/permission_set_rules/_builder.html.erb +35 -15
  95. data/app/views/permission_forge/permission_set_rules/_preview.html.erb +17 -0
  96. data/app/views/permission_forge/permission_set_rules/confirm_destroy.html.erb +3 -3
  97. data/app/views/permission_forge/permission_set_rules/preview.html.erb +12 -0
  98. data/app/views/permission_forge/permission_sets/_assignments_tab.html.erb +37 -22
  99. data/app/views/permission_forge/permission_sets/_audit_tab.html.erb +4 -4
  100. data/app/views/permission_forge/permission_sets/_condition_problems.html.erb +15 -0
  101. data/app/views/permission_forge/permission_sets/_conditions_tab.html.erb +14 -8
  102. data/app/views/permission_forge/permission_sets/_header.html.erb +6 -5
  103. data/app/views/permission_forge/permission_sets/_records_summary.html.erb +2 -2
  104. data/app/views/permission_forge/permission_sets/_resources_tab.html.erb +4 -4
  105. data/app/views/permission_forge/permission_sets/confirm_destroy.html.erb +4 -4
  106. data/app/views/permission_forge/permission_sets/confirm_switch_mode.html.erb +3 -3
  107. data/app/views/permission_forge/permission_sets/index.html.erb +6 -6
  108. data/app/views/permission_forge/permission_sets/new.html.erb +2 -2
  109. data/app/views/permission_forge/permission_sets/review.html.erb +13 -3
  110. data/app/views/permission_forge/permission_sets/show.html.erb +7 -5
  111. data/app/views/permission_forge/resource_actions/_across_sets.html.erb +7 -10
  112. data/app/views/permission_forge/resource_actions/_effect.html.erb +5 -5
  113. data/app/views/permission_forge/resource_actions/_form.html.erb +1 -1
  114. data/app/views/permission_forge/resource_actions/_managed_note.html.erb +5 -3
  115. data/app/views/permission_forge/resource_actions/confirm_destroy.html.erb +1 -1
  116. data/app/views/permission_forge/resource_actions/new.html.erb +1 -1
  117. data/app/views/permission_forge/resource_actions/show.html.erb +2 -2
  118. data/app/views/permission_forge/resource_fields/_explain.html.erb +19 -15
  119. data/app/views/permission_forge/resource_fields/show.html.erb +21 -12
  120. data/app/views/permission_forge/resources/_actions_tab.html.erb +4 -4
  121. data/app/views/permission_forge/resources/_fields_tab.html.erb +17 -9
  122. data/app/views/permission_forge/resources/_rules_tab.html.erb +9 -6
  123. data/app/views/permission_forge/resources/_sync.html.erb +8 -5
  124. data/app/views/permission_forge/resources/confirm_destroy.html.erb +3 -3
  125. data/app/views/permission_forge/resources/new.html.erb +3 -2
  126. data/app/views/permission_forge/resources/show.html.erb +6 -6
  127. data/app/views/permission_forge/settings/_row.html.erb +2 -2
  128. data/app/views/permission_forge/settings/show.html.erb +10 -1
  129. data/app/views/permission_forge/shared/_across_sets.html.erb +8 -9
  130. data/app/views/permission_forge/shared/_audit_events.html.erb +2 -2
  131. data/app/views/permission_forge/shared/_audit_footer.html.erb +13 -2
  132. data/app/views/permission_forge/shared/_crud_cell.html.erb +6 -6
  133. data/app/views/permission_forge/shared/_mode_banners.html.erb +1 -1
  134. data/app/views/permission_forge/shared/_savebar.html.erb +4 -2
  135. data/config/locales/permission_forge.en.yml +480 -69
  136. data/config/locales/permission_forge.mk.yml +499 -79
  137. data/config/locales/permission_forge.sq.yml +509 -80
  138. data/config/routes.rb +38 -23
  139. data/lib/generators/permission_forge/install/install_generator.rb +21 -0
  140. data/lib/generators/permission_forge/install/templates/initializer.rb.tt +35 -34
  141. data/lib/generators/permission_forge/install/templates/migration.rb.tt +5 -5
  142. data/lib/permission_forge/activation_conditions.rb +200 -0
  143. data/lib/permission_forge/admin/action_label.rb +33 -0
  144. data/lib/permission_forge/admin/action_rows.rb +3 -1
  145. data/lib/permission_forge/admin/actor_label.rb +52 -0
  146. data/lib/permission_forge/admin/attention_items.rb +152 -31
  147. data/lib/permission_forge/admin/audit_log.rb +4 -7
  148. data/lib/permission_forge/admin/audit_trail.rb +33 -17
  149. data/lib/permission_forge/admin/bulk_people_matcher.rb +32 -14
  150. data/lib/permission_forge/admin/chain_entries.rb +42 -0
  151. data/lib/permission_forge/admin/change_set.rb +12 -1
  152. data/lib/permission_forge/admin/change_set_parts/base.rb +5 -4
  153. data/lib/permission_forge/admin/change_set_parts/context.rb +3 -1
  154. data/lib/permission_forge/admin/change_set_parts/field_edit.rb +4 -1
  155. data/lib/permission_forge/admin/change_set_parts/field_levels.rb +1 -1
  156. data/lib/permission_forge/admin/change_set_parts/new_actions.rb +1 -1
  157. data/lib/permission_forge/admin/change_set_parts/new_fields.rb +38 -11
  158. data/lib/permission_forge/admin/change_set_parts/resource_edit.rb +17 -1
  159. data/lib/permission_forge/admin/change_set_parts/resource_flags.rb +1 -1
  160. data/lib/permission_forge/admin/change_set_parts/set_attrs.rb +4 -0
  161. data/lib/permission_forge/admin/change_set_parts/values.rb +10 -4
  162. data/lib/permission_forge/admin/column_fields.rb +22 -21
  163. data/lib/permission_forge/admin/conditions_form.rb +47 -15
  164. data/lib/permission_forge/admin/controller_catalog.rb +42 -14
  165. data/lib/permission_forge/admin/editable_lock.rb +83 -3
  166. data/lib/permission_forge/admin/effective_access.rb +147 -48
  167. data/lib/permission_forge/admin/effective_fields.rb +75 -67
  168. data/lib/permission_forge/admin/error_sentence.rb +47 -0
  169. data/lib/permission_forge/admin/expiry_presets.rb +17 -0
  170. data/lib/permission_forge/admin/exports/holdings.rb +6 -3
  171. data/lib/permission_forge/admin/field_across_sets.rb +7 -4
  172. data/lib/permission_forge/admin/field_chain.rb +109 -0
  173. data/lib/permission_forge/admin/field_detail.rb +24 -11
  174. data/lib/permission_forge/admin/field_label.rb +109 -0
  175. data/lib/permission_forge/admin/field_level.rb +4 -68
  176. data/lib/permission_forge/admin/field_provenance.rb +132 -0
  177. data/lib/permission_forge/admin/field_rows.rb +26 -11
  178. data/lib/permission_forge/admin/last_changes.rb +14 -0
  179. data/lib/permission_forge/admin/list_sentence.rb +32 -0
  180. data/lib/permission_forge/admin/mask_strategy_choices.rb +57 -0
  181. data/lib/permission_forge/admin/model_catalog.rb +83 -39
  182. data/lib/permission_forge/admin/model_label.rb +66 -0
  183. data/lib/permission_forge/admin/people_candidates.rb +21 -9
  184. data/lib/permission_forge/admin/people_directory.rb +23 -5
  185. data/lib/permission_forge/admin/permission_set_factory.rb +6 -15
  186. data/lib/permission_forge/admin/person_label.rb +105 -16
  187. data/lib/permission_forge/admin/prefill.rb +92 -0
  188. data/lib/permission_forge/admin/record_name.rb +45 -0
  189. data/lib/permission_forge/admin/rule_builder.rb +77 -3
  190. data/lib/permission_forge/admin/rule_form.rb +44 -10
  191. data/lib/permission_forge/admin/rule_form_input.rb +27 -11
  192. data/lib/permission_forge/admin/rule_notes.rb +43 -0
  193. data/lib/permission_forge/admin/rule_preview.rb +96 -0
  194. data/lib/permission_forge/admin/sensitive_columns.rb +10 -62
  195. data/lib/permission_forge/admin/settings_catalog.rb +64 -12
  196. data/lib/permission_forge/admin/state_label.rb +97 -0
  197. data/lib/permission_forge/admin_method_override.rb +44 -0
  198. data/lib/permission_forge/api/schema.rb +322 -0
  199. data/lib/permission_forge/api/serializers/base_serializer.rb +87 -1
  200. data/lib/permission_forge/api/serializers/permission_set_group_user_serializer.rb +1 -1
  201. data/lib/permission_forge/api/serializers/permission_set_rule_serializer.rb +1 -1
  202. data/lib/permission_forge/api/serializers/permission_set_serializer.rb +5 -2
  203. data/lib/permission_forge/api/serializers/permission_set_user_serializer.rb +1 -1
  204. data/lib/permission_forge/api/serializers/resource_action_serializer.rb +1 -1
  205. data/lib/permission_forge/api/serializers/resource_field_serializer.rb +1 -1
  206. data/lib/permission_forge/api/serializers/resource_serializer.rb +1 -1
  207. data/lib/permission_forge/api_authorizations.rb +59 -0
  208. data/lib/permission_forge/api_only_admin.rb +81 -0
  209. data/lib/permission_forge/audit/actor_codes.rb +32 -0
  210. data/lib/permission_forge/audit/actor_resolver.rb +1 -2
  211. data/lib/permission_forge/audit/boot_warnings.rb +29 -9
  212. data/lib/permission_forge/audit/event_catalog.rb +16 -6
  213. data/lib/permission_forge/audit/event_query.rb +3 -3
  214. data/lib/permission_forge/audit/reason_codes.rb +25 -0
  215. data/lib/permission_forge/audit/runtime.rb +9 -2
  216. data/lib/permission_forge/audit/settings_observer.rb +2 -2
  217. data/lib/permission_forge/audit/unmasked_without_user.rb +169 -0
  218. data/lib/permission_forge/audit.rb +24 -10
  219. data/lib/permission_forge/boot_checks.rb +180 -63
  220. data/lib/permission_forge/cache/base.rb +10 -0
  221. data/lib/permission_forge/cache/null_cache.rb +5 -0
  222. data/lib/permission_forge/cache/outcome.rb +119 -0
  223. data/lib/permission_forge/cache/rails_cache.rb +6 -0
  224. data/lib/permission_forge/cache/report.rb +150 -0
  225. data/lib/permission_forge/cache/user_keys.rb +8 -4
  226. data/lib/permission_forge/cache_warnings.rb +140 -0
  227. data/lib/permission_forge/concerns/auto_permission_scoping.rb +19 -2
  228. data/lib/permission_forge/concerns/has_tenant_scoping.rb +1 -1
  229. data/lib/permission_forge/concerns/has_uuid.rb +1 -1
  230. data/lib/permission_forge/concerns/keeps_copied_values.rb +48 -0
  231. data/lib/permission_forge/concerns/masked_attributes/alias_access.rb +12 -2
  232. data/lib/permission_forge/concerns/masked_attributes/virtual_readers.rb +6 -4
  233. data/lib/permission_forge/concerns/masked_attributes.rb +150 -34
  234. data/lib/permission_forge/concerns/normalizes_label.rb +78 -0
  235. data/lib/permission_forge/concerns/permission_cache_accessor.rb +3 -3
  236. data/lib/permission_forge/concerns/permission_scoped_findable.rb +121 -29
  237. data/lib/permission_forge/concerns/permissioned_user.rb +1 -1
  238. data/lib/permission_forge/concerns/records_audit_events.rb +13 -6
  239. data/lib/permission_forge/concerns/requires_name.rb +37 -0
  240. data/lib/permission_forge/concerns/validates_column_lengths.rb +24 -0
  241. data/lib/permission_forge/concerns/validates_dependency.rb +5 -2
  242. data/lib/permission_forge/concerns/validates_required_columns.rb +74 -0
  243. data/lib/permission_forge/conditional_sets.rb +13 -6
  244. data/lib/permission_forge/configuration/admin_ui_settings.rb +18 -4
  245. data/lib/permission_forge/configuration/audit_settings.rb +2 -1
  246. data/lib/permission_forge/configuration/controller_action_settings.rb +5 -4
  247. data/lib/permission_forge/configuration/masked_attributes_settings.rb +73 -19
  248. data/lib/permission_forge/configuration/presets.rb +20 -11
  249. data/lib/permission_forge/configuration/query_scoping_settings.rb +55 -0
  250. data/lib/permission_forge/configuration/strategy_values.rb +2 -1
  251. data/lib/permission_forge/configuration.rb +41 -18
  252. data/lib/permission_forge/controller_actions/managed_index.rb +8 -7
  253. data/lib/permission_forge/database_reach.rb +93 -0
  254. data/lib/permission_forge/deprecation.rb +1 -1
  255. data/lib/permission_forge/docs/api_samples.rb +250 -0
  256. data/lib/permission_forge/docs/configuration_reference.rb +172 -0
  257. data/lib/permission_forge/encrypted_attributes.rb +150 -0
  258. data/lib/permission_forge/engine.rb +46 -14
  259. data/lib/permission_forge/execution_state/restore.rb +74 -0
  260. data/lib/permission_forge/execution_state.rb +8 -1
  261. data/lib/permission_forge/field_level.rb +123 -0
  262. data/lib/permission_forge/field_names.rb +145 -0
  263. data/lib/permission_forge/field_variants.rb +96 -0
  264. data/lib/permission_forge/generated_text.rb +95 -0
  265. data/lib/permission_forge/inspection_filters.rb +83 -0
  266. data/lib/permission_forge/managed_fields.rb +66 -0
  267. data/lib/permission_forge/masked_models.rb +25 -19
  268. data/lib/permission_forge/masking_sync.rb +100 -25
  269. data/lib/permission_forge/model_bindings.rb +30 -19
  270. data/lib/permission_forge/model_lookup.rb +42 -0
  271. data/lib/permission_forge/operators/encrypted_alias.rb +67 -0
  272. data/lib/permission_forge/operators/registry.rb +6 -0
  273. data/lib/permission_forge/operators/rule_column.rb +242 -27
  274. data/lib/permission_forge/operators/rule_path.rb +179 -0
  275. data/lib/permission_forge/operators/rule_problem.rb +309 -0
  276. data/lib/permission_forge/operators/rule_sources.rb +96 -0
  277. data/lib/permission_forge/operators/value_casters/postgresql_types.rb +237 -0
  278. data/lib/permission_forge/operators/value_casters/temporal.rb +9 -20
  279. data/lib/permission_forge/operators/value_casters.rb +61 -13
  280. data/lib/permission_forge/operators/value_typing.rb +83 -7
  281. data/lib/permission_forge/query_scoping_warnings.rb +22 -17
  282. data/lib/permission_forge/rake_audit.rb +5 -4
  283. data/lib/permission_forge/rake_dry_run.rb +32 -0
  284. data/lib/permission_forge/rake_output.rb +189 -0
  285. data/lib/permission_forge/record_read_access.rb +51 -0
  286. data/lib/permission_forge/resource_chain.rb +14 -2
  287. data/lib/permission_forge/schema_read/ordered_types.rb +72 -0
  288. data/lib/permission_forge/schema_read.rb +335 -0
  289. data/lib/permission_forge/secret_columns.rb +49 -0
  290. data/lib/permission_forge/sensitive_columns.rb +114 -0
  291. data/lib/permission_forge/services/attribute_access_guard.rb +22 -14
  292. data/lib/permission_forge/services/bulk_permission_set_assignment.rb +16 -8
  293. data/lib/permission_forge/services/concerns/clonable.rb +9 -0
  294. data/lib/permission_forge/services/concerns/settles_cache.rb +58 -0
  295. data/lib/permission_forge/services/consent_withdrawal_handler.rb +37 -19
  296. data/lib/permission_forge/services/data_subject_erasure.rb +83 -45
  297. data/lib/permission_forge/services/expired_assignment_cleaner.rb +62 -25
  298. data/lib/permission_forge/services/extra_field_defaults.rb +1 -1
  299. data/lib/permission_forge/services/field_permission_merger.rb +44 -17
  300. data/lib/permission_forge/services/filters_response_attributes.rb +20 -5
  301. data/lib/permission_forge/services/gdpr_field_auditor/text_report.rb +33 -5
  302. data/lib/permission_forge/services/gdpr_field_auditor.rb +68 -48
  303. data/lib/permission_forge/services/host_key_guard.rb +8 -5
  304. data/lib/permission_forge/services/mask_value.rb +72 -11
  305. data/lib/permission_forge/services/permission_cache_clearer.rb +36 -15
  306. data/lib/permission_forge/services/permission_hash_builder.rb +48 -19
  307. data/lib/permission_forge/services/permission_rule_applier.rb +131 -68
  308. data/lib/permission_forge/services/permission_set_cloner.rb +32 -8
  309. data/lib/permission_forge/services/permission_set_condition_evaluator.rb +38 -36
  310. data/lib/permission_forge/services/permission_set_group_cloner.rb +2 -1
  311. data/lib/permission_forge/services/permits_attributes/result.rb +31 -0
  312. data/lib/permission_forge/services/permits_attributes.rb +48 -27
  313. data/lib/permission_forge/services/resource_field_generator/row_builder.rb +96 -0
  314. data/lib/permission_forge/services/resource_field_generator.rb +71 -42
  315. data/lib/permission_forge/services/resource_with_fields_builder.rb +2 -1
  316. data/lib/permission_forge/services/rls_operator_sql.rb +83 -24
  317. data/lib/permission_forge/services/rls_policy_generator.rb +83 -39
  318. data/lib/permission_forge/services/rls_read_access_sql.rb +131 -0
  319. data/lib/permission_forge/services/security_audit/action_check.rb +49 -5
  320. data/lib/permission_forge/services/security_audit/assignment_check.rb +2 -1
  321. data/lib/permission_forge/services/security_audit/audit_trail_check.rb +1 -3
  322. data/lib/permission_forge/services/security_audit/conditions_check.rb +92 -0
  323. data/lib/permission_forge/services/security_audit/config_check.rb +41 -18
  324. data/lib/permission_forge/services/security_audit/integrity_check.rb +71 -4
  325. data/lib/permission_forge/services/security_audit/masking_without_user.rb +66 -0
  326. data/lib/permission_forge/services/security_audit/personal_data_check.rb +7 -11
  327. data/lib/permission_forge/services/security_audit/rule_check.rb +180 -50
  328. data/lib/permission_forge/services/security_audit/text_report.rb +14 -2
  329. data/lib/permission_forge/services/security_audit/unmanaged_actions.rb +145 -0
  330. data/lib/permission_forge/services/security_audit/user_model_check.rb +4 -3
  331. data/lib/permission_forge/services/security_audit.rb +16 -4
  332. data/lib/permission_forge/services/stale_assignment_auditor.rb +8 -5
  333. data/lib/permission_forge/timestamp_fields.rb +60 -0
  334. data/lib/permission_forge/unique_conflict.rb +117 -0
  335. data/lib/permission_forge/version.rb +1 -1
  336. data/lib/permission_forge/virtual_fields/discovery.rb +2 -1
  337. data/lib/permission_forge/virtual_fields.rb +15 -5
  338. data/lib/permission_forge/warnings.rb +263 -0
  339. data/lib/permission_forge/web_request.rb +248 -0
  340. data/lib/permission_forge.rb +54 -0
  341. data/lib/tasks/permission_forge.rake +26 -6
  342. data/lib/tasks/permission_forge_audit_events.rake +8 -4
  343. data/lib/tasks/permission_forge_cache.rake +12 -12
  344. data/lib/tasks/permission_forge_docs.rake +48 -0
  345. data/lib/tasks/permission_forge_expiration.rake +11 -5
  346. data/lib/tasks/permission_forge_gdpr.rake +36 -20
  347. metadata +84 -1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,2378 @@ defaults, and every such change is listed under **Changed** with its opt-out.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.15.0] - 2026-10-19
10
+
11
+ Upgrading from 0.14 is the three steps of the
12
+ [migration guide's checklist](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#upgrading-to-015):
13
+ no generator to run (`permission_forge:upgrade` is for installs older than 0.13), `bin/rails permission_forge:audit`
14
+ before the deploy — it lists the stored rows this release reads differently (rules that match nothing, broken
15
+ activation conditions, variant rows, unknown mask strategies) — and an RLS re-sync after it. From 0.13, see
16
+ [From 0.13](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#from-013): a revocation
17
+ made on 0.15 does not reach what 0.13 processes have cached. Most Changed
18
+ entries need nothing from you. These may need a change in your app or its clients: **A mapped controller action no
19
+ Permission Set manages is refused (403) unless `unconfigured_action_strategy = :allow`.** (manage the actions the
20
+ audit lists as `unmanaged_actions_refused` — with tenancy, in each tenant named — or opt out), **An unrescued denial answers 403 Forbidden instead of 500.**
21
+ (add `public/403.html`, or rescue the errors), **RLS: `RlsPolicyGenerator::GENERATOR_VERSION` is 7** (run `permission_forge:rls:sync` after the deploy), **`include_nulls` is off unless asked for — in code, through
22
+ the JSON API and in the admin's rule builder alike.** (pass `include_nulls: true` where a rule should let empty values
23
+ through), **A refusing `api_auth` answers 401 Unauthorized instead of 403.** and **API: a value of the wrong shape for
24
+ its attribute is a 422 keyed by it, never dropped or cast.** (API clients), **A Permission Set, a group or a rule
25
+ created without a name — or renamed to a blank one — is refused, and a rule created without a blocked message stores
26
+ none.** (code and API clients that create without a name, or with a blank one), **More warnings are SECURITY warnings.** (log alerts that match the warning text), and **`config.preset = :strict` sets
27
+ `masked_attributes_nil_user_strategy = :hide_in_requests`, and new installs get it from the generator** (pages that
28
+ read a masked model before sign-in).
29
+
30
+ ### Added
31
+
32
+ - **`rake permission_forge:audit` reports every kind of rule that matches nothing.** `Operators::RuleProblem::KINDS`
33
+ is the one taxonomy, and each kind lands in exactly one finding: `missing_rule_columns` ("Rules naming a column,
34
+ table or association that does not exist": High while people hold the set, otherwise Medium; set, resource, field,
35
+ operator, value, rule and people per item) lists a rule whose field name is not a column of its table, one whose
36
+ name is qualified with a table this database does not have, one whose field name is not a name (a trailing or
37
+ doubled dot), and a join rule whose `field_accessor_path` names an association its model does not have (`ownr`) or
38
+ one no join can be built through (a polymorphic `belongs_to`, a scope that takes the record, a `:through` or
39
+ `inverse_of` naming nothing, a class that cannot be loaded); the new `unknown_rule_operators` (High while held,
40
+ otherwise Medium) lists rules with an operator or join type this version does not know (written by a newer version
41
+ in a rolling deploy, by another application on the shared tables, or past the model); the new
42
+ `foreign_table_names` (Info) lists rules qualified with a real table of another model, which are left to the
43
+ database. Run it before upgrading.
44
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#a rule naming a column its table does not have is a high finding while people hold the set -->
45
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#a rule whose association path cannot be followed is a high finding while people hold the set -->
46
+ <!-- pin: test/permission_forge/services/security_audit_rule_check_test.rb#each kind lands in exactly one finding -->
47
+ - **Home lists the rules that match nothing.** A "Rules that match nothing" card on the admin's Home lists every active
48
+ rule in effect that the audit lists under `missing_rule_columns`, `unknown_rule_operators` or `unusable_rule_values`
49
+ (`AttentionItems#broken_rules`, by `Operators::RuleProblem`) — a missing column, table or relationship, a condition
50
+ or a way of joining this version does not know, a value the field cannot hold — with why, the name as stored
51
+ ("Matches nothing: `stauts` is not a column of Customer"), how many people hold the set, and **Fix rule** (the
52
+ rule's edit page; for a locked set the Locked badge and **Open rules**). The Rules tab, the rule's edit page and a
53
+ person's effective access say the same, in every locale.
54
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#Home lists each rule in effect that matches nothing, with why, linking to the rule -->
55
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#Home, the Rules tab and the edit page say why, for every kind, in every locale -->
56
+ - **`rake permission_forge:audit` reports fields naming nothing on their model** (`integrity/unknown_fields`). A
57
+ `ResourceField` named `zzz` (or `emial`) on a `Customer` Resource was saved — 201 through the API — and configured
58
+ nothing, so a typo went unnoticed. The audit now lists the rows in effect whose name is none of: a column of the
59
+ model's table (read again before it is listed), an attribute or `alias_attribute`, a declared or discovered virtual
60
+ field, a public reader or writer, or a configured variant (`field_variant_mappings`) of one of those or of another
61
+ row of the Resource — on the model or on a loaded single-table-inheritance subclass of it. Low when a muting set
62
+ holds one (it mutes nothing it was meant to), otherwise Info; set, resource, field and people per row; Resources
63
+ whose model the app cannot load are skipped. Saving still accepts such a name: `FiltersResponseAttributes` filters
64
+ any key of a hash, so a key only a serializer computes is configured by a row of its name — declare it in
65
+ `extra_fields_for_permissions` to say it is meant.
66
+ <!-- pin: test/permission_forge/services/security_audit_unknown_fields_test.rb#a row naming nothing on its model still saves, and the audit lists it (info for a granting set) -->
67
+ <!-- pin: test/permission_forge/services/security_audit_unknown_fields_test.rb#a muting set holding one makes it low: it mutes nothing -->
68
+ - **`rake permission_forge:audit` reports fields masked with a strategy this app does not know**
69
+ (`integrity/unknown_mask_strategies`: High while people hold the set, otherwise Medium — a row written past the
70
+ model, a blank, or a custom strategy since removed from `config.custom_mask_strategies`). Such a field is masked
71
+ fully until the row or the configuration is fixed.
72
+ <!-- pin: test/permission_forge/services/security_audit_integrity_check_test.rb#a field in effect with a mask strategy this app does not know is reported, high while people hold the set -->
73
+ - **`rake permission_forge:audit` reports `secret_rule_columns`** (Medium): record rules in effect that compare a
74
+ password digest, a token, an `encrypted_*` column or a secret (`config.rule_secret_column_patterns`, by name; an
75
+ attribute declared with `encrypts` is `encrypted_rule_columns`, under Changed). The item leaves the rule value out.
76
+ <!-- pin: test/permission_forge/admin/attention_parity_test.rb#secret_rule_columns: the audit's finding is on a Home card (secret_rules) -->
77
+ - **`rake permission_forge:audit` has a `conditions` check**: `incomplete_conditions` (Medium — stored activation
78
+ conditions nobody can judge: an IP entry that is not an address, a list with no entry, the same start and end hour,
79
+ one hour of the two, an hour outside 0–23 — with what each set does now) and `variant_rows_conflict` (Info — a row
80
+ next to an explicit row for one of its variants; the explicit row decides).
81
+ <!-- pin: test/permission_forge/services/security_audit_conditions_check_test.rb#incomplete_conditions lists every stored row the validation would refuse, saying what each does now -->
82
+ - **Three more Home cards for what the security audit reports**: fields masked with a strategy this app does not
83
+ know (`unknown_mask_strategies`, with a link to the field drawer), Permission Sets whose activation conditions nobody
84
+ can judge (`incomplete_conditions`, with a link to the Conditions tab) and record rules that compare a secret column
85
+ (`secret_rule_columns`). Home and the audit read the same rows (`ResourceField.with_unknown_mask_strategy`,
86
+ `PermissionSet.with_broken_conditions`, `AttentionItems#secret_rules`).
87
+ <!-- pin: test/permission_forge/admin/attention_parity_test.rb#unknown_mask_strategies: the audit's finding is on a Home card (unknown_mask_strategies) -->
88
+ - **`config.rule_secret_column_patterns`** — the column names a record rule may not compare
89
+ (`PermissionForge::SecretColumns`, matched against the column name in lower case; default
90
+ `[/\Apassword_digest\z/, /_digest\z/, /token/, /\Aencrypted_/, /_secret/]`). Listed on the Settings page (Record
91
+ rules) and in the configuration reference. See **Changed** for what it refuses.
92
+ <!-- pin: test/permission_forge/models/permission_set_rule_secret_columns_test.rb#rule_secret_column_patterns takes a list of regular expressions -->
93
+ - **`NotAuthorizedError#failure_messages`.** The `failure_message` of each row-level rule that denied the record, as
94
+ the admin wrote it, in rule order and without repeats (`[]` when no rule denied), so a host can render a friendly 403
95
+ without parsing `message` (`[Forbidden by Rule: <name>] with message: <failure_message>`). Only the rules that deny
96
+ the record on their own are named (each rule is checked against the record on the denial path, one query per rule;
97
+ should no single rule deny — a custom operator that is not a plain filter — the cumulative attribution is kept, so a
98
+ denial never names no rule). A rule whose `failure_message` is blank gives `permission_forge.denials.rule_default`
99
+ in the current locale ("You do not have permission to perform this action."). `message`, `details` and the
100
+ `access.denied` event's `messages` use the same list of rules. See
101
+ [Handling Denials](docs/row-level-rules.md#handling-denials).
102
+ <!-- pin: test/permission_forge/concerns/permission_scoped_findable_test.rb#NotAuthorizedError#failure_messages are the denying rules' own messages, without parsing -->
103
+ <!-- pin: test/permission_forge/concerns/permission_scoped_findable_test.rb#a denial names only the rules that deny the record on their own, in rule order -->
104
+ <!-- pin: test/permission_forge/concerns/permission_scoped_findable_test.rb#a denial no single rule explains falls back to the cumulative attribution, never to no rule -->
105
+ <!-- pin: test/permission_forge/services/rule_blame_property_test.rb#a denial blames exactly the rules that exclude the record on their own, in rule order -->
106
+ - **The install generator prints the next steps.** After the files it lists `bin/rails db:migrate` (not with
107
+ `--skip-migration`), the mount line, where the admin credentials go (`PF_ADMIN_USER` / `PF_ADMIN_PASSWORD`, or your
108
+ own admin controller), `bin/rails permission_forge:audit` and a link to the Quick Start, plus the line that makes
109
+ `can_*?` work: `include PermissionForge::Concerns::PermissionedUser` in your User model. `rails destroy` prints none.
110
+ <!-- pin: test/permission_forge/generators/install_generator_next_steps_test.rb#an install prints the next steps after the files -->
111
+ <!-- pin: test/permission_forge/generators/install_generator_next_steps_test.rb#rails destroy prints no next steps -->
112
+ - **A boot warning in development when the permission cache is per process.** Rails 8.0+ generates
113
+ `config.cache_store = :memory_store` for development (7.1/7.2: after `bin/rails dev:cache`) and the generated
114
+ initializer caches through `Rails.cache`, so a permission change made from a console, `bin/rails runner`, a rake
115
+ task or `db:seed` invalidated only that process's cache: a running server kept the permissions it had cached until
116
+ `cache_ttl` (an hour) or a restart, and the change looked lost. The development boot now says so, with the two
117
+ remedies (`config.cache_store = :file_store, "tmp/cache/store"` in `config/environments/development.rb`, or
118
+ `cache_backend = :null` in development), when `cache_backend` is `:memory` or the store is a `MemoryStore`; file,
119
+ Redis, Solid Cache, Memcached and null stores are not warned about. The generated initializer's comment above
120
+ `cache_backend` and [Cache Consistency](docs/configuration.md#cache-consistency) say so.
121
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#development with Rails.cache as a :memory_store warns, naming what happens and both remedies -->
122
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#the development warning reads right in any process, not only a server -->
123
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#development with a shared or a null store does not warn -->
124
+ - **A boot warning for an API-only application that mounts the engine** (`config.api_only = true`): it names the
125
+ middlewares its admin screens need (`ActionDispatch::Cookies`, `ActionDispatch::Session::CookieStore`,
126
+ `ActionDispatch::Flash`) and the lines to add — without them every admin page that renders a form is a 500; the
127
+ JSON API works without them. Checked once the routes are drawn (it never draws them); an app that uses only the
128
+ API sets `config.api_only_admin_warning = false`.
129
+ <!-- pin: test/permission_forge/boot_checks_api_only_test.rb#the warning goes through the emitter and names the middlewares and the lines to add -->
130
+ <!-- pin: test/permission_forge/boot_checks_api_only_test.rb#the warning comes when the routes are drawn, which the check never does itself -->
131
+ <!-- pin: test/permission_forge/boot_checks_api_only_test.rb#config.api_only_admin_warning = false silences the warning (an app that uses only the JSON API) -->
132
+ - **One cache outcome for every operation that clears cached permissions.** `PermissionForge::Cache::Outcome`
133
+ (`confirmed`, `process_local`, `in_request`, `unconfirmed`), measured by `Cache::Report`, is what the terminal, the
134
+ admin flash, the API body and the summary audit event report: `PermissionCacheClearer`, `DataSubjectErasure`,
135
+ `ExpiredAssignmentCleaner`, `ConsentWithdrawalHandler` and `BulkPermissionSetAssignment` answer it under `cache:`;
136
+ `cache.cleared`, `erasure.performed`, `cleanup.expired_assignments` and `consent.withdrawn` record it in their data
137
+ (`cache.settled` the answer of the last two once their cache work ran); the admin's flashes (one assignment's
138
+ changes and a batch of edits too), `POST /api/permission_sets/:id/users/bulk` (`cache: {…}`) and `POST` / `DELETE
139
+ …/users` (`meta.cache`) say when the store did not confirm and when it lives in one process; inside a caller's
140
+ transaction every one of them reports `confirmed: nil` (`Cache::Report#deferred?`). `process_local` is
141
+ `PermissionForge.cache.process_local?` (`cache_backend = :memory`, or `Rails.cache` a `:memory_store`): there a clear
142
+ from rake reaches the rake process only, which the terminal said and the audit log did not.
143
+ <!-- pin: test/permission_forge/audit/cache_scope_events_test.rb#erasure.performed on the suite's memory store says process-local in the event and the result, in every locale -->
144
+ <!-- pin: test/permission_forge/services/permission_cache_clearer_test.rb#every event says whether the clear reached only this process (a memory store) or a shared store -->
145
+ <!-- pin: test/permission_forge/audit/cache_scope_events_test.rb#cleanup.expired_assignments on a shared store says process_local false and reads without a suffix -->
146
+ - **The audit log says which processes kept their cached permissions.** One suffix per locale for an event whose
147
+ data says `process_local: true`, chosen by where the operation ran and what the store said: in a web server process
148
+ (an admin or API request, a host controller), "The cached permissions were cleared in this server process only (an
149
+ in-memory store): the other web workers and the job processes kept theirs."; from a rake task, runner, console or
150
+ job, "The cached permissions were cleared only in the process that ran it (an in-memory store): every other process
151
+ kept theirs."; when the store did not confirm, "The cache store lives in each process (an in-memory store): the other
152
+ processes keep their cached permissions either way." (`audit_log.cache_scope.server_process` / `own_process` /
153
+ `unconfirmed`, en/sq/mk). On a shared store the sentence reads as before.
154
+ <!-- pin: test/permission_forge/html/audit_sentences_test.rb#a clear made from an admin request names the other workers, not the server itself -->
155
+ <!-- pin: test/permission_forge/html/audit_sentences_test.rb#a cache clear that reached only its own in-memory store says so; one on a shared store reads as before -->
156
+ - **`PermissionForge::RakeOutput`: one presenter for what the rake tasks print.** `rake permission_forge:erasure`,
157
+ `cleanup_expired_assignments`, `cache:clear` and `audit_events:purge` print one line per key of the result their
158
+ summary event records (the cache outcome included: "Cached permissions removed", "Cache scope", "Ran in"), name
159
+ people as the admin does (`Admin::PersonLabel`; "id 7 (not in the people list)" for someone outside
160
+ `user_list_resolver`) instead of `User 7` / `User IDs: 1, 2`, and say "would" on a dry run; `expired_assignments`
161
+ and `stale_assignments` name people the same way. The erasure prints how many audit-log name labels it would clear
162
+ (dry run) or cleared, as actor and as subject, as `erasure.performed` records them;
163
+ `Services::DataSubjectErasure` returns them (`actor_labels_to_clear` / `record_labels_to_clear` in a dry run,
164
+ `actor_labels_cleared` / `record_labels_cleared` otherwise).
165
+ <!-- pin: test/permission_forge/tasks/rake_output_contract_test.rb#permission_forge:erasure prints every key its summary event records, on the memory store with the process-local note -->
166
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#the erasure says how many audit-log name labels it cleared -->
167
+ <!-- pin: test/permission_forge/services/data_subject_erasure_test.rb#a dry run counts the name labels to clear, the run returns the ones it cleared -->
168
+ - **`rake -T permission_forge` shows every description whole.** Every task's first sentence is at most 100
169
+ characters, ends on a word (the erasure task's stopped at "(supports GDPR Art"; it now says "Article 17") and the
170
+ description names every environment variable the task reads (`ACTOR=`, `REASON=`, `USER_ID=`, `DRY_RUN=`,
171
+ `FAIL_ON=`, `FORMAT=`, `THRESHOLD_DAYS=`).
172
+ <!-- pin: test/permission_forge/tasks/rake_descriptions_test.rb#permission_forge:erasure: the first sentence shows whole in rake -T -->
173
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#the erasure task description is not cut at an abbreviation -->
174
+ - **`config.api_stats_authorization`.** A lambda called with the controller that decides who may read
175
+ `GET /api/stats` while `api_stats_require_admin` is on (the default); a truthy answer allows. It replaces an
176
+ `api_auth` that set `@admin = true` on the controller (see Deprecated). A refusal answers 403 with a body (it had
177
+ none) and is recorded as `api.auth_failed` with reason `stats_not_authorized`; while the lambda is unset the 403
178
+ names it. See [Dashboard Stats](docs/admin-api.md#dashboard-stats).
179
+ <!-- pin: test/permission_forge/api/dashboard_controller_test.rb#api_stats_authorization decides who reads the stats -->
180
+ <!-- pin: test/permission_forge/api/dashboard_controller_test.rb#GET /api/stats with api_stats_require_admin true and no api_stats_authorization is a 403 that names it -->
181
+ - **`PermitsAttributes.call(..., report: true)` says what was dropped.** It answers a `PermitsAttributes::Result`:
182
+ `params` (the filtered Hash, as before), `dropped` (the paths of the dropped keys, never values), `dropped?` and
183
+ `dropped_fields` (the top-level keys), so a controller can say "Not saved: status, notes" instead of a silent
184
+ success. The paths are collected whether or not `params.filtered` is heard; with no model (`resource: nil`) every
185
+ key is reported as dropped. Without `report:` the return value is the Hash, unchanged. See
186
+ [Telling the user what was not saved](docs/field-filtering.md#telling-the-user-what-was-not-saved).
187
+ <!-- pin: test/permission_forge/services/permits_attributes_test.rb#report: true answers the same filtered hash plus the dropped paths -->
188
+ <!-- pin: test/permission_forge/services/permits_attributes_test.rb#report: true with no model to filter for lists every key, with or without a user -->
189
+ - **Bulk assignment reports the ids it skipped.** `POST …/users/bulk` answers `skipped` next to `assigned`, `revoked`
190
+ and `invalidated`: the requested ids that name nobody `user_list_resolver` lists (`[99]`; `[]` when none), so a typo
191
+ in an automated list no longer goes unnoticed. An id outside the resolver and an unknown one read the same.
192
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#POST bulk reports the ids it skipped: nobody, or outside user_list_resolver -->
193
+ - **`permission_set_group_id` takes the group's uuid.** The API answers with uuids (`api_record_identifier = :uuid`),
194
+ but `PATCH`/`POST …/permission_sets` read `permission_set_group_id` only as a numeric id: the group's uuid was a 422.
195
+ Under `:uuid` it now takes the uuid or the id; under `:id`, the id. The value is looked up in the current tenant
196
+ like every identifier (see Fixed for a uuid that starts with digits).
197
+ <!-- pin: test/permission_forge/api/record_identifier_test.rb#under :uuid permission_set_group_id takes the group uuid, or its id -->
198
+ <!-- pin: test/permission_forge/api/record_identifier_test.rb#under :id permission_set_group_id takes the id only -->
199
+ - **API `GET /api/permission_sets/:id` answers `allowed_ip_list`** (the entries as the engine reads them) beside
200
+ `allowed_ips`.
201
+ <!-- pin: test/permission_forge/api/permission_set_conditions_api_test.rb#the serializer shows the entries as the engine reads them -->
202
+ - **Cross-set unmasking findings name the masking sets too.** `GdprFieldAuditor`'s `:cross_set_unmasking` finding
203
+ gains `masked_in` (the names of the granting Permission Sets that mask the field), `rake permission_forge:audit_masks`
204
+ prints them ("masked in Permission Set Support") and `rake permission_forge:audit` lists them in parentheses after
205
+ the set that unmasks. The other keys are unchanged.
206
+ <!-- pin: test/permission_forge/services/gdpr_field_auditor_test.rb#a cross-set finding names the sets that mask the field -->
207
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#cross-set unmasking names the set that unmasks and the sets that mask -->
208
+ - **`MaskValue.call!`** (strict: raises for a strategy this app does not know — the admin preview, the validation),
209
+ **`MaskValue.known?`**, and the **`mask_strategy_unknown.permission_forge`** notification (payload `strategy`,
210
+ `fallback`), published once per process per name together with a warning.
211
+ <!-- pin: test/permission_forge/services/mask_value_test.rb#call reports a strategy it does not know once per process per name: a warning and a notification -->
212
+ - **`PermissionForge::ModelLookup.model_for(name)`** — the one definition of "this name is a model with a table"
213
+ (an ActiveRecord class, not abstract, whose table exists; nil for `Kernel`, `String`, an abstract model, a model
214
+ without its table, a constant whose file raises as it loads).
215
+ <!-- pin: test/permission_forge/model_lookup_test.rb#anything that is not a model with a table is nil -->
216
+ - **The JSON API's `POST …/resources/:id/generate_fields` answers 422** `{ "name": ["names no model this app can
217
+ load"] }` for a Resource named after a constant that is no model (`Kernel`), instead of a 500 or the generic
218
+ message.
219
+ <!-- pin: test/permission_forge/api/resources_controller_test.rb#POST generate_fields on a Resource naming no model this app can load is a 422 on name -->
220
+ - **`PermissionForge::ActivationConditions`**: the one reading of a Permission Set's `allowed_ips`
221
+ (`#allowed_ip_list`, `#allowed_ip_ranges`, `#invalid_ip_entries`) and hour window (`#hours_window`: `set?`,
222
+ `wraps?`, `broken?`, `include?`), with `#conditions_broken?` / `#condition_problems`; included in `PermissionSet` and
223
+ `ConditionalSets::Member`, read by the evaluator, the admin Conditions form, the sentences, the API serializer and
224
+ the audit.
225
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#hours_window: none, a window, an overnight window, and the broken shapes -->
226
+ - **`PermissionHashBuilder.granted_flags(resource)`**: the one definition of what a granting Resource row grants after
227
+ its dependencies; **`FieldPermissionMerger.targets_of(rows, variants)`**: the one definition of which names a
228
+ resource's rows write and in what order.
229
+ <!-- pin: test/permission_forge/services/field_permission_merger_test.rb#targets_of lists own names first, then the variants no row of the resource names -->
230
+ - **`PermissionForge::SensitiveColumns`** — the one definition of "this column looks like personal data, with this
231
+ mask": `config.gdpr_field_patterns` first, then the host's `filter_parameters` (strategy `full`) while
232
+ `filter_parameters_integration` is on. `match(name)` answers a `Match` (`pattern`: the alternative that matched or
233
+ `filter_parameters`; `strategy`; `source`). The admin's presets, Sync, Fields tab and Home, the field generator,
234
+ `GdprFieldAuditor` and the boot warning all ask it.
235
+ <!-- pin: test/permission_forge/sensitive_columns_test.rb#matches gdpr patterns with the alternative that matched, its strategy and the source -->
236
+ - **One row builder for generated field rows** — `ResourceFieldGenerator.build(resource:, column:, level:)` /
237
+ `.rows(resource)` (`ResourceFieldGenerator::RowBuilder`, reading the level tables of `PermissionForge::FieldLevel`,
238
+ moved from `Admin::FieldLevel`, which stays as an alias): the flags of the level, the privacy default (under
239
+ `gdpr_defaults`, on a granting set, a sensitive column granted Read-only starts Masked; Editable masks nothing;
240
+ Hidden stays Hidden), Rails' timestamps at most read-only, the recommended mask strategy on every row, the label,
241
+ and no row for a level that is none (Unchanged on a muting set). The admin's presets and "Sync fields"
242
+ (`Admin::ColumnFields`), the Fields tab's bulk add (`Admin::ChangeSet`) and the generator (the API's
243
+ `generate_fields`, `Resource.grant_full_access!`, the console) write the same row for the same level.
244
+ <!-- pin: test/support/decision_parity/fields.rb#(a) Customer, muting=false, gdpr_defaults=true, read: presets, bulk and the generator write the same rows -->
245
+ <!-- pin: test/permission_forge/services/resource_field_generator_gdpr_test.rb#build: the privacy default masks a sensitive column granted Read-only, and only that -->
246
+ - **`ResourceField#exposes_in_clear?`** and the scope **`ResourceField.exposing_in_clear`** — the one definition of
247
+ "readable in the clear" (active, read on, no mask) that the GDPR audit, the security audit's `personal_data`
248
+ check, Home's privacy card and the boot warning ask.
249
+ <!-- pin: test/permission_forge/models/resource_field_in_clear_test.rb#the predicate and the scope agree on every combination of active, read and mask -->
250
+ - **`Admin::EditableLock.locked?(record)` walks the chain and `EditableLock.permits?(record, action)` is the one
251
+ table of what the admin may offer on a locked record** (`:edit`, `:add`, `:toggle`, `:delete` — also the container,
252
+ a set in a locked group —, `:grant` / `:revoke` / `:change_expiry` by direction, `:duplicate` and `:review` always).
253
+ Every view asks it (`pf_locked?`, `pf_lock_badge(record)`); `html/locked_pages_test.rb` renders every page for a
254
+ locked set, resource, field, rule, group and Muting Permission Set and checks every enabled control against it.
255
+ <!-- pin: test/permission_forge/html/locked_pages_test.rb#a locked Permission Set offers no change to what it holds, only duplicate, review and narrowing -->
256
+ - **`PermissionSetUser.ending_within(days)` / `#ending_soon?` / `#stale?(days)` / `.ended?(time)`** and
257
+ **`Admin::AttentionItems.stale_days`** — the one window of "ending soon" (`ENDING_SOON_DAYS`, 7) and the one review
258
+ threshold, read by Home, the People page, the end-date cell, the audit's `stale_assignments` and
259
+ `StaleAssignmentAuditor`.
260
+ <!-- pin: test/support/decision_parity/rules.rb#ending soon: the end-date cell, Home, the People filter and ending_within agree -->
261
+ - **`PermissionSet.in_hash_order`** (the order sets merge in), **`Operators::RuleColumn.names_for(model)`** (the
262
+ columns a rule may compare), **`FiltersResponseAttributes.unconfigured_strategy`** (one lookup for the response
263
+ filter and the attribute guard).
264
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: the order sets merge in (PermissionSet.in_hash_order) -->
265
+ - **`PermissionForge::SchemaRead`, the one reader of a model's columns** (`@api private`). A read that answers "no"
266
+ (not a column, no such table) reads the schema again once first — one metadata query, and `reset_column_information`
267
+ only when the database's columns differ from what Rails holds; a name confirmed missing costs nothing afterwards
268
+ until the table is read again, and a column that is there costs no query. Lists that build rows or reports, and the
269
+ save of a rule, compare with the database (`verify: true`). `SchemaRead.rereads` counts the re-reads.
270
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#a name confirmed missing is not read again until the table is -->
271
+ - **Rule values are typed for PostgreSQL's `uuid`, `inet`, `cidr` and native enum columns, and for `json` / `jsonb`
272
+ / array columns on every database** (`Operators::ValueCasters::PostgresqlTypes`, `ValueCasters::OPAQUE`). A `uuid`
273
+ column takes the text of a UUID in either case and in each notation PostgreSQL reads (RFC 4122 8-4-4-4-12, in
274
+ braces, after `urn:uuid:`, or the 32 digits without hyphens), normalised to RFC 4122 text; `inet` an IPv4 or IPv6
275
+ address IPAddr reads, with an optional prefix length (no netmask, no zone); `cidr` the same with no bits set right
276
+ of the prefix; a native enum (`CREATE TYPE … AS ENUM`) one of the type's labels exactly as the database has it, read
277
+ from `pg_enum` once per process and type and read again once before a label is called missing
278
+ (`SchemaRead.enum_label?`: a label `ALTER TYPE … ADD VALUE` added while the process runs); a model `enum` over a
279
+ native enum column maps its label first. A `json`, `jsonb` or array column takes no value under any built-in
280
+ operator but the null checks. `current_user` on a `uuid` column is the id where user ids are uuids. What this
281
+ changes for existing rules is under **Changed**.
282
+ <!-- pin: test/permission_forge/operators/value_typing_test.rb#a uuid column takes the text of a uuid, in any case and in each notation PostgreSQL reads, and nothing else -->
283
+ - **One schema per API answer.** `PermissionForge::Api::Schema` (`@api private`) declares the success envelopes
284
+ (`single`, `collection` with `meta.pagination`, `list`), the error envelope `{error: {status, messages}}` (field →
285
+ messages for 422, one sentence for 400, 401, 403, 404), the answers without a body, every serializer's keys and
286
+ types, and what each endpoint answers (`Schema::ENDPOINTS`). The numeric ids of other PermissionForge records are
287
+ marked `:foreign_identifier`; their value is unchanged.
288
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#every serializer's keys are its Api::Schema payload, and every payload has its serializer -->
289
+ - **Every API route, in its success and every error class, is asserted against the schema**
290
+ (`api/envelope_schema_test.rb`, one test per verb and path, the catch-all included with `OPTIONS` and `TRACE`: 401
291
+ refused or unset, 404 API off or unknown record, 403 tenant, lambdas or lock, 400 for an unreadable JSON, form or
292
+ multipart body, 422), under the pseudo-locale `xx`.
293
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#every API route has an Endpoint, and every Endpoint a route -->
294
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#~answers the schema in its success and in every error class -->
295
+ - **The JSON samples of docs/admin-api.md are generated** by `bundle exec rake docs:api`
296
+ (`PermissionForge::Docs::ApiSamples`) from the schema and the serializers; `docs/api_samples_test.rb` fails while
297
+ the document is stale. The success samples show every key a permission set, a group and an audit event answer.
298
+ <!-- pin: test/permission_forge/docs/api_samples_test.rb#the json samples of docs/admin-api.md are what the schema and the serializers render -->
299
+ - **Rule builder: the preview is the saved rule's sentence, from the server.** A new `GET …/rules/preview` renders the
300
+ rule being built with the helper the Rules tab draws a saved rule with (`SentencesHelper#pf_rule_sentence`);
301
+ `pf_admin.js` fetches it as the value, "the current user", the list, the range or "or empty" change, instead of
302
+ rebuilding a value token in JavaScript (ticking "the current user" left the preview at "Only show Customers where
303
+ Owner is …." until the rule was saved). Without JavaScript the preview changes on the builder's refresh, and the
304
+ URL is a page of its own.
305
+ <!-- pin: test/permission_forge/html/rule_builder_explanations_test.rb#the preview is the sentence the saved rule shows, one string from one helper -->
306
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#the rule preview names the current user once it is ticked, without a doubled period -->
307
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#pf_admin.js fetches the preview sentence as the value inputs change -->
308
+ - **`Operators::RuleProblem::REMEDIES` / `#remedy`** — what the admin changes to correct a rule that matches nothing,
309
+ by kind (`:join`, `:path`, `:operator`, `:field`, `:value`); the taxonomy guard fails for a kind without one.
310
+ <!-- pin: test/permission_forge/html/rule_builder_explanations_test.rb#the banner names the fix for each kind of problem, in every locale -->
311
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#every kind has a remedy, and every remedy is a kind with a fix sentence in every locale -->
312
+ - **Labels of stored names have one definition.** `Admin::FieldLabel` (a column, field or association name),
313
+ `Admin::ModelLabel` (a model or Resource, and its plural) and `Admin::ActionLabel` (an action key) answer the host's
314
+ own translation when it has one (`activerecord.attributes.*` / `activerecord.models.*`, through
315
+ `human_attribute_name` / `model_name.human` with a sentinel default — Rails' humanized English fallback is not a
316
+ translation), else the row's label, else the name verbatim, which the admin draws in `<code>`.
317
+ `Admin::ErrorSentence` says a validation sentence from the gem's keys (`permission_forge.validations.attributes.*`,
318
+ `full_message`), `Admin::Prefill` what a new-record form shows. Helpers `pf_field_label`, `pf_field_label_text`,
319
+ `pf_action_label_text`; `pf_resource_label` / `pf_resource_plural` read `ModelLabel`.
320
+ <!-- pin: test/permission_forge/admin/labels_test.rb#a field is labelled by the host translation, else the row label, else the name verbatim -->
321
+ - **`Admin::StateLabel`** (the one registry of the admin's state words and glyphs, `ui.states.<kind>.<state>`) and
322
+ **`Admin::ActorLabel`** (how the admin names who made a change): internal.
323
+ <!-- pin: test/permission_forge/html/state_rendering_test.rb#the Effective access legend words the states as its cells do -->
324
+ - **`PermissionForge::ParamShapes#pf_integer_param(key, default:, range:)`** — the one reader of a whole-number
325
+ request parameter (a page, a page size, a count): the number when the value is one inside the range (digits or a
326
+ JSON number), the default for every other shape (text, `2abc`, fractions, lists, objects, blanks, 0 and negatives
327
+ below the range). The API's pagination reads `page` and `per_page` through it (see **Changed**), and so do the audit
328
+ log's and the person list's page, the audit tab's `days` and the rule builder's `remove_step` (`ParamShapes.integer`);
329
+ a static guard refuses `params[...].to_i` in the admin and the API.
330
+ <!-- pin: test/permission_forge/controllers/param_shapes_test.rb#anything else is the default: 0 and negatives below the range, text, fractions, lists, objects, blanks -->
331
+ - **Every code sample in README.md and docs/ says what it is**: its fence carries `run` (a console sample the
332
+ documentation app executes, `# => literal` asserted), `file=<path>` (a complete file written into that app) or
333
+ `fragment`. A ruby block that rescues a denial error is held to the handler that works in a Rails 8 Turbo app.
334
+ <!-- pin: test/permission_forge/docs/doc_blocks_test.rb#every ruby, erb and bash block in README.md and docs/ carries a marker and follows the rules -->
335
+ - **The documentation names only what exists** (`docs/references_test.rb`): links and anchors (GitHub links to
336
+ this repository too, also those the code prints), options (`config.<name>`, `name = value`, a bare name shaped like
337
+ one), `permission_forge:<task>` names (each documented with the environment variables it reads, `ACTOR` / `REASON`
338
+ included), `PermissionForge::…` constants, and the defaults a sentence states.
339
+ <!-- pin: test/permission_forge/docs/references_test.rb#every defined rake task is documented, with the environment variables it reads -->
340
+ <!-- pin: test/permission_forge/docs/references_test.rb#every link to this repository the code prints names a file and anchor of it -->
341
+ <!-- pin: test/permission_forge/docs/references_test.rb#every option the docs write as `name = value`, or as a bare `name` shaped like an option, is one -->
342
+ - **`bundle exec rake docs:configuration`** regenerates the Option reference of `docs/configuration.md` from the code
343
+ (the Settings catalog's sections and order, `Configuration.new`'s defaults, `StrategyValues`' values; every public
344
+ option has an entry); the suite fails while it is stale. Gem development only.
345
+ <!-- pin: test/permission_forge/docs/configuration_reference_test.rb#the Option reference in docs/configuration.md is what the code generates -->
346
+ - **The admin's layout is measured in a browser**: `test/browser/layout_probe.js` renders every page, error state and
347
+ flow of `test/support/preview_app.rb` at phone and desktop widths in en, sq and mk under Chrome (the `browser` CI
348
+ job, non-required until green for a release); without a browser, `html/layout_lint_test.rb` checks placeholders,
349
+ wide grids and scroll containers, and `html/admin_page_paths_test.rb` keeps the screen list complete (every GET
350
+ route, every `422` render site).
351
+ <!-- pin: test/permission_forge/html/admin_page_paths_test.rb#every GET route of the admin is reached by a screen -->
352
+ - **Scroll frames** (`pf_scroll_frame`, `.pf-scroll-x`) are regions a keyboard reaches (`tabindex="0"`, named for
353
+ assistive technology) with shadows at the edges that show when there is more to the side.
354
+ <!-- pin: test/permission_forge/html/layout_lint_test.rb#every wide grid is in a scroll frame that is reachable and named, on every screen -->
355
+ - **The suite proves the gem owns every key it renders.** The dummy app runs without `rails-i18n`, and
356
+ `html/locale_leak_test.rb` renders every screen, re-rendered form, toast, CSV export and API error class under a
357
+ pseudo-locale (`test/support/pseudo_locale.rb`): a word that is neither a translation nor host data fails.
358
+ <!-- pin: test/permission_forge/html/locale_leak_test.rb#every screen shows only translated words and host data -->
359
+ - **Static lints for the admin's words.** `release_readiness/inflection_guard_test.rb`: no English inflection
360
+ (`humanize`, `pluralize`, `to_sentence`, `full_messages`, `time_ago_in_words`, ...) and no Rails-core key family in
361
+ `app/` or `lib/permission_forge/`; `i18n_test.rb`: a sq/mk value identical to English needs a reason, one pair
362
+ of quotation marks per locale, one label per state; `release_readiness/column_default_prefill_test.rb`: no form
363
+ pre-fills a string column default; `AdminPagesTest` lints every re-rendered form as it does every screen.
364
+ <!-- pin: test/permission_forge/release_readiness/inflection_guard_test.rb#nothing in app/ or lib/permission_forge inflects English -->
365
+ <!-- pin: test/permission_forge/release_readiness/column_default_prefill_test.rb#no new-record screen or re-rendered form pre-fills a text default -->
366
+ <!-- pin: test/permission_forge/html/admin_pages_test.rb#markup is CSP-safe and namespaced, on every screen and every re-rendered form -->
367
+ - **Release-readiness guards.** `stored_data_fail_closed_test.rb` poisons every interpreted column of the eight models
368
+ past validation (nothing raises or widens, the audit names the row); `one_definition_guard_test.rb` (G4) fails a
369
+ re-derivation of a one-definition rule (`test/support/one_definition/*.rb`); `decision_parity_test.rb` (G5) checks
370
+ that two surfaces answering one question agree; `schema_freshness_test.rb` (G6) alters a table while the suite runs;
371
+ `services/rule_blame_property_test.rb` (G7) checks a denial's blame on generated cases; `changelog_pins_test.rb`
372
+ ties every CHANGELOG bullet to its test.
373
+ <!-- pin: test/permission_forge/release_readiness/stored_data_fail_closed_test.rb#PermissionSet: every interpreted column, poisoned past validation, never raises or widens -->
374
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#a re-derivation is a hit (negative fixture) -->
375
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#every reader sees a column a migration added after the schema was read, without a reset -->
376
+ <!-- pin: test/permission_forge/release_readiness/changelog_pins_test.rb#the guard reports what it should, on a fixture -->
377
+ - **Host model shapes, Rails' reader surface and hostile parameters are covered end to end.** `host_shapes_test.rb`
378
+ runs every enforcement path on a namespaced model with a uuid primary key (`Billing::Invoice`) and one with
379
+ `encrypts` and an `enum` (`PfEncryptedNote`); `concerns/masked_reader_surface_test.rb` calls every attribute-method
380
+ pattern of the Rails in use on a hidden column (one that hands out the stored value must be a raw path
381
+ `docs/masked-attributes.md` lists); `security/route_fuzz_test.rb` says what each hostile parameter must be read as
382
+ and plays the uniqueness race; a canary watches the `default:` option of `human_attribute_name` / `ModelName#human`.
383
+ <!-- pin: test/permission_forge/host_shapes_test.rb#Billing::Invoice: permission_scoped lists the rows the rule allows and no other -->
384
+ <!-- pin: test/permission_forge/concerns/masked_reader_surface_test.rb#every pattern that returns the stored value of a hidden column is a documented raw path, and every documented raw path still does -->
385
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#~reads every shape of -->
386
+ <!-- pin: test/permission_forge/rails_internals_canary_test.rb#human_attribute_name / ModelName#human: the default: option is answered when the host has no translation, the host's translation when it has one -->
387
+ - **`test/rls/rls_rule_parity_test.rb` has a case for every `Operators::RuleProblem` kind**: each compiles to `FALSE`,
388
+ selects nothing in the app layer and hides every row under a real policy; the matrix covers `uuid`, `inet`, `cidr`,
389
+ native enum, `jsonb` and `text[]` columns.
390
+ <!-- pin: test/rls/rls_rule_parity_test.rb#every kind of rule the app layer matches nothing for has a parity case -->
391
+ - **`config.no_read_access_strategy`: record reads that need read access.** `:rules_only` (the default) keeps the
392
+ record reads as they were: `find_by`, `find_by!`, `permission_scoped` and the `auto_query_scoping` default scope
393
+ apply the rules of the user's entries, so a signed-in user with no entry for the model, or one without
394
+ `read_access`, has no rules there and gets every row. `:deny` refuses them whole: for a signed-in user whose
395
+ entries along the model's Resource chain do not all grant `read_access` (whatever took it — no grant, a Muting
396
+ Permission Set, a Resource renamed off its model, a granting set whose conditions cannot be judged),
397
+ `permission_scoped` and the default scope are `none`, and `find_by` / `find_by!` raise `NotAuthorizedError` for an
398
+ existing record with one `failure_messages` entry from the locale (`permission_forge.denials.no_read_access`; en,
399
+ sq, mk), `nil` / `RecordNotFound` for a missing one; each is an `access.denied` with reason `no_read_access`. View
400
+ All and Modify All never stand in for read; with nobody signed in `nil_user_strategy` decides. One definition,
401
+ `PermissionForge::RecordReadAccess`. A README checklist row, a Settings page row and a strategy value checked when
402
+ set. The PostgreSQL RLS policies follow it: under `:deny` the SELECT / UPDATE / DELETE policies show a session user
403
+ no row without read access to the table's Resource chain, read per statement as `ResourceChain.readable?` reads it
404
+ (an active, unexpired assignment to an active granting set with an active Resource row for the model, every entry
405
+ along the STI chain granting `read_access`, no Muting Permission Set taking it; a conditional set never grants read
406
+ there, a conditional muting set always takes it — narrower, never wider); under `:rules_only` the policy is
407
+ unchanged. The strategy is part of the policy digest, so `rls:status` reports `STALE` after it is switched until
408
+ the next `rls:sync`. Part of `GENERATOR_VERSION` 7 (new in this release, so no further bump).
409
+ <!-- pin: test/permission_forge/concerns/no_read_access_strategy_test.rb#no entry, :deny: no rows, find_by raises with the locale message, a missing record is still missing -->
410
+ <!-- pin: test/permission_forge/concerns/no_read_access_strategy_test.rb#no entry, :rules_only: every read returns every row, as before -->
411
+ <!-- pin: test/permission_forge/concerns/no_read_access_strategy_test.rb#STI: an unreadable base denies the subclass that grants read itself -->
412
+ <!-- pin: test/permission_forge/release_readiness/stored_data_fail_closed_test.rb#~under no_read_access_strategy :deny, no poisoning widens a record read -->
413
+ <!-- pin: test/rls/rls_read_access_parity_test.rb#a user with no entry for the model: every row under :rules_only, none under :deny, in both layers -->
414
+ <!-- pin: test/rls/rls_read_access_parity_test.rb#read removed by a Muting Permission Set: none under :deny, in both layers -->
415
+ <!-- pin: test/rls/rls_read_access_parity_test.rb#STI: a subclass entry without read hides its rows (and its subclasses) under :deny -->
416
+ <!-- pin: test/permission_forge/services/rls_policy_generator_test.rb#the digest follows no_read_access_strategy, so rls:status reports the policy stale after it changes -->
417
+
418
+ - **`cache.settled`: what the cache store said after an erasure or a consent withdrawal.** `erasure.performed` and
419
+ `consent.withdrawn` are written in the operation's transaction, before its cache work, so they record
420
+ `cache_confirmed: nil`. A `cache.settled` event (category `system`, `subject_user_id`, `data.operation` `erasure` or
421
+ `consent_withdrawal`, and the `Cache::Outcome`) now follows on the same `correlation_id` with the store's answer;
422
+ inside a caller's transaction the work runs again after its commit, measured, and the event is written then (never
423
+ after a rollback). A failed post-commit invalidation of an erased person leaves this trace
424
+ (`cache_confirmed: false`), and its sentence says the store did not confirm it (en/sq/mk).
425
+ <!-- pin: test/permission_forge/services/cache_settlement_outcome_test.rb#an erasure records what the store said in cache.settled, on the erasure correlation id -->
426
+ <!-- pin: test/permission_forge/services/cache_settlement_outcome_test.rb#inside a caller transaction cache.settled is written after the commit, with the outcome then -->
427
+ <!-- pin: test/permission_forge/services/cache_settlement_outcome_test.rb#a rolled-back erasure settles nothing -->
428
+ - **The route fuzz poisons each parameter alone, inside a valid request.** `security/route_fuzz_test.rb` poisoned
429
+ every parameter at once, so a parameter read only when another is valid (`days` behind `tab=audit`,
430
+ `rule[remove_step]` behind a rule form) was never reached. Per route it now poisons each parameter the controller
431
+ reads (found in the source by `FuzzParams`: `params[...]`, the `pf_*_param` readers, permitted keys) one at a time
432
+ inside each valid request of the route (`valid_contexts`), then each attribute of each attribute section, the API
433
+ also as a JSON body with `null`; a guard fails when a parameter the engine reads is missing from the fuzz table.
434
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#every parameter the engine reads is in the fuzz table -->
435
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#every attribute section the engine reads has a valid request that reaches it -->
436
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#~answers each read parameter alone below 500 -->
437
+ - **Static guards: the cache report per method, integer parameters, warning severities.** The cache-report guard
438
+ places each invalidation per method (a `Cache::Report` block, `cache_outcome`, or a method all of whose callers are
439
+ measured) instead of exempting a whole file once it mentions `Cache::Report`, and also sees
440
+ `regenerate_permissions`, `cache.delete` / `expunge_many` / `delete_matched` and `lib/permission_forge/admin`; a new
441
+ guard refuses `params[...].to_i` and `.to_s.to_i` in the admin and the API; every `Warnings.emit` key has its
442
+ severity, and a reason when it is not SECURITY, in the warnings channel test, whose needle also sees a logger's
443
+ `error` / `add` / `fatal`.
444
+ <!-- pin: test/permission_forge/release_readiness/cache_report_guard_test.rb#negative fixture: an unmeasured method in a file that measures elsewhere is a hit -->
445
+ <!-- pin: test/permission_forge/release_readiness/integer_param_guard_test.rb#no request value is read with to_i -->
446
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#every warning has its severity in SEVERITY, the one its site passes, and a reason when it is config -->
447
+ - **API: every reference to another record is answered by its uuid too.** Under `api_record_identifier = :uuid` the
448
+ answers named the records they refer to by numeric id only — a Permission Set said `permission_set_group_id: 4` right
449
+ after the PATCH that sent the group's uuid — so a client that only knows uuids could not follow them (field test
450
+ round 8, D#29). Each such key is now followed by the record's uuid: `permission_set_group_uuid` (a Permission Set, a
451
+ group member), `permission_set_uuid` (a Resource, a rule, an assignment) and `resource_uuid` (a field, an action, a
452
+ rule), under either identifier setting; the `_id` keys keep their value and type. One declaration in the serializer
453
+ layer (`Serializers::BaseSerializer.reference`), one query per reference for a page of records; `Api::Schema` types
454
+ the new keys `:foreign_uuid`. The `_uuid` keys are answer-only: send the `_id` key to change a reference (it takes
455
+ the uuid under `:uuid`, as before); a `_uuid` key sent back with a whole answer is ignored like any key the endpoint
456
+ does not take, so such a PATCH stays idempotent and a stale copy cannot undo another admin's move.
457
+ `user_id` (your user's key), a serializer of your own (`api_serializer_overrides`) and an AMS serializer are
458
+ unchanged.
459
+ <!-- pin: test/permission_forge/api/reference_uuids_api_test.rb#a set moved into a group by its uuid answers the group by its uuid too -->
460
+ <!-- pin: test/permission_forge/api/reference_uuids_api_test.rb#every answer names the set, the Resource and the group it refers to by their uuids -->
461
+ <!-- pin: test/permission_forge/api/reference_uuids_api_test.rb#a page looks the uuids up in one query per reference, however many rows it has -->
462
+ <!-- pin: test/permission_forge/api/reference_uuids_api_test.rb#an answer sent back whole moves the set as its id key says, the same every time -->
463
+ <!-- pin: test/permission_forge/api/reference_uuids_api_test.rb#a body with only permission_set_group_uuid changes nothing: the companion is answer-only -->
464
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#every foreign identifier is followed by the uuid of the same record, null where it may be null -->
465
+ - **`masked_attributes_nil_user_strategy = :hide_in_requests`: masked attributes are hidden in a web request with
466
+ nobody signed in, and keep their stored values in jobs, rake, the runner and the console** (GitHub #11). Under
467
+ `:bypass` a public page, a webhook, a controller that forgot authentication or a resolver that returns nil got every
468
+ guarded attribute as stored, and nothing recorded it. The new value hides them there exactly as `:hide` does (the
469
+ primary key and the raw paths untouched, the user model exempt for sign-in). "A web request" has one definition,
470
+ `PermissionForge::WebRequest`: the engine puts `WebRequest::Middleware` at the top of the host's middleware stack
471
+ (`config.app_middleware`; a middleware inserted above it with `insert_before 0`, as rack-cors documents, reads
472
+ outside the request: the boot log names it, `web_request_middleware_above_hide` / `_record`, and
473
+ `rake permission_forge:audit` lists it, `middleware_above_web_request`, info), which marks the request in `ExecutionState` slot `:pf_web_request` (execution scope, kept
474
+ at the end of an executor run) until the response is finished (`rack.response_finished`, else when the body is
475
+ closed; at once when the app raises) — so a middleware or a `before_action` that reads a model before sign-in counts,
476
+ as do a job run with `perform_now` and a mail sent with `deliver_now` during the request; jobs run by a worker,
477
+ Action Cable channel actions, rake, the runner, the console and `ActionController::TestCase` tests are outside.
478
+ `Configuration#masked_attributes_without_user_for(klass)` (`:hide`, `:record` or `:bypass`) is the one decision
479
+ behind `hide_masked_attributes_without_user_for?`; `hide_masked_attributes_without_user_in_requests?` says whether
480
+ the strategy hides at least in requests. `MaskedAttributes.explain` adds `in_web_request`. The Settings page lists
481
+ the three values with what each does (en, sq, mk). A record that read an attribute as hidden because nobody was
482
+ signed in refuses a later write to that attribute as a masked write-back (`masked_write_protection`: `:skip` keeps
483
+ the stored value, `:raise` raises; `write.masked_skipped` `detected_by: hidden_without_user`), so a webhook that
484
+ normalizes `name` from its hidden value does not store `""`. Only an attribute that held a value when it was read
485
+ counts (an empty column or an unfilled attribute of a new record takes a default or a fill-if-blank; a value
486
+ submitted on a new record is kept from a normalizer or a default that reads it), and the attributes are forgotten
487
+ on `reload`, not after a save. A deliberate overwrite of a value nobody could see (a change-email link
488
+ with nobody signed in) is refused too, and logged once per attribute and process; a value derived for another
489
+ attribute (`domain` from a hidden `email`) cannot be told apart: sign a system user in for such flows and for
490
+ webhooks that normalize data.
491
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#a value worked out from an attribute read as hidden with nobody signed in is not saved over it -->
492
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#under masked_write_protection :raise such a write raises, and an attribute never read is written -->
493
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#nothing real was hidden: a new record and an empty column take a default or a fill-if-blank -->
494
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#a stored value read hidden is kept; overwriting it without a sign-in is refused, recorded and logged once -->
495
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#the hidden reads are forgotten on reload, not after a save -->
496
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#a value submitted on a new record is kept from a normalizer or a default that reads it hidden -->
497
+ <!-- pin: test/permission_forge/web_request_test.rb#the boot check warns while the strategy depends on the request, naming what runs above -->
498
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#middlewares inserted above the request marker are an info, unless the strategy is :hide -->
499
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#:hide_in_requests hides every guarded attribute in a web request with nobody signed in, before_action included -->
500
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#:hide_in_requests hides a read in a middleware below WebRequest::Middleware, before any controller -->
501
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#:hide_in_requests keeps the stored values outside a web request: a job, the console, a runner, rake -->
502
+ <!-- pin: test/permission_forge/web_request_test.rb#the engine puts the middleware at the top of the host application's stack -->
503
+ <!-- pin: test/permission_forge/web_request_test.rb#below the middleware the request is a web request, with its env, until the server closes the body -->
504
+ <!-- pin: test/permission_forge/web_request_test.rb#a run completed inside the request does not end it -->
505
+ <!-- pin: test/permission_forge/html/masked_nil_user_setting_test.rb#the Settings page lists bypass, hide_in_requests and hide, marks the current one and explains them in en, sq and mk -->
506
+ - **Under `:bypass`, a masked value returned in a web request with nobody signed in is recorded** (GitHub #11): the
507
+ runtime audit event `field.unmasked_without_user` (group `:bypass`: published always, stored when
508
+ `audit_runtime_events` includes `:bypass`) once per request per model and attribute — the model, the attribute, the
509
+ request's method, path and endpoint, and its request id and IP as columns; never a value — for exactly the reads
510
+ `:hide_in_requests` would hide; and one SECURITY warning per model and process in the log, naming the model, the
511
+ attribute and the request (models by name; an `inspect` records nothing). With nobody listening and the group not
512
+ stored, nothing is built or allocated: once the model has been warned about, `attributes` returns the stored hash
513
+ as outside a request and a read pays a few lookups (in the gem's benchmark, 1,000 records of 8 columns: getters
514
+ about 20% and `as_json` about 17% slower inside a request than in 0.14, about 5% outside one). Sentence in en,
515
+ sq and mk.
516
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#:bypass returns the stored values in a web request and publishes field.unmasked_without_user once per request per attribute -->
517
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#field.unmasked_without_user is stored with the bypass group, never with a value -->
518
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#a SECURITY warning names the model, the attribute and the request, once per model and process -->
519
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#with nobody listening and the bypass group not stored, a read in a request builds nothing and allocates no more -->
520
+ <!-- pin: test/permission_forge/concerns/masked_attributes_in_requests_test.rb#inspect in a request publishes nothing and warns nothing: a debugging read -->
521
+
522
+ ### Changed
523
+
524
+ - **`config.preset = :strict` sets `masked_attributes_nil_user_strategy = :hide_in_requests`, and new installs get it
525
+ from the generator** (it was in no preset, `Presets::OPT_IN`, which is now empty). Under the strict preset, or in a
526
+ new install, a page that reads a masked model before anyone signs in (a tenant or subdomain lookup, a public sign-up
527
+ page, a mail sent with `deliver_now`) reads hidden values; jobs and the console keep the stored values. `:hide`
528
+ counts as closed (no `strict_overrides`). The library default stays `:bypass`. `rake permission_forge:audit` reports
529
+ `:bypass` (`masked_nil_user_bypass`, medium) with `:hide_in_requests` as the remedy and its trade-off, and
530
+ `:hide_in_requests` as `masked_nil_user_outside_requests` (info); `user_model_unmasked_without_user` covers both
531
+ hiding values. **Opt out:** `config.masked_attributes_nil_user_strategy = :bypass`.
532
+ <!-- pin: test/permission_forge/configuration_presets_test.rb#:strict sets masking with nobody signed in to :hide_in_requests; :hide counts as closed -->
533
+ <!-- pin: test/permission_forge/configuration_presets_test.rb#a new install gets the safe subset explicitly and :strict offered, not active -->
534
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#masking with nobody signed in: :bypass is medium and recommends :hide_in_requests, which leaves an info -->
535
+ <!-- pin: test/permission_forge/install/fresh_install_test.rb#a default install on integer user keys works end to end -->
536
+
537
+ - **A rule whose field name is not a column of its model does not save.** `PermissionSetRule` adds "names
538
+ `onwer_id`, which is not a column of Customer" on `field_name` (en, sq, mk; it names the stored value, like the path
539
+ message) when the Resource's model loads and the name is no column of its table — along `field_accessor_path` for a
540
+ join rule — so the JSON API answers its field-keyed 422 instead of 201, and the admin shows the message. Checked for
541
+ a new rule and when the field name, path, join type or resource changes, so an existing row stays editable in its
542
+ other attributes (a stored `'filter'` join type and the admin's nil are one); saving compares the table's columns
543
+ with the database (`RuleColumn.for_rule(rule, verify: true)`, one metadata query per model along the path), so a
544
+ column a migration just added is accepted; skipped when the model cannot be loaded, its table does not exist or the
545
+ path cannot be followed. Copying a Permission Set (or a group) copies such a rule as it is: leaving it out would
546
+ widen the copy. **No opt-out:** the rule could only ever raise (and now matches nothing); write it past the model if
547
+ you must.
548
+ <!-- pin: test/permission_forge/models/permission_set_rule_column_test.rb#a field name that is no column of the model is refused on field_name -->
549
+ <!-- pin: test/permission_forge/api/permission_set_rules_controller_test.rb#POST create answers 422 on field_name for a column the model does not have -->
550
+ <!-- pin: test/permission_forge/models/permission_set_rule_column_test.rb#an existing row is checked only when what it names changes -->
551
+ <!-- pin: test/permission_forge/models/permission_set_rule_column_test.rb#a column the process confirmed missing before a migration added it is accepted at save -->
552
+ <!-- pin: test/support/decision_parity/rules.rb#the column-typo validation message names the stored value, like the path message -->
553
+ - **A join rule whose association path cannot be joined does not save.** `PermissionSetRule` adds "names ownr, which
554
+ is not an association of Customer" on `field_accessor_path`, or why no join can be built through it (a polymorphic
555
+ association, a class that cannot be loaded, a scope that takes the record — also along a `:through` —, a `:through`
556
+ / `:source` / `inverse_of` naming nothing, an empty step in `parent..owner`; en, sq, mk;
557
+ `Operators::RuleProblem#cause` / `#key`), so the JSON API answers its field-keyed 422 instead of 201. Checked for a
558
+ new join rule and when its path, join type or resource changes (an existing row stays editable in its other
559
+ attributes); a filter rule's path is never checked. Every association ActiveRecord joins is accepted
560
+ (`Operators::RulePath` asks what `Associations::JoinDependency#build` asks; a canary watches it). Copying a
561
+ Permission Set copies such a rule as it is. **No opt-out:** the rule could only ever raise (and now matches
562
+ nothing).
563
+ <!-- pin: test/permission_forge/models/permission_set_rule_path_test.rb#each cause has its own message -->
564
+ <!-- pin: test/permission_forge/api/permission_set_rules_controller_test.rb#POST create answers 422 on field_accessor_path for an association the model does not have -->
565
+ <!-- pin: test/permission_forge/models/permission_set_rule_path_test.rb#a path ActiveRecord cannot join is refused with its cause, and one it joins is accepted -->
566
+ - **A rule qualified with a name its query gives no table matches nothing, and is refused for a new or changed
567
+ rule:** `nosuch.name`, the typo `customer.name`, a column of a join's ON clause (`supplier_id.id` on a rule joining
568
+ `supplier`) and, on a filter rule, another model's table (`suppliers.name`: a filter rule compares its own table).
569
+ They reached SQL as written and raised for every holder, also under `auto_query_scoping`. The audit
570
+ (`missing_rule_columns`, `foreign_table_filter_rules`) and Home list stored ones. A join rule may still name a table
571
+ its query joins itself or an alias its join gives (`parents_customers`). **Opt out:** use a join rule (`join_type` +
572
+ `field_accessor_path`) for a column of another table.
573
+ <!-- pin: test/permission_forge/models/permission_set_rule_column_test.rb#a qualifier naming no table is refused; a join rule may name a table or an alias its query gives -->
574
+ <!-- pin: test/permission_forge/services/permission_rule_applier_test.rb#a filter rule qualified with a table of another model matches nothing in every gem-built query -->
575
+ <!-- pin: test/permission_forge/services/permission_rule_applier_test.rb#a join rule qualified with a name the ON clause mentions, which is no table or alias, matches nothing -->
576
+ - **A rule value its column cannot hold is refused on save** (`field_value`: "is not a value Customer.supplier_id can
577
+ hold"; the JSON API answers 422), for a new rule and when its value, operator, field name, path, join type or
578
+ resource changes — `'5_id'` on an integer column, `'maybe'` on a boolean one, `'banana'` on a `uuid` one, a
579
+ `between` bound or a list element the column cannot hold. Such a rule matched nothing for everyone it applies to. A
580
+ rule saved before (or written past the model) stays editable in its other attributes, still matches nothing, and is
581
+ still listed by the audit (`unusable_rule_values`) and on Home; a copy keeps its original's value. `current_user` is
582
+ refused only where no id could be held (a boolean, date, address, json or enum column). **No opt-out:** the rule
583
+ could only ever deny every record to its holders; saving it hid a lock-out behind a successful save.
584
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#unusable_value: a new rule is refused with the problem's message -->
585
+ - **What a rule or field row names is checked when it changes.** `operator` and `join_type` (`PermissionSetRule`) and
586
+ `mask_strategy` (`ResourceField`, also when `mask_access` changes) are validated for a new row and when the
587
+ attribute changes — as `field_name` and the association path are — so a row written before the check, or past the
588
+ model (a rolling deploy, another application on the shared tables, a custom mask strategy since removed from the
589
+ configuration), stays editable in its other attributes and can be switched off or renamed through the admin and the
590
+ API. It matches nothing (a rule) or masks fully (a field) until it is fixed; the audit and the admin name it.
591
+ **No opt-out:** a loosening (such rows could not be edited at all).
592
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#unknown_operator: a row written before the check stays editable in its other attributes -->
593
+ - **A rule whose field name is not a name** (a trailing or doubled dot) is listed by the audit under
594
+ `missing_rule_columns` (High while held) instead of `unusable_rule_values`, and on Home. **No opt-out:** a report
595
+ classification; the rule matched nothing before and after.
596
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#rules whose value the column cannot hold are reported as matching nothing -->
597
+ - **The admin's rule builder refuses a posted condition it does not know** ("Pick a condition from the list.")
598
+ instead of silently rewriting it to "is" (`RuleFormInput` keeps the operator as posted); a saved rule's condition is
599
+ kept as stored on its edit page, which says the rule matches nothing. **No opt-out:** the form offers only the
600
+ conditions it knows.
601
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#a posted condition that is not one is refused, never rewritten to "is" -->
602
+ <!-- pin: test/support/decision_parity/rules.rb#the operator is kept as posted, refused when it is none (no rewrite to equal) -->
603
+ - **A new record rule on a column that holds a secret is refused.** A rule whose column (after its association path,
604
+ qualified or not) matches `config.rule_secret_column_patterns` — digests, tokens, `encrypted_*`, secrets, passwords,
605
+ salts, API / private / access keys, OTP seeds — fails validation on create and when its column, path, join type or model changes
606
+ (`names a column that holds a secret …`), and the admin's rule builder no longer offers such columns (except the one
607
+ a stored rule already names). Rules stored before keep evaluating as they did and stay editable except their value
608
+ and operator; the audit (`secret_rule_columns`) and Home list them. **Opt out:** `config.rule_secret_column_patterns =
609
+ []` (or a narrower list).
610
+ <!-- pin: test/permission_forge/models/permission_set_rule_secret_columns_test.rb#a new rule on a secret column is refused, with a message in every locale -->
611
+ - **`include_nulls` is off unless asked for — in code, through the JSON API and in the admin's rule builder alike.**
612
+ The column default (`true`) made a rule created in code or through the API let the NULL rows through
613
+ (`owner_id equal 5` also matched unassigned records) while the admin's builder started unchecked; the model now
614
+ declares the default (`attribute :include_nulls, :boolean, default: false`; the install migration writes `false`
615
+ too, and on an upgraded install the model's default wins over the column's). Rows already stored keep their value.
616
+ **Opt out:** pass `include_nulls: true` (code, API) or tick "Also show records where … is empty" in the builder — per
617
+ rule, as before.
618
+ <!-- pin: test/support/decision_parity/rules.rb#include_nulls has one default: off in code, through the API and in the builder -->
619
+ - **The rule builder offers every column of the table along the path, `id` included** (flagged "no field row
620
+ (ignored_fields)"), the same list the model's validation accepts (`RuleColumn.names_for`); a virtual field is never
621
+ offered. A rule on `id` — the docs' own example — can be built and repaired in the admin again. **No opt-out:** the
622
+ validation did not change; the admin stopped hiding columns it accepts.
623
+ <!-- pin: test/support/decision_parity/rules.rb#the builder offers exactly the columns the model accepts, ignored_fields included and flagged -->
624
+ - **The rule builder only offers associations a join can be built through.** `Admin::ModelCatalog#associations` keeps
625
+ the reflections `Operators::RulePath.unjoinable_cause` accepts: an association with a record-dependent scope, a
626
+ broken `:through` / `inverse_of`, a polymorphic one or one whose class cannot be loaded is no longer listed (a path
627
+ through it matched nothing and the model refused it on save). **No opt-out:** a rule through such an association
628
+ could never match a record.
629
+ <!-- pin: test/permission_forge/admin/services_test.rb#associations are the ones a join can be built through -->
630
+ - **RLS: `RlsPolicyGenerator::GENERATOR_VERSION` is 7** (5 in 0.14). A policy compiles every rule the application
631
+ layer matches nothing for — every `Operators::RuleProblem` kind — to `FALSE`, where such rules raised during the
632
+ sync or were skipped and left the policy wider than the application layer (a custom operator without SQL is still
633
+ skipped and reported). It types `uuid` / `inet` / `cidr` / native enum / `json` / array values like the app layer,
634
+ counts an assignment until its `expires_at` included (`>= now()`, as `PermissionSetUser.active_assignments`; it was
635
+ `>`), computes `today` / `now` / `tomorrow` / `yesterday` per statement (frozen at the sync before), grants the
636
+ bypass for View All alone (Modify All without it bypassed in the policy only), and only to a set without a condition as
637
+ `PermissionSet.unconditional` reads it (`conditions_present_sql`): a set with only an end hour no longer bypasses
638
+ the rules, and one whose condition texts are only blanks does. Existing installs see drift until they run `rake
639
+ permission_forge:rls:sync`. **No opt-out:** a policy is never wider than the application layer; the old policies
640
+ stay in force until the sync.
641
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#unknown_operator: a row written past the model is named, denies, is listed, compiles to FALSE -->
642
+ <!-- pin: test/permission_forge/services/rls_policy_generator_test.rb#only unconditional sets grant the view_all bypass (consent guard) -->
643
+ <!-- pin: test/permission_forge/services/rls_policy_generator_test.rb#view_all bypass branch checks granting and muting sets -->
644
+ <!-- pin: test/rls/rls_rule_parity_test.rb#PostgreSQL's relative date words are read per statement by the app layer and by the policy -->
645
+ - **PostgreSQL: a `uuid`, `inet`, `cidr` or native enum value the column cannot hold matches nothing** where it raised
646
+ `ActiveRecord::StatementInvalid` for every holder (`find_by`, `permission_scoped`, every query under
647
+ `auto_query_scoping`) — `'banana'` against a `uuid` column, an address IPAddr cannot read against an `inet` column, an
648
+ address with host bits against a `cidr` column, a word that is not a label against a native enum column. Such a rule
649
+ is refused on save and listed by `rake permission_forge:audit` (`unusable_rule_values`, Medium while held) and on
650
+ Home. **No opt-out:** a value PostgreSQL cannot read never matched a row; a custom operator (an injected
651
+ `operator_registry:`) still receives the value as written.
652
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#'banana' against a uuid column matches nothing, is refused on save and is listed by the audit -->
653
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#each of these rules is named by RuleProblem and refused on save -->
654
+ - **PostgreSQL: a rule value the gem cannot type itself is asked of PostgreSQL, once per process** (a `CAST` in a
655
+ savepoint, `ValueCasters::PostgresqlTypes::ReadBack`): date and time text in another notation than ISO 8601
656
+ (`today`, `31 Jan 2026`) and every column type no caster knows that PostgreSQL can compare (`money`, `interval`,
657
+ `macaddr`, ...; `xml`, `point`, `hstore` cannot: no value, only the null checks). A timeout is not kept as unreadable.
658
+ What PostgreSQL reads is compared as written, as before; what it cannot read (`'banana'` for a date, `'noon'` for a
659
+ time) now matches nothing and is refused on save — it raised for every holder of the rule, on every query, and failed
660
+ `rake permission_forge:rls:sync`. `citext` is text. **No opt-out:** such a rule never matched anything; a custom
661
+ operator still gets the value as written.
662
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#date text and types no caster knows are read back: what PostgreSQL cannot read matches nothing -->
663
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#a type PostgreSQL cannot compare takes no value: xml and point match nothing, the null checks work -->
664
+ <!-- pin: test/permission_forge/operators/read_back_memo_test.rb#a timeout, a lock timeout or a lost connection denies one read and is asked again -->
665
+ - **PostgreSQL: a LIKE operator (`contains`, `starts_with`, ...) matches text columns only.** On an integer, decimal,
666
+ boolean, date, `uuid`, `inet`, `cidr`, native enum, `json` or other non-text column it matches nothing and is refused
667
+ on save — PostgreSQL has no LIKE for those, so the query raised for every holder (the RLS policy already compiled it
668
+ to `FALSE`). MySQL and SQLite, which compare such a column's text, are unchanged. **No opt-out:** such a rule never
669
+ matched anything.
670
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#LIKE matches text columns only: an integer, a date or a boolean matches nothing instead of raising -->
671
+ - **A rule on a `json`, `jsonb` or array column matches nothing under every built-in operator but `is_null` /
672
+ `is_not_null`** — also an array or JSON literal that PostgreSQL used to compare as a whole document (`codes equal
673
+ '{1,2}'`), and on MySQL and SQLite, where a `json` column was compared as text. **Opt out:** register a custom
674
+ operator (`operator_registry:`), which receives the value as written.
675
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#a json or array column matches nothing under every built-in operator but the null checks -->
676
+ - **An explicit field row for a variant name decides that name within its Resource.** An `owner_uuid` row next to an
677
+ `owner_id` row used to be OR-ed with the variant derived from `owner_id`, so an explicit Hidden `owner_uuid` read as
678
+ Editable in either row order. Now the row the admin wrote wins, whichever row is older; a derived variant only fills
679
+ a name no row of the resource has. Muting rows still mute their variants. The audit's `variant_rows_conflict` lists
680
+ the pairs before you upgrade. **No opt-out:** the explicit row is what the admin wrote.
681
+ <!-- pin: test/permission_forge/services/permission_hash_builder_test.rb#G5f: the owner_uuid entry is the explicit row when there is one, else the derived variant, in either order -->
682
+ - **A field variant gets a permission-hash entry only when the model has that name.** The default
683
+ `field_variant_mappings` (`_id` → `_uuid`, `_cents` → base name) added an entry for every mapped name, so a
684
+ `Customer` with an `owner_id` row and no `owner_uuid` column had an `owner_uuid` entry, which the admin's Effective
685
+ access listed and counted. A variant is now added (and muted) only when the model has it: a column or `attribute`,
686
+ an `alias_attribute`, a public reader or writer, or a declared virtual field; a model this app cannot load keeps
687
+ every variant. The hash format is unchanged, with fewer keys:
688
+ `permissions_hash_for(:Customer)[:fields][:owner_uuid]` is `nil` for a name the model lacks.
689
+ `FiltersResponseAttributes` still filters such a key of a serializer's hash by the entry of its base field.
690
+ **No opt-out:** none needed; if your code reads the entry of a computed variant, give the model the reader (or declare it
691
+ in `extra_fields_for_permissions`).
692
+ <!-- pin: test/permission_forge/field_variants_test.rb#the permission hash has entries for the real variants only -->
693
+ <!-- pin: test/permission_forge/field_variants_test.rb#a response key that is a variant the model lacks follows its base field -->
694
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#no variant field for a name the model does not have, in the engine or here -->
695
+ - **A grant counts only with the permission it depends on**, at read time as well as at save time: a field row with
696
+ `edit_access` but no `read_access`, a Resource row with `edit_access` / `delete_access` but no `read_access` or
697
+ `modify_all` without `view_all` (rows written past `ValidatesDependency`: SQL, an older version, another app) no
698
+ longer grant the dependent permission, and `PermitsAttributes` keeps a key only where the field is readable and
699
+ editable. The audit's `dependency_violations` lists such rows. **No opt-out:** the validation already forbade it.
700
+ <!-- pin: test/permission_forge/services/permission_hash_builder_test.rb#resource flags count edit and delete only with read, Modify All only with View All -->
701
+ - **A broken activation condition is unjudgeable in every mode.** An IP entry that is not an address, a list of
702
+ separators, one hour of the two, the same hour twice, or an hour outside 0–23 (stored past the validation) now makes
703
+ a **muting** set apply to every request (it never applied: a lockdown written with newlines through the API muted
704
+ nothing) and a **granting** set never pass, with or without a request context (a blank one let it grant). Called out: a granting set with **one hour of the two** used to pass (the
705
+ hours were ignored) and now never does; a granting set with `start == end` never passed before either. IP lists
706
+ written with newlines or spaces are now read entry by entry (they used to be one invalid entry). The audit's
707
+ `incomplete_conditions` lists every such set — run `rake permission_forge:audit` before upgrading; the admin's
708
+ Conditions tab and the API normalise the list on the next save. **No opt-out:** fix the condition on the set's
709
+ Conditions tab.
710
+ <!-- pin: test/permission_forge/conditional_sets_test.rb#a muting set with a broken condition applies in every mode; a granting one never passes -->
711
+ <!-- pin: test/permission_forge/concerns/conditional_sets_per_context_test.rb#a granting set with a broken condition grants nothing, not even without a context -->
712
+ - **A Permission Set's activation conditions are validated on a new row or a changed condition**: an `allowed_ips`
713
+ entry that is not an IP address or CIDR range, a window whose start equals its end, one hour of the two (en/sq/mk
714
+ messages); the stored list is normalised to commas on save, a text of whitespace alone (any Unicode space) to nil (a
715
+ row stored before keeps SQL's narrower reading); a copy keeps its original's. A row stored before the check stays
716
+ editable in its other attributes. **No opt-out:** such a condition cannot be judged (see above).
717
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#a legacy row with a broken list stays editable until the list changes -->
718
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#a new row with the same start and end hour is refused -->
719
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#a condition text of whitespace alone is stored as nil, so Ruby and SQL read it the same -->
720
+ - **`PermissionSet#conditional?` and the `conditional` / `unconditional` scopes agree**: a text of blanks is no
721
+ condition (`TRIM`), one hour of the two is one. Counts on pages and in the audit may change for such rows.
722
+ **No opt-out:** two readings of one row.
723
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#conditional? and the SQL scopes agree: whitespace is no condition, one hour of the two is -->
724
+ - **The admin's condition sentence names a broken hour window as one** ("between 09:00 and 09:00 (a window nobody can
725
+ judge — fix it on the Conditions tab)") instead of calling it overnight; the Conditions form refuses a start hour
726
+ equal to the end hour (error keyed `:hours`). **No opt-out:** wording.
727
+ <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#hours_window: none, a window, an overnight window, and the broken shapes -->
728
+ - **A mapped controller action no Permission Set manages is refused (403) unless `unconfigured_action_strategy = :allow`.** (issue #10)
729
+ `unconfigured_action_strategy` defaults to `:block` (it was `:allow`), in `Configuration.new` and so in the `:compat`
730
+ preset too. In a controller that includes `ControllerActionGuard`, an action that maps to a Resource no Permission Set
731
+ manages, or a custom action no Permission Set manages, raises `ActionNotAuthorizedError` (reason
732
+ `resource_not_managed_blocked` / `action_not_managed_blocked`, an `action.denied` event; 403 unless you rescue it)
733
+ instead of running for every signed-in user — a new action nobody configured is refused until someone decides who
734
+ may run it, as an unconfigured field already was. Unchanged: actions that map to no Resource are never checked, a
735
+ request with nobody signed in follows `action_guard_nil_user_strategy`, `action_guard_bypass` and grants come
736
+ first. Every refused request stores an `action.denied` audit event (the denied group is stored by default), so a
737
+ page left unmanaged fills the audit log. `rake permission_forge:audit` lists the guarded actions this refuses
738
+ (`action/unmanaged_actions_refused`, Low: per Resource not managed, then per custom action; with tenancy, each with
739
+ the tenants that do not manage it), read from the routes whether or not `controller_action_discovery` is on — run it
740
+ before the deploy; a CI step running it with `FAIL_ON=low` (or `info`) now fails on that finding. It cannot see a
741
+ per-action `skip_before_action :pf_authorize_action!, only: …` (those actions are listed although they run), and
742
+ `action/actions_not_checked` (Low) names the controller actions it could not judge (a controller that fails to load,
743
+ a raising resolver or mapping). The admin's Actions tab says "Not managed — blocked until a set permits it", and its
744
+ note and review say the first row opens an action to the people permitted. If you copied
745
+ `config.unconfigured_action_strategy = :allow` from the 0.14 reference block of docs/configuration.md, remove it to
746
+ get the new default. **Opt out:** `config.unconfigured_action_strategy = :allow` restores the 0.14 behaviour;
747
+ it fails open, so the audit reports it (`config/unconfigured_actions_allowed`, Medium, and
748
+ `action/unmanaged_actions_allowed`, Info, listing the actions it opens), the Settings page flags it and `:strict`
749
+ counts it as an override.
750
+ <!-- pin: test/permission_forge/controller_actions/guard_integration_test.rb#an action nobody lists is refused by default and runs under :allow -->
751
+ <!-- pin: test/permission_forge/controller_actions/guard_integration_test.rb#a resource no Permission Set manages is refused by default and runs under :allow -->
752
+ <!-- pin: test/permission_forge/configuration_test.rb#controller action options default to discovery off and block -->
753
+ <!-- pin: test/permission_forge/services/security_audit_action_check_test.rb#guarded actions no Permission Set manages are listed as refused, per resource and per action -->
754
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#an explicit :allow for unconfigured actions is a medium finding -->
755
+ <!-- pin: test/permission_forge/services/security_audit_action_check_test.rb#with tenancy, an action is listed with the tenants that do not manage it -->
756
+ <!-- pin: test/permission_forge/services/security_audit_action_check_test.rb#controller actions the catalog could not judge are reported, and the rest still checked -->
757
+ <!-- pin: test/permission_forge/html/action_discovery_test.rb#under :allow, review warns that the first row restricts the action -->
758
+ - **An unrescued denial answers 403 Forbidden instead of 500.** The engine registers
759
+ `PermissionForge::Concerns::PermissionScopedFindable::NotAuthorizedError` and
760
+ `PermissionForge::ActionNotAuthorizedError` in `config.action_dispatch.rescue_responses` as `:forbidden`, as
761
+ ActiveRecord registers `RecordNotFound` as 404. In production Rails renders `public/403.html`, which Rails apps do
762
+ not ship (an HTML request gets an empty 403 page until you add one; JSON gets `{"status":403,"error":"Forbidden"}`).
763
+ Like every mapped exception, an unrescued denial is no longer reported to `Rails.error` as unhandled; it stays in
764
+ the audit log (`access.denied`, `action.denied`). Request tests under `show_exceptions = :rescuable` get the 403
765
+ instead of the raised error. A `rescue_from` in your controllers still decides first. **Opt out:** rescue the errors
766
+ yourself, or set another status, e.g.
767
+ `config.action_dispatch.rescue_responses['PermissionForge::ActionNotAuthorizedError'] = :internal_server_error`.
768
+ <!-- pin: test/permission_forge/controllers/denial_status_test.rb#both denials are registered as 403 where ActionDispatch reads them -->
769
+ <!-- pin: test/permission_forge/controllers/denial_status_test.rb#an unrescued row-rule denial answers 403 with an empty page, and JSON for a JSON request -->
770
+ <!-- pin: test/permission_forge/controllers/denial_status_test.rb#a host rescue_from still decides, and reads the rule messages without parsing -->
771
+ - **Permission Set names are unique within a tenant, ignoring case and surrounding spaces.** The admin form already
772
+ refused a second "Sales Team"; the console, seeds, services and the API created one silently, and both then showed
773
+ as "Sales Team" in every picker. A new set or a rename to a name another set of the same tenant uses is now a
774
+ validation error (`name_taken`). Two sets that already share a name stay valid until one of them is renamed. A
775
+ clone without a name (`PermissionSetCloner`, the group clone, `POST …/clone`) gets a numbered copy name ("Sales
776
+ Team (copy)", "Sales Team (copy) 2"). No migration: the check is a validation. **No opt-out:** give duplicates
777
+ distinct names.
778
+ <!-- pin: test/permission_forge/models/permission_set_name_uniqueness_test.rb#a second set with the same name is refused, whatever its case -->
779
+ <!-- pin: test/permission_forge/models/permission_set_name_uniqueness_test.rb#two sets that already share a name stay valid until one is renamed -->
780
+ <!-- pin: test/permission_forge/models/permission_set_name_uniqueness_test.rb#a clone without a name gets a numbered copy name instead of failing validation -->
781
+ - **Full access applies no personal-data masks.** `ResourceFieldGenerator.call(grant_all: true)`, and through it
782
+ `Resource.grant_full_access!`, `Services::ResourceWithFieldsBuilder` and the admin API's `generate_fields` with
783
+ `grant_all`, now create every field readable and editable in the clear, also the columns that match
784
+ `gdpr_field_patterns` under `gdpr_defaults`. Before, those were created masked (a field test's Admin set read
785
+ customer names as `G*************tion`). The recommended strategy is still stored in `mask_strategy` (with
786
+ `mask_access: false`), so masking the field later uses it, and the fields are still flagged: `GdprFieldAuditor` and
787
+ `rake permission_forge:audit_masks` report them as `unmasked_field`, the admin's Home lists them under "Privacy
788
+ audit". (Without `grant_all`, see the next entry.) **Opt out:** generate without `grant_all` and open the other
789
+ fields, or mask the reported fields afterwards (`resource.resource_fields.where(name: %w[name email]).find_each { |f|
790
+ f.update!(mask_access: true) }`; the stored strategy applies).
791
+ <!-- pin: test/permission_forge/services/resource_field_generator_gdpr_test.rb#grant_all true with gdpr_defaults true leaves the email field unmasked -->
792
+ <!-- pin: test/permission_forge/models/resource_test.rb#.grant_full_access! applies no personal-data masks under gdpr_defaults -->
793
+ <!-- pin: test/permission_forge/api/resources_controller_test.rb#POST generate_fields with grant_all applies no masks under gdpr_defaults -->
794
+ - **Generating fields without `grant_all` grants nothing — the personal-data columns included.**
795
+ `ResourceFieldGenerator.call(resource:)`, the API's `POST …/generate_fields` without `grant_all` and the console
796
+ used to create, under `gdpr_defaults`, the columns that look like personal data *readable and masked* while every
797
+ other column was Hidden. They are now Hidden like the rest, with the recommended mask strategy on the row — the same
798
+ row the admin's "Nothing" preset, "Add all new as Hidden" and bulk Hidden write. Rows already written are not
799
+ changed. **Opt out:** to have them readable and masked, add them Read-only (the admin's Sync "Add all new as
800
+ Read-only", bulk Read, or the field drawer: under `gdpr_defaults` a sensitive column granted Read-only starts
801
+ Masked), or generate with `grant_all: true` and mask what the audit lists.
802
+ <!-- pin: test/permission_forge/services/resource_field_generator_gdpr_test.rb#grant_all false with gdpr_defaults true grants nothing: the email field is Hidden, its strategy recorded -->
803
+ - **Generating fields on a Muting Permission Set writes the level chosen, and no row without `grant_all`.** Without
804
+ `grant_all` the generator wrote, under `gdpr_defaults`, `read_access: true, mask_access: true` rows for the
805
+ personal-data columns of a muting set — rows that *hide* those columns for the set's holders and force a mask — and
806
+ an Unchanged row (every flag off) for every other column, where the admin's presets and bulk add write none.
807
+ `ResourceFieldGenerator.call(resource:)` (the API's `generate_fields`, the console) now writes no row on a muting set
808
+ (Unchanged mutes nothing, which is what no row means: `FieldLevel.row?`), and `grant_all: true` still writes the
809
+ Hide rows. Rows already written are not changed (the audit's `dependency_violations` and Home list the privacy
810
+ ones). **No opt-out:** a muting row written by a privacy default was a silent removal nobody chose, and the rows it
811
+ no longer writes took nothing away.
812
+ <!-- pin: test/support/decision_parity/fields.rb#(a) Customer, muting=true, gdpr_defaults=true, leave: presets, bulk and the generator write the same rows -->
813
+ <!-- pin: test/permission_forge/services/resource_field_generator_test.rb#without grant_all a Muting Permission Set gets no rows at all, the timestamps included -->
814
+ - **Every field-creation path writes the same names and caps Editable alike.** The generator also writes rows for
815
+ virtual fields under `extra_fields_default_access = :grant` (Hidden without `grant_all`, replacing the implicit
816
+ grant), and Editable on a Resource without Edit is Read-only on every path, after the privacy default
817
+ (`ManagedFields`, `FieldLevel.capped`). **Opt out:** give the Resource Edit first; `grant_all` keeps virtual fields readable.
818
+ <!-- pin: test/support/decision_parity/fields.rb#(a) Editable on a Resource without Edit is Read-only on every path, and the privacy default does not mask it -->
819
+ <!-- pin: test/support/decision_parity/fields.rb#(a) a declared virtual field gets the level chosen on every path under extra_fields_default_access = :grant -->
820
+ - **The admin's "Full access" masks nothing.** Adding a resource with the **Full access** preset, or Sync's "Add all
821
+ new with full access", now gives every field Editable, also the columns that look like personal data under
822
+ `gdpr_defaults` (the field test's `Customer.name` started Masked). Those columns keep the **Sensitive** badge and
823
+ their recommended mask strategy, and Home's Privacy audit lists them as shown unmasked, with **Fix** and **This is
824
+ intended**. **Read-only** (preset and Sync) still starts them Masked; the muting presets are unchanged. **Opt out:**
825
+ add the resource with **Read-only** and raise the other fields to Editable, or set the reported fields to Masked
826
+ from the Fields tab (bulk: filter **Sensitive**, select all, Set to Masked).
827
+ <!-- pin: test/permission_forge/html/full_access_privacy_test.rb#the Full access preset masks nothing and keeps the recommended strategy -->
828
+ <!-- pin: test/permission_forge/html/full_access_privacy_test.rb#sync: "with full access" masks nothing, "Read-only" masks the sensitive columns -->
829
+ - **Generated rows always carry the recommended mask strategy** of a sensitive column (`email` for `email`,
830
+ `first1_last4` for `name`), also with `gdpr_defaults` off and also on a muting set, as the admin's rows already did;
831
+ it is used only when someone masks the field. **No opt-out:** the flags are unchanged.
832
+ <!-- pin: test/permission_forge/services/resource_field_generator_test.rb#without grant_all gdpr_defaults records the recommended strategy and grants nothing: every row Hidden -->
833
+ - **Generated fields give `created_at` / `updated_at` a row, at most read-only.** They were in the default
834
+ `ignored_fields`, so they got no row and, under the default `unconfigured_field_strategy = :block`, every reader saw
835
+ `null` timestamps. `ignored_fields` no longer lists them, and every path that generates rows (the generator,
836
+ `Resource.grant_full_access!`, the API's `generate_fields`, the admin's presets, Sync and bulk add) gives them one
837
+ through `TimestampFields`: on a granting set, read-only unmasked for a level that grants read, hidden otherwise; on
838
+ a muting set, the level chosen. **What an existing install sees:** right after the upgrade the admin lists
839
+ `created_at` / `updated_at` as not configured on every resource until its fields are synced. **Opt out:**
840
+ `config.ignored_fields += %w[created_at updated_at]`.
841
+ <!-- pin: test/permission_forge/services/resource_field_generator_test.rb#a user granted the generated fields reads the timestamps under :block, and cannot edit them -->
842
+ <!-- pin: test/permission_forge/admin/column_fields_test.rb#a level that grants read gives the timestamps Read-only, never Editable or Masked -->
843
+ <!-- pin: test/permission_forge/configuration_test.rb#has default ignored_fields -->
844
+ - **The GDPR audit, the security audit and the boot warning also count the columns your `filter_parameters` name.**
845
+ While `filter_parameters_integration` is on (the default), a column the host's `filter_parameters` name
846
+ (`api_token`, `license_key`, …) readable in the clear is reported as `unmasked_field` / `unmasked_personal_data`
847
+ with the suggested strategy `full`, and named by the boot warning — as the admin's presets and Sync already masked
848
+ it on Read-only. A String / Symbol entry names a column when a `_`-segment of it starts with the entry (`:otp`:
849
+ `otp_secret`, not `footprint`; `:_key`: `api_key`, also `sort_key`); a Regexp matches as written. **Opt out:**
850
+ `config.filter_parameters_integration = false`, or mark the field "This is intended" in the admin's privacy audit.
851
+ <!-- pin: test/support/decision_parity/fields.rb#(b) the GDPR audit and the boot warning name exactly the columns SensitiveColumns matches, with its strategy -->
852
+ <!-- pin: test/support/decision_parity/fields.rb#(a) a column named only by the host filter_parameters starts Masked on every path (Read-only, gdpr_defaults) -->
853
+ - **The boot warning about unmasked personal data counts the rows that reach someone.** `unmasked_gdpr_fields` used
854
+ to name any row with `mask_access: false` — Hidden rows, switched-off rows, rows of a muting set. It now names a
855
+ row that is readable in the clear, in effect, in a granting set (`ResourceField.exposing_in_clear`). **No opt-out:**
856
+ a warning about a row that shows nothing was noise.
857
+ <!-- pin: test/support/decision_parity/fields.rb#(b) the GDPR audit and the boot warning name exactly the columns SensitiveColumns matches, with its strategy -->
858
+ - **`email_address` is detected as an email.** `DEFAULT_GDPR_FIELD_PATTERNS` adds `email_address` and
859
+ `*_email_address` (the column Rails 8's authentication generator creates) to the email pattern, so it is recommended
860
+ and generated with the `email` strategy (`j***@example.com`) instead of `full` (it matched `address`). Log filtering
861
+ is unchanged. Existing field rows are never rewritten (a row generated with `full` keeps the stricter mask); `rake
862
+ permission_forge:audit_masks` suggests `email` for an unmasked one. Apps that set `gdpr_field_patterns` to their own
863
+ hash keep their patterns; apps that merged into `DEFAULT_GDPR_FIELD_PATTERNS` get the new alternatives. **Opt out:**
864
+ put a pattern ahead of the defaults: `config.gdpr_field_patterns = { /\Aemail_address\z/ => 'full'
865
+ }.merge(PermissionForge::Configuration::DEFAULT_GDPR_FIELD_PATTERNS)`.
866
+ <!-- pin: test/permission_forge/gdpr_default_patterns_test.rb#email_address is detected as an email, everywhere the patterns are read -->
867
+ - **The admin says a privacy flag may not be personal data.** Detection is unchanged (by column name, for every
868
+ model: `name` on a company model is still flagged). The field drawer's recommendation now reads "Matches privacy
869
+ pattern “name”, so it may be personal data", and Home's Privacy audit says that fields are flagged by column name
870
+ and that **This is intended** takes a field that is fine unmasked off the list. **No opt-out:** wording only.
871
+ <!-- pin: test/permission_forge/html/full_access_privacy_test.rb#a flag may not be personal data: the card says so, and This is intended clears it -->
872
+ - **A Muting Permission Set's column with no setting is "Unchanged" in the admin.** Its Fields tab counted such
873
+ columns under "Not configured" next to an "Unchanged 0" chip, and their level control had no segment selected,
874
+ although "Not configured · Mutes nothing" and Unchanged are one state. The row now starts on Unchanged (saving it
875
+ still creates no field row), the muting Fields filters drop "Not configured", and the field drawer and "Across all
876
+ Permission Sets" say Unchanged too (`FieldLevel.unconfigured_level`). A turned-off row keeps its saved choice and
877
+ its "Turned off · Mutes nothing" badge. Granting sets are unchanged. **No opt-out:** wording and counts only.
878
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#in a Muting Permission Set a column with no setting is on Unchanged, counted under Unchanged -->
879
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#saving a muting column left on Unchanged creates no field row -->
880
+ <!-- pin: test/permission_forge/html/state_rendering_test.rb#a column with no setting: Not configured on a granting set, Unchanged on a muting one, alike everywhere -->
881
+ - **The admin words each state one way.** Every field level, effective outcome, CRUD cell, action state, lock and
882
+ actor kind renders from `Admin::StateLabel` (glyph and word together). Visible differences: "Across all
883
+ Permission Sets" shows a muting set's Hide as "⊘ Hide" (was "Muted: hidden"), its Force mask with the strategy;
884
+ the Effective access legend reads "⊘ Blocked" and "– No", as its cells do (was "Muted" / "Not given"); the Fields
885
+ tab's filter for Hide reads "Hide" (was "Hidden"). Locale keys moved: `ui.levels.*` → `ui.states.field_level.*`,
886
+ `ui.states.{granted,blocked,none}` → `ui.states.effective.*`, `people.effective_tab.hidden_by_muting` →
887
+ `ui.states.effective.hidden_by_muting`, `audit_log.actor_types.*` → `ui.states.actor.*`, `ui.badges.locked` →
888
+ `ui.states.lock.locked`, `shared.crud_cell.needs_read` → `ui.needs_read_first`; removed `ui.levels.muted`,
889
+ `ui.badges.not_configured`, `ui.audit.system`, `people.effective_tab.legend_muted` / `legend_none`,
890
+ `shared.across_sets.muted_hidden` and the level keys under `resources.fields_tab.filters`. **Opt out:** a host that
891
+ overrides one of these keys in its own locale files renames it to the new key; the gem's own locales need nothing.
892
+ <!-- pin: test/permission_forge/i18n_test.rb#one state, one label: no English label repeats within the label namespaces -->
893
+ <!-- pin: test/permission_forge/html/state_rendering_test.rb#a column with no setting: Not configured on a granting set, Unchanged on a muting one, alike everywhere -->
894
+ - **Settings' health summary reads Home.** While Home lists items that need attention, the summary says how many
895
+ ("Home lists 3 items that need attention.", with a link to Home) and no longer says the settings "look good"
896
+ ("N settings are set as recommended" instead); "look good" is said only with no risky setting and nothing on
897
+ Home — it said "7 settings look good" while Home warned that automatic query scoping leaves record rules to
898
+ `find_by` / `permission_scoped`, which Settings now flags too. `unconfigured_field_strategy = :warn` is now a
899
+ flagged row, as `:pass_through` was (it lets an unconfigured field through too). **No opt-out:** wording and a flag;
900
+ nothing is enforced differently.
901
+ <!-- pin: test/permission_forge/html/labels_and_attention_admin_test.rb#Settings never says the settings look good while Home lists items that need attention -->
902
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#Settings warns about automatic query scoping while Home lists rules it leaves out -->
903
+ - **A host model, column, association or action without a translation is never inflected into English on a page in
904
+ another language.** "Full address", "Created at", "Customers", "Archive" and "Supplier" on a Macedonian or Albanian
905
+ page read `full_address`, `created_at`, "Customer records" (`permission_forge.ui.resource_plural`), `archive`,
906
+ `supplier`, in every admin screen and in `Admin::ModelCatalog`'s labels (the rule builder, Sync, "Add resource"); an
907
+ English page keeps Rails' own English for a column or action name ("Created at", `human_attribute_name`), and names
908
+ a model by its constant. `ResourceAction#display_name` without a label is the key (Rails' English on an English
909
+ page). **Opt out:** translate your models and columns (`activerecord.models.<model>` with `one`/`other`,
910
+ `activerecord.attributes.<model>.<name>`, [docs/i18n.md](docs/i18n.md#your-models-and-columns-in-the-admin)) or give
911
+ the row a label; there is no switch back to `humanize` on a page that is not English.
912
+ <!-- pin: test/permission_forge/admin/labels_test.rb#a model is labelled by the Resource row, else the host translation, else the constant name; the plural likewise -->
913
+ <!-- pin: test/permission_forge/admin/services_test.rb#labels are the host translation, else the stored name verbatim (Rails' English on an English page) -->
914
+ - **Every validation message of the gem's models is the gem's own, in the request's locale.** Rails' default messages
915
+ (`uniqueness`, `length`, `numericality`, `presence`, `ValidatesDependency`, `HasUuid`) and the messages that were
916
+ translated once at class load read from `permission_forge.validations.*` at validation time: `blank`, `taken`,
917
+ `too_long`, `requires`, `permission_set.hours_range` ("must be between 0 and 23"), `resource.name_taken`,
918
+ `permission_set_user.taken` ("already holds this Permission Set"), `permission_set_group_user.taken` ("is already a
919
+ member of this group"). The English wording changes for the two assignment uniqueness messages (was "has already
920
+ been taken") and the hours range (was "must be in 0..23"). The audit log's refused filters (`Audit::EventQuery`) say
921
+ `validations.blank` / `validations.invalid`. **No opt-out:** these replace Rails-core keys that are English, or
922
+ "Translation missing", in every host without rails-i18n.
923
+ <!-- pin: test/permission_forge/html/locale_leak_test.rb#every API error class answers in the request language -->
924
+ - **A validation sentence in the admin names the attribute with the gem's label.** The uniqueness race flash and
925
+ `Admin::ChangeSet::Invalid` say "Person already holds this Permission Set" (`Admin::ErrorSentence`), not Rails'
926
+ `full_messages` ("User has already been taken"). **No opt-out:** `full_messages` is English without rails-i18n.
927
+ <!-- pin: test/permission_forge/unique_conflict_test.rb#the real error of each indexed table is answered with the message its validation gives -->
928
+ - **The rule builder starts a new rule's name and blocked message blank** instead of the install migration's English
929
+ column defaults, which admins of an Albanian or Macedonian admin saved for their end users; a rule left without a
930
+ blocked message denies with `permission_forge.denials.rule_default` in the current locale (see
931
+ `NotAuthorizedError#failure_messages` under Added). **Opt out:** write a `failure_message` on the rule; the stored
932
+ English default of rules created before 0.15 stays until edited (docs/i18n.md
933
+ [Column Defaults Are English](docs/i18n.md#column-defaults-are-english) has the one-liner that blanks them).
934
+ <!-- pin: test/permission_forge/release_readiness/column_default_prefill_test.rb#no new-record screen or re-rendered form pre-fills a text default -->
935
+ <!-- pin: test/permission_forge/concerns/permission_scoped_findable_test.rb#NotAuthorizedError#failure_messages are the denying rules' own messages, without parsing -->
936
+ - **`pf_user_option_label`** (the group "Add member" select) is `Admin::PersonLabel`'s answer: a formatter that
937
+ returns blank falls through to the name as everywhere else in the admin (was "User #id"); the default
938
+ `profile_builder` reads `name` raw-column-first (`PersonLabel.raw_value`) past a masked User model. **No opt-out:**
939
+ one definition of a person's label.
940
+ <!-- pin: test/permission_forge/helpers/application_helper_test.rb#pf_user_option_label falls through to the name when the formatter returns blank (Admin::PersonLabel) -->
941
+ - **Settings draws configuration values as `<code>`** (the enum pills, the key → value map, the default and the docs
942
+ link's key); the locale keys `settings.row.default` and `settings.row.docs` became `default_html` and `docs_html`.
943
+ **Opt out:** a host that overrode the two keys renames them.
944
+ <!-- pin: test/permission_forge/html/locale_leak_test.rb#every screen shows only translated words and host data -->
945
+ - **Three placeholders are short hints**: `permission_forge.ui.search_people` and `home.index.search_placeholder`
946
+ read "Name or email" (sq "Emër ose email", mk "Име или е-пошта"), the Home explanation moved to the new
947
+ `home.index.search_help` under the search box, and `permission_set_rules.builder.message_placeholder` became
948
+ `permission_set_rules.builder.message_help`, a hint under the blocked-message textarea. **Opt out:** a host that
949
+ wants the old sentences overrides these keys in its own locale files (a placeholder longer than 40 Latin / 34
950
+ Cyrillic characters clips on a phone).
951
+ <!-- pin: test/permission_forge/html/layout_lint_test.rb#every placeholder fits a phone-width box in every locale -->
952
+ - **"Refresh cached permissions" says when it is needed.** The person page's menu read "Rarely needed: changes
953
+ already apply immediately.", which is wrong with a per-process cache (a memory store) after a change from a
954
+ console, runner or job: the process that made the change cleared only its own entries, and the server keeps the old
955
+ permissions until they expire. It now says that (en, sq, mk), in the words `Cache::Outcome` reports.
956
+ **No opt-out:** text only.
957
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#Refresh cached permissions says when it is needed, in every locale -->
958
+ - **"What people lose" (Remove, Remove all, a set's impact line) counts the resources in effect**
959
+ (`Resource.in_effect`: active rows of an active set), not every active row. **No opt-out:** a label.
960
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: the resources that count right now (Resource.in_effect), also for "what people lose" -->
961
+ - **Boot and configuration warnings reach the terminal where the log is not read.** Every boot/configuration warning
962
+ of the gem (the per-process cache, conditional sets with an empty context, the API without authorization lambdas,
963
+ unmasked GDPR fields, missing or stale RLS policies, `audit_events = false`, ...) goes through one emitter,
964
+ `PermissionForge::Warnings.emit`: the log always; standard error as well in development and, outside test, in any
965
+ process that is not a web server (a console, a runner, a rake task, a job worker) — once per warning per process,
966
+ and not when the logger already writes to standard output or error. Before, only the development cache warnings
967
+ reached standard error, and every SECURITY warning (a fail-open condition) was in the log alone. The warnings about
968
+ the admin and the API stay in the log outside a server process (next entry). **No opt-out:** one line per warning per
969
+ process, only where the log is not on the terminal; each warning names the setting that removes its condition.
970
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#the emitter writes where the policy table says -->
971
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#development writes both warnings to standard error as well, once each -->
972
+ - **The warnings about the admin and the API are printed by the server, not by every runner.** HTTP Basic auth
973
+ without credentials ("The admin UI will return 401 for all requests"), a shared Basic-auth login without
974
+ `audit_actor_resolver`, an API-only host missing the admin's middlewares, tenantless admin access and the API without
975
+ its authorization lambdas went to standard error in every `bin/rails runner`, rake task, console and `db:seed`, which
976
+ serve no admin — and with the README's credentials set for the server only, the 401 sentence was untrue there.
977
+ `Warnings.emit` takes `audience: :server` (beside `severity:`): such a warning goes to the log only outside a server
978
+ process; the server prints it as before. Every other warning keeps reaching every process. A server process is now
979
+ also one started by `unicorn` / `unicorn_rails` (`Unicorn::Launcher`), Passenger (`PhusionPassenger::App`), `falcon`
980
+ (`Falcon::Command`), `pitchfork` or `rackup` (by program name: the name the process was started with,
981
+ `Process.argv0`, which pitchfork's process titles do not change, or `$0`, which `bundle exec rackup` sets), as by
982
+ `bin/rails server` and `puma`; a server started some other way is not recognised (docs/configuration.md "Where
983
+ warnings appear"). In production these write
984
+ every warning to the log only, as Puma's processes do (they wrote them to standard error), and a change their host
985
+ controllers make reports `in_request: true` in its cache outcome (`Cache::Report.in_request?`). **No opt-out:** the
986
+ log keeps every warning; a runner that must check the admin's setup reads it there.
987
+ <!-- pin: test/permission_forge/install/runner_warnings_test.rb#bin/rails runner keeps the admin warnings off standard error; the log has them, the other warnings print -->
988
+ <!-- pin: test/permission_forge/install/runner_warnings_test.rb#bin/rails server prints the admin warnings -->
989
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#every warning has its audience in AUDIENCE, the one its site passes, and a reason when it is server -->
990
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#a server warning is written where the server policy table says -->
991
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#a server process is one bin/rails server or a web server command started -->
992
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#a process the pitchfork or rackup command started is a server, by its original program name -->
993
+ - **Nothing is checked or logged while `bin/rails generate` or `destroy` boots the app.** The install generator boots
994
+ the app before it writes the initializer and the migration that creates the audit table, and that boot logged six
995
+ `[PermissionForge]` lines describing the defaults (the in-memory cache, HTTP Basic Auth without credentials, masking
996
+ not complete) and told a fresh install to run `permission_forge:upgrade`. While a generator runs
997
+ (`Warnings.generator_running?`: the `generate` / `destroy` command is loaded) the boot checks, their warnings, the
998
+ missing-table check (the first audit event checks again), the "Added N GDPR patterns" line, `PermissionForge.configure`'s
999
+ model-binding warning and the masking machinery keep quiet; `validate_gdpr_explicit_choice!` still raises.
1000
+ **No opt-out:** the next server, console or task boot checks and warns about the configuration the app has.
1001
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#nothing is written while a generator boots the app -->
1002
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#BootChecks.run checks and logs nothing while a generator boots the app -->
1003
+ <!-- pin: test/permission_forge/audit/boot_warnings_test.rb#no missing-table warning while a generator runs; the first event checks again -->
1004
+ - **The development masking note is written to standard error too** (with `auto_apply_masked_attributes = :auto` and a
1005
+ per-process or null cache store): it says the server re-reads the `Resource` names from the database in development
1006
+ and that production would mask a model first named in another process only after a restart. It was in the log only.
1007
+ **Opt out:** share the store (`config.cache_store = :file_store, "tmp/cache/store"` in development), list the models
1008
+ in `auto_apply_masked_attributes`, or include `MaskedAttributes` in the models with `auto_apply_masked_attributes =
1009
+ false` — each removes the condition, and the warning with it.
1010
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#development writes both warnings to standard error as well, once each -->
1011
+ - **The production boot warning about an in-process cache also covers a `:memory_store`.** It fired only for
1012
+ `cache_backend = :memory`; `cache_backend = :rails_cache` over a `Rails.cache` that is a `:memory_store` (or a
1013
+ `MemoryStore` instance as `cache_backend`) is as inconsistent across Puma workers and is now warned about too. The
1014
+ line reads "Using an in-process permission cache in production" (it said "in-process MemoryCache"): update a log
1015
+ alert that matches the old text. **Opt out:** use a store the workers share.
1016
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#production gets the production warning, not the development one -->
1017
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#production with a shared store does not warn -->
1018
+ - **What the cache tasks print is one outcome.** `rake permission_forge:cache:clear`, `erasure` and
1019
+ `cleanup_expired_assignments` print through `RakeOutput` (see Added): "Cached permissions removed: yes (the cache
1020
+ store confirmed it)" and a "Cache scope" line that says what the clear means (a shared store: every process sees it,
1021
+ each user rebuilds on their next request; a per-process store: this process only), instead of "Cache cleared: true",
1022
+ "invalidated 1 user(s)" and a "The cache is cold" line followed by the per-process note it contradicted. Labels
1023
+ changed ("Audit log name labels cleared as actor: 2", one key per line). **No opt-out:** output only; the result
1024
+ Hashes and events are additive. A script that matched `Cache cleared: true` matches `Cached permissions removed:`.
1025
+ <!-- pin: test/permission_forge/tasks/cache_clear_task_test.rb#with ACTOR and REASON: every holder is invalidated, the event names the actor from rake -->
1026
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#an erasure on an in-memory store says the running servers kept the cached permissions, in output and event -->
1027
+ - **`DRY_RUN=1`, `yes` or `TRUE` now mean a dry run for `rake permission_forge:erasure` and `audit_events:purge`;
1028
+ only `false`, `0` or `no` executes.** The erasure took only the literal `true` as a dry run, so `DRY_RUN=1` (the
1029
+ purge's documented dry run) erased the person. Unset, each keeps its default: the erasure is a dry run, the purge
1030
+ deletes. **No opt-out:** a value other than an explicit false never deletes.
1031
+ <!-- pin: test/permission_forge/tasks/gdpr_erasure_task_test.rb#test DRY_RUN values other than an explicit false are a dry run -->
1032
+ - **Service results gained keys (additive).** `ConsentWithdrawalHandler.call` answers the same keys for a real run and
1033
+ a dry run (the real run answered `{ revoked_sets:, invalidated: }` only; it now also answers `affected_sets`,
1034
+ `affected_assignment_count` and `dry_run: false`, and a dry run also answers `invalidated: false`), and both answer
1035
+ `cache`; `ExpiredAssignmentCleaner.call` answers `affected_user_ids`, `permission_sets` and `cache`;
1036
+ `DataSubjectErasure.call` answers `permission_sets`, `groups`, `events_scrubbed` and `cache` (`cache_cleared` kept);
1037
+ `BulkPermissionSetAssignment.call` and `PermissionCacheClearer.call` answer `cache`. See
1038
+ [GDPR data handling](docs/gdpr-data-handling.md). **No opt-out:** nothing was removed or renamed.
1039
+ <!-- pin: test/permission_forge/services/consent_withdrawal_handler_test.rb#a real run answers the keys a dry run answers -->
1040
+ - **`rake permission_forge:audit` reports a per-process cache from the configured store.** `process_local_cache`
1041
+ (High, production) now asks the store the configuration builds (`cache_store.process_local?`), so `cache_backend =
1042
+ :rails_cache` over a `:memory_store` is reported too, not only `cache_backend = :memory`. **No opt-out:** a finding.
1043
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#a process-local cache is flagged only in production -->
1044
+ - **`rails -T` descriptions.** `permission_forge:audit` no longer says "Read-only": it changes no permission data and
1045
+ records the run in the audit log (`report.generated`); the report's footer says "No permission data was changed"
1046
+ and that the run is recorded (it said "Read-only: nothing was changed"). `permission_forge:cache:clear` says what it
1047
+ does first; the upgrade hint for 0.13 installs comes last. **No opt-out:** text only.
1048
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#the audit task says it changes no permission data and records the run -->
1049
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#the cache:clear description leads with what it does, the upgrade hint last -->
1050
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#report counts, threshold filtering and renderings -->
1051
+ - **The stale-assignment report and the admin agree on the threshold.** Without `THRESHOLD_DAYS` the rake task, and
1052
+ `Services::StaleAssignmentAuditor.call` without `threshold_days:`, now use `config.admin_stale_assignment_days`, as
1053
+ the Home Stale list, the person page and `rake permission_forge:audit` already did; they used a fixed 90 before.
1054
+ Nothing changes unless you set `admin_stale_assignment_days` to something other than 90. Callers that pass
1055
+ `threshold_days:` (the security audit does) are unaffected; `threshold_days: nil` now means the setting instead of
1056
+ raising. **Opt out:** run the task with `THRESHOLD_DAYS=90`, and call
1057
+ `Services::StaleAssignmentAuditor.call(threshold_days: 90)`.
1058
+ <!-- pin: test/permission_forge/services/stale_assignment_auditor_test.rb#the default threshold follows admin_stale_assignment_days -->
1059
+ <!-- pin: test/permission_forge/tasks/audit_rake_tasks_test.rb#stale_assignments without THRESHOLD_DAYS uses admin_stale_assignment_days -->
1060
+ - **`rake permission_forge:audit` refuses a `FAIL_ON` it does not know.** A typo counted as `info` and failed the build
1061
+ on any finding; the task now prints the levels on stderr and exits 2 before the review runs (as `stale_assignments`
1062
+ does with a bad `THRESHOLD_DAYS`). Levels are read in any case (`FAIL_ON=HIGH` is `high`). **No opt-out:** a valid
1063
+ level reads as before; `FAIL_ON=info` keeps failing on every finding.
1064
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#an unknown FAIL_ON stops before the review with exit 2 and the levels -->
1065
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#FAIL_ON is read in any case -->
1066
+ - **`rake permission_forge:audit_masks` names the Permission Set, the suggested strategy and what matched.** An
1067
+ unmasked field is reported once per set, and the lines looked like duplicates; "matches pattern 'first1_last4'"
1068
+ named the mask the pattern suggests, not the pattern. The lines now read `[UNMASKED] Customer.email (Permission Set:
1069
+ Support) — personal data, not masked (suggested strategy: 'email'; matches 'email')` and `[UNMAPPED] Customer.phone
1070
+ — personal data, no active ResourceField (suggested strategy: 'last4')` (plus `; matches '<pattern>'` or `; named by
1071
+ filter_parameters` where the finding records what matched); `unmasked_field` and `unmapped_column`
1072
+ findings gain a `pattern` key (the other keys are kept). A CI step that greps `[UNMASKED] Customer.name` still
1073
+ matches; one that matches the rest of the old line must change. **No opt-out:** match on `[UNMASKED]
1074
+ <Model>.<field>` (or read `Services::GdprFieldAuditor.call`'s Hash) instead of the whole line.
1075
+ <!-- pin: test/permission_forge/tasks/audit_masks_task_test.rb#prints one line per finding and exits 1 -->
1076
+ <!-- pin: test/permission_forge/services/gdpr_field_auditor_text_report_test.rb#keeps the start of the lines existing CI output already had, names the set and the suggested strategy -->
1077
+ - **`rule/unenforced_rules` leaves out Resources named after no model.** A Resource named `Custmer` was listed under
1078
+ `integrity/unknown_models` and, with its rules, under `rule/unenforced_rules` (and Home's record-rules card) — there
1079
+ is no query to scope. Only Resources whose model the app can load (`Admin::ModelCatalog#find`) are listed there now.
1080
+ **No opt-out:** `integrity/unknown_models` still names the Resource.
1081
+ <!-- pin: test/permission_forge/admin/services_test.rb#rules of a Resource named after no model the app can load are not flagged -->
1082
+ <!-- pin: test/permission_forge/services/security_audit_integrity_check_test.rb#a Resource named after a constant that is no model with a table is a Resource for a model this app does not have -->
1083
+ - **A missing column costs one metadata query per process, and a reset only after a real schema change.** The engine,
1084
+ the admin and the services read a missing name (a `_uuid` variant, a timestamp a model lacks, a declared virtual
1085
+ field) again through `SchemaRead` once per process: one metadata query, and a model reset only when the database's
1086
+ columns differ from Rails' (a migration while the process was up). Pages that list models (Sync, the rule
1087
+ builder) compare their columns with the database once per render; Home and the navigation read the schema cache. `FieldVariants` asks `FieldNames.declares?` plus
1088
+ `VirtualFields#include?` whether the model has a variant name. **No opt-out:** the alternative is the stale answer
1089
+ until the process restarts.
1090
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#a missing name on an unchanged schema never resets the model -->
1091
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#every reader sees a column a migration added after the schema was read, without a reset -->
1092
+ - **The JSON API answers JSON only.** `GET /api/permission_sets.xml` (any extension but `.json`) answered the JSON list
1093
+ with a 200; it now names no endpoint and gets the API's JSON 404 (`No API endpoint answers this path and method`).
1094
+ No extension and `.json` answer as before. **No opt-out:** the API has no other format.
1095
+ <!-- pin: test/permission_forge/api/unmatched_routes_test.rb#an extension other than .json reaches no endpoint: the JSON 404 envelope -->
1096
+ <!-- pin: test/permission_forge/api/unmatched_routes_test.rb#.json and no extension still answer -->
1097
+ - **Unrouted API calls answer the JSON 404 envelope.** Any other path or method under the API mount
1098
+ (`/api/unknown`, a `POST`, `PATCH` or `DELETE` on the read-only `audit_events`) answered Rails' HTML 404 page; it is
1099
+ now `{"error":{"status":404,"messages":"No API endpoint answers this path and method"}}`, after the same guards as
1100
+ every endpoint (a disabled API stays a bodiless 404, a refused `api_auth` a 401). **No opt-out:** an HTML page was no
1101
+ API answer.
1102
+ <!-- pin: test/permission_forge/api/unmatched_routes_test.rb#an unknown path or method under the API answers the JSON 404 envelope -->
1103
+ <!-- pin: test/permission_forge/api/unmatched_routes_test.rb#it runs api_auth first: an unauthenticated caller gets the 401, recorded like any API call -->
1104
+ - **Every API 422 has one shape: field-keyed messages.** Input the API cannot read answered `messages` as one string
1105
+ (`"expires_at is not an ISO 8601 date or time"`, `"param is missing or the value is empty or invalid:
1106
+ permission_set"`, `"Invalid action: …"`), while a failed validation answered an object. Both are now an object that
1107
+ maps the field to a list of messages, keyed by the request parameter (`{"expires_at": ["is not an ISO 8601 date or
1108
+ time"]}`, `{"permission_set": ["is missing, empty or not an object"]}`, `{"bulk_action": ["must be assign or
1109
+ revoke"]}`) or `base` when the error names none (`generate_fields`' failure). The messages follow the request's
1110
+ locale. 401, 403 and 404 keep their string. **No opt-out:** a client that read the string reads
1111
+ `messages.values.flatten` instead. See [`docs/admin-api.md`](docs/admin-api.md#response-format).
1112
+ <!-- pin: test/permission_forge/api/permission_sets_controller_test.rb#a section that is not an object is a field-keyed 422, like a validation error -->
1113
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#an expires_at that is not ISO 8601 is a 422 and assigns nobody, single and bulk -->
1114
+ - **An API `expires_at` given as a date means the end of that day.** `"2026-10-22"` on `POST …/users` and
1115
+ `POST …/users/bulk` is now 23:59:59.999999 of that day in `Time.zone`, as the admin's end-date presets and its custom
1116
+ date; it was the beginning of the day (00:00), so the assignment had ended when its last day started. A date and time
1117
+ (`2026-10-22T10:00`, with or without an offset) is read as before. **Opt out:** send the moment you mean,
1118
+ `2026-10-22T00:00`.
1119
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a date alone is the end of that day, so today is accepted and lasts through it -->
1120
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a date or a time without an offset is read in Time.zone -->
1121
+ - **The API refuses an `expires_at` that is not after now.** A single assignment or a bulk `assign` with an end that
1122
+ has passed (`"2020-01-01"`, yesterday, a minute ago) created an assignment that granted nothing, answered 201/200. It
1123
+ is now a 422 (`{"expires_at": ["must be in the future"]}`) and nobody is assigned. A bulk `revoke` ignores
1124
+ `expires_at`, also one that is not ISO 8601 (a revoke removes the assignments whatever their end); the admin's Undo
1125
+ still restores a removed assignment with the end it had. **No opt-out:** such an assignment granted nothing.
1126
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#an expires_at that is not in the future is a 422 and assigns nobody, single and bulk -->
1127
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a bulk revoke ignores an expires_at it cannot read -->
1128
+ - **A refusing `api_auth` answers 401 Unauthorized instead of 403.** A wrong or missing token is a failed
1129
+ authentication: clients that refresh credentials on 401 did not on the 403. The body is
1130
+ `{"error":{"status":401,"messages":"Not authenticated"}}`, in the request's locale; the `api.auth_failed` event keeps
1131
+ its reason (`api_auth_denied`) and records `status: 401`. No `WWW-Authenticate` challenge is invented (the gem does
1132
+ not know your scheme): one your `api_auth` sets on `controller.response.headers` is kept. 403 now means an
1133
+ authenticated caller the authorization lambdas refuse, a lock, a missing tenant or `audit_log_authorization`.
1134
+ **No opt-out:** a client that treated 403 as "bad token" checks 401.
1135
+ <!-- pin: test/permission_forge/api/base_controller_test.rb#a WWW-Authenticate challenge set by api_auth is sent with the 401; none is invented -->
1136
+ <!-- pin: test/permission_forge/audit/admin_plane_events_test.rb#an API request api_auth refuses records api.auth_failed (401) with who the host knows -->
1137
+ - **A 403 for a missing authorization lambda says so.** While `api_require_authorization` is on and
1138
+ `api_action_authorization` / `api_assignment_authorization` is unset, a change or an assignment answered "Action not
1139
+ authorized" / "Assignment not authorized", the same text as a real denial. It now names the missing option ("API
1140
+ changes need config.api_action_authorization; …"); a lambda's refusal keeps its message. **No opt-out:** a message.
1141
+ <!-- pin: test/permission_forge/api/base_controller_test.rb#a mutation refused because an authorization lambda is unset says which option is missing -->
1142
+ - **`generate_fields` reads `grant_all` as a boolean.** Only the strings `"true"` and `"1"` granted: a JSON `true`
1143
+ (or `1`) answered 201 with fields without edit. `true`, `1` and the words `true`, `t`, `on`, `1` (any case) now
1144
+ grant; `false`, `0`, the words `false`, `f`, `off`, `0`, `null` and no `grant_all` do not — the one boolean reading
1145
+ of the admin and the API (`ParamShapes.boolean`); anything else (`"yes"`, `""`, `2`) is a 422
1146
+ `{"grant_all": ["must be true or false"]}` and creates no field. **No opt-out:** send one of the values above.
1147
+ <!-- pin: test/permission_forge/api/resources_controller_test.rb#POST generate_fields reads grant_all as a JSON boolean, 1 or their strings -->
1148
+ <!-- pin: test/permission_forge/api/resources_controller_test.rb#POST generate_fields refuses a grant_all it cannot read with a 422 keyed by grant_all -->
1149
+ - **A bulk assignment that names nobody is a 422.** `POST …/users/bulk` without `user_ids` (missing, `[]`, `""` or
1150
+ only blanks) answered 200 with nothing done; it is now `{"user_ids": ["must name at least one user"]}`, on `assign`
1151
+ and `revoke`, and records no event, like the other 422s. Ids that name nobody are still reported in `skipped`.
1152
+ **No opt-out:** such a request did nothing.
1153
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a bulk request that names nobody is a 422 keyed by user_ids, recorded like the other 422s -->
1154
+ - **A bulk `user_ids` that is not a list is a 422.** `"user_ids": "1,3"` was read as one unknown id and answered 200
1155
+ `{"assigned": 0, "skipped": ["1,3"]}`; a string, a number or an object now answers
1156
+ `{"user_ids": ["must be a list of user ids"]}` and changes nothing. **No opt-out:** send a JSON list.
1157
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a bulk user_ids that is not a list is a 422 keyed by user_ids and changes nothing -->
1158
+ - **API: a value of the wrong shape for its attribute is a 422 keyed by it, never dropped or cast.** Every attribute
1159
+ section (`permission_set`, `resource`, `resource_field`, `resource_action`, `permission_set_rule`,
1160
+ `permission_set_group`) is read through one shape table, `PermissionForge::Api::InputShapes` (each attribute's shape
1161
+ from its column; the activation conditions folded in), and every wrong attribute of the request is named. Strong
1162
+ parameters dropped a list or an object sent for a scalar with a log line (`PATCH {"is_active": [false]}` answered 200
1163
+ with the set still active: a deactivation or revocation that did nothing), and ActiveModel cast the rest: `"nope"`
1164
+ read as `true`, a JSON `true` for a name stored `"t"`, `1.5` for a description `"1.5"`. A boolean now takes `true`,
1165
+ `false`, `1`, `0` and the words `true`, `false`, `t`, `f`, `on`, `off`, `1`, `0` in any case (`""` is no boolean);
1166
+ text a String; `null` is passed on (a nullable column is cleared, a NOT NULL one keeps its own 422). A rule's
1167
+ `field_value` takes a JSON number or boolean (stored as its text, `5` → `"5"`) and, under `in`, `not_in` and
1168
+ `between`, a list stored joined with commas (`["active", "lead"]` → `"active,lead"`: it was a 422 "can't be blank");
1169
+ `permission_set_group_id` goes on taking the group's id or uuid, `null` or `""` clearing it. Outside a section, a
1170
+ copy's `name` that is not text (`…/clone`; it became the default name) and a bulk `user_ids` element that is no id (it
1171
+ was left out) — an object, a list, a fraction, a boolean — are 422s too; a blank string beside an id is ignored, as
1172
+ Rails' own `collection_singular_ids=` ignores it (a form built with `collection_check_boxes` sends one), and a JSON
1173
+ `null` in the list is removed by Rails' parameter parsing before the API reads it. The admin reads its flags the same way (the field drawer, a group, an action, the set
1174
+ editor): a crafted `group[is_active]=nope` switched the group on; it now goes back with "is_active must be true or
1175
+ false. Nothing was changed." A static guard keeps `.permit` out of the API controllers. **No opt-out:** send each
1176
+ attribute in its shape (docs/admin-api.md "Attribute shapes"); a dropped or miscast value was a silent no-op or an
1177
+ inverted flag.
1178
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#a list, an object, an unknown word, a fraction or "" for a boolean is a 422 keyed by it and changes nothing -->
1179
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#true, a number, a list or an object for a text attribute is a 422 keyed by it; nothing is renamed -->
1180
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#a list for field_value under in, not_in or between is joined with commas -->
1181
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#a copy name that is not text is a 422 keyed by name, and nothing is copied -->
1182
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#a bulk user_ids with an element that is no id is a 422 keyed by user_ids, and nobody is assigned -->
1183
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#a bulk user_ids holding a blank string beside an id ignores the blank, as Rails does -->
1184
+ <!-- pin: test/permission_forge/api/input_shapes_test.rb#no .permit in the API controllers outside pf_record_attributes -->
1185
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#~answers a wrong shape of each section attribute with a 422 keyed by it -->
1186
+ <!-- pin: test/permission_forge/html/admin_boolean_params_test.rb#a group flag the admin cannot read is a flash, and the group is unchanged -->
1187
+ - **A single assignment or membership with an unusable `user_id` is a 422 keyed by `user_id`.**
1188
+ `POST …/permission_sets/:id/users` and `POST …/permission_set_groups/:id/users` answered 404 `"Record not found"`
1189
+ for a missing `user_id`, for one that is not an id (`"abc"`, `[2]`) and for one that names nobody, which read as if
1190
+ the set or the group were wrong. They now answer `{"user_id": ["is missing"]}`, `["is not a user id"]` or
1191
+ `["names nobody who can be assigned"]`, after the authorization lambdas, and assign nobody. An unknown id and
1192
+ someone `user_list_resolver` leaves out read the same, as in a bulk request's `skipped`; a 404 now always means the
1193
+ set or the group in the path. **No opt-out:** a client that treated that 404 as "no such person" reads the 422.
1194
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#a missing or malformed user_id is a 422 keyed by user_id, on a set that exists -->
1195
+ <!-- pin: test/permission_forge/api/permission_set_group_users_controller_test.rb#a missing, malformed or unknown user_id is a 422 keyed by user_id, on a group that exists -->
1196
+ - **An unknown `permission_set_group_id` is a 422 keyed by `permission_set_group_id`.** A value that names no group of
1197
+ the current tenant was a validation error under `permission_set_group`; the API now answers
1198
+ `{"permission_set_group_id": ["…"]}`, the parameter the request sent, before anything is saved. **No opt-out:** a
1199
+ client that read `messages.permission_set_group` reads `messages.permission_set_group_id`.
1200
+ <!-- pin: test/permission_forge/api/record_identifier_test.rb#a permission_set_group_id that names no group is a 422 keyed by it -->
1201
+ - **A request body the API cannot parse is a 400 in the API's envelope.** Invalid JSON sent as `application/json`
1202
+ answered Rails' own `{"status":400,"error":"Bad Request"}` (or raised where exceptions are not shown); it is now
1203
+ `{"error":{"status":400,"messages":"The request body could not be read. …"}}`, in the `Accept-Language` locale.
1204
+ Rails parses the body before `api_auth` runs, so this 400 can come before a 401; a disabled API stays a 404.
1205
+ **No opt-out:** the envelope is the API's.
1206
+ <!-- pin: test/permission_forge/api/base_controller_test.rb#a body that is not valid JSON is a 400 in the API envelope, in the Accept-Language locale -->
1207
+ <!-- pin: test/permission_forge/api/base_controller_test.rb#a body that is not valid JSON on a disabled API stays a bodiless 404 -->
1208
+ - **A `per_page` or `page` the API cannot read is the default, not 1.** `per_page=abc`, `0` or a negative number gave
1209
+ pages of one row; anything that is not a positive whole number is now `api_default_per_page` (a `page`, the first
1210
+ page). The `api_max_per_page` cap is unchanged. **No opt-out:** `meta.pagination` says what was used.
1211
+ <!-- pin: test/permission_forge/security/pagination_limits_test.rb#a per_page of 0, a negative one or one that is not a number is the default page size -->
1212
+ <!-- pin: test/permission_forge/security/pagination_limits_test.rb#a page that is not a positive whole number is the first page; the max clamp stays -->
1213
+ - **An API clone without a name is named as the admin's Duplicate names it.** `POST …/permission_sets/:id/clone`
1214
+ and `POST …/permission_set_groups/:id/clone` named the copy `"<name> (Copy)"` in English; they now use
1215
+ `permission_sets.copy_name`, `"<name> (copy)"` in the request's locale (`(kopje)`, `(копија)`), a set's copy
1216
+ numbered when taken, as the admin and the cloners do. **Opt out:** send `name`.
1217
+ <!-- pin: test/permission_forge/api/permission_sets_controller_test.rb#POST clone without a name names the copy as the admin does, numbered when taken, in the request locale -->
1218
+ - **`config.preset = :strict` sets `no_read_access_strategy = :deny`.** Under the strict preset a signed-in user
1219
+ whose Permission Sets do not grant Read on a model finds none of its records: `permission_scoped` and auto-scoping
1220
+ return no rows (associations and `belongs_to` presence checks included), `find_by` raises `NotAuthorizedError`.
1221
+ Give each role Read on the models its pages and validations load. The library default is unchanged. **Opt out:** `config.no_read_access_strategy = :rules_only`.
1222
+ <!-- pin: test/permission_forge/concerns/no_read_access_strategy_test.rb#the library default is :rules_only, the strict preset :deny -->
1223
+ <!-- pin: test/permission_forge/configuration_presets_test.rb#:strict closes exactly the README hardening checklist -->
1224
+
1225
+ - **One assignment's change says what the cache store confirmed, as a bulk revoke does.** Assigning, changing the end
1226
+ date, keeping, removing, restoring and undoing one person's assignment in the admin, and saving a batch of edits
1227
+ (Review and save, a field's drawer), were announced as done whatever the store answered; with the store down the
1228
+ toast is now an alert that adds "The cache store did not confirm that the cached permissions were cleared: the old
1229
+ permissions may stay in use until the cache lifetime ends." (`flash.cache_not_confirmed`, en/sq/mk), and on a
1230
+ per-process store every such toast ends with `flash.cache_process_local`. Group membership changes no cached
1231
+ permissions and reports none. **No opt-out:** a toast's wording; share the cache store to drop the per-process
1232
+ sentence.
1233
+ <!-- pin: test/permission_forge/html/assignment_cache_outcome_test.rb#removing one assignment with the store down is an alert, and still removes it -->
1234
+ <!-- pin: test/permission_forge/html/assignment_cache_outcome_test.rb#saving batched edits with the store down is an alert -->
1235
+ - **`POST` and `DELETE …/permission_sets/:uuid/users` answer the cache outcome.** The single grant answers
1236
+ `{ "data": {…}, "meta": { "cache": { "confirmed", "process_local", "in_request", "unconfirmed" } } }` (201), as the
1237
+ bulk endpoint answers `cache`; the single revoke answers 200 with the removed assignment and the same `meta.cache`
1238
+ (through 0.14.x a 204 without a body). Declared in `Api::Schema` (`single_with_cache`). **No opt-out:** a client
1239
+ that requires 204 must accept 200 (every 2xx is success); `meta` is additive.
1240
+ <!-- pin: test/permission_forge/api/assignment_cache_outcome_api_test.rb#a single revoke with the store down answers that nothing was confirmed -->
1241
+ <!-- pin: test/permission_forge/api/assignment_cache_outcome_api_test.rb#a single grant answers the cache outcome -->
1242
+ - **More warnings are SECURITY warnings.** A warning means access wider than configured, or a change unrecorded, and
1243
+ now says so (`[PermissionForge] SECURITY warning:`) for: a model loaded with another `tenant_model` (or none) than the
1244
+ configuration's; auto-applied masking not complete at boot, a masking token the store did not take, a store that
1245
+ fails the masking sync, and a sync that raised (it was a bare `logger.error`); the production per-process cache and
1246
+ the production masking that cannot be announced (their `WARNING:` prefix is gone); `database_row_level_security` on
1247
+ an adapter that is not PostgreSQL; the audit table that cannot be checked (a bare `logger.warn` before). A log alert
1248
+ that matched `WARNING: Using an in-process permission cache` matches `Using an in-process permission cache`.
1249
+ **No opt-out:** log text and severity; each warning names the setting that removes its condition.
1250
+ <!-- pin: test/permission_forge/model_bindings_test.rb#a tenant_model mismatch is a SECURITY warning; a user_class one is not -->
1251
+ <!-- pin: test/permission_forge/boot_warnings_channel_test.rb#every warning has its severity in SEVERITY, the one its site passes, and a reason when it is config -->
1252
+ - **Inside a caller's transaction, a consent withdrawal and the expired-assignment clean-up report a pending cache
1253
+ outcome.** They reported the outcome measured before the commit as confirmed, while the bulk assignment and the
1254
+ erasure reported `confirmed: nil`. "Deferred" has one reading now, `Cache::Report#deferred?` (a transaction was open
1255
+ when the work was measured): `cache[:confirmed]` and `invalidated` are `nil` there, as is
1256
+ `cleanup.expired_assignments`' `invalidated`. **No opt-out:** the answer is only known at the commit; `cache.settled`
1257
+ records it for the consent withdrawal.
1258
+ <!-- pin: test/permission_forge/services/cache_settlement_outcome_test.rb#inside a caller transaction a consent withdrawal reports deferred, not confirmed -->
1259
+ <!-- pin: test/permission_forge/services/cache_settlement_outcome_test.rb#inside a caller transaction the clean-up reports deferred, not confirmed -->
1260
+ - **The audit log's page reads like every page parameter.** A whole number past the last page (`page=99999…`, 40
1261
+ digits) is the last page, as `page=99` was; it was the first. **No opt-out:** a page that does not exist was never
1262
+ meant.
1263
+ <!-- pin: test/permission_forge/release_readiness/integer_param_guard_test.rb#the audit log reads its page with the one reader: a number past the last page is the last page -->
1264
+
1265
+ - **A rule comparing a value with an attribute declared with `encrypts` is refused, and a stored one is named.**
1266
+ Active Record encryption keeps the column's name and stores the ciphertext in it; a rule compares the column in raw
1267
+ SQL, so `phone equal '+1 555 0100'` compared the plaintext with the ciphertext — deterministic encryption included
1268
+ (Active Record encrypts a value only in `where(attribute: value)`) — and silently matched nothing (field test round 7,
1269
+ E#73). Every built-in operator that takes a value now matches nothing on such a column in the application layer and in
1270
+ the RLS policy (version 7 compiles it to `FALSE`), and `PermissionSetRule` refuses a new rule — or a change of its
1271
+ column, path, join type or resource, or of its operator off the null checks — on `field_name` ("names phone, an
1272
+ encrypted attribute of Customer: the database holds its ciphertext, so a rule can only check whether it is empty";
1273
+ en, sq, mk; the JSON API answers 422). `is_null` / `is_not_null` keep working: the ciphertext is NULL exactly when the
1274
+ value is. A rule saved before the host encrypted the attribute stays editable in its other attributes (a copy keeps
1275
+ it as it is), the audit lists it as `encrypted_rule_columns` (High while people hold the set) and Home under "Rules
1276
+ that match nothing" (`Operators::RuleProblem` kind `:encrypted_column`; one reader of the declarations,
1277
+ `PermissionForge::EncryptedAttributes`, a canary watches `encrypted_attributes`). The column is found wherever the
1278
+ rule reaches it: in any table its query joins — an alias (`twins_notes.body` on a self-join), a `has_many :through`'s
1279
+ join table, a table a default scope or a host's join brings in (each name the query gives a table is resolved to that
1280
+ table, `Operators::RuleSources.tables`, and to every loaded model on it, `EncryptedAttributes.models_on`; a name no
1281
+ loaded model is on counts as encrypted when any model along the rule's path encrypts a column of that name) — and
1282
+ under single-table inheritance (a column is encrypted when the
1283
+ class, its base class or any loaded class of the hierarchy on the same table encrypts it — a subclass's `encrypts`
1284
+ also refuses a rule on its parent, and Rails' own answer for the parent depends on load order; a class not loaded yet
1285
+ is not seen, so `rake permission_forge:audit`, which runs without eager loading, loads the application's models
1286
+ first, through the admin's model list's one definition, `Admin::ModelCatalog.eager_load_app_models`). Under
1287
+ `support_unencrypted_data` such a rule used to match the rows still stored in plaintext; it no longer does.
1288
+ **Opt out:** compare a deterministically encrypted attribute through a custom operator whose strategy calls
1289
+ `query.where(attribute => value)` (a custom operator gets the value as written).
1290
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_column_test.rb#a new rule comparing a value with an encrypted attribute is refused on field_name, with a message in every locale -->
1291
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_column_test.rb#a stored one matches nothing, is named, and stays editable in its other attributes -->
1292
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_column_test.rb#it is checked again when its column changes or its operator moves off the null checks -->
1293
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#encrypted_column: a row written past the model is named, denies, is listed, compiles to FALSE -->
1294
+ <!-- pin: test/rls/rls_rule_parity_test.rb#a rule on an encrypted attribute: the null checks agree, every other built-in operator matches nothing -->
1295
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_reach_test.rb#a stored one through the alias matches nothing, never the row it should hide, and is reported -->
1296
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_reach_test.rb#an alias that cannot be resolved counts as encrypted when a model along the path encrypts the column -->
1297
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_reach_test.rb#a stored one on the join table matches nothing, never the row it should hide, and is reported -->
1298
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_reach_test.rb#a table a default scope joins is seen by the applier: the rule matches nothing -->
1299
+ <!-- pin: test/permission_forge/tasks/audit_rake_tasks_test.rb#the audit loads the application models before it reviews -->
1300
+ <!-- pin: test/permission_forge/models/permission_set_rule_encrypted_reach_test.rb#a rule on the base class is refused, and a stored one matches nothing for the base and the subclass -->
1301
+ - **The default `profile_builder` leaves out an attribute the user model encrypts.** The profile is cached with each
1302
+ person's permission hash, in the cache store (Redis, Solid Cache, a file or memory store). With `encrypts :email` (or
1303
+ `:name`) on the user model, the default builder stored the ciphertext JSON there before 0.15; reading it decrypted
1304
+ would put the plaintext in the cache store. `permissions_hash[:profile][:email]` and `[:name]` are now `nil` for an
1305
+ encrypted attribute (an encrypted `email` gives way to a plain `email_address`), and so is a name or email the model
1306
+ composes in a reader while it encrypts any attribute a name or email is read from (`name`, `full_name`,
1307
+ `display_name`, `first_name`/`last_name`, `firstname`/`lastname`, `email`, `email_address`: such a reader would hand
1308
+ over their plaintext; an encrypted attribute the label never reads is not looked at); the admin's labels, which are
1309
+ never cached, still read them decrypted (`Admin::PersonLabel`, `decrypt: false` for the profile). The generated
1310
+ initializer no longer sets `profile_builder`: the line is a commented example, which calls the public readers (they
1311
+ decrypt), so a new install uses this default; an initializer generated before keeps its own lambda, which does the
1312
+ same: remove the line to get the default. **Opt out:** set a `config.profile_builder` of your own that returns what the cache store may hold.
1313
+ <!-- pin: test/permission_forge/admin/encrypted_people_test.rb#the default profile_builder leaves encrypted attributes out; the admin label reads them decrypted -->
1314
+ <!-- pin: test/permission_forge/admin/encrypted_people_test.rb#under decrypt: false no reader is asked for a name or email while the model encrypts one the label reads -->
1315
+ <!-- pin: test/permission_forge/release_readiness/claims_regression_test.rb#CLAIMS-32 the generated initializer works without a Current class and keeps uuid in the profile -->
1316
+ - **A LIKE operator on a column that is not text is refused on `operator`, naming the column's type.** `seats
1317
+ contains '1'` on an integer column (PostgreSQL), or any LIKE operator on a `json` column, was refused as "is not a
1318
+ value Customer.seats can hold" on `field_value`, although the value was fine (field test round 7, D#22). It is now
1319
+ "matches text only, and Customer.seats is a column of type integer: use a condition that compares values" on
1320
+ `operator` (en, sq, mk; the JSON API's 422 is keyed `operator`), checked on the same occasions as a value; a stored one
1321
+ still matches nothing, and the audit lists it as `non_text_pattern_rules` (Medium while people hold the set) instead of
1322
+ `unusable_rule_values` (`Operators::ValueTyping.problem`, `RuleProblem` kind `:pattern_on_non_text`). **No opt-out:**
1323
+ such a rule never matched anything; a client that reads the 422 reads `operator`, and an alert on the finding's name
1324
+ uses the new one.
1325
+ <!-- pin: test/permission_forge/models/permission_set_rule_pattern_operator_test.rb#a LIKE operator on a column no pattern matches is refused on operator, naming the column type -->
1326
+ <!-- pin: test/permission_forge/api/generated_defaults_api_test.rb#POST create answers 422 on operator, naming the column type, for a LIKE operator on a column it cannot match -->
1327
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#a LIKE operator on an integer, date or boolean column is refused on operator, naming the column type -->
1328
+ <!-- pin: test/permission_forge/operators/rule_problem_taxonomy_test.rb#pattern_on_non_text: a row written past the model is named, denies, is listed, compiles to FALSE -->
1329
+ - **Field and action rows the gem creates are labelled with their name, never with English made up from it.** The
1330
+ field generator, the admin's presets, Sync and bulk add, a row created without a label, and an action row added from
1331
+ a controller action stored `name.humanize` ("Billing email", "Created at"), which the Albanian and Macedonian admin
1332
+ then showed as if an admin had written it (field test round 7, A#127); a field label an admin blanks is stored as the
1333
+ name too (it went back to the humanized English). Rows already stored keep their label;
1334
+ `PermissionForge::GeneratedText` recognises a label equal to the name, to the humanized name or to "Untitled Field" as
1335
+ generated. `release_readiness/inflection_guard_test.rb` scans all of `lib/permission_forge` now. **Opt out:** write a
1336
+ label of your own (admin or API), or translate the column (`activerecord.attributes.<model>.<column>`).
1337
+ <!-- pin: test/permission_forge/services/resource_field_generator_test.rb#labels a generated row with its column name, never the humanized English -->
1338
+ <!-- pin: test/permission_forge/generated_text_test.rb#an action row made from a controller action key is labelled with the key -->
1339
+ <!-- pin: test/permission_forge/generated_text_test.rb#a field label an admin blanks is stored as the name -->
1340
+ <!-- pin: test/permission_forge/release_readiness/inflection_guard_test.rb#nothing in app/ or lib/permission_forge inflects English -->
1341
+ - **A Permission Set, a group or a rule created without a name — or renamed to a blank one — is refused, and a rule
1342
+ created without a blocked message stores none.** The install migration's English column defaults ("Untitled Permission Set", "Untitled
1343
+ Permission Set Group", "Untitled Rule", "You do not have permission to perform this action.") were stored by an API
1344
+ create or a create in code that sent none, and then shown in every language, returned by
1345
+ `NotAuthorizedError#failure_messages` and recorded as the audit's `record_label` (field test round 7, D#47). The models
1346
+ declare no default (`attribute :name, default: nil`, `attribute :failure_message, default: nil`), so the JSON API
1347
+ answers 422 on `name`. A name that is empty or only whitespace is refused the same way, in code, through the API and in
1348
+ the admin: every named model (Permission Set, group, rule, Resource, field row, action row) takes the one rule of
1349
+ `Concerns::RequiresName`, checked on create and on rename; a rule could be saved with name `""` or `" "` before
1350
+ (field test round 8), and a row stored so stays editable and shows as "Untitled …" in the admin; the install migration of a new install gives those columns no default (an upgraded install
1351
+ keeps its column defaults: the model's wins; see the migration guide). A rule whose stored message is the install
1352
+ default denies with the locale's sentence (`permission_forge.denials.rule_default`), read at denial time — the cached
1353
+ permission hash keeps the stored text. **Opt out:** send a name, and write a `failure_message` of your own.
1354
+ <!-- pin: test/permission_forge/generated_text_test.rb#a Permission Set, a group or a rule created without a name is refused, in every locale -->
1355
+ <!-- pin: test/permission_forge/api/generated_defaults_api_test.rb#POST create without a name answers 422 on name for a Permission Set, a group and a rule -->
1356
+ <!-- pin: test/permission_forge/models/requires_name_test.rb#a new record with a blank name is refused, with one message -->
1357
+ <!-- pin: test/permission_forge/api/generated_defaults_api_test.rb#a blank name is refused on create and on update for a Permission Set, a group and a rule -->
1358
+ <!-- pin: test/permission_forge/generated_text_test.rb#a rule created without a blocked message stores none, and denies with the sentence of the locale -->
1359
+ <!-- pin: test/permission_forge/generated_text_test.rb#a stored install default denies with the sentence of the current locale -->
1360
+ <!-- pin: test/permission_forge/generated_text_test.rb#the install migration gives no name of a set, group or rule and no blocked message an English default -->
1361
+ - **The audit log's own words are codes, shown in the page's language.** The reason the expired-assignment cleanup
1362
+ records when given none (`expired`, `Audit::ReasonCodes`) read "Arsyeja: expired" on an Albanian page (field test
1363
+ round 7, E#70); the event page, the log's rows and a person's audit tab render it from the locale
1364
+ (`pf_audit_reason`). An API change nobody names is stored with the actor label `api_client` (was "API client") and
1365
+ the settings observer's `boot` is a code too (`Audit::ActorCodes`); `Admin::ActorLabel` names both in the page's
1366
+ language (also an "API client" stored before), and so does the "who holds what" export's `granted_by` (a document
1367
+ people read; `granted_by_type` and `granted_by_id` stay as stored). The events export and the API return the stored
1368
+ values. **Opt out:** set `config.audit_actor_resolver`, so an API change names its client.
1369
+ <!-- pin: test/permission_forge/audit/generated_codes_test.rb#the cleaner records the reason code, and the event page, the log and the person tab render it translated -->
1370
+ <!-- pin: test/permission_forge/audit/generated_codes_test.rb#an API client nobody names and the boot are stored as codes and named in the locale -->
1371
+ <!-- pin: test/permission_forge/admin/audit_log_test.rb#who holds what names an actor stored as a code in the language of the export -->
1372
+
1373
+ - **A label the gem generated is shown as no label, and an English page keeps Rails' English.** A field or action row
1374
+ labelled with its own name, the name in English ("Created at", what the generator stored through 0.14) or "Untitled
1375
+ Field" read on a Macedonian or Albanian page as written — "Created at created_at" on the Fields tab (field test
1376
+ round 7, A#127) — and a column with a row and one without carried two names on one grid ("Api token" against
1377
+ `api_token`, B#26). `Admin::FieldLabel` and `Admin::ActionLabel` treat a generated label as none
1378
+ (`PermissionForge::GeneratedText.label?`) and fall back to the host's translation, then — only while `I18n.locale` is
1379
+ English — Rails' own English for the name (`human_attribute_name`: "Created at", what an English host saw before),
1380
+ then the name in `<code>`, shown once (the grid no longer reads "api_token api_token"); a label an admin wrote still
1381
+ wins. The field drawer, the action form, a set's and a group's name and the rule builder show generated text as a
1382
+ blank field with a translated placeholder (`Admin::Prefill.text`), and saving that blank back is no change: the
1383
+ stored text stays and no change or audit event is recorded (`Admin::Prefill.posted`, asked by every one of those
1384
+ saves; a set's and a group's name field is not `required` while it holds generated text, so a page under an
1385
+ install-default name saves any other change; typed text renames, and a blank over a label an admin wrote is stored
1386
+ as the name, as before). A blank sent for a description that holds nothing is no change either. A Permission Set,
1387
+ group or rule stored under an install-default English name ("Untitled Rule") is named in the page's language
1388
+ wherever the admin shows it (`Admin::RecordName`, `pf_record_name`; the locale-leak walk stores those defaults on the
1389
+ walked records) — the record lists of a Permission Set's and a person's Audit tab included (under
1390
+ `audit_trail_enabled`), whose CSV exports keep the stored values. **Opt out:** write a label or a name of your own, or
1391
+ translate the column (`activerecord.attributes.<model>.<column>`).
1392
+ <!-- pin: test/permission_forge/admin/generated_text_display_test.rb#a generated field label is no label: Rails English on an English page, the name verbatim elsewhere -->
1393
+ <!-- pin: test/permission_forge/html/generated_text_pages_test.rb#a generated label and no row read the same on a muting set, in every language (B#26) -->
1394
+ <!-- pin: test/permission_forge/html/generated_text_pages_test.rb#the field drawer pre-fills no generated label, and keeps an admin label -->
1395
+ <!-- pin: test/permission_forge/html/generated_text_unchanged_submit_test.rb#an unchanged field drawer keeps a generated label: no change, no event -->
1396
+ <!-- pin: test/permission_forge/html/generated_text_unchanged_submit_test.rb#a set under an install-default name: an unchanged submit changes nothing, a change saves -->
1397
+ <!-- pin: test/permission_forge/html/generated_text_unchanged_submit_test.rb#a rule under an install-default name and message: an unchanged builder save changes nothing -->
1398
+ <!-- pin: test/permission_forge/html/generated_text_pages_test.rb#the Permission Set Audit tab names generated text in the page language; its CSV keeps the stored values -->
1399
+ <!-- pin: test/permission_forge/admin/generated_text_display_test.rb#a set, group or rule stored under an install default name is named in the page language -->
1400
+ <!-- pin: test/permission_forge/html/locale_leak_test.rb#every screen shows only translated words and host data -->
1401
+ - **The rule builder's preview says what the save would say.** It runs the save path in memory — the builder's own
1402
+ checks, then the model's validations on the attributes the sentence shows — and shows the first refusal
1403
+ (`Admin::RulePreview`): an "is between" with one end typed read "a, is not a value status of Customer can hold"
1404
+ (field test round 7, B#87), and now reads "⚠ Will not save Enter both ends of the range.", as the save does; a rule
1405
+ the save would take shows its sentence. A preview before a value is entered names the missing value instead of
1406
+ "…". **No opt-out:** the preview only words what the save already refuses.
1407
+ <!-- pin: test/permission_forge/html/rule_preview_property_test.rb#the preview says the first error the save would show, or none when the save goes through -->
1408
+ <!-- pin: test/permission_forge/html/rule_preview_property_test.rb#B#87: an "is between" with one end says the range needs both ends -->
1409
+ - **The rule builder's checkboxes are read as every admin flag is.** `current_user`, `include_nulls` and `is_active`
1410
+ were compared with `"1"`, so `"true"` or `"on"` read as false; they go through `ParamShapes.flag` now (`true`, `on`,
1411
+ `1`, `t` in any case; an unchecked box keeps its default), and a value that is no boolean goes back with the
1412
+ `flash.invalid_param` message, nothing saved. **No opt-out:** the admin's own forms post `1` and `0`, which read as
1413
+ before.
1414
+ <!-- pin: test/permission_forge/html/rule_form_flags_test.rb#the builder reads "true", "on", "off" as the one boolean reading does -->
1415
+ <!-- pin: test/permission_forge/html/rule_form_flags_test.rb#a include_nulls the admin cannot read goes back with the flash, nothing saved -->
1416
+ - **Dry runs say "would", and answer that they did no cache work.** `rake permission_forge:audit_events:purge` with
1417
+ `DRY_RUN=1` printed "Would delete 3 audit event(s)" above "Audit events deleted: 3" (field test round 7, E#55); every
1418
+ label of `RakeOutput` that says a change was made has its dry-run form in the same table ("Audit events that would
1419
+ be deleted"). The dry runs of `ConsentWithdrawalHandler`, `DataSubjectErasure` and `ExpiredAssignmentCleaner` answer
1420
+ `cache: { confirmed: nil, ..., work: false }` (`Cache::Outcome.none`, `Cache::Report#none`; the consent dry run
1421
+ answered `confirmed: true`, the other two had no `cache`), and the rake line reads "none — no cache work was done".
1422
+ An outcome of real cache work keeps its four keys. **No opt-out:** a script that read a dry run's `cache[:confirmed]`
1423
+ reads `work`.
1424
+ <!-- pin: test/permission_forge/tasks/dry_run_output_test.rb#every label that says a change was made has its "would" form -->
1425
+ <!-- pin: test/permission_forge/tasks/dry_run_output_test.rb#the purge's dry run says would on every line -->
1426
+ <!-- pin: test/permission_forge/tasks/dry_run_output_test.rb#the consent withdrawal's dry run did no cache work, and says so -->
1427
+ <!-- pin: test/permission_forge/tasks/dry_run_output_test.rb#every dry run answers no cache work -->
1428
+ ### Deprecated
1429
+
1430
+ - **An `api_auth` that sets `@admin = true` on the controller to open `GET /api/stats`.** Set
1431
+ `config.api_stats_authorization = ->(controller) { … }` instead. While that option is unset, `@admin` still allows,
1432
+ warning once per process through `PermissionForge.deprecator`; once it is set, `@admin` is ignored. Removed in 0.16.
1433
+ <!-- pin: test/permission_forge/api/dashboard_controller_test.rb#an @admin set by api_auth still allows the stats, with a deprecation warning -->
1434
+ <!-- pin: test/permission_forge/api/dashboard_controller_test.rb#with api_stats_authorization set, @admin no longer decides -->
1435
+ - **`PermissionForge::Admin::SensitiveColumns`** — use `PermissionForge::SensitiveColumns`; the old constant answers
1436
+ with the new class and warns through `PermissionForge.deprecator`. Removed in 0.16.
1437
+ <!-- pin: test/permission_forge/sensitive_columns_test.rb#PermissionForge::Admin::SensitiveColumns is a deprecated name for the same class -->
1438
+ - **`pf_time_ago`** (`ApplicationHelper`): it read Rails' `datetime.distance_in_words` keys, English without
1439
+ rails-i18n; it now answers as `pf_relative` and warns once through `PermissionForge.deprecator`. Removed in 0.16.
1440
+ <!-- pin: test/permission_forge/helpers/application_helper_test.rb#pf_time_ago is deprecated and reads as pf_relative -->
1441
+
1442
+ ### Removed
1443
+
1444
+ - **`PermissionForge::Engine.apply_masked_attributes_to_managed_models`**, deprecated in 0.14: use
1445
+ `PermissionForge::MaskedModels.apply(only:)`.
1446
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#the engine carries no boot, masking or warning logic, and nothing reaches into it -->
1447
+ - **The removal of 0.13's cache keys.** Through 0.14 every invalidation also removed the bare keys 0.13 wrote
1448
+ (`user_<id>`, `tenant_<t>_user_<id>`), so that a rolling deploy from 0.13 reached both versions' entries;
1449
+ `Cache::UserKeys::PREVIOUS_ENTRY_NAMESPACES` is now empty and an invalidation removes this version's (`v2:`) entries
1450
+ only: a revocation made by a 0.15 process no longer reaches what a 0.13 process has cached, which keeps serving it
1451
+ until `cache_ttl`. An application still on 0.13 follows
1452
+ [From 0.13](https://github.com/fetijashari/permission_forge/blob/main/docs/migration-guide.md#from-013), which states
1453
+ both directions and its two paths (through 0.14, with no such window, or straight to 0.15, accepting it).
1454
+ <!-- pin: test/permission_forge/cache/user_keys_test.rb#an invalidation names the stamp and the v2 keys, and no bare key of 0.13 -->
1455
+ <!-- pin: test/permission_forge/cache/user_keys_test.rb#the entries live under one namespace, the previous ones are gone -->
1456
+ <!-- pin: test/permission_forge/docs/upgrade_notes_test.rb#From 0.13 states the cache gap in both directions and the two paths -->
1457
+
1458
+ ### Fixed
1459
+
1460
+ - **A session flag with a control character is a condition nobody can judge.** `requires_session_flag` with a newline,
1461
+ a tab or a NUL byte is refused on save; a stored one makes a muting set apply to every request and a granting set grant
1462
+ nothing (`ActivationConditions#session_flag_broken?`), and `rake permission_forge:audit` and Home list it with the other
1463
+ broken conditions. <!-- pin: test/permission_forge/models/permission_set_condition_rows_test.rb#a session flag with a control character is refused, and a stored one cannot be judged -->
1464
+ - **A NUL byte in any text attribute of the permission models is a validation error.** A save from code (the console,
1465
+ a host's service) with one raised — PostgreSQL's driver refuses any statement carrying it, the uniqueness checks
1466
+ included, and SQLite on Rails 7.1 reads the statement cut short; the admin and the API already dropped NUL bytes from
1467
+ request parameters. A value stored before keeps the row's other attributes editable.
1468
+ <!-- pin: test/permission_forge/concerns/validates_column_lengths_test.rb#a NUL byte in a string being written is a validation error, before any validator queries with it -->
1469
+ - **The rule builder's live sentence keeps up with typing**: a newer input aborts the request before it, so a late
1470
+ answer no longer overwrites the newer sentence, and a redirect (a lapsed session) or a whole page is never put into
1471
+ the panel.
1472
+ <!-- pin: test/permission_forge/html/admin_script_hygiene_test.rb#the rule preview aborts a stale request and injects fragments only -->
1473
+ - **The dependency validation names the prerequisite by its label** ("requires Read to be enabled", in the request's
1474
+ language) instead of the column (`read_access`); sq and mk no longer say "Permission Set", "commit and deploy" or
1475
+ "fail-open" in English, and the Effective access action pills word their state, not only a glyph.
1476
+ <!-- pin: test/permission_forge/models/resource_test.rb#the dependency message names the prerequisite by its label in every locale -->
1477
+ <!-- pin: test/permission_forge/i18n_test.rb#the left-behind lint bites -->
1478
+ <!-- pin: test/permission_forge/html/state_rendering_test.rb#an action pill on Effective access words its state, not only its glyph -->
1479
+ - **A toast no longer covers the sidebar's language and theme switch at desktop widths** (1024px): toasts centre over
1480
+ the main column (`--pf-sidebar-width`). The browser layout check now also runs at 390 and 1024px.
1481
+ <!-- pin: test/permission_forge/html/layout_lint_test.rb#every scroll container in the stylesheet is a containing block, and the frame paints its cue -->
1482
+ - **"Review and save" titled the browser tab with a raw value** (`{one: "Review %{count} change", …}`) in every locale:
1483
+ the page title now counts the changes. Every admin template is now rendered by the whole-UI walks (the reviews,
1484
+ previews, Home cards, the audit log off or refused), so their lints cover it.
1485
+ <!-- pin: test/permission_forge/html/admin_page_paths_test.rb#every POST answered with a page renders it -->
1486
+ <!-- pin: test/permission_forge/html/admin_page_paths_test.rb#every template under app/views is rendered by a walked screen -->
1487
+ - **A stored rule never raises at read time.** A rule row with an operator this version does not know (`regex`) raised
1488
+ `ArgumentError`, one with a join type that is none (`inner`) raised `ConfigurationError` — from `permission_scoped`,
1489
+ `find_by`, `find_by!`, and under `auto_query_scoping = :filter_only` from `Customer.count` and every other query —
1490
+ a 500 for every holder of the set. Such a rule matches nothing: `permission_scoped` is empty, `find_by` denies with
1491
+ `NotAuthorizedError`. A registry injected as a Hash or as an object answering `fetch(name)` is asked the same way.
1492
+ <!-- pin: test/permission_forge/services/permission_rule_applier_test.rb#the holder of such rules is denied every record, in every read path, never a 500 -->
1493
+ - **A rule naming a missing column denies instead of raising.** A rule whose field name is not a column of its table
1494
+ (`onwer_id` on `Customer`, written past the model, or a column renamed since) reached the database, so every
1495
+ `find_by` / `find_by!` / `permission_scoped` (and every query under `auto_query_scoping`) of each holder of its
1496
+ Permission Set raised `ActiveRecord::StatementInvalid` — a 500 in the host app. Under every built-in operator it now
1497
+ matches nothing, like a value the column cannot hold: its holders are denied every record of the resource. A custom
1498
+ operator's rule is still left to the database.
1499
+ An RLS policy compiles such a rule to `FALSE` (the sync failed on it; see `GENERATOR_VERSION` 7 under Changed). A new
1500
+ such rule is refused on save (see Changed). See
1501
+ [Using the wrong field name](docs/row-level-rules.md#using-the-wrong-field-name).
1502
+ <!-- pin: test/permission_forge/services/rule_value_typing_test.rb#a column the table does not have matches nothing, under every built-in operator -->
1503
+ <!-- pin: test/permission_forge/services/rule_value_typing_test.rb#a table that is none of the rule's matches nothing; a custom operator is still left to the database -->
1504
+ <!-- pin: test/permission_forge/services/rls_operator_sql_test.rb#a column the table does not have compiles to FALSE, whatever the operator or value -->
1505
+ - **Duplicating a set no longer fails on a row stored before today's checks** (an unknown operator or join type, a
1506
+ mask strategy that is not one, an IP entry that is no address): the copy keeps the stored values and behaves like
1507
+ its original; whatever a copy still cannot hold is a flash naming the row, never a 500 (admin Duplicate, "Start
1508
+ from", group Duplicate, API `clone`).
1509
+ <!-- pin: test/permission_forge/services/copy_stored_values_test.rb#admin_duplicate copies a set holding a stored rule_operator and keeps it -->
1510
+ <!-- pin: test/permission_forge/services/copy_stored_values_test.rb#a copy the validations still refuse is a flash naming the row, in the admin -->
1511
+ - **A join type this version does not know is applied by the default scope too** (it matches nothing); skipped there,
1512
+ it left `auto_query_scoping` wider than `find_by` and `permission_scoped`. A stored rule whose field name holds a
1513
+ control character can be edited (switched off). An IPv4-mapped request address (`::ffff:10.0.0.5`) matches IPv4
1514
+ entries. `EditableLock.permits?` answers for a group membership.
1515
+ <!-- pin: test/permission_forge/security/rule_join_type_test.rb#a blank join_type is a filter rule to the applier, the default scope and the model alike -->
1516
+ <!-- pin: test/permission_forge/models/permission_set_rule_validation_test.rb#a stored field name the format refuses stays editable in its other attributes, refused when it changes -->
1517
+ <!-- pin: test/permission_forge/services/permission_set_condition_evaluator_test.rb#an IPv4-mapped IPv6 request address matches the IPv4 entries -->
1518
+ <!-- pin: test/permission_forge/admin/editable_lock_test.rb#permits? answers for a group membership: refused both ways while the group is locked -->
1519
+ - **A rule whose association path cannot be joined denies instead of raising.** A join rule whose
1520
+ `field_accessor_path` names no association (`ownr` instead of `owner`, saved through the API with a 201 before this
1521
+ release) made every `find_by` / `find_by!` / `permission_scoped` of each holder of its Permission Set raise
1522
+ `ActiveRecord::ConfigurationError` ("Can't join 'Customer' to association named 'ownr'") — a 500 in the host app —
1523
+ and the audit did not list it. Such a rule — and one through a polymorphic `belongs_to`, a record-dependent scope
1524
+ (also one reached along a `:through`, which raised `NoMethodError` when Rails built the join), a broken `:through` /
1525
+ `inverse_of` or a class that cannot be loaded — now matches nothing under every operator: the join is never built.
1526
+ Paths ActiveRecord joins are unchanged. An RLS policy compiles such a rule to `FALSE` (see Changed).
1527
+ <!-- pin: test/permission_forge/models/permission_set_rule_path_test.rb#a holder of a rule whose path cannot be followed is denied every record, never a 500 -->
1528
+ <!-- pin: test/permission_forge/security/rule_join_type_test.rb#an association path that cannot be followed matches nothing -->
1529
+ <!-- pin: test/permission_forge/operators/rule_path_test.rb#every path ActiveRecord raises for is broken, at the segment it raises for -->
1530
+ - **A rule value with a NUL byte, or a field name with a control character, matches nothing.** A `field_value`
1531
+ carrying a NUL byte (a row written past the model; the request layer drops NUL bytes from parameters) was quoted
1532
+ into the statement, which the database read cut short — `ActiveRecord::StatementInvalid` for every holder. It is
1533
+ now unusable under every operator, built-in or custom (`Operators::ValueTyping.unquotable?`): the rule matches
1534
+ nothing, the audit lists it (`unusable_rule_values`) and Home names it; a new or changed value with a NUL byte is
1535
+ refused ("must not contain a NUL byte"; a stored one leaves the row editable). A `field_name` with a control
1536
+ character (a newline, a NUL byte) in a segment is no name (`Operators::RuleColumn.name?`): it matches nothing under
1537
+ a custom operator too, where a missing column used to reach the database as written.
1538
+ <!-- pin: test/permission_forge/services/permission_rule_applier_test.rb#a value with a NUL byte, or a field name with a control character, matches nothing -->
1539
+ - **The admin no longer draws a rule that matches nothing as a working rule, and shows what it names as stored.** A
1540
+ rule naming a missing column read "Only show Customers where Stauts is active, or Stauts is empty" on the Rules tab.
1541
+ The Rules tab, the delete confirmation, the field page, the rule preview, a person's effective access (and its
1542
+ Records cell, which now summarises such a rule first) and the Permission Set's records summary now say "⊘ Matches
1543
+ nothing" and why ("`stauts` is not a column of Customer", "`ownr` is not a relationship of Customer") with the rule
1544
+ as stored, never "or empty"; the rule's edit page opens with a banner saying so (a rule written with
1545
+ `update_column` is still `valid?` — the checks run when what it names changes — so the page shows what the row
1546
+ holds) (en, sq, mk). A missing name is shown as code as stored (it read "Onwer" and "Ownr" like real names); a
1547
+ relationship that exists but cannot be followed keeps its label.
1548
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#the Rules tab says the rule matches nothing, and never "or empty" -->
1549
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#the edit page of such a rule says so, as stored -->
1550
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#a broken name is shown as stored, in code, wherever the problem is said -->
1551
+ - **An empty step in a rule's association path no longer breaks the admin.** A path like `parent..owner` (written past
1552
+ the model) made Home and the Rules tab raise (`human_attribute_name('')`); it reads "The relationship path has an
1553
+ empty step (two dots in a row, or a dot at the start)", and saving one says "has an empty association name".
1554
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#Home says why a relationship cannot be followed, and names an empty step of the path -->
1555
+ <!-- pin: test/permission_forge/models/permission_set_rule_path_test.rb#each cause has its own message -->
1556
+ - **A model file that raises while it loads no longer breaks Home or the Rules tab.** `Operators::RuleProblem` and
1557
+ `Operators::RuleColumn.for_rule` rescued only `NameError`, `ArgumentError` and `ActiveRecordError`: a model (or an
1558
+ association's class) whose file raised while it loaded made every page that describes rules a 500. A `ScriptError`
1559
+ (`SyntaxError`, `LoadError`) there, or any error while the rule's own model loads, is now "cannot tell" for those
1560
+ callers (no problem shown, the save not refused); a `StandardError` while an association's class loads makes the
1561
+ path broken ("… whose class cannot be loaded": shown, refused on save, matches nothing). The applier never answers
1562
+ for a model it cannot load: a `ScriptError` propagates from the query as before.
1563
+ <!-- pin: test/permission_forge/models/permission_set_rule_path_test.rb#a model file that raises while it loads is "cannot tell" for the admin, never an answer from the applier -->
1564
+ - **A field masked with a strategy this app does not know never raises.** `Services::MaskValue.call` raised
1565
+ `ArgumentError` for a strategy it did not know — from the model's masked getter, `attributes`, `as_json`,
1566
+ `FiltersResponseAttributes`, the admin's Fields tab — for a row written past the model, a blank strategy, or a custom
1567
+ strategy removed from `config.custom_mask_strategies` after rows named it. It masks fully (`********`) and reports
1568
+ the name once per process.
1569
+ <!-- pin: test/permission_forge/services/mask_value_test.rb#call masks fully with a strategy it does not know, blank included, and never raises -->
1570
+ - **The field drawer no longer rewrites a mask strategy this app does not know to `full`.** Saving another change
1571
+ (a label) saved `full`, audited as a strategy change, and the drawer showed "Masked (emial)" with no option
1572
+ selected. It now flags the strategy ("Unknown mask strategy — masked fully until fixed") and offers the stored name,
1573
+ selected and marked unknown, so a save that changes something else keeps it; only picking a strategy changes it.
1574
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#saving the drawer with another change keeps a strategy this app does not know; picking one changes it -->
1575
+ <!-- pin: test/permission_forge/html/broken_rules_admin_test.rb#a field whose mask strategy this app does not know is drawn masked, with a badge, never a 500 -->
1576
+ - **A Resource named after a constant that is no model** (`Kernel`, `String`, a model without its table) made
1577
+ `ResourceFieldGenerator`, `ResourceWithFieldsBuilder` (the admin's "Add resource" preset path) and the API's
1578
+ `generate_fields` raise `NoMethodError`. The generator creates no fields and logs one line; the API answers 422.
1579
+ <!-- pin: test/permission_forge/services/resource_field_generator_test.rb#returns empty array when resource name is not a valid class -->
1580
+ - **API: an activation condition is never dropped silently.** `POST`/`PATCH /api/permission_sets` permitted
1581
+ `allowed_ips` as a scalar only, so a JSON array was dropped without a word (201 with `allowed_ips: null`, 200
1582
+ changing nothing) and an "office only" granting set applied from any IP. `allowed_ips` now takes a String (commas,
1583
+ newlines or spaces between entries) or an Array of Strings, normalised by the one parser; hours take an integer (or a
1584
+ String of digits); a session flag a String; `null` clears. Any other shape (an object, a boolean, a number for a
1585
+ list, an array for a flag or an hour) is a 422 keyed by the attribute, as is a list with no entry (`[]`, `[""]`).
1586
+ <!-- pin: test/permission_forge/api/permission_set_conditions_api_test.rb#the recorded scenario: an office-only granting set written as an array grants from the office only -->
1587
+ <!-- pin: test/permission_forge/api/permission_set_conditions_api_test.rb#an IP list with no entry is a 422 saying null clears the condition -->
1588
+ - **A uniqueness race is no longer a 500.** Two requests that create the same assignment, group membership, Resource
1589
+ or field at the same moment both pass the uniqueness validation, and the unique index refuses the second INSERT
1590
+ (`ActiveRecord::RecordNotUnique`), which nothing rescued. The API now answers it with the validation's own 422,
1591
+ keyed by the attribute (`{"user_id": ["already holds this Permission Set"]}`, `{"name": ["already exists in this
1592
+ permission set"]}`); the admin goes back to the page the form was on (303, never another host; its home page
1593
+ without a Referer) with the same message in an alert. `PermissionForge::UniqueConflict` names the validation from
1594
+ the index the adapter's error names and parses nothing else; an index that belongs to no validation of the gem's
1595
+ models is a 422 on `base` / a generic alert.
1596
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#duplicate insert: POST /api/permission_sets/:set/users is the validation 422 -->
1597
+ <!-- pin: test/permission_forge/security/route_fuzz_test.rb#duplicate insert: POST /assignments goes back to the form with the message -->
1598
+ - **A column a migration added while the process was up was invisible**: a rule on it matched nothing at run time
1599
+ (`RuleColumn.find`), "Sync fields" and the field generator did not create its row, the Fields tab and the rule
1600
+ builder did not offer it and flagged a configured row on it "missing", a new `updated_at` was not made read-only, a
1601
+ `_uuid` variant got no entry in the permission hash, a declared virtual field that became a column stayed
1602
+ "virtual", the GDPR audit skipped it and the RLS generator left the tenant predicate out. Each reads it now without
1603
+ a restart (`SchemaRead`). A removed column leaves the process at its next comparing read (the admin's Resource page,
1604
+ Sync fields, the audit). A table a migration created while the process was up is found too (`data_source_exists?`
1605
+ caches a negative answer; `SchemaRead.table?` asks again at most every 30 s).
1606
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#every reader sees a column a migration added after the schema was read, without a reset -->
1607
+ <!-- pin: test/permission_forge/schema_freshness_test.rb#a table created after the schema cache said it is not there is found -->
1608
+ - **A response key two fields map to reads by the stricter of them.** With custom `field_variant_mappings` that send
1609
+ two fields to one name (`supplier_id` and `supplier_ref` both to `supplier_key`) and a model without that name,
1610
+ `FiltersResponseAttributes` read a serializer's `supplier_key` by the first field's entry, so a hidden second field
1611
+ left the key readable. It now reads by the strictest of the fields' entries: read only if every one reads, edit only
1612
+ if every one edits, masked if any masks (with that field's strategy). The default mappings never send two fields to
1613
+ one name.
1614
+ <!-- pin: test/permission_forge/field_variants_test.rb#a response key two fields map to is hidden when either field is -->
1615
+ <!-- pin: test/permission_forge/field_variants_test.rb#a variant two fields map to gets the strictest of their entries -->
1616
+ - **The admin's effective-access explanation follows the engine on explicit-over-derived variant rows** (`variant_of`
1617
+ is nil for an explicit row).
1618
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#an explicit Hidden row for a variant name is hidden, in the engine and here -->
1619
+ - **A person's effective access composes single-table inheritance the way the engine does.** The view of
1620
+ `VipCustomer` showed the subclass entry's own fields and rules, while the engine reads the strictest field entry
1621
+ along the chain (`ResourceChain.fields`) and the rules of every entry: a field the base masks read as Editable, and a
1622
+ base entry without Read (which hides every field) was not shown at all. A subclass entry now lists the rules of its
1623
+ base entries too (a View All on the subclass skips only its own rules), its fields are composed along the chain, the
1624
+ resource flags follow `PermissionHashBuilder.granted_flags` (edit and delete only with read, Modify All only with
1625
+ View All), the Why panel says which Resource decided ("Decided by Customer, which this inherits from …", "Hidden: it
1626
+ inherits from Customer, which doesn't allow Read."), and a field drawer's "In other sets" / "Explain for" reach the
1627
+ base Resources a subclass answers to.
1628
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#a subclass entry lists the base entry's rules too, and View All on the subclass skips only its own -->
1629
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#a subclass shows its fields as the chain composes them: the strictest entry decides -->
1630
+ - **Effective access: the Why panel credits a set only with what it did to the entry.** A Muting Permission Set that
1631
+ masks or hides a field no set shows made the panel read "⊘ Hidden by muting" and "Contractors forces this field
1632
+ masked." for a field that was hidden because nobody granted it; a muting row that takes editing away was listed for
1633
+ a field nobody could edit; with two mutes the weaker one was named first; two masking sets were listed in an
1634
+ unstable order, so the first strategy named could differ from the one applied; a mask on a field already masked, or
1635
+ a mute on a subclass field its base's grants already hide, was credited too. The badge and the first reason now
1636
+ always word the entry's level (and strategy).
1637
+ <!-- pin: test/permission_forge/html/admin_view_models_test.rb#every WHY level word equals the level the hash entry gives, for every state -->
1638
+ - **Effective access says how each Permission Set shows a field.** The WHY panel of a field said "✓ Sales rep shows
1639
+ this field." when Sales rep shows it masked. It now reads by level: "shows this field masked (Email)", "shows this
1640
+ field read-only", "shows this field and lets people edit it", and for Muting Permission Sets "hides this field" or
1641
+ "forces this field masked" (`Admin::EffectiveAccess::FieldSource`, `FieldOutcome#grants` / `#mutes`).
1642
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#the WHY panel says how each set shows the field -->
1643
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#a muting set that forces a mask says so, in every locale -->
1644
+ - **A field hidden by a Muting Permission Set reads "⊘ Hidden by muting" everywhere.** The field's row said so, but its
1645
+ Why panel and the field drawer's "Explain for" showed "∅ Hidden", the badge of a field no set grants; "Explain for"
1646
+ also did not say which base Resource decides an inherited field. Row, panel and "Explain for" now draw the same
1647
+ badge (`pf_effective_field_badge`) and the same "Decided by …" sentence.
1648
+ <!-- pin: test/permission_forge/html/field_test_round_four_admin_ui_test.rb#the Why panel of a field hidden by muting reads as its row does -->
1649
+ <!-- pin: test/permission_forge/html/labels_and_attention_admin_test.rb#Explain for names the base Resource that decides an inherited field, as the Why panel does -->
1650
+ - **"Across all Permission Sets" on an action drew two glyphs** ("✎ ✓ Permitted", "⊘ ⊘ Blocked"); it now reads as
1651
+ the action's "Right now" badge.
1652
+ <!-- pin: test/permission_forge/html/state_rendering_test.rb#action permitted: Right now and Across all sets word it alike -->
1653
+ - **The Conditions tab dropped the "same start and end hour" error**: the form refused the window but showed no
1654
+ message. The error is drawn under the hour selects, which are marked invalid and point to it.
1655
+ <!-- pin: test/permission_forge/html/labels_and_attention_admin_test.rb#the same start and end hour is said next to the hour selects, which are marked invalid -->
1656
+ - **A record footer that reads "Unknown" says it once, and why.** A change written with no actor (a runner, a job)
1657
+ read "Last edited by Unknown" with nothing more, a record created and edited that way read "Created by Unknown · … ·
1658
+ Last edited by Unknown", and an explanation started with "Unknown:" again. The footer names Unknown once ("… · Last
1659
+ edited today"), adds that no actor was named for the change and that `PermissionForge.with_audit_actor` names one —
1660
+ the event page's explanation, from the same helper.
1661
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#the footer and the event page explain Unknown with one helper and the same words -->
1662
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#a change made by code with no audit actor names it as the audit log does, with a hint -->
1663
+ <!-- pin: test/permission_forge/html/labels_and_attention_admin_test.rb#a record created and edited with no known actor reads Unknown once in its footer -->
1664
+ - **The audit event page explains an unknown actor.** An event with no actor read "Unknown · Unknown" with an "All
1665
+ events by this actor" link. It now names Unknown once, gives the same explanation as the record footer without
1666
+ repeating "Unknown:" in front of it, and the link reads "All events with no known actor".
1667
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#the event page of a change with no actor explains Unknown and links to every such event -->
1668
+ <!-- pin: test/permission_forge/html/field_test_round_four_admin_ui_test.rb#the event page names Unknown once: the explanation does not start with it again -->
1669
+ - **"Created by" names who the audit log names.** A record created by the HTTP Basic admin (or an API client) has
1670
+ no `created_by_id` — the column holds a user id — so the footer said "Created by System" and a Permission Set's
1671
+ Assignments tab "by System", next to "Last edited by admin (password prompt)" and an audit log that named
1672
+ "admin (password prompt)". Footers, the Assignments tab, "Assigned by" (person pages, Home), the audit tabs of the
1673
+ Person and Permission Set pages and their CSV now take the creator from the record's creation event
1674
+ (`permission_set.created`, `assignment.granted`, ...) when the audit log has one, and fall back to `created_by`
1675
+ otherwise. The `created_by_id` / `updated_by_id` columns are unchanged.
1676
+ <!-- pin: test/permission_forge/html/creator_label_test.rb#a set and an assignment made by the HTTP Basic admin: footer, grid and audit tab name who the audit log names -->
1677
+ <!-- pin: test/permission_forge/html/creator_label_test.rb#a person stamped in created_by, with no event, is still named from the column -->
1678
+ - **Views decided "locked" from the record alone** (13 sites) and so offered what the server refused: Remove and bulk
1679
+ revoke on a locked Muting Permission Set's holders, Delete on a set in a locked group, adding or removing members of a
1680
+ locked group, the rule links on a locked field's drawer; the Locked badge on a row now follows the chain (a resource or
1681
+ rule of a locked set is locked).
1682
+ <!-- pin: test/permission_forge/html/locked_pages_test.rb#a locked Muting Permission Set: holders may be added and kept longer, not removed or ended sooner -->
1683
+ - **A locked Permission Set only offers the end dates it accepts.** The "Change end date…" menu of a locked granting set
1684
+ offered "30 days from today" and "No expiry", which the lock then refused; it now lists only the presets that narrow
1685
+ access (the other way round for a Muting Permission Set; `Admin::EditableLock.expiry_choices`). A person's Assign
1686
+ drawer lists a locked set that cannot take holders as disabled with a "Locked" badge and a note, instead of leaving it
1687
+ out. Home's "Ending soon" card shows the "Locked" badge in place of "Extend 30 days" where extending would be
1688
+ refused (a locked granting set), and keeps offering it for a locked Muting Permission Set, where extending narrows.
1689
+ <!-- pin: test/permission_forge/admin/editable_lock_test.rb#expiry_choices offers only what the lock lets through, in the set's direction -->
1690
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#the Assign drawer lists a locked granting set as locked, not hidden, and cannot assign it -->
1691
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#Home offers "Extend 30 days" only where the lock lets it through, and says Locked where not -->
1692
+ - **Home's privacy audit no longer offers "Fix" for a field of a locked Permission Set.** The field drawer refuses
1693
+ every change to a locked set's (or Resource's, or field's) fields, including a mask, so "Fix" opened a drawer with
1694
+ nothing to do and "This is intended" was offered when only the set was locked. Such a finding now shows the Locked
1695
+ badge and a "View field" link (`AttentionItems::SensitiveFinding#locked?`, by `Admin::EditableLock`), and the
1696
+ drawer of a locked field says "Locked, so nothing here can be saved." instead of "Saving applies to … immediately."
1697
+ The note under the card's title says to choose "This is intended" only while a listed finding has the button
1698
+ (`AttentionItems::SensitiveFinding#acknowledgeable?`).
1699
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#the privacy audit shows a field of a locked set as Locked, with no Fix and no This is intended -->
1700
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#the drawer of a locked field does not say what saving would do -->
1701
+ <!-- pin: test/permission_forge/html/field_test_round_four_admin_ui_test.rb#a privacy audit whose rows are all locked does not tell the admin to choose This is intended -->
1702
+ - **A locked rule that matches nothing says how it gets fixed.** On Home its row has the Locked badge (as on the
1703
+ privacy card) and says the set is managed by the system or API that created it, so the rule is fixed there (or the
1704
+ set unlocked in code): its page only offers a copy, which would leave the rule as it is.
1705
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#Home shows the Locked badge on a locked rule and says where it gets fixed -->
1706
+ - **Read-only inputs look read-only.** A locked record's inputs (the field drawer's Label and Description, the
1707
+ conditions' IP list) were white boxes like editable ones; they are now filled and do not light up on hover (still
1708
+ focusable and announced read-only), and a locked set's name and description no longer show an edit outline.
1709
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#read-only inputs are drawn filled, without the hover of an editable one -->
1710
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#a locked field drawer keeps Label and Description read-only, labelled -->
1711
+ - **Re-saving a filter rule that carried a relationship path turned it into a join rule.** The builder loaded the
1712
+ stored path of a filter rule (`join_type` nil) and saved it back as `joins`, which changed what the rule matched. It
1713
+ loads a path for join rules only (`PermissionSetRule#join_rule?`, the one definition), so a filter rule stays one.
1714
+ <!-- pin: test/support/decision_parity/rules.rb#a filter rule that carries a path stays a filter rule through the builder -->
1715
+ - **Fixing a misspelled relationship keeps the rule's field.** A join rule whose stored path names no relationship
1716
+ (`ownr`) showed the first relationship as if it were chosen next to "This model or relationship no longer exists";
1717
+ its select now shows "Pick a relationship…" (marked invalid) and the field select keeps the stored field
1718
+ (`email_address (as stored)`), so picking the relationship again keeps it when it is a column there — Save answered
1719
+ "Pick the field to check." and the field was lost.
1720
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#a relationship that is not there shows "Pick a relationship…", not the first one as if chosen -->
1721
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#picking the relationship again keeps the stored field when it is a column there -->
1722
+ - **A save the rule builder refuses names the field as the builder does.** The model's errors were shown as Rails'
1723
+ full messages ("Field accessor path names ownr, …", in English in every locale); they now carry the builder's own
1724
+ label ("Relationship names ownr, …", "Field to check names `onwer_id`, which is not a column of …", in the page's
1725
+ language). The API's field-keyed messages are unchanged.
1726
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#a refused save names the field as the builder does, in every locale -->
1727
+ - **Rule builder: the banner of a rule that matches nothing names the right fix.** It said "Pick the field again" for
1728
+ an unknown condition, join type or a value the column cannot hold; it now says what to change for each kind of
1729
+ problem (`RuleProblem#remedy`), and no longer repeats the stored name ("`stauts` is not a column of Customer ·
1730
+ stauts").
1731
+ <!-- pin: test/permission_forge/html/rule_builder_explanations_test.rb#the banner says the stored name once -->
1732
+ - **Home's "unenforced rules" and "rules that match nothing" worked on two different sets of resources** (one added
1733
+ `read_access`); both read `Resource.in_effect` now. A `join_type` of blanks counted as a join rule in SQL and as a
1734
+ filter rule in Ruby; Home and the boot warning count by `filter_join_type?`. A `requires_session_flag` /
1735
+ `allowed_ips` of newlines counted as a condition to the SQL scopes and not to `conditional?`; the scopes strip them
1736
+ too.
1737
+ <!-- pin: test/support/decision_parity/rules.rb#join_type_rules agrees with filter_join_type? on whitespace, for Home and the boot warning -->
1738
+ - **One definition per decision, read where it was re-derived.** `ManagedIndex.cache_key` reads the tenant through
1739
+ `PermissionForge.ambient_tenant_id`; `FieldVariants` composes a shared variant through `ResourceChain.strictest`;
1740
+ the cloner copies rows in the hash builder's order (`PermissionHashBuilder.sort_rows`).
1741
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: the ambient tenant (PermissionForge.ambient_tenant_id / .ambient_tenant) -->
1742
+ - **The People page marked every permanent assignment "due for review" while `admin_stale_assignment_days` was nil**
1743
+ (`nil.to_i` days); it now uses the same threshold as Home and the audit (the 90-day default).
1744
+ <!-- pin: test/support/decision_parity/rules.rb#stale: the People page, Home, the audit and the auditor agree with the setting nil and set -->
1745
+ - **The Fields tab's bulk "Add" wrote rows the presets and Sync never would.** Bulk "Editable" on new columns gave
1746
+ `created_at` / `updated_at` edit access (the presets and Sync give Rails' timestamps read-only), and bulk "Read-only"
1747
+ on `email` gave it unmasked under `gdpr_defaults` (the presets start it Masked), Editable on a Resource without Edit
1748
+ gave `email` Masked, and any posted name (`id`, `xyz`) got a row. The three paths now build the row in one place.
1749
+ <!-- pin: test/support/decision_parity/fields.rb#(a) Customer, muting=false, gdpr_defaults=true, edit: presets, bulk and the generator write the same rows -->
1750
+ - **Under Rails 7.2+ `permanent_connection_checkout = :disallowed` a schema read raised** (saving a Permission Set with
1751
+ tenancy on, a rule's column, the timestamps): reads go through the connection pool. An unreadable schema now leaves
1752
+ generated timestamps Read-only, not Editable.
1753
+ <!-- pin: test/permission_forge/schema_read_connection_test.rb#a Permission Set saves with tenancy on, and the tenant readers answer, where the deprecated reader raises -->
1754
+ <!-- pin: test/permission_forge/schema_read_connection_test.rb#a timestamp row stays Read-only when the columns cannot be read (fail closed) -->
1755
+ - **A column added after `MaskedAttributes` arrived was read raw through its getter** (`[]` and `as_json` hid it); it
1756
+ is guarded once Rails defines the attribute methods again.
1757
+ <!-- pin: test/permission_forge/concerns/masked_late_columns_test.rb#a column added after the concern arrived is guarded by its getter, as by [] and as_json -->
1758
+ - **"This is intended" did not reach `rake permission_forge:audit_masks` (exit 1) or the boot warning**, which also
1759
+ said "GDPR pattern" for a `filter_parameters` match; all four surfaces share one predicate now.
1760
+ <!-- pin: test/support/decision_parity/fields.rb#(b) a row marked "This is intended" is reported by no surface, and by every surface without the mark -->
1761
+ <!-- pin: test/support/decision_parity/fields.rb#(b) the boot warning names what matched: the GDPR pattern, or the host filter_parameters -->
1762
+ - **The GDPR audit reported a Hidden row as "readable unmasked".** `rake permission_forge:audit_masks`, the
1763
+ `unmasked_personal_data` finding and `cross_set_unmasking` took every row with `mask_access: false`, so a Hidden
1764
+ row (read off) of `email` was listed as personal data in the clear and as overriding another set's mask. Only a row
1765
+ that exposes the field (read on, no mask) is reported now.
1766
+ <!-- pin: test/support/decision_parity/fields.rb#(g) the GDPR audit names exactly the rows that expose a field in the clear; a Hidden row never -->
1767
+ - **`config.ignored_fields` given as Symbols were ignored by the field generator** (the admin honoured them): the
1768
+ generator created rows for `uuid`, `id`, …
1769
+ <!-- pin: test/support/decision_parity/fields.rb#(a) Symbol ignored_fields keep their columns out on every path -->
1770
+ - **A blank virtual field name made "Add resource", Sync and the Fields tab fail** (`ActiveRecord::RecordInvalid`, a
1771
+ 500) for a host whose `extra_fields_method` lists one; the generator already skipped it. Every path skips it.
1772
+ <!-- pin: test/permission_forge/services/blank_name_rows_test.rb#the admin paths skip a blank virtual field name like the generator does -->
1773
+ - **Copying a Permission Set that holds a field or action row with a blank name failed whole** (such a row, written
1774
+ before the names were checked, cannot be created again). The copy leaves those rows out, copies the rest and logs
1775
+ how many it skipped.
1776
+ <!-- pin: test/permission_forge/services/blank_name_rows_test.rb#cloning a set with blank-named field and action rows skips those rows and copies the rest -->
1777
+ - **`GdprFieldAuditor`'s `view_all_on_pii_resource` finding listed switched-off field rows** among the resource's
1778
+ personal-data fields.
1779
+ <!-- pin: test/permission_forge/services/gdpr_field_auditor_view_all_test.rb#a switched-off field row is not among the personal-data fields of a bypassing resource -->
1780
+ - **A field row created without a label is labelled with its name.** A `ResourceField` created through the
1781
+ API or a console without a label took the install migration's column default, "Untitled Field", and the admin
1782
+ (Home's privacy audit, the Fields tab) showed that instead of the field; generated rows already had a label from
1783
+ their name. A new row whose label is blank or "Untitled Field" now gets its name as its label (`billing_email`) on
1784
+ create; a label set later is kept, and existing rows are not changed (rename them in the admin). Resource actions
1785
+ have no label default (a blank one reads as the action key).
1786
+ <!-- pin: test/permission_forge/models/resource_field_test.rb#a new row without a label is labelled with its name -->
1787
+ <!-- pin: test/permission_forge/models/resource_field_test.rb#the label default applies on create only -->
1788
+ - **The console shows the gem's own configuration names.** With `filter_parameters_integration` the GDPR pattern
1789
+ `\Aname\z` (meant for people's names) reached the gem's models through `ActiveRecord::Base.filter_attributes`, so
1790
+ `#inspect` printed `name: [FILTERED]` for a Permission Set, a Permission Set Group, a Resource, a field, an action
1791
+ and a rule. Each of these models now sets its own `filter_attributes`: the application's filters, unable to match
1792
+ `name`, `label` or `field_name` (configuration), still hiding whatever else they matched (a rule's `field_value`, a
1793
+ description). `AuditEvent` hides `actor_label` and `record_label`, which name people, whenever the application's
1794
+ filters hide a `name`. Request-parameter logging, SQL bind logging and the host's models are unchanged.
1795
+ <!-- pin: test/permission_forge/inspection_filters_test.rb#a gem model's inspect shows its name under the GDPR patterns; another attribute stays filtered -->
1796
+ <!-- pin: test/permission_forge/inspection_filters_test.rb#AuditEvent hides the labels that name people when the application's filters hide a name -->
1797
+ - **Auto-applied masking reaches the development server.** With Rails 8's development `:memory_store` (or 7.1/7.2's
1798
+ `:null_store`) and `auto_apply_masked_attributes = :auto`, a `Resource` row created from a runner, a console or a
1799
+ rake task never reached the running server, which served the model's columns in clear while `masked_field?` said
1800
+ `true`, until a restart. In development, with a store that cannot carry the masking token, each request or job now
1801
+ re-reads the `Resource` names (one query at most every 2 seconds) and masks the model at the next request. No other
1802
+ environment runs that query; there the boot warns that a model first named in another process stays unmasked until a
1803
+ restart, with the remedies (a shared store, `include PermissionForge::Concerns::MaskedAttributes` with
1804
+ `auto_apply_masked_attributes = false`, or a list of models). See [Cache
1805
+ Consistency](docs/configuration.md#cache-consistency).
1806
+ <!-- pin: test/permission_forge/masking_sync_test.rb#development with a per-process store masks a model named from another process at the next request -->
1807
+ <!-- pin: test/permission_forge/masking_sync_test.rb#development re-reads the Resource names at most every DATABASE_RECHECK seconds -->
1808
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#production with a per-process or a null store warns that masking is not announced -->
1809
+ - **The masking-token warning says why, and a store that keeps nothing is not asked.** A store that keeps nothing
1810
+ (`cache_backend = :null`, a `:null_store`, Rails 7.1/7.2's development default) logged "The cache store did not
1811
+ give or keep the masking token" at boot with no cause or remedy; such a store is no longer asked for the token (the
1812
+ boot warning above explains what it means). For a store that fails, the warning now names what the store did — its
1813
+ error, or "(it did not answer, or a read right after a write came back empty: the store is down, full, or evicts the
1814
+ key at once)" — before the consequence and the remedy (a shared store that keeps entries, or `MaskedAttributes`
1815
+ included in the models).
1816
+ <!-- pin: test/permission_forge/masking_sync_test.rb#a store that keeps nothing is asked for no token and logs no token warning -->
1817
+ <!-- pin: test/permission_forge/release_readiness/masking_sync_regression_test.rb#PERF-N1b a store that does not answer is asked once per pause and logged once -->
1818
+ - **A gem that loads `rails/generators` at boot no longer hides the audit-table check.** The boot took any process
1819
+ with `Rails::Generators` loaded for a generator run and skipped the missing-table warning. It now looks for the
1820
+ command `bin/rails generate` / `destroy` loads (`Rails::Command::GenerateCommand` / `DestroyCommand`).
1821
+ <!-- pin: test/permission_forge/audit/boot_warnings_test.rb#Rails::Generators alone is no generator run -->
1822
+ <!-- pin: test/permission_forge/audit/boot_warnings_test.rb#a generator is running while bin/rails generate or destroy boots the app -->
1823
+ - **The admin's Erase and Home Clean-up said the web processes kept the cached permissions — while running in the web
1824
+ process.** Both recorded `process_local: true` and the log read "in its own process only: the web and job processes
1825
+ kept theirs" although the serving process had cleared its store (the person lost access at once). The outcome now
1826
+ records where it ran (`in_request`), and the sentence and the flash name the *other* web workers and the job
1827
+ processes.
1828
+ <!-- pin: test/permission_forge/audit/cache_scope_events_test.rb#cleanup.expired_assignments from an admin request says process-local for the other processes only -->
1829
+ - **`ConsentWithdrawalHandler` from a runner on a per-process store answered `invalidated: true` and recorded no
1830
+ scope.** `consent.withdrawn` carries the outcome like the other three events, the result carries `cache`, and the
1831
+ admin's Withdraw consent flash says when the store is per-process.
1832
+ <!-- pin: test/permission_forge/audit/cache_scope_events_test.rb#consent.withdrawn on the suite's memory store says process-local in the event and the result, in every locale -->
1833
+ - **`ExpiredAssignmentCleaner` and `BulkPermissionSetAssignment` from the admin and the API ignored the cache store's
1834
+ answer in the flash and said nothing about scope.** The Home Clean-up flash and the bulk assign/revoke flashes say
1835
+ when the store did not confirm (`maintenance.cleaned_cache_kept`, `bulk_assignments.assigned_cache_kept` /
1836
+ `revoked_cache_kept`) and when it is per-process.
1837
+ <!-- pin: test/permission_forge/html/refresh_cache_failure_test.rb#a refresh the store confirmed is announced as done -->
1838
+ - **`permissions.invalidated` says which processes it reached.** The runtime event (the `cache` group) now carries
1839
+ `process_local` and `in_request` (the scope every summary event's `Cache::Outcome` carries), so with a per-process
1840
+ store its audit sentence ends with the same cache-scope suffix instead of reading as if every process dropped the
1841
+ user's permissions; with `confirmed: false` it says the store did not confirm the removal, never that it was
1842
+ cleared.
1843
+ <!-- pin: test/permission_forge/audit/runtime_plumbing_test.rb#a rebuilt and an invalidated permission hash are published, stored only with the cache group -->
1844
+ - **Users made by Rails 8's `bin/rails generate authentication` show and are found by their email.** That generator
1845
+ names the column `email_address`, and PermissionForge only read `email`: a person without a name column showed as
1846
+ "User #2" in the admin, searching people by email found nobody, "Paste emails or names" in bulk assignment reported
1847
+ existing people as not found, and the cached profile (`permissions_hash[:profile][:email]`) was empty. The admin
1848
+ labels, people search and bulk matching now read `email`, then `email_address` (bulk matching tries every such
1849
+ column the table has), and the default `profile_builder` and the one the install generator writes fill the
1850
+ profile's `email` key from `email_address` when `email` is missing or blank. An existing generated initializer keeps
1851
+ its `profile_builder`: change its `email:` to `user.try(:email).presence || user.try(:email_address)` to get the
1852
+ same. Users with an `email` column see no change.
1853
+ <!-- pin: test/permission_forge/admin/email_address_people_test.rb#the label shows the email_address when there is no name -->
1854
+ <!-- pin: test/permission_forge/admin/email_address_people_test.rb#people search finds an email_address in SQL -->
1855
+ <!-- pin: test/permission_forge/admin/email_address_people_test.rb#the default profile_builder fills email from email_address and still prefers email -->
1856
+ - **An `expires_at` that is not ISO 8601 is refused.** The API (`POST …/users`, `POST …/users/bulk`) and the admin's
1857
+ Undo after a removal read `expires_at` with `Time.zone.parse`, which takes any text with a recognisable fragment:
1858
+ `"tuesday"` or `"next tuesday-ish"` became today 00:00 and was answered 201 with an assignment that had already ended,
1859
+ and `"2030-02-30"` became 2 March. `expires_at` is now read only as an ISO 8601 date (`2026-10-22`, now the end of
1860
+ that day in `Time.zone`: see **Changed**) or date and time (`2026-10-22T10:00`, optional seconds and fraction, `T` or one
1861
+ space, optional `Z` or `±HH:MM`); anything else is a 422 in the API (a 400 in the admin) and assigns nobody. The
1862
+ formats are listed in [`docs/admin-api.md`](docs/admin-api.md#expires_at-formats). The values the admin itself sends
1863
+ are unchanged.
1864
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#an expires_at that is not ISO 8601 is a 422 and assigns nobody, single and bulk -->
1865
+ <!-- pin: test/permission_forge/api/permission_set_users_controller_test.rb#every accepted expires_at form is stored as the time it names, single and bulk -->
1866
+ <!-- pin: test/permission_forge/security/bad_input_test.rb#restore with an unreadable end date is a 400, not a permanent assignment -->
1867
+ - **An API `permission_set_group_id` that starts with digits no longer names another group.** The integer column cast
1868
+ `"12ab…"` (a uuid that starts with digits) to 12, so `PATCH …/permission_sets/:uuid` could move the set into the
1869
+ group whose id is 12. Only a string of digits is read as an id now; anything else is a uuid (under `:uuid`) or a 422.
1870
+ <!-- pin: test/permission_forge/api/record_identifier_test.rb#a uuid that starts with digits is never read as a group id -->
1871
+ - **The 401 of a refused `api_auth` was always in the default locale** (the request's locale was assigned by a later
1872
+ callback); it now follows `locale` / `Accept-Language` like every other API error.
1873
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#GET /api/permission_sets (permission_sets#index) answers the schema in its success and in every error class -->
1874
+ - **`GET …/permission_sets/new`, `…/:identifier/edit`** (and the same under resources, fields, actions, rules and
1875
+ groups) raised `AbstractController::ActionNotFound` — Rails' own 404 page, outside the API envelope: the API routes
1876
+ no longer draw `new` / `edit`, so `…/new` reads as an identifier (the JSON 404 "Record not found") and `…/edit`
1877
+ reaches the catch-all (the JSON 404 "No API endpoint answers this path and method").
1878
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#…/new and …/:identifier/edit answer the JSON 404 envelope -->
1879
+ - **The audit called `current_user` on a `uuid` column a value the column cannot hold** (it judged the id as `0`); it
1880
+ now judges it as a number and as a uuid, and lists it only when neither fits.
1881
+ <!-- pin: test/permission_forge/operators/postgres_column_types_test.rb#current_user on a uuid column is a uuid only when the user id is one -->
1882
+ - **RLS: the policy's "no activation condition" quoted a newline, a carriage return and a tab through `squish`**,
1883
+ which turned them into spaces; the `view_all` EXISTS is built without it.
1884
+ <!-- pin: test/rls/rls_rule_parity_test.rb#a set whose condition texts are blanks is unconditional to the policy, as to the app layer -->
1885
+ - **`rake permission_forge:stale_assignments` refuses a `THRESHOLD_DAYS` it cannot read.** The value was read with
1886
+ `to_i`, so `THRESHOLD_DAYS=`, `abc`, `0` or `-3` became a threshold of 0 (or less) days and reported every permanent
1887
+ assignment, exiting 1. Anything that is not a whole number of days from 1 to 36500 now prints what is expected on
1888
+ stderr and exits 2, without running the report.
1889
+ <!-- pin: test/permission_forge/tasks/audit_rake_tasks_test.rb#stale_assignments refuses a THRESHOLD_DAYS that is not a number of days -->
1890
+ - **`rake permission_forge:audit` says why `FAIL_ON` fails the build**: `Failing: N finding(s) at or above <level>
1891
+ (FAIL_ON=<value>).` on stderr before the exit 1 (FORMAT=json output on stdout stays parseable).
1892
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#FAIL_ON says why it fails the build -->
1893
+ <!-- pin: test/permission_forge/tasks/rake_output_test.rb#FORMAT=json keeps stdout parseable when FAIL_ON fails -->
1894
+ - **The `no_rules` finding's advice follows `auto_query_scoping`.** It said "enable auto_query_scoping" also when it
1895
+ was on (under `config.preset = :strict`).
1896
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#no_rules advises enabling auto_query_scoping only while it is off -->
1897
+ - **Lists in the admin read in Albanian and Macedonian.** A list of people, sets or resources ("Bob Sales and Carol
1898
+ Support" on the set page, the review, the assign drawer, the confirmations, the "Assigned" notice) was joined by
1899
+ `Array#to_sentence`, which reads Rails' `support.array` keys: an app without `rails-i18n` has them in English only.
1900
+ The admin now joins every list with the connectors of the gem's own keys (`permission_forge.support.array.*`, in
1901
+ en, sq and mk; helper `pf_to_sentence`, `Admin::ListSentence`); a test fails on a bare `to_sentence` in `app/` or
1902
+ `lib/`.
1903
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#a list reads with the connectors of each locale -->
1904
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#nothing in app/ or lib/ joins a list with a bare to_sentence -->
1905
+ - **In Albanian and Macedonian the admin showed English where it had no key of its own**: Rails-core validation
1906
+ messages ("can't be blank", "has already been taken", or "Translation missing: sq.errors.messages.blank"),
1907
+ humanized column, model, association and action names in the effective-access tab, the Home privacy card, the Field
1908
+ lookup, the rule builder's selects and sentences, the sync panel and the "New Resource" list, the audit log's record
1909
+ names ("Permission Set #1" from `titleize`), the person and Permission Set audit CSV headers, the Settings page's
1910
+ configuration values. Each now comes from a `permission_forge.*` key or is the stored name verbatim.
1911
+ <!-- pin: test/permission_forge/html/locale_leak_test.rb#every screen shows only translated words and host data -->
1912
+ - **Locale file fixes.** The sq locale file mixed „…“ and “…” (three keys); `ui.badges.fail_open` was "Fail-open" in sq
1913
+ and mk; the Settings intro said "deploy" in both.
1914
+ <!-- pin: test/permission_forge/i18n_test.rb#sq quotes with one pair of quotation marks -->
1915
+ - **`pf_operator_label`, the audit log's actor type / category / source / filter labels and the condition flag
1916
+ labels showed an unknown value humanized** ("made up operator"); they show it as stored.
1917
+ <!-- pin: test/permission_forge/helpers/application_helper_test.rb#pf_operator_label shows an unknown operator as stored, never humanized -->
1918
+ - **Admin wording.** The empty Rules tab says "every Customer record they can read" (it said "every Customers"); the
1919
+ banner of a Muting Permission Set's resource says "take access away from everyone who has “No PII exports”" (it
1920
+ said "No PII exports's people"). A person shown by the email or the "User #2" fallback is named in full in sentences
1921
+ ("What User #2 can do right now"; it said "What User can do"), and an email label takes its initials from the local
1922
+ part (`dave@acme.test` is "DA"; it was "DT"). In a Muting Permission Set the drawer of a field without a setting
1923
+ says the set takes nothing away from it, instead of "Blocked by default" and a link to
1924
+ `unconfigured_field_strategy`, which only decides for granting sets.
1925
+ <!-- pin: test/permission_forge/html/admin_copy_test.rb#the empty Rules tab names the resource in the singular, in every locale -->
1926
+ <!-- pin: test/permission_forge/html/admin_copy_test.rb#the muting banner quotes the set name instead of a possessive -->
1927
+ <!-- pin: test/permission_forge/html/admin_copy_test.rb#a person labelled by the fallback is named in full on the person page -->
1928
+ <!-- pin: test/permission_forge/admin/services_test.rb#an email label takes its initials from the local part -->
1929
+ <!-- pin: test/permission_forge/html/admin_copy_test.rb#the field drawer of a Muting Permission Set explains the unconfigured field the same way -->
1930
+ - **Adding a resource to a Muting Permission Set says what it takes away.** With "Leave everything unchanged" the toast
1931
+ read "Added Customer with 0 fields" and the page warned "⚠ Sync fields with model · 8 new columns not configured",
1932
+ although a column with no setting in a Muting Permission Set takes nothing away. The toast now says "Added Customer.
1933
+ It mutes nothing yet: choose below what it takes away." (or what the preset took away: "It takes away Create, Edit,
1934
+ and Delete."), and the sync panel is shown as information ("8 columns with no setting, so they mute nothing"). It
1935
+ still warns when a configured field's column is gone, and for granting sets as before
1936
+ (`Admin::FieldRows#sync_warning?`).
1937
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#a muting resource added with nothing changed says it mutes nothing yet, and the sync panel is no warning -->
1938
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#a muting resource added with a preset that takes something away says what it takes away -->
1939
+ - **The savebar of a Permission Set only shows up on its other tabs once there is something to save.** The
1940
+ Assignments tab said "Assigning or revoking applies immediately" above "No unsaved changes · Review and save". On
1941
+ every tab but Resources the bar is hidden while nothing joined to it (the set's name, description, group, Active)
1942
+ has changed; without JavaScript it stays, as the way to save those.
1943
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#the set savebar is quiet on the tabs that apply at once, and stays rendered for the header -->
1944
+ <!-- pin: test/permission_forge/html/field_test_round_two_copy_test.rb#only a page running the script hides a quiet savebar, and only while nothing changed -->
1945
+ - **"Review and save" says what it did not apply.** A request that turns on Edit (or Delete, View All, Modify All)
1946
+ while Read is off — a form the admin never sends — was answered "No changes to save." It now says "Customer: Edit not
1947
+ turned on, because Read is off" (Modify All: "because it needs View All and Edit"), in the alert when nothing else
1948
+ changed and on the review page next to the changes that do apply (`Admin::ChangeSet#refused`).
1949
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#a crafted Edit-without-Read says Edit was not turned on because Read is off -->
1950
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#the review lists a refused flag next to the changes that do apply -->
1951
+ <!-- pin: test/permission_forge/admin/change_set_test.rb#Modify All without View All and Edit is refused as needing them -->
1952
+ - **A success toast no longer covers the "unsaved changes" bar.** On a page with that sticky bar the toasts are shown
1953
+ at the top of the window instead of the bottom.
1954
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#toasts move to the top of a page that has a savebar -->
1955
+ - **Effective access shows the whole rule in the Records cell**, wrapped, instead of cutting it mid-word ("Owner is
1956
+ the cur").
1957
+ <!-- pin: test/permission_forge/html/admin_behaviour_test.rb#the Records cell shows the whole rule, wrapped, not cut with an ellipsis -->
1958
+ - **Every Permission Set page scrolled the whole document sideways on a phone** (to 619px at 360–600px) and the
1959
+ Permission Set index to 410px, with no cue: a screen-reader-only header cell of the wide grid is `position:
1960
+ absolute` and the scroll frame was not its containing block, so the cell sat at its static position in the page,
1961
+ outside the frame's clipping, and widened the document. `.pf-scroll-x` and every scroll container are `position:
1962
+ relative` now, so what they clip scrolls with them.
1963
+ <!-- pin: test/permission_forge/html/layout_lint_test.rb#every scroll container in the stylesheet is a containing block, and the frame paints its cue -->
1964
+ - **Placeholders that were sentences clipped at phone width**: the Home search in every locale, the people pickers in
1965
+ Macedonian, the Permission Set description in Macedonian (53 characters). They are short now (a value or
1966
+ placeholder wider than its input ends in an ellipsis), with the sentence as a hint under the control where one is
1967
+ needed.
1968
+ <!-- pin: test/permission_forge/html/layout_lint_test.rb#every placeholder fits a phone-width box in every locale -->
1969
+ <!-- pin: test/permission_forge/html/field_test_round_five_admin_ui_test.rb#the Permission Set description placeholder fits a phone-width input in every locale -->
1970
+ - **On a phone, long content ran past the card's edge**: a setting's long option list or pattern, the how-to snippet
1971
+ panel, a person's long email, a long model name and the group's people picker were cut off; they wrap, or scroll
1972
+ inside their own box.
1973
+ - **The Assignments grid's column headers line up with their cells.** Each row was its own grid, so the
1974
+ content-sized actions column took a different width in the header (empty) than in the rows, and "Until 14 Nov"
1975
+ sat under "Assigned". The Assignments and Permission Sets tables now share one grid between head and rows
1976
+ (CSS subgrid). When the card is narrower than the table (a phone), each Assignments row stacks instead of scrolling
1977
+ sideways, where "Reason" was cut off.
1978
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#a table with a content-sized column shares one grid between its head and rows (subgrid) -->
1979
+ <!-- pin: test/permission_forge/html/field_test_round_four_admin_ui_test.rb#a narrow card stacks each Assignments row instead of scrolling it -->
1980
+ - **The Fields grid's level controls line up across rows.** A masked row's strategy preview ("Email · j***@acme.com")
1981
+ was a cell of its own that pushed its level control left, and at 600px wrapped "Details ›" onto its own line. The
1982
+ preview now sits under the level control, no wider than it.
1983
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#the mask preview sits under the level control, inside its column -->
1984
+ <!-- pin: test/permission_forge/html/field_test_round_three_admin_test.rb#the preview does not widen the level column -->
1985
+
1986
+ - **JSON `null`, or a blank form value, for a NOT NULL column was a 500.** `"is_active": null`, `"label": null`,
1987
+ `"mask_strategy": null`, a rule's `"name": null` or `"include_nulls": null` on any API create or update — and a
1988
+ blank Active flag on the admin's group form (a boolean column casts `""` to nil) — raised
1989
+ `ActiveRecord::NotNullViolation`. The eight permission models now refuse nil for every column the schema declares
1990
+ NOT NULL (`ValidatesRequiredColumns`, read from the schema, so a new column is covered; the primary key, the
1991
+ timestamps and `uuid` are filled on create, a column with a presence rule of its own or of a required `belongs_to`
1992
+ keeps that message): a 422 keyed by the attribute in the API, the form again with the message in the admin (a blank
1993
+ flag is no boolean: "must be true or false"). A NOT NULL violation no validation foresaw is the 422 envelope (`base`)
1994
+ in the API and a flash in the admin.
1995
+ <!-- pin: test/permission_forge/security/not_null_input_test.rb#API: JSON null or a blank form value on a NOT NULL column is a 422 keyed by the attribute, on create and update -->
1996
+ <!-- pin: test/permission_forge/security/not_null_input_test.rb#admin: a blank Active flag on the group form goes back to the group with the message -->
1997
+ <!-- pin: test/permission_forge/security/not_null_input_test.rb#API: an unforeseen NotNullViolation is a 422 envelope -->
1998
+ <!-- pin: test/permission_forge/concerns/validates_required_columns_test.rb#every permission model refuses nil in each of its NOT NULL columns -->
1999
+ - **A Hash or a list in the audit tab's `days`, `who`, `type` or `views` was a 500** (a Permission Set's audit tab, its
2000
+ CSV, and a person's audit tab). They are read as typed text, `days` as one of the offered counts.
2001
+ <!-- pin: test/permission_forge/security/bad_input_test.rb#the audit tab reads a Hash or a list in days, who, type or views as no filter -->
2002
+ - **A negative or huge `rule[remove_step]` in the rule builder was a 500** (`path.first(-1)`); it is read as a count
2003
+ of steps between 0 and the path's length, else no removal.
2004
+ <!-- pin: test/permission_forge/security/bad_input_test.rb#the rule builder reads a negative or huge remove_step as no removal -->
2005
+ - **The person list read `page=2abc` as page 2.** A page is read by `ParamShapes.integer` there too: anything that is
2006
+ not a whole number is the first page.
2007
+ <!-- pin: test/permission_forge/release_readiness/integer_param_guard_test.rb#a page that is not a whole number is the first page of people -->
2008
+ - **An audit sentence said "cleared" when the cache store said no.** `cache.cleared` with `cache_confirmed: false` and
2009
+ `permissions.invalidated` with `confirmed: false` read "cleared the cached permissions", and on a per-process store
2010
+ the suffix added "cleared in its own process only". They now say the store did not confirm the clear, and the suffix
2011
+ for an unconfirmed operation says only that the store lives in each process (`audit_log.cache_scope.unconfirmed`,
2012
+ en/sq/mk).
2013
+ <!-- pin: test/permission_forge/audit/cache_sentence_truth_test.rb#a clear of every cache the store did not confirm is not told as cleared -->
2014
+ <!-- pin: test/permission_forge/audit/cache_sentence_truth_test.rb#an invalidation the store did not confirm is not told as cleared -->
2015
+ <!-- pin: test/permission_forge/audit/cache_sentence_truth_test.rb#the scope suffix of an unconfirmed operation on a per-process store does not say cleared either -->
2016
+ - **An invalidation inside a host's web request said the web processes kept their cached permissions — from a web
2017
+ process.** `in_request` was true only for the admin and the API; it is now true for any code of a web server
2018
+ process (`Cache::Report.in_request?`, the one definition, with `Warnings.server_process?`), and the suffix for a
2019
+ process of its own no longer names the web processes ("…cleared only in the process that ran it (an in-memory
2020
+ store): every other process kept theirs.").
2021
+ <!-- pin: test/permission_forge/audit/cache_sentence_truth_test.rb#an invalidation inside a host controller request of a server process records in_request -->
2022
+ <!-- pin: test/permission_forge/audit/cache_sentence_truth_test.rb#the own-process suffix never names the web processes -->
2023
+ - **`OPTIONS` and `TRACE` under the API, and a form or multipart body Rack cannot parse, answered Rails' own pages.**
2024
+ The verbs reached no route (`RoutingError`); a body such as `a[=1&%%%` raised `ActionController::BadRequest` while
2025
+ Rails read the parameters for its instrumentation, before `rescue_from`, giving Rails' 400 in `text/html`. The verbs
2026
+ now get the JSON 404 envelope, the bodies the 400 envelope an unreadable JSON body gets.
2027
+ <!-- pin: test/permission_forge/api/unmatched_routes_test.rb#OPTIONS and TRACE under the API answer the JSON 404 envelope -->
2028
+ <!-- pin: test/permission_forge/api/envelope_schema_test.rb#~answers the schema in its success and in every error class -->
2029
+
2030
+ - **Every admin form works in an API-only application.** `rails new --api` also leaves out `Rack::MethodOverride`, so
2031
+ with the three middlewares docs/admin-ui.md names, every edit and delete form (a POST with a hidden `_method`) answered
2032
+ 404 "No route matches [POST] …" (field test round 7, D#67). The engine brings its own,
2033
+ `PermissionForge::AdminMethodOverride`, in the engine's middleware stack: it runs for the admin's paths only (a
2034
+ full-stack host's own is a no-op before it) and skips the JSON API's. A full-stack host's own `Rack::MethodOverride`
2035
+ runs before the router for every path, the API's included, as Rails has it: there a POST with `_method` or
2036
+ `X-HTTP-Method-Override` reaches the API as that verb, and `api_auth`, the forgery posture and the authorization
2037
+ lambdas still decide it (docs/admin-api.md "Method override"). An API-only host has none, so its API sees the verb
2038
+ that was sent.
2039
+ <!-- pin: test/permission_forge/engine_method_override_test.rb#an admin form posted with _method=delete reaches the destroy route -->
2040
+ <!-- pin: test/permission_forge/engine_method_override_test.rb#the JSON API is left alone: _method and the override header do not change its verb -->
2041
+ <!-- pin: test/permission_forge/engine_method_override_test.rb#in a full-stack host the host's Rack::MethodOverride applies to the JSON API too, behind api_auth -->
2042
+ <!-- pin: test/permission_forge/docs/doc_samples_test.rb#an API-only host: the JSON API works, the admin names the middlewares it needs -->
2043
+ - **The admin reads a person's encrypted name or email decrypted.** With `encrypts :email` (or `:name`) on the user
2044
+ model, people labels showed the ciphertext JSON, the people search's LIKE matched it (`iv` found everyone) and bulk
2045
+ assignment compared it with the pasted text (nobody). Labels read such a column through its attribute type; the
2046
+ search and bulk matching leave it out, and match a deterministically encrypted one against the whole value exactly.
2047
+ A value this application cannot decrypt (another key) is no label, also under `support_unencrypted_data`, where
2048
+ Rails answers it with the stored ciphertext; a value stored in plaintext there reads as written. The default
2049
+ `profile_builder` leaves such an attribute out (Changed).
2050
+ <!-- pin: test/permission_forge/encrypted_attributes_test.rb#decrypt is nil for ciphertext under another key, whether support_unencrypted_data is on or off -->
2051
+ <!-- pin: test/permission_forge/encrypted_attributes_test.rb#decrypt reads a value stored in plaintext under support_unencrypted_data, and nil without it -->
2052
+ <!-- pin: test/permission_forge/admin/encrypted_people_test.rb#a label reads the name and email decrypted, never the ciphertext -->
2053
+ <!-- pin: test/permission_forge/admin/encrypted_people_test.rb#the search never matches the ciphertext -->
2054
+ <!-- pin: test/permission_forge/admin/encrypted_people_test.rb#bulk matching finds a deterministically encrypted email exactly, and nobody by an encrypted name -->
2055
+ - **A missing name or value is said once.** A Permission Set or a group created without a name answered
2056
+ `{"name": ["can't be blank", "can't be blank"]}` (a rule without a value too, on `field_value`): the presence rule
2057
+ holds a condition, and the NOT NULL check (`ValidatesRequiredColumns`) did not see it. The NOT NULL check now runs
2058
+ after every other validation and adds nothing to an attribute that already has an error, whatever the order the
2059
+ validations were declared in (a canary watches the `validate` chain's `:after` callbacks).
2060
+ <!-- pin: test/permission_forge/concerns/validates_required_columns_test.rb#a column that already has an error gets no second one, whatever the order of the validators -->
2061
+ <!-- pin: test/permission_forge/api/generated_defaults_api_test.rb#POST create without a name or a value names each once -->
2062
+
2063
+ - **The Conditions tab explains a condition with the definition that judges it.** A window from 9 to 9 read "☾
2064
+ Overnight: active from 09:00 until 09:00 the next morning" under its own error, and the tab's summary sent the reader
2065
+ "to the Conditions tab" they were on (field test round 7, E#32): the tab asks `ActivationConditions::HoursWindow`
2066
+ (`set?`, `wraps?`, `broken?`), and its summary has an on-tab form. An IP list with an entry that is no address read
2067
+ "Only works from office-network." (B#48a): the summary says the list cannot be judged, and one banner lists every
2068
+ problem `ActivationConditions#condition_problems` names, with the effect Home's card names (a granting set grants
2069
+ nothing, a muting set applies to every request; one key for both, `ui.conditions.effect`). The form refuses what
2070
+ the model refuses for the same reason (`HoursWindow#problem`, an hour outside 0–23 included;
2071
+ `ActivationConditions.ip_range`).
2072
+ <!-- pin: test/permission_forge/html/conditions_explanations_test.rb#a window from an hour to the same hour is never shown as overnight, nor sent to the tab it is on -->
2073
+ <!-- pin: test/permission_forge/html/conditions_explanations_test.rb#a granting set's list with an entry that is no address shows one banner with Home's effect -->
2074
+ <!-- pin: test/permission_forge/html/conditions_explanations_test.rb#the form refuses an hour outside the day for the reason the model has -->
2075
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: an hour window: set, overnight, broken (ActivationConditions::HoursWindow) -->
2076
+ - **A stored rule on a secret column says so where Home's "Fix rule" leads.** The builder opened with no banner and a
2077
+ preview that read as fine (field test round 7, B#48b). `Admin::RuleNotes.for(rule)` is the one list of what is said
2078
+ beside a stored rule — why it matches nothing (`RuleProblem`), then a secret column (refused for a new rule; this one
2079
+ still applies) — drawn by the builder's banner, the preview and a badge on the Rules tab's row. The builder no
2080
+ longer offers a column declared with `encrypts`, beside the secret ones, except the one a stored rule names.
2081
+ <!-- pin: test/permission_forge/html/rule_notes_test.rb#Home's Fix rule opens a builder that says the rule compares a secret column -->
2082
+ <!-- pin: test/permission_forge/html/rule_notes_test.rb#the preview of that rule says it too, and the Rules tab badges the row -->
2083
+ <!-- pin: test/permission_forge/html/rule_notes_test.rb#the builder offers no encrypted column, but keeps the one a stored rule names -->
2084
+ - **A rule's sentence reads its operators from the definitions that decide.** "The current user" is said only where
2085
+ the user's id is substituted (one value compared, `ValueTyping.current_user?`), not in a list, where the word is
2086
+ compared as written; a range's ends are split as `ValueTyping.split` splits them; "or empty" follows
2087
+ `Operators::Registry::NULL_INCLUDING`, the list the RLS policy reads too. `SentencesHelper` keeps no operator list of
2088
+ its own.
2089
+ <!-- pin: test/permission_forge/helpers/rule_sentence_operators_test.rb#"the current user" is said only where one value is compared -->
2090
+ <!-- pin: test/permission_forge/helpers/rule_sentence_operators_test.rb#the operators that add the NULL rows are the strategies that do, and the policy reads the same list -->
2091
+ - **A Permission Set with two Resources of one single-table-inheritance chain is named with the Resource.** The field
2092
+ drawer's "Across all Permission Sets" showed "Sales rep ∅ Hidden" and "Sales rep ◉ Read-only" side by side, and a
2093
+ person's effective access "from Sales rep" (field test round 7, B#35); such a row reads "Sales rep · VipCustomer",
2094
+ the model marked as host data (`Admin::ChainEntries`, `pf_set_entry_name`). A Muting Permission Set's reasons there —
2095
+ a flag, a field or an action it takes away — name the Resource whose row does the muting the same way
2096
+ (`pf_muting_set_name`).
2097
+ <!-- pin: test/permission_forge/html/sti_set_entries_test.rb#the drawer's rows name the Resource when the set holds more than one of the chain -->
2098
+ <!-- pin: test/permission_forge/html/sti_set_entries_test.rb#a person's effective access names the Resource the same way -->
2099
+ <!-- pin: test/permission_forge/html/sti_set_entries_test.rb#a person's effective access names the muting Resource the same way -->
2100
+ - **Home counts people with one Macedonian noun.** "1 лице го има овој сет" stood beside "1 корисник" (field test round
2101
+ 7, C#92); every count of people on Home uses the noun of `ui.people_count`, in every locale (a glossary test).
2102
+ <!-- pin: test/permission_forge/locale_glossary_test.rb#mk: Home counts people with the noun of ui.people_count -->
2103
+ - **A save that changed nothing says "No changes to save." on every admin form.** The action form said "Saved
2104
+ “Export”. It applies immediately.", the rule builder "Saved the rule “Own customers”.", the group page "Group saved.
2105
+ No one's access changed.", the Conditions tab "Activation conditions saved.", an applied batch "Saved 0 changes", and
2106
+ an end date set to the one the assignment had restated it — none of them changed a row or wrote an audit event (field
2107
+ test round 8, B#70). "The save changed something" has one definition, `RecordsAuditEvents#saved_audited_changes?`
2108
+ (saved changes minus the bookkeeping columns), which also decides whether the change event is written, with its twin
2109
+ before the save, `#audited_changes_pending?`: the admin saves a record only when it holds such a change
2110
+ (`SaveNotice#pf_save_changed`), so an unchanged submit no longer invalidates every holder's cached permissions (Rails
2111
+ runs the commit callbacks of a save that wrote nothing). The flash goes through `SaveNotice#pf_save_flash`, with no
2112
+ cache note; an apply whose every requested change was refused says why, as the review step does. The group keeps its
2113
+ sentence for a change that affects nobody. The key moved from `flash.permission_sets.no_changes` to
2114
+ `flash.no_changes`.
2115
+ <!-- pin: test/permission_forge/html/unchanged_save_flash_test.rb#every admin form that saves a record, submitted unchanged, says No changes to save, writes no event, keeps the cache -->
2116
+ <!-- pin: test/permission_forge/html/unchanged_save_flash_test.rb#every walked form submitted with a change says what it saved, writes one event, invalidates the holder -->
2117
+ <!-- pin: test/permission_forge/html/unchanged_save_flash_test.rb#an apply whose every change was refused says why, as the review does -->
2118
+ <!-- pin: test/permission_forge/html/unchanged_save_flash_test.rb#no admin controller decides by itself whether a save changes something -->
2119
+ <!-- pin: test/permission_forge/html/unchanged_save_flash_test.rb#every template with a PATCH form is classified: walked as a save, or why it is not one -->
2120
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: a save changed something (RecordsAuditEvents#saved_audited_changes?, #audited_changes_pending?) -->
2121
+ - **The rule builder names a relationship path in the page's language.** Under the relationship picker it printed
2122
+ `field_accessor_path: ownr` — the column's name — in every locale, while the banner and the picker's label said
2123
+ "Relationship" in the page's language (field test round 8, B#95); it reads "Relationship path: `ownr`" (en, sq, mk).
2124
+ A walk of every admin screen, error state and posted page now refuses a column name of the gem's tables followed by a
2125
+ colon anywhere but the Technical details panels and the initializer snippets.
2126
+ <!-- pin: test/permission_forge/html/raw_attribute_names_test.rb#the rule builder names the stored relationship path in the page language -->
2127
+ <!-- pin: test/permission_forge/html/raw_attribute_names_test.rb#no screen prints a column name of the gem as a label -->
2128
+ - **A person's Effective access row names the Resource a Muting Permission Set acts through.** On a single-table-
2129
+ inheritance subclass, Email (muted on Customer) and Phone (muted on VipCustomer) both read "Hidden by No PII exports"
2130
+ beside "from Sales rep · VipCustomer", while the Why panel said "No PII exports · Customer" / "· VipCustomer" (field
2131
+ test round 8, B#38): the row named the set by itself, and the Why panel found the Resource by walking the set's rows
2132
+ again (a guess that missed a field variant and a row whose mute the resource's Read made moot). Each
2133
+ `EffectiveAccess::FieldSource` now carries the Resource whose row it credits (`resource_name`: the deciding entry's
2134
+ for a grant, the muting row's for a mute) and each blocked flag or action the Resource whose row blocks it
2135
+ (`Outcome#muted_on`, the row nearest the model first); the row, the Why panel and "Explain for" name every set
2136
+ through `pf_field_source_set_name` / `pf_muting_set_name`. The row and "Explain for" also word the level the
2137
+ deciding mutes give and name the same sets (`FieldOutcome#deciding_mutes`: the strongest mute, when its level is the
2138
+ level shown, and every set at it): "Masked by MaskA" / "Ana sees it masked: MaskA masks it.", where the row said
2139
+ "Hidden by" next to a Masked badge and "Explain for" said "MaskA and NoEditB hides or masks it" (counted now: "HideA
2140
+ and HideB hide it", per-level keys in en, sq and mk). A mute that only takes editing away decides nothing when a
2141
+ grant shows the field masked (the Why panel still lists it), and is not credited at all when the Resource gives no
2142
+ Edit: the field reads "from Sales rep", shown as Read-only because Edit isn't allowed.
2143
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#the row names the Resource whose row mutes the field, as the Why panel and the grants beside it do -->
2144
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#a muting reason words the level the mute decides, not always Hidden -->
2145
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#Explain for words the deciding mute's level and names the sets the row names -->
2146
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#two sets that hide a field are counted in Explain for -->
2147
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#a mute that only takes editing away from a masked field does not decide it: the mask does -->
2148
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#an edit mute on a resource that gives no Edit changes nothing and is not credited -->
2149
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#each source names the Resource whose row it credits; a set with two rows, the one nearest the model -->
2150
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: the Resource a set acts through on a person's effective access (FieldSource#resource_name, Outcome#muted_on) -->
2151
+ - **A field hidden by the entry it inherits says so on every surface.** Bob's VipCustomer › Notes row read "∅ Hidden ·
2152
+ No set shows it" and "Explain for" said "No set shows this field to Bob.", beside "Sales rep · VipCustomer ◉
2153
+ Read-only", while the Why panel said Customer decides (field test round 8, B#39). What decides is the granting
2154
+ Customer entry, which hides Notes — not No PII exports, whose mute on Customer.notes changes nothing the granting
2155
+ sets showed, so it still never reads "Hidden by muting". `EffectiveAccess::FieldOutcome#reason` is the one decision
2156
+ (`:muted`, `:unconfigured`, `:granted`, `:implicit`, `:unreadable`, `:inherited`, `:none`) the row, its colour, the
2157
+ Why panel and "Explain for" all word: an inherited hidden field reads "Hidden on Customer, which this inherits from."
2158
+ on all three (Explain for draws no other sentence then), and an entry without Read says why on the row too, where it
2159
+ read "No set shows it".
2160
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#a field hidden on the base reads the same on the row, the Why panel and Explain for, in every locale -->
2161
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#every field of the subclass reads the same reason on the row, the Why panel and Explain for, in every locale -->
2162
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#the hidden-on reason follows the decision: muting the subclass row too still leaves the base deciding -->
2163
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#a subclass entry without Read says so on the row, as the Why panel does -->
2164
+ <!-- pin: test/permission_forge/html/sti_field_reasons_test.rb#Explain for draws no empty sentence when the inherited entry decides -->
2165
+ <!-- pin: test/permission_forge/release_readiness/one_definition_guard_test.rb#one definition: why a person's field is at its level (EffectiveAccess::FieldOutcome#reason) -->
2166
+ - **A muting row on a single-table-inheritance base mutes the variants of the subclass entries it reaches.** This
2167
+ widens protection: a muting set whose base Resource hid `price_cents` left the subclass entry's `price` (a reader
2168
+ only the subclass defines, a configured field variant) readable and editable in the permission hash, so the derived
2169
+ value of a hidden column stayed readable through the subclass, while the admin showed it hidden (field test round 8,
2170
+ review P1). `PermissionHashBuilder` took the variants from the muting row's Resource; it now takes each reached
2171
+ entry's own (`FieldVariants` of the entry's model), the variants the granting phase wrote. The hash format is
2172
+ unchanged.
2173
+ <!-- pin: test/permission_forge/services/permission_hash_builder_muting_test.rb#a base row mutes the variants of each entry it reaches, a reader only the subclass has included -->
2174
+ <!-- pin: test/permission_forge/admin/effective_access_test.rb#a base row hides a variant only the subclass has, in the engine and here -->
2175
+ - **An app with no database configured boots.** A fresh `rails new --skip-kamal` app comments its production database
2176
+ paths out (the deploy supplies `DATABASE_URL`), and `RAILS_ENV=production SECRET_KEY_BASE_DUMMY=1 bin/rails
2177
+ assets:precompile` (the Dockerfile's build step) or any production runner aborted once the gem was installed: the
2178
+ boot ping rescued Active Record's errors only, and the sqlite3 adapter refuses a configuration without a file with an
2179
+ `ArgumentError` (field test round 8, E#7). `PermissionForge::DatabaseReach` is the one definition of "this process
2180
+ cannot use its database" (Active Record's errors and an adapter's `ArgumentError` for its configuration, rescued
2181
+ only around Rails' own connection code, a ping or `table_exists?`). The boot ping, auto-applied masking, the audit
2182
+ table checks and the audit's `audit_trail` check ask it; the audit table check of `Audit.record` keeps its narrower
2183
+ list (`UNREACHABLE_ERRORS`: a database that does not answer or does not exist), so a configuration error there still
2184
+ rolls the change back under `audit_change_failure_strategy :raise`. The boot skips its database checks with one line
2185
+ naming the cause (`The database could not be reached at boot, or the gem's models could not be loaded
2186
+ (ArgumentError: No database file specified. ...)`), and a request
2187
+ or job of a process that has not synced reads neither the Resource rows nor the masking token until the Resource
2188
+ table can be read: a production runner of such an app — Solid Cache, its production cache store, has no database
2189
+ either — logs no SECURITY warning ("masking could not be synced", "The cache store did not give or keep the masking
2190
+ token"). A missing table is not remembered either: Active Record's schema cache kept the "no" of a process that
2191
+ booted before the Resource table existed, which then never masked until a restart; `DatabaseReach.table_exists?`
2192
+ drops a "no" from the cache, so such a process syncs at its first request or job after the migration. A model whose
2193
+ table check raises is named in its unmaskable warning with the error. A process that runs
2194
+ nothing but asset tasks (`assets:precompile`, `assets:clean`, `assets:clobber`: `BootChecks.asset_build?`) runs the
2195
+ configuration warnings only and never connects. (An adapter whose driver gem is missing still fails as Active Record
2196
+ loads, as it does without the gem.)
2197
+ <!-- pin: test/permission_forge/install/production_boot_without_database_test.rb#assets:precompile boots in production with no database file configured, and never connects -->
2198
+ <!-- pin: test/permission_forge/install/production_boot_without_database_test.rb#a production runner with no database file configured boots, says the checks were skipped, and runs -->
2199
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#DatabaseReach.problem counts an adapter that refuses its configuration as no database -->
2200
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#MaskedModels.apply answers false, without raising, when the database is not configured -->
2201
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#a process that has not synced and cannot use its database does not read the masking token -->
2202
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#a model whose table check raises is named with the error in the unmaskable warning -->
2203
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#a configuration error of the audit table check rolls the change back; a database gone is unchecked -->
2204
+ <!-- pin: test/permission_forge/masking_sync_test.rb#a process that found no Resource table syncs at its next run once the table is there -->
2205
+ <!-- pin: test/permission_forge/engine_boot_paths_test.rb#asset_build? is true only when every task Rake was asked for is an asset task -->
2206
+ - **A production boot no longer calls a `:memory_store` "the Rails development default".** The masking warning of a
2207
+ production boot with `config.cache_store = :memory_store` said so (field test round 8, E#66); the clause is now
2208
+ added in a development boot only (`CacheWarnings::DEVELOPMENT_DEFAULT`).
2209
+ <!-- pin: test/permission_forge/cache_warnings_test.rb#a :memory_store is called the development default in a development boot only -->
2210
+ - **The JSON API says which authorization lambda it lacks before the first 403.** With `api_enabled` and `api_auth`
2211
+ set but `api_action_authorization`, `api_assignment_authorization` or `api_stats_authorization` unset, the API refused
2212
+ every change, assignment or stats read (each 403 names the option) and nothing said so earlier (field test round 8,
2213
+ D#7; round 7 #12). The server's boot now logs one note naming each missing lambda and what the API refuses without
2214
+ it (`api_authorization_unset`, audience `:server`), `rake permission_forge:audit` lists them as the info finding
2215
+ `api_authorization_unset`, both read from `PermissionForge::ApiAuthorizations`, and the generated initializer shows
2216
+ `api_stats_authorization` beside the other two. The stats item says that an `api_auth` setting the deprecated
2217
+ `@admin = true` still passes until 0.16 (the note cannot see it).
2218
+ <!-- pin: test/permission_forge/security/api_require_authorization_default_test.rb#the boot names each authorization lambda the API needs and does not have, and what it refuses -->
2219
+ <!-- pin: test/permission_forge/services/security_audit_test.rb#an API with api_auth whose authorization lambdas are unset gets an info note naming each and what it guards -->
2220
+ <!-- pin: test/permission_forge/security/api_require_authorization_default_test.rb#the install initializer shows all three authorization lambdas -->
2221
+ - **A blank label is "no label" on every write path: `null`, `""` and white space store the row's name.** Through the
2222
+ JSON API a field row's `label: null` was a 422 `{"label": ["can't be blank"]}` while `label: ""` answered 200 and
2223
+ stored the empty label, and an action row stored either as sent; the admin stored a blanked field label as the name
2224
+ and a blanked action label as `""` (field test round 8, D#25). One model concern, `Concerns::NormalizesLabel`, now
2225
+ stores a blank label — and on a new row the install's `'Untitled Field'` column default — as the row's name, on create
2226
+ and on update, in code, through the API (200 / 201) and in the admin alike; a Resource's blank label is stored as
2227
+ `nil`. A rename relabels a row whose label the gem would generate (the old name or its humanized form:
2228
+ `GeneratedText.label?`) instead of leaving
2229
+ the old name to read as a label an admin wrote. Checked on create, when the label changes and on a rename only: a
2230
+ row stored with a blank label keeps it through a save of its other attributes, whose audit event names no label
2231
+ change. **No opt-out:** a label was never meant to be empty; write a label of
2232
+ your own to show something else.
2233
+ <!-- pin: test/permission_forge/models/normalizes_label_test.rb#a blank field or action label is stored as the name, on create and on update -->
2234
+ <!-- pin: test/permission_forge/models/normalizes_label_test.rb#a blank Resource label is stored as nil -->
2235
+ <!-- pin: test/permission_forge/models/normalizes_label_test.rb#a save of other attributes leaves a stored blank label, and its event says nothing of a label -->
2236
+ <!-- pin: test/permission_forge/models/normalizes_label_test.rb#a rename relabels a row whose label was generated, and keeps one someone wrote -->
2237
+ <!-- pin: test/permission_forge/api/blank_label_api_test.rb#a blank label is stored as the name on PATCH, for a field row and an action row alike -->
2238
+ <!-- pin: test/permission_forge/api/blank_label_api_test.rb#a blank label is stored as the name on POST, for a field row and an action row alike -->
2239
+ <!-- pin: test/permission_forge/html/editing_flows_test.rb#an action label an admin blanks is stored as the name, on create and on edit -->
2240
+ - **Rails 8.2: an alias of an attribute the query did not load raises `ActiveModel::MissingAttributeError`, as plain
2241
+ Rails does.** Rails 8.2 reads an alias with `_read_attribute!`, and its `_read_attribute` no longer takes the block
2242
+ that raised for an attribute not loaded (`select(:id)`), so the alias reader `MaskedAttributes` defines returned `nil`
2243
+ there. It uses `_read_attribute!` where Rails has it, and the block form on 7.1–8.1 (the Rails-internals canary checks
2244
+ both).
2245
+ <!-- pin: test/permission_forge/concerns/masked_attribute_aliases_test.rb#that parent and its other subclasses answer as if the concern were nowhere -->
2246
+ ### Documentation
2247
+
2248
+ - [Migration guide](docs/migration-guide.md#upgrading-to-015): "Upgrading to 0.15" — run `rake
2249
+ permission_forge:audit` first, the behaviour changes with their opt-outs, the locale keys that moved, the removed
2250
+ deprecations, and the RLS re-sync (`GENERATOR_VERSION` 7).
2251
+ <!-- pin: docs/migration-guide.md#upgrading-to-015 -->
2252
+ - Every `run` and `file=` block of README.md and docs/*.md executes in a generated Rails 8.1 app (`bin/rails generate
2253
+ authentication` + a scaffold `Customer` as generated, PermissionForge installed by the README's own install lines)
2254
+ on every test run, one console per document where nobody holds a set yet, with `# => literal` comments asserted;
2255
+ the denial flows of "Handling Denials" (a
2256
+ Turbo sign-in with no Permission Set, Reload, Sign out, the public index with and without `resume_session`, the
2257
+ sign-in page under the guard, README step 4 after an admin-made set, seeds under `:strict`) are driven as requests,
2258
+ and in Chrome with Turbo in the `browser` CI job (`test/browser/docs_flows.spec.js`).
2259
+ <!-- pin: test/permission_forge/docs/doc_samples_test.rb#every run and file= block of the documentation executes in the docs app, and every literal # => claim holds -->
2260
+ - [Handling denials](docs/row-level-rules.md#rescuing-them-yourself): the denial handler lives in one place, the
2261
+ complete `app/controllers/application_controller.rb` of a Rails 8 authentication-generator app;
2262
+ `docs/controller-actions.md` points to it. It renders `errors/forbidden` in the app's layout (with Sign out) and
2263
+ sends to sign in only a visitor with no session — never to the refused page (`redirect_back` looped on a refused
2264
+ root; a Turbo sign-in ending on a bare 403 reloaded into an empty form). `resume_session` ahead of the guard is
2265
+ required on a guarded public action; the `SessionsController` sample is the generator's file plus one `new` line.
2266
+ All of it is `file=` blocks the docs app applies.
2267
+ <!-- pin: test/permission_forge/docs/doc_blocks_test.rb#the five handler versions the rounds went through are refused -->
2268
+ <!-- pin: docs/row-level-rules.md#rescuing-them-yourself -->
2269
+ - `permission_scoped` / `find_by` apply the row-level rules only: the resource's CRUD flags are the guard's (REST
2270
+ actions → flags) or your own `can_read?` check; documented with a driven scenario (a set with a rule and no read:
2271
+ rows answered, the page refused).
2272
+ <!-- pin: test/permission_forge/docs/doc_samples_test.rb#every run and file= block of the documentation executes in the docs app, and every literal # => claim holds -->
2273
+ - README Installation and the getting-started guide mount the admin UI and set its credentials; without them there
2274
+ is no admin to open. The README Quick Start defines the `user` it assigns, says to run step 4's lines in order (a
2275
+ failing `create!` first), and step 5 looks up the person step 4 assigned instead of `User.first`.
2276
+ <!-- pin: README.md#installation -->
2277
+ - The initializer template, README and guides no longer call `controller.current_user` / `controller.current_admin` in
2278
+ the authorization examples, which Rails 8's generator does not define (a 500 on the default base controllers):
2279
+ they use `PermissionForge.current_user`, and the `audit_actor_resolver` examples show a Rails 8 and a Devise variant
2280
+ for HTTP Basic auth.
2281
+ <!-- pin: README.md#1-tell-it-who-is-signed-in -->
2282
+ - [Admin UI](docs/admin-ui.md#rails-8-authentication-generator): with Rails 8's generator as `base_controller_class`, a
2283
+ signed-out visit was a 500 (`new_session_path` does not resolve inside the engine); a complete admin base controller
2284
+ that redirects through `main_app` and checks for an admin. "API-only applications" sections in the admin UI and
2285
+ admin API guides.
2286
+ <!-- pin: docs/admin-ui.md#rails-8-authentication-generator -->
2287
+ - [Controller actions](docs/controller-actions.md#callback-order): the user must be resolved before the guard and any
2288
+ model read; an action opened with `allow_unauthenticated_access` needs `prepend_before_action :resume_session`.
2289
+ <!-- pin: docs/controller-actions.md#callback-order -->
2290
+ - [Configuration](docs/configuration.md#how-people-are-labelled-and-searched): how people are labelled, which columns
2291
+ are searched in SQL, and that an Array-returning `user_list_resolver` is searched in Ruby and loads every user.
2292
+ <!-- pin: docs/configuration.md#how-people-are-labelled-and-searched -->
2293
+ - [Configuration](docs/configuration.md#where-warnings-appear), Where warnings appear: the one channel policy of
2294
+ every boot and configuration warning.
2295
+ <!-- pin: docs/configuration.md#where-warnings-appear -->
2296
+ - [Admin API](docs/admin-api.md#response-format): a flat JSON body is accepted where Rails wraps parameters (only an
2297
+ empty, missing or non-object section answers 422); the collection sample shows `meta.pagination`; the condition
2298
+ shapes and their 422; the `RecordNotUnique` 422.
2299
+ <!-- pin: docs/admin-api.md#response-format -->
2300
+ - [Admin API](docs/admin-api.md#who-the-audit-log-names-for-an-api-change): why API changes read "API client" even
2301
+ from a signed-in browser (the default `api_base_controller_class` runs none of your sign-in, so `Current.user` is not
2302
+ set), and the two ways to name the caller: an `api_base_controller_class` that resumes the session, or an
2303
+ `audit_actor_resolver` that maps the credential `api_auth` verified (never a caller-chosen header).
2304
+ <!-- pin: docs/admin-api.md#who-the-audit-log-names-for-an-api-change -->
2305
+ - [Admin API](docs/admin-api.md#permission-set-group-users-group-membership--grants-nothing): the group-member
2306
+ endpoints say that membership is organisational and grants none of the group's sets.
2307
+ <!-- pin: docs/admin-api.md#permission-set-group-users-group-membership--grants-nothing -->
2308
+ - [Admin API](docs/admin-api.md#disabling-the-api-entirely): while `api_enabled` is off the empty 404 (not the JSON
2309
+ envelope) is deliberate: it does not reveal that the API exists.
2310
+ <!-- pin: docs/admin-api.md#disabling-the-api-entirely -->
2311
+ - [Assignment expiration](docs/assignment-expiration.md#rake-task-stale-assignments): `rake
2312
+ permission_forge:stale_assignments`, its `THRESHOLD_DAYS`, its output and its exit status 1 when it finds stale
2313
+ assignments.
2314
+ <!-- pin: docs/assignment-expiration.md#rake-task-stale-assignments -->
2315
+ - [Getting started](docs/getting-started.md#wiring-a-scaffold): `as_json` leaves out a field with a Hidden row, but a
2316
+ column with no row is serialized as `null` under the default `unconfigured_field_strategy = :block`; how to give
2317
+ every column a row. The jbuilder example's `only:` keeps the scaffold's columns, and the scaffold's HTML partial
2318
+ skips the fields the user may not read (a helper on `field_permissions`). `ResourceFieldGenerator` without
2319
+ `grant_all` leaves every column Hidden, `created_at` / `updated_at` and the personal-data columns included; only
2320
+ `grant_all` makes the timestamps read-only.
2321
+ <!-- pin: docs/getting-started.md#wiring-a-scaffold -->
2322
+ - [Getting started](docs/getting-started.md#using-rails-8s-authentication-generator): the symptoms of a guarded
2323
+ sign-in page (the 403 page where the form should be, also after Sign out) and of a public action without
2324
+ `resume_session`; steps 5 and 6 look up the person they assign by email instead of `User.first`, which may be
2325
+ someone holding no set; a seed's `find_or_create_by!` run with nobody signed in creates a duplicate on every run
2326
+ under `config.preset = :strict` (also in the generated initializer's comment).
2327
+ <!-- pin: docs/getting-started.md#using-rails-8s-authentication-generator -->
2328
+ - [Field filtering](docs/field-filtering.md#telling-the-user-what-was-not-saved): an unchanged form posts every field
2329
+ back, so "Not saved: …" named every field the user may not edit; the form renders such fields disabled (not
2330
+ submitted) and leaves out the ones they may not read.
2331
+ <!-- pin: docs/field-filtering.md#telling-the-user-what-was-not-saved -->
2332
+ - The generated initializer, README and [Presets](docs/configuration.md#presets): `config.preset = :strict` does not
2333
+ close every finding. Masking with nobody signed in becomes `:hide_in_requests` (jobs and the console keep the stored
2334
+ values: the audit notes `masked_nil_user_outside_requests`), and the checklist rows that need your code are in no
2335
+ preset.
2336
+ <!-- pin: docs/configuration.md#presets -->
2337
+ - [Without a current user](docs/masked-attributes.md#without-a-current-user): what each value of
2338
+ `masked_attributes_nil_user_strategy` does in a web request and elsewhere, what a web request is (and is not), the
2339
+ trade-off of `:hide_in_requests` and what `:bypass` records.
2340
+ <!-- pin: docs/masked-attributes.md#without-a-current-user -->
2341
+ - [filter_parameters_integration](docs/configuration.md#filter_parameters_integration) and the GDPR guide: the patterns
2342
+ filter every `name` parameter in the app's request logs, the admin's own forms included; how to narrow them. While
2343
+ on, the columns `filter_parameters` name count as personal data for the presets, Sync, the audits and the boot
2344
+ warning.
2345
+ <!-- pin: docs/configuration.md#filter_parameters_integration -->
2346
+ - [Activation conditions](docs/activation-conditions.md#ip-range-restrictions): IP lists (entries, separators, arrays
2347
+ through the API, refusal of entries that are not addresses, normalisation), hour windows (both hours or none;
2348
+ `start == end` refused; what a broken stored window does) and the model methods (`allowed_ip_list`, `hours_window`,
2349
+ `condition_problems`); [Security audit](docs/security-audit.md): the `conditions` check rows.
2350
+ <!-- pin: docs/activation-conditions.md#ip-range-restrictions -->
2351
+ - [I18n](docs/i18n.md#your-models-and-columns-in-the-admin): what "fully internationalized" means and how it is
2352
+ proven; translating `activerecord.models` / `activerecord.attributes` per locale for the admin's labels, else the
2353
+ row's label, else the stored name in code; [Column Defaults Are English](docs/i18n.md#column-defaults-are-english)
2354
+ (the install migration's defaults, the admin never pre-fills them, the blank-message denial, the one-liner for
2355
+ existing rows); the locale-file lints for contributors; the `denials` and `validations` key families.
2356
+ <!-- pin: docs/i18n.md#your-models-and-columns-in-the-admin -->
2357
+ - `rake permission_forge:erasure` is documented with `USER_ID`, `DRY_RUN`, `ACTOR` and `REASON`
2358
+ ([GDPR data handling](docs/gdpr-data-handling.md#running-checks-without-claude)), and `rake
2359
+ permission_forge:rls:remove` by its full name next to `rls:sync` and `rls:status`
2360
+ ([Query scoping](docs/query-scoping.md#how-the-policies-work)).
2361
+ <!-- pin: docs/query-scoping.md#how-the-policies-work -->
2362
+ - The wave-1 docs of this release applied: [Using the wrong field name](docs/row-level-rules.md#using-the-wrong-field-name)
2363
+ and the other rule problems (unknown operators and mask strategies, NUL bytes, values the column cannot hold —
2364
+ refused on save, matching nothing when stored), activation conditions, variants and the merge logic, the cache scope
2365
+ every operation reports, the `RecordNotUnique` 422, the pagination rule, the raw reader table of masked attributes,
2366
+ and phone width.
2367
+ <!-- pin: test/permission_forge/docs/references_test.rb#every relative link and anchor in the docs resolves -->
2368
+ - **Upgrading to 0.15 opens with a three-step checklist**, and the 0.13 path is stated once
2369
+ ([From 0.13](docs/migration-guide.md#from-013)): `permission_forge:upgrade` is for installs older than 0.13, the
2370
+ audit runs before the deploy and RLS is re-synced after it; the README, this section's intro and its Removed entry
2371
+ link there (field test round 7, A#131). `docs/i18n.md` says what an English page shows for a host name without a
2372
+ translation ([Your Models and Columns in the Admin](docs/i18n.md#your-models-and-columns-in-the-admin)).
2373
+ <!-- pin: test/permission_forge/docs/upgrade_notes_test.rb#Upgrading to 0.15 opens with the three-step checklist -->
2374
+ <!-- pin: test/permission_forge/docs/upgrade_notes_test.rb#the 0.13 path is stated once, in From 0.13, and the rest link to it -->
2375
+ - [Wiring a scaffold](docs/getting-started.md#wiring-a-scaffold) says what to change in the scaffold's migration:
2376
+ `owner:references` writes `foreign_key: true`, a foreign key to a table `owners`, so the first insert failed on
2377
+ SQLite (`no such table: main.owners`); the guide points it at the users table (`foreign_key: { to_table: :users }`)
2378
+ (field test round 8, A#13).
2379
+ <!-- pin: docs/getting-started.md#wiring-a-scaffold -->
2380
+
9
2381
  ## [0.14.1] - 2026-10-14
10
2382
 
11
2383
  ### Security