@fuzdev/fuz_app 0.54.0 → 0.56.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 (348) hide show
  1. package/dist/actions/CLAUDE.md +214 -103
  2. package/dist/actions/action_bridge.d.ts +8 -5
  3. package/dist/actions/action_bridge.d.ts.map +1 -1
  4. package/dist/actions/action_bridge.js +1 -11
  5. package/dist/actions/action_codegen.d.ts +32 -0
  6. package/dist/actions/action_codegen.d.ts.map +1 -1
  7. package/dist/actions/action_codegen.js +35 -15
  8. package/dist/actions/action_registry.d.ts.map +1 -1
  9. package/dist/actions/action_registry.js +5 -2
  10. package/dist/actions/action_rpc.d.ts +141 -22
  11. package/dist/actions/action_rpc.d.ts.map +1 -1
  12. package/dist/actions/action_rpc.js +106 -187
  13. package/dist/actions/action_spec.d.ts +55 -16
  14. package/dist/actions/action_spec.d.ts.map +1 -1
  15. package/dist/actions/action_spec.js +16 -11
  16. package/dist/actions/action_types.d.ts +28 -60
  17. package/dist/actions/action_types.d.ts.map +1 -1
  18. package/dist/actions/action_types.js +13 -5
  19. package/dist/actions/broadcast_api.d.ts +2 -2
  20. package/dist/actions/broadcast_api.js +2 -2
  21. package/dist/actions/compile_action_registry.d.ts +50 -0
  22. package/dist/actions/compile_action_registry.d.ts.map +1 -0
  23. package/dist/actions/compile_action_registry.js +69 -0
  24. package/dist/actions/heartbeat.d.ts +8 -4
  25. package/dist/actions/heartbeat.d.ts.map +1 -1
  26. package/dist/actions/heartbeat.js +5 -4
  27. package/dist/actions/perform_action.d.ts +145 -0
  28. package/dist/actions/perform_action.d.ts.map +1 -0
  29. package/dist/actions/perform_action.js +258 -0
  30. package/dist/actions/register_action_ws.d.ts +46 -40
  31. package/dist/actions/register_action_ws.d.ts.map +1 -1
  32. package/dist/actions/register_action_ws.js +101 -159
  33. package/dist/actions/register_ws_endpoint.d.ts +15 -10
  34. package/dist/actions/register_ws_endpoint.d.ts.map +1 -1
  35. package/dist/actions/register_ws_endpoint.js +54 -7
  36. package/dist/actions/transports.d.ts.map +1 -1
  37. package/dist/actions/transports.js +0 -4
  38. package/dist/actions/transports_ws_auth_guard.d.ts +1 -1
  39. package/dist/actions/transports_ws_auth_guard.js +1 -1
  40. package/dist/actions/transports_ws_backend.d.ts +1 -1
  41. package/dist/actions/transports_ws_backend.js +1 -1
  42. package/dist/auth/CLAUDE.md +794 -410
  43. package/dist/auth/account_action_specs.d.ts +28 -7
  44. package/dist/auth/account_action_specs.d.ts.map +1 -1
  45. package/dist/auth/account_action_specs.js +7 -7
  46. package/dist/auth/account_actions.d.ts +7 -13
  47. package/dist/auth/account_actions.d.ts.map +1 -1
  48. package/dist/auth/account_actions.js +26 -35
  49. package/dist/auth/account_queries.d.ts +52 -16
  50. package/dist/auth/account_queries.d.ts.map +1 -1
  51. package/dist/auth/account_queries.js +87 -38
  52. package/dist/auth/account_routes.d.ts +9 -11
  53. package/dist/auth/account_routes.d.ts.map +1 -1
  54. package/dist/auth/account_routes.js +118 -46
  55. package/dist/auth/account_schema.d.ts +46 -35
  56. package/dist/auth/account_schema.d.ts.map +1 -1
  57. package/dist/auth/account_schema.js +21 -28
  58. package/dist/auth/admin_action_specs.d.ts +100 -32
  59. package/dist/auth/admin_action_specs.d.ts.map +1 -1
  60. package/dist/auth/admin_action_specs.js +64 -33
  61. package/dist/auth/admin_actions.d.ts +13 -19
  62. package/dist/auth/admin_actions.d.ts.map +1 -1
  63. package/dist/auth/admin_actions.js +37 -41
  64. package/dist/auth/audit_emitter.d.ts +160 -0
  65. package/dist/auth/audit_emitter.d.ts.map +1 -0
  66. package/dist/auth/audit_emitter.js +83 -0
  67. package/dist/auth/audit_log_queries.d.ts +17 -48
  68. package/dist/auth/audit_log_queries.d.ts.map +1 -1
  69. package/dist/auth/audit_log_queries.js +20 -56
  70. package/dist/auth/audit_log_routes.d.ts +1 -1
  71. package/dist/auth/audit_log_routes.d.ts.map +1 -1
  72. package/dist/auth/audit_log_routes.js +7 -3
  73. package/dist/auth/audit_log_schema.d.ts +92 -32
  74. package/dist/auth/audit_log_schema.d.ts.map +1 -1
  75. package/dist/auth/audit_log_schema.js +75 -46
  76. package/dist/auth/auth_guard_resolver.d.ts +44 -0
  77. package/dist/auth/auth_guard_resolver.d.ts.map +1 -0
  78. package/dist/auth/auth_guard_resolver.js +56 -0
  79. package/dist/auth/bearer_auth.d.ts +9 -7
  80. package/dist/auth/bearer_auth.d.ts.map +1 -1
  81. package/dist/auth/bearer_auth.js +13 -21
  82. package/dist/auth/bootstrap_account.d.ts +7 -7
  83. package/dist/auth/bootstrap_account.d.ts.map +1 -1
  84. package/dist/auth/bootstrap_account.js +7 -7
  85. package/dist/auth/bootstrap_routes.d.ts.map +1 -1
  86. package/dist/auth/bootstrap_routes.js +11 -10
  87. package/dist/auth/cleanup.d.ts +20 -26
  88. package/dist/auth/cleanup.d.ts.map +1 -1
  89. package/dist/auth/cleanup.js +33 -42
  90. package/dist/auth/credential_type_schema.d.ts +115 -0
  91. package/dist/auth/credential_type_schema.d.ts.map +1 -0
  92. package/dist/auth/credential_type_schema.js +127 -0
  93. package/dist/auth/daemon_token_middleware.d.ts +23 -11
  94. package/dist/auth/daemon_token_middleware.d.ts.map +1 -1
  95. package/dist/auth/daemon_token_middleware.js +28 -22
  96. package/dist/auth/ddl.d.ts +2 -2
  97. package/dist/auth/ddl.d.ts.map +1 -1
  98. package/dist/auth/ddl.js +6 -6
  99. package/dist/auth/deps.d.ts +7 -18
  100. package/dist/auth/deps.d.ts.map +1 -1
  101. package/dist/auth/grant_path_schema.d.ts +117 -0
  102. package/dist/auth/grant_path_schema.d.ts.map +1 -0
  103. package/dist/auth/grant_path_schema.js +137 -0
  104. package/dist/auth/invite_queries.d.ts +12 -1
  105. package/dist/auth/invite_queries.d.ts.map +1 -1
  106. package/dist/auth/invite_queries.js +12 -1
  107. package/dist/auth/invite_schema.d.ts +1 -1
  108. package/dist/auth/invite_schema.d.ts.map +1 -1
  109. package/dist/auth/invite_schema.js +1 -1
  110. package/dist/auth/middleware.d.ts.map +1 -1
  111. package/dist/auth/middleware.js +9 -4
  112. package/dist/auth/migrations.d.ts +37 -14
  113. package/dist/auth/migrations.d.ts.map +1 -1
  114. package/dist/auth/migrations.js +79 -32
  115. package/dist/auth/request_context.d.ts +331 -61
  116. package/dist/auth/request_context.d.ts.map +1 -1
  117. package/dist/auth/request_context.js +378 -95
  118. package/dist/auth/{permit_offer_action_specs.d.ts → role_grant_offer_action_specs.d.ts} +163 -94
  119. package/dist/auth/role_grant_offer_action_specs.d.ts.map +1 -0
  120. package/dist/auth/role_grant_offer_action_specs.js +262 -0
  121. package/dist/auth/role_grant_offer_actions.d.ts +104 -0
  122. package/dist/auth/role_grant_offer_actions.d.ts.map +1 -0
  123. package/dist/auth/role_grant_offer_actions.js +473 -0
  124. package/dist/auth/{permit_offer_notifications.d.ts → role_grant_offer_notifications.d.ts} +90 -70
  125. package/dist/auth/role_grant_offer_notifications.d.ts.map +1 -0
  126. package/dist/auth/role_grant_offer_notifications.js +182 -0
  127. package/dist/auth/role_grant_offer_queries.d.ts +242 -0
  128. package/dist/auth/role_grant_offer_queries.d.ts.map +1 -0
  129. package/dist/auth/role_grant_offer_queries.js +533 -0
  130. package/dist/auth/role_grant_offer_schema.d.ts +150 -0
  131. package/dist/auth/role_grant_offer_schema.d.ts.map +1 -0
  132. package/dist/auth/{permit_offer_schema.js → role_grant_offer_schema.js} +60 -36
  133. package/dist/auth/role_grant_queries.d.ts +231 -0
  134. package/dist/auth/role_grant_queries.d.ts.map +1 -0
  135. package/dist/auth/role_grant_queries.js +320 -0
  136. package/dist/auth/role_schema.d.ts +150 -40
  137. package/dist/auth/role_schema.d.ts.map +1 -1
  138. package/dist/auth/role_schema.js +144 -45
  139. package/dist/auth/scope_kind_schema.d.ts +96 -0
  140. package/dist/auth/scope_kind_schema.d.ts.map +1 -0
  141. package/dist/auth/scope_kind_schema.js +94 -0
  142. package/dist/auth/self_service_role_action_specs.d.ts +6 -1
  143. package/dist/auth/self_service_role_action_specs.d.ts.map +1 -1
  144. package/dist/auth/self_service_role_action_specs.js +3 -1
  145. package/dist/auth/self_service_role_actions.d.ts +34 -27
  146. package/dist/auth/self_service_role_actions.d.ts.map +1 -1
  147. package/dist/auth/self_service_role_actions.js +68 -48
  148. package/dist/auth/session_cookie.d.ts +43 -6
  149. package/dist/auth/session_cookie.d.ts.map +1 -1
  150. package/dist/auth/session_cookie.js +31 -5
  151. package/dist/auth/session_middleware.d.ts +37 -3
  152. package/dist/auth/session_middleware.d.ts.map +1 -1
  153. package/dist/auth/session_middleware.js +33 -7
  154. package/dist/auth/signup_routes.d.ts.map +1 -1
  155. package/dist/auth/signup_routes.js +48 -19
  156. package/dist/auth/standard_action_specs.d.ts +2 -2
  157. package/dist/auth/standard_action_specs.js +4 -4
  158. package/dist/auth/standard_rpc_actions.d.ts +23 -19
  159. package/dist/auth/standard_rpc_actions.d.ts.map +1 -1
  160. package/dist/auth/standard_rpc_actions.js +12 -12
  161. package/dist/db/migrate.d.ts +12 -8
  162. package/dist/db/migrate.d.ts.map +1 -1
  163. package/dist/db/migrate.js +10 -7
  164. package/dist/dev/setup.d.ts +2 -2
  165. package/dist/dev/setup.d.ts.map +1 -1
  166. package/dist/dev/setup.js +9 -7
  167. package/dist/env/load.d.ts +1 -1
  168. package/dist/env/load.js +1 -1
  169. package/dist/hono_context.d.ts +64 -5
  170. package/dist/hono_context.d.ts.map +1 -1
  171. package/dist/hono_context.js +38 -2
  172. package/dist/http/CLAUDE.md +264 -87
  173. package/dist/http/auth_shape.d.ts +191 -0
  174. package/dist/http/auth_shape.d.ts.map +1 -0
  175. package/dist/http/auth_shape.js +237 -0
  176. package/dist/http/common_routes.js +3 -3
  177. package/dist/http/db_routes.d.ts +4 -0
  178. package/dist/http/db_routes.d.ts.map +1 -1
  179. package/dist/http/db_routes.js +44 -7
  180. package/dist/http/error_schemas.d.ts +132 -19
  181. package/dist/http/error_schemas.d.ts.map +1 -1
  182. package/dist/http/error_schemas.js +132 -40
  183. package/dist/http/jsonrpc_errors.d.ts +27 -2
  184. package/dist/http/jsonrpc_errors.d.ts.map +1 -1
  185. package/dist/http/jsonrpc_errors.js +26 -2
  186. package/dist/http/pending_effects.d.ts +71 -18
  187. package/dist/http/pending_effects.d.ts.map +1 -1
  188. package/dist/http/pending_effects.js +87 -18
  189. package/dist/http/proxy.d.ts +52 -5
  190. package/dist/http/proxy.d.ts.map +1 -1
  191. package/dist/http/proxy.js +92 -14
  192. package/dist/http/route_spec.d.ts +113 -41
  193. package/dist/http/route_spec.d.ts.map +1 -1
  194. package/dist/http/route_spec.js +130 -52
  195. package/dist/http/schema_helpers.d.ts +3 -2
  196. package/dist/http/schema_helpers.d.ts.map +1 -1
  197. package/dist/http/schema_helpers.js +9 -2
  198. package/dist/http/surface.d.ts +2 -1
  199. package/dist/http/surface.d.ts.map +1 -1
  200. package/dist/http/surface.js +1 -2
  201. package/dist/http/surface_query.d.ts +39 -35
  202. package/dist/http/surface_query.d.ts.map +1 -1
  203. package/dist/http/surface_query.js +79 -36
  204. package/dist/primitive_schemas.d.ts +39 -0
  205. package/dist/primitive_schemas.d.ts.map +1 -0
  206. package/dist/primitive_schemas.js +40 -0
  207. package/dist/realtime/sse_auth_guard.d.ts +5 -5
  208. package/dist/realtime/sse_auth_guard.js +9 -9
  209. package/dist/runtime/mock.d.ts +1 -1
  210. package/dist/runtime/mock.js +1 -1
  211. package/dist/server/app_backend.d.ts +14 -11
  212. package/dist/server/app_backend.d.ts.map +1 -1
  213. package/dist/server/app_backend.js +12 -8
  214. package/dist/server/app_server.d.ts +7 -7
  215. package/dist/server/app_server.d.ts.map +1 -1
  216. package/dist/server/app_server.js +36 -31
  217. package/dist/server/validate_nginx.d.ts +1 -1
  218. package/dist/server/validate_nginx.js +1 -1
  219. package/dist/testing/CLAUDE.md +73 -55
  220. package/dist/testing/admin_integration.d.ts +5 -6
  221. package/dist/testing/admin_integration.d.ts.map +1 -1
  222. package/dist/testing/admin_integration.js +100 -96
  223. package/dist/testing/adversarial_headers.js +1 -1
  224. package/dist/testing/app_server.d.ts +11 -14
  225. package/dist/testing/app_server.d.ts.map +1 -1
  226. package/dist/testing/app_server.js +18 -17
  227. package/dist/testing/assertions.d.ts.map +1 -1
  228. package/dist/testing/assertions.js +2 -1
  229. package/dist/testing/attack_surface.d.ts.map +1 -1
  230. package/dist/testing/attack_surface.js +15 -9
  231. package/dist/testing/audit_completeness.d.ts +2 -2
  232. package/dist/testing/audit_completeness.d.ts.map +1 -1
  233. package/dist/testing/audit_completeness.js +53 -39
  234. package/dist/testing/auth_apps.d.ts +5 -4
  235. package/dist/testing/auth_apps.d.ts.map +1 -1
  236. package/dist/testing/auth_apps.js +28 -22
  237. package/dist/testing/data_exposure.d.ts.map +1 -1
  238. package/dist/testing/data_exposure.js +5 -5
  239. package/dist/testing/db.d.ts +1 -1
  240. package/dist/testing/db.d.ts.map +1 -1
  241. package/dist/testing/db.js +4 -4
  242. package/dist/testing/db_entities.d.ts +22 -0
  243. package/dist/testing/db_entities.d.ts.map +1 -0
  244. package/dist/testing/db_entities.js +28 -0
  245. package/dist/testing/entities.d.ts +10 -8
  246. package/dist/testing/entities.d.ts.map +1 -1
  247. package/dist/testing/entities.js +22 -18
  248. package/dist/testing/integration.d.ts.map +1 -1
  249. package/dist/testing/integration.js +13 -14
  250. package/dist/testing/integration_helpers.d.ts +8 -6
  251. package/dist/testing/integration_helpers.d.ts.map +1 -1
  252. package/dist/testing/integration_helpers.js +29 -23
  253. package/dist/testing/middleware.d.ts +15 -11
  254. package/dist/testing/middleware.d.ts.map +1 -1
  255. package/dist/testing/middleware.js +75 -32
  256. package/dist/testing/rpc_attack_surface.d.ts.map +1 -1
  257. package/dist/testing/rpc_attack_surface.js +40 -24
  258. package/dist/testing/rpc_helpers.d.ts.map +1 -1
  259. package/dist/testing/rpc_helpers.js +3 -1
  260. package/dist/testing/rpc_round_trip.d.ts +1 -1
  261. package/dist/testing/rpc_round_trip.d.ts.map +1 -1
  262. package/dist/testing/rpc_round_trip.js +14 -13
  263. package/dist/testing/sse_round_trip.d.ts +3 -4
  264. package/dist/testing/sse_round_trip.d.ts.map +1 -1
  265. package/dist/testing/sse_round_trip.js +7 -11
  266. package/dist/testing/standard.d.ts +1 -1
  267. package/dist/testing/stubs.d.ts +25 -0
  268. package/dist/testing/stubs.d.ts.map +1 -1
  269. package/dist/testing/stubs.js +43 -2
  270. package/dist/testing/surface_invariants.d.ts +2 -2
  271. package/dist/testing/ws_round_trip.d.ts +12 -13
  272. package/dist/testing/ws_round_trip.d.ts.map +1 -1
  273. package/dist/testing/ws_round_trip.js +24 -12
  274. package/dist/ui/AdminAccounts.svelte +23 -20
  275. package/dist/ui/AdminOverview.svelte +15 -13
  276. package/dist/ui/AdminOverview.svelte.d.ts.map +1 -1
  277. package/dist/ui/{AdminPermitHistory.svelte → AdminRoleGrantHistory.svelte} +12 -12
  278. package/dist/ui/AdminRoleGrantHistory.svelte.d.ts +4 -0
  279. package/dist/ui/AdminRoleGrantHistory.svelte.d.ts.map +1 -0
  280. package/dist/ui/BootstrapForm.svelte +1 -1
  281. package/dist/ui/CLAUDE.md +65 -59
  282. package/dist/ui/{PermitOfferForm.svelte → RoleGrantOfferForm.svelte} +37 -22
  283. package/dist/ui/RoleGrantOfferForm.svelte.d.ts +20 -0
  284. package/dist/ui/RoleGrantOfferForm.svelte.d.ts.map +1 -0
  285. package/dist/ui/{PermitOfferHistory.svelte → RoleGrantOfferHistory.svelte} +12 -12
  286. package/dist/ui/{PermitOfferHistory.svelte.d.ts → RoleGrantOfferHistory.svelte.d.ts} +4 -4
  287. package/dist/ui/RoleGrantOfferHistory.svelte.d.ts.map +1 -0
  288. package/dist/ui/{PermitOfferInbox.svelte → RoleGrantOfferInbox.svelte} +14 -14
  289. package/dist/ui/{PermitOfferInbox.svelte.d.ts → RoleGrantOfferInbox.svelte.d.ts} +4 -4
  290. package/dist/ui/RoleGrantOfferInbox.svelte.d.ts.map +1 -0
  291. package/dist/ui/SignupForm.svelte +1 -1
  292. package/dist/ui/SurfaceExplorer.svelte +35 -15
  293. package/dist/ui/SurfaceExplorer.svelte.d.ts.map +1 -1
  294. package/dist/ui/account_sessions_state.svelte.d.ts +2 -3
  295. package/dist/ui/account_sessions_state.svelte.d.ts.map +1 -1
  296. package/dist/ui/account_sessions_state.svelte.js +2 -3
  297. package/dist/ui/admin_accounts_state.svelte.d.ts +25 -18
  298. package/dist/ui/admin_accounts_state.svelte.d.ts.map +1 -1
  299. package/dist/ui/admin_accounts_state.svelte.js +28 -17
  300. package/dist/ui/admin_rpc_adapters.d.ts +20 -20
  301. package/dist/ui/admin_rpc_adapters.d.ts.map +1 -1
  302. package/dist/ui/admin_rpc_adapters.js +17 -17
  303. package/dist/ui/admin_sessions_state.svelte.d.ts +2 -2
  304. package/dist/ui/admin_sessions_state.svelte.js +2 -2
  305. package/dist/ui/audit_log_state.svelte.d.ts +7 -7
  306. package/dist/ui/audit_log_state.svelte.d.ts.map +1 -1
  307. package/dist/ui/audit_log_state.svelte.js +6 -6
  308. package/dist/ui/auth_state.svelte.d.ts +3 -3
  309. package/dist/ui/auth_state.svelte.d.ts.map +1 -1
  310. package/dist/ui/auth_state.svelte.js +6 -6
  311. package/dist/ui/format_scope.d.ts +2 -2
  312. package/dist/ui/format_scope.js +2 -2
  313. package/dist/ui/{permit_offers_state.svelte.d.ts → role_grant_offers_state.svelte.d.ts} +39 -31
  314. package/dist/ui/role_grant_offers_state.svelte.d.ts.map +1 -0
  315. package/dist/ui/{permit_offers_state.svelte.js → role_grant_offers_state.svelte.js} +25 -19
  316. package/dist/ui/ui_format.js +2 -2
  317. package/package.json +3 -3
  318. package/dist/auth/permit_offer_action_specs.d.ts.map +0 -1
  319. package/dist/auth/permit_offer_action_specs.js +0 -227
  320. package/dist/auth/permit_offer_actions.d.ts +0 -110
  321. package/dist/auth/permit_offer_actions.d.ts.map +0 -1
  322. package/dist/auth/permit_offer_actions.js +0 -452
  323. package/dist/auth/permit_offer_notifications.d.ts.map +0 -1
  324. package/dist/auth/permit_offer_notifications.js +0 -182
  325. package/dist/auth/permit_offer_queries.d.ts +0 -183
  326. package/dist/auth/permit_offer_queries.d.ts.map +0 -1
  327. package/dist/auth/permit_offer_queries.js +0 -408
  328. package/dist/auth/permit_offer_schema.d.ts +0 -103
  329. package/dist/auth/permit_offer_schema.d.ts.map +0 -1
  330. package/dist/auth/permit_queries.d.ts +0 -210
  331. package/dist/auth/permit_queries.d.ts.map +0 -1
  332. package/dist/auth/permit_queries.js +0 -294
  333. package/dist/auth/require_keeper.d.ts +0 -20
  334. package/dist/auth/require_keeper.d.ts.map +0 -1
  335. package/dist/auth/require_keeper.js +0 -35
  336. package/dist/auth/route_guards.d.ts +0 -21
  337. package/dist/auth/route_guards.d.ts.map +0 -1
  338. package/dist/auth/route_guards.js +0 -32
  339. package/dist/auth/session_lifecycle.d.ts +0 -37
  340. package/dist/auth/session_lifecycle.d.ts.map +0 -1
  341. package/dist/auth/session_lifecycle.js +0 -29
  342. package/dist/ui/AdminPermitHistory.svelte.d.ts +0 -4
  343. package/dist/ui/AdminPermitHistory.svelte.d.ts.map +0 -1
  344. package/dist/ui/PermitOfferForm.svelte.d.ts +0 -14
  345. package/dist/ui/PermitOfferForm.svelte.d.ts.map +0 -1
  346. package/dist/ui/PermitOfferHistory.svelte.d.ts.map +0 -1
  347. package/dist/ui/PermitOfferInbox.svelte.d.ts.map +0 -1
  348. package/dist/ui/permit_offers_state.svelte.d.ts.map +0 -1
@@ -18,15 +18,15 @@ as their first arg.
18
18
  Pure, I/O-free operations. Framework-dependent middleware lives in later
19
19
  sections.
20
20
 
21
- | Module | Exports |
22
- | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
- | `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
24
- | `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
25
- | `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
26
- | `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
27
- | `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
28
- | `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
29
- | `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
21
+ | Module | Exports |
22
+ | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `keyring.ts` | `Keyring`, `create_keyring`, `validate_keyring`, `create_validated_keyring`, `ValidatedKeyringResult` |
24
+ | `session_cookie.ts` | `SessionOptions<T>`, `SessionCookieOptions`, `SESSION_COOKIE_OPTIONS`, `SESSION_AGE_MAX`, `SESSION_REFRESH_THRESHOLD_S`, `ParsedSession`, `ProcessSessionResult`, `parse_session`, `create_session_cookie_value`, `process_session_cookie`, `create_session_config`, `fuz_session_config` |
25
+ | `password.ts` | `Password`, `PasswordProvided`, `PasswordHashDeps`, `PASSWORD_LENGTH_MIN` (12, OWASP), `PASSWORD_LENGTH_MAX` (300) |
26
+ | `password_argon2.ts` | `hash_password`, `verify_password`, `verify_dummy`, `argon2_password_deps` |
27
+ | `api_token.ts` | `API_TOKEN_PREFIX` (`secret_fuz_token_`), `hash_api_token`, `generate_api_token` |
28
+ | `daemon_token.ts` | `DaemonToken` (Zod), `DAEMON_TOKEN_HEADER` (`X-Daemon-Token`), `generate_daemon_token`, `validate_daemon_token`, `DaemonTokenState` |
29
+ | `bootstrap_account.ts` | `bootstrap_account`, `BootstrapAccountDeps`, `BootstrapAccountInput`, `BootstrapAccountSuccess`, `BootstrapAccountFailure`, `BootstrapAccountResult` |
30
30
 
31
31
  Design notes:
32
32
 
@@ -41,7 +41,13 @@ Design notes:
41
41
  `string` for session-id references (server-side sessions, per-session
42
42
  revocation), `number` for direct account-id references (no server state).
43
43
  The canonical fuz pattern is `SessionOptions<string>` via
44
- `create_session_config(name)`.
44
+ `create_session_config(name)`. `SessionOptions.max_age` is the single
45
+ source of truth for cookie lifetime — drives both the signed `expires_at`
46
+ and the HTTP `Max-Age` attribute. `process_session_cookie` re-signs on
47
+ key rotation **or** when within `refresh_threshold_seconds` (default
48
+ `SESSION_REFRESH_THRESHOLD_S` = 1 day) of expiry, mirroring the DB-side
49
+ `AUTH_SESSION_EXTEND_THRESHOLD_MS` so a continuously-active user's
50
+ cookie tracks their server session.
45
51
  - **Password** has two schemas deliberately. `Password` enforces the current
46
52
  length policy (used at account creation and password change);
47
53
  `PasswordProvided` is minimal (`min(1)`) for login / verification so a
@@ -61,7 +67,7 @@ Design notes:
61
67
  - **Bootstrap account** is one-shot; protected by the `bootstrap_lock` table
62
68
  via atomic `UPDATE ... WHERE id = 1 AND bootstrapped = false RETURNING id`.
63
69
  Token read + password hash happen outside the transaction (CPU + I/O);
64
- lock acquisition + account + actor + two permits (`keeper` and `admin`)
70
+ lock acquisition + account + actor + two role_grants (`keeper` and `admin`)
65
71
  happen inside. On commit, the token file is deleted — if that fails,
66
72
  `token_file_deleted: false` is returned and the caller is expected to
67
73
  surface an error (the `/bootstrap` handler throws so the operator gets a
@@ -70,23 +76,26 @@ Design notes:
70
76
 
71
77
  ## Schemas, types, and DDL
72
78
 
73
- | Module | What's inside |
74
- | ------------------------------- | ----------------------------------------------------------------------------------------- |
75
- | `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
76
- | `role_schema.ts` | Role vocabulary and extensibility |
77
- | `ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings |
78
- | `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
79
- | `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
80
- | `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, table DDL |
81
- | `permit_offer_schema.ts` | Permit offer DDL, types, and client-safe schemas |
82
- | `permit_offer_notifications.ts` | WS notification specs for the consentful-permits lifecycle |
79
+ | Module | What's inside |
80
+ | ----------------------------------- | ----------------------------------------------------------------------------------------- |
81
+ | `account_schema.ts` | Runtime types + client-safe Zod schemas for identity entities |
82
+ | `role_schema.ts` | Role vocabulary and extensibility |
83
+ | `ddl.ts` | Raw `CREATE TABLE` / index / seed SQL strings |
84
+ | `invite_schema.ts` | `Invite`, `InviteJson`, `InviteWithUsernamesJson`, `CreateInviteInput` |
85
+ | `app_settings_schema.ts` | `AppSettings`, `AppSettingsJson`, `AppSettingsWithUsernameJson`, `UpdateAppSettingsInput` |
86
+ | `audit_log_schema.ts` | Event-type enum, per-type metadata schemas, table DDL |
87
+ | `role_grant_offer_schema.ts` | Role grant offer DDL, types, and client-safe schemas |
88
+ | `role_grant_offer_notifications.ts` | WS notification specs for the consentful-role-grant lifecycle |
83
89
 
84
90
  ### Identity entities (`account_schema.ts`)
85
91
 
86
92
  - `Account` (primary identity, holds `password_hash`), `Actor` (the entity
87
- that acts — owns cells, holds permits, appears in audit trails; 1:1 with
88
- account in v1), `Permit` (time-bounded, revocable grant of a role to an
89
- actor carries `scope_id`, `source_offer_id`, `revoked_reason`),
93
+ that acts — owns cells, holds role_grants, appears in audit trails; an account
94
+ may host one or more actors, with the dispatcher's authorization phase
95
+ resolving the acting actor per-request via `acting?: ActingActor` on
96
+ inputs), `RoleGrant` (time-bounded, revocable grant of a role to an
97
+ actor — carries `scope_kind` + `scope_id` paired-null,
98
+ `source_offer_id`, `revoked_reason`),
90
99
  `AuthSession` (server-side, keyed by blake3), `ApiToken`.
91
100
  - Every `id` / `*_id` field on entity interfaces, `*Json` schemas, and
92
101
  `*Input` types is branded `Uuid` (from `@fuzdev/fuz_util/uuid.js`), except
@@ -96,44 +105,137 @@ Design notes:
96
105
  `UsernameProvided`: `min(1).max(255)` — permissive for login/lookup so
97
106
  tightening creation rules won't lock out existing users.
98
107
  - `Email`: `z.email()`.
99
- - `PERMIT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
100
- and the `permit_revoke` WS payload.
108
+ - `ROLE_GRANT_REVOKED_REASON_LENGTH_MAX = 500` — bounds both the admin input
109
+ and the `role_grant_revoke` WS payload.
101
110
  - Client-safe Zod schemas (every exported schema has a same-named `z.infer`
102
111
  type export):
103
112
  - `SessionAccountJson` — strips sensitive fields from `Account`
104
113
  - `AuthSessionJson` — `id` is the blake3 hash (safe for client)
105
114
  - `ClientApiTokenJson` — excludes `token_hash`
106
- - `PermitSummaryJson` — the client-safe permit shape carried by
115
+ - `RoleGrantSummaryJson` — the client-safe role_grant shape carried by
107
116
  `GET /api/account/status` and the admin account listing; includes
108
- `scope_id` so clients can make per-scope auth decisions. Excludes
117
+ `scope_kind` + `scope_id` (paired-null) so clients can make
118
+ per-scope auth decisions. Excludes
109
119
  `revoked_at` / `revoked_by` / `revoked_reason` because the callers
110
- that return it already filter to active permits.
120
+ that return it already filter to active role_grants.
111
121
  - `ActorSummaryJson`
112
122
  - `AdminAccountJson` extends `SessionAccountJson` with `updated_at` / `updated_by`
113
- - `PendingOfferSummaryJson` — narrower than `PermitOfferJson`; omits
123
+ - `PendingOfferSummaryJson` — narrower than `RoleGrantOfferJson`; omits
114
124
  `message` and `decline_reason` so cross-admin visibility of the listing
115
125
  does not expose grantor-authored text beyond what the audit log
116
126
  discloses. `from_username` is resolved server-side so admins can see
117
127
  whose pending offer is blocking a "+ role" button.
118
- - `AdminAccountEntryJson` — composes `{account, actor, permits, pending_offers}`
128
+ - `AdminAccountEntryJson` — composes `{account, actor, role_grants, pending_offers}`
119
129
  - Converters: `to_session_account(account)`, `to_admin_account(account)`,
120
- `is_permit_active(p, now?)`.
121
- - Input types: `CreateAccountInput`, `GrantPermitInput` (with optional
122
- `scope_id`, `source_offer_id`).
130
+ `is_role_grant_active(p, now?)`.
131
+ - Input types: `CreateAccountInput`, `CreateRoleGrantInput` (with optional
132
+ `scope_kind`, `scope_id`, `source_offer_id` — `scope_kind` paired-null
133
+ with `scope_id` per the `role_grant_scope_kind_paired` CHECK).
134
+
135
+ ### Scope-kind system (`scope_kind_schema.ts`)
136
+
137
+ Open string registry tagging the polymorphic `role_grant.scope_id` /
138
+ `role_grant_offer.scope_id` with a machine-readable kind. Mirrors the open
139
+ registry pattern used for `RoleName` / `AuditEventTypeName` /
140
+ `CredentialType`.
141
+
142
+ - `SCOPE_KIND_NAME_REGEX` / `ScopeKindName`: lowercase letters and
143
+ underscores (`^[a-z][a-z_]*[a-z]$|^[a-z]$`), no leading/trailing
144
+ underscore. Same shape as `RoleName`. Uppercase `'GLOBAL'` is
145
+ structurally rejected — it appears only as an index-side token in
146
+ `COALESCE(scope_kind, 'GLOBAL')` inside the partial unique indexes,
147
+ never as a column value.
148
+ - `ScopeKindMeta`: `{description?: string}` — admin-UI-facing copy.
149
+ Open shape so v2 can extend without breaking change.
150
+ - `create_scope_kind_schema(consumer_kinds: Record<string, ScopeKindMeta>)`
151
+ → `{ScopeKind, scope_kinds: ReadonlyMap}`. No builtins. Construction-
152
+ time guards: regex on every name, duplicate detection. Empty registry
153
+ returns `z.never()` — every parse fails. Pass the result into
154
+ `create_role_schema` to validate `RoleSpec.applicable_scope_kinds`
155
+ entries (informative-only in v1; INSERT-time `(role, scope_kind)`
156
+ enforcement reserved for v2).
157
+ - Encoding: paired-null with `scope_id`. Both null = global, both
158
+ non-null = scoped, mismatch rejected by the
159
+ `role_grant_scope_kind_paired` / `role_grant_offer_scope_kind_paired` CHECK
160
+ constraints.
161
+
162
+ ### Credential-type system (`credential_type_schema.ts`)
163
+
164
+ Open string registry over the credential types that can authenticate a
165
+ request. Three builtins (`session`, `api_token`, `daemon_token`); the
166
+ wire-validated `CredentialType` Zod enum in `hono_context.ts` mirrors
167
+ those three. Mirrors the open-registry pattern used for `RoleName` /
168
+ `ScopeKindName` / `GrantPathName` / `AuditEventTypeName`.
169
+
170
+ - `CREDENTIAL_TYPE_NAME_REGEX` / `CredentialTypeName`: lowercase letters
171
+ and underscores. Same shape as `RoleName`.
172
+ - `CREDENTIAL_TYPE_SESSION` / `CREDENTIAL_TYPE_API_TOKEN` /
173
+ `CREDENTIAL_TYPE_DAEMON_TOKEN` — the three builtin literals. The
174
+ constant is named `_API_TOKEN` (not `_BEARER`) so wire literal and
175
+ the `api_token` storage table stay in lockstep.
176
+ - `BUILTIN_CREDENTIAL_TYPES` const tuple, `BuiltinCredentialType` Zod
177
+ enum, `BUILTIN_CREDENTIAL_TYPE_META` admin-UI-facing descriptions.
178
+ - `create_credential_type_schema(consumer_types?)`
179
+ → `{CredentialType, credential_types: ReadonlyMap}`. Builtins always
180
+ present; consumer collisions / regex failures / duplicates throw at
181
+ construction. Pass the result into `create_role_schema`'s optional
182
+ `credential_types` parameter to validate every
183
+ `RoleSpec.required_credential_types` entry at construction time.
184
+
185
+ ### Grant-path system (`grant_path_schema.ts`)
186
+
187
+ Open string registry over the surfaces through which a role can be
188
+ granted. Four builtins (`admin`, `self_service`, `system`, `bootstrap`).
189
+
190
+ - `GRANT_PATH_NAME_REGEX` / `GrantPathName`: lowercase letters and
191
+ underscores, mirrors `RoleName`.
192
+ - `GRANT_PATH_ADMIN` / `_SELF_SERVICE` / `_SYSTEM` / `_BOOTSTRAP` —
193
+ builtin literal constants.
194
+ - `BUILTIN_GRANT_PATHS` const tuple, `BuiltinGrantPath` Zod enum,
195
+ `BUILTIN_GRANT_PATH_META` descriptions.
196
+ - `create_grant_path_schema(consumer_paths?)`
197
+ → `{GrantPath, grant_paths: ReadonlyMap}`. Same construction-time
198
+ guards as the credential-type schema. Pass the result into
199
+ `create_role_schema`'s optional `grant_paths` parameter to validate
200
+ every `RoleSpec.grant_paths` entry at construction time.
201
+
202
+ Drives downstream defaults:
203
+
204
+ - `admin_actions.grantable_roles` ⊇ `{role : 'admin' ∈ grant_paths}`.
205
+ - `self_service_role_actions` default eligibility ⊇
206
+ `{role : 'self_service' ∈ grant_paths}`.
123
207
 
124
208
  ### Role system (`role_schema.ts`)
125
209
 
210
+ `RoleSpec` is the structured per-role configuration that replaced the
211
+ flat `RoleOptions` shape (no `requires_daemon_token` / `web_grantable`
212
+ booleans). Each role declares the credential types its holders must
213
+ use, the scope kinds it applies to, and the grant paths through which
214
+ it can be granted; the factory validates every cross-axis field
215
+ against the corresponding open registries at construction time.
216
+
126
217
  - `RoleName`: lowercase letters + underscores, no leading/trailing
127
218
  underscore.
128
- - `ROLE_KEEPER = 'keeper'` (requires daemon token, not `web_grantable`).
129
- - `ROLE_ADMIN = 'admin'` (web-grantable).
130
- - `BUILTIN_ROLES`, `BuiltinRole` (Zod enum).
131
- - `RoleOptions`: `requires_daemon_token`, `web_grantable` (defaults `false`
132
- and `true`).
133
- - `BUILTIN_ROLE_OPTIONS` — fixed, not overridable by consumers.
134
- - `create_role_schema(app_roles)` call once at startup; returns `{Role, role_options}`.
135
- Collisions with builtin names throw at construction. Used by middleware
136
- to check `requires_daemon_token` and by admin UI to filter `web_grantable`.
219
+ - `ROLE_KEEPER = 'keeper'` bootstrap-only via daemon token; `grant_paths: ['bootstrap']`,
220
+ `required_credential_types: ['daemon_token']`.
221
+ - `ROLE_ADMIN = 'admin'` — admin-grantable; `grant_paths: ['admin']`.
222
+ - `BUILTIN_ROLES`, `BuiltinRole` (Zod enum), `BUILTIN_ROLE_SPECS_BY_NAME`
223
+ (`ReadonlyMap<string, RoleSpec>`) — not overridable by consumers.
224
+ - `RoleSpec`: `{name, description?, required_credential_types?, applicable_scope_kinds?, grant_paths?}`
225
+ every cross-axis field is an open-registry string array. Empty
226
+ arrays carry meaning (`grant_paths: []` role unreachable through
227
+ any registered path; `applicable_scope_kinds: []` global only).
228
+ - `create_role_schema(consumer_roles, options?)` — call once at startup;
229
+ returns `{Role, role_specs}`. Construction-time guards: name regex,
230
+ duplicate detection, builtin-collision rejection, registry-membership
231
+ check on every `required_credential_types` / `applicable_scope_kinds` /
232
+ `grant_paths` entry when the corresponding registry is supplied via
233
+ `options.{credential_types, scope_kinds, grant_paths}`. Omitting a
234
+ registry skips its membership check (incremental adoption hatch).
235
+ - `role_has_grant_path(role_specs, role, path)` /
236
+ `list_roles_with_grant_path(role_specs, path)` — predicate /
237
+ filter helpers used by `admin_actions` and
238
+ `self_service_role_actions` to derive their default eligibility.
137
239
 
138
240
  ### Raw DDL (`ddl.ts`)
139
241
 
@@ -143,8 +245,13 @@ Separated from runtime types to isolate DDL concerns. Consumed by
143
245
  - `ACCOUNT_SCHEMA` (plus `ACCOUNT_EMAIL_INDEX`, `ACCOUNT_USERNAME_CI_INDEX`
144
246
  — both case-insensitive partial uniques)
145
247
  - `ACTOR_SCHEMA`, `ACTOR_INDEX`
146
- - `PERMIT_SCHEMA`, `PERMIT_INDEXES` — v0 has `permit_actor_role_active_unique`
147
- which is replaced in v1 with the scope-aware `permit_actor_role_scope_active_unique`
248
+ - `ROLE_GRANT_SCHEMA`, `ROLE_GRANT_INDEXES` — v0 has `role_grant_actor_role_active_unique`
249
+ which is replaced in v1 with the scope-aware
250
+ `role_grant_actor_role_scope_active_unique` keyed on
251
+ `(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
252
+ v1 also adds `scope_kind TEXT NULL` (paired-null with `scope_id` via
253
+ the `role_grant_scope_kind_paired` CHECK; idempotent DO-block guards
254
+ re-runs).
148
255
  - `AUTH_SESSION_SCHEMA`, `AUTH_SESSION_INDEXES`
149
256
  - `API_TOKEN_SCHEMA`, `API_TOKEN_INDEX`
150
257
  - `BOOTSTRAP_LOCK_SCHEMA`, `BOOTSTRAP_LOCK_SEED` — seeded as `bootstrapped`
@@ -159,58 +266,102 @@ Separated from runtime types to isolate DDL concerns. Consumed by
159
266
 
160
267
  #### Audit event types
161
268
 
162
- `AUDIT_EVENT_TYPES` — 21 events covering auth + permit + offer + invite +
163
- settings mutations. Offer lifecycle: `permit_offer_create` / `_accept` /
269
+ `AUDIT_EVENT_TYPES` — 21 events covering auth + role_grant + offer + invite +
270
+ settings mutations. Offer lifecycle: `role_grant_offer_create` / `_accept` /
164
271
  `_decline` / `_retract` / `_expire` / `_supersede`. `AuditEventType` is the
165
272
  Zod enum; `AuditOutcome` is `'success' | 'failure'`.
166
273
 
167
- | Event type |
168
- | ------------------------ |
169
- | `login` |
170
- | `logout` |
171
- | `bootstrap` |
172
- | `signup` |
173
- | `password_change` |
174
- | `session_revoke` |
175
- | `session_revoke_all` |
176
- | `token_create` |
177
- | `token_revoke` |
178
- | `token_revoke_all` |
179
- | `permit_grant` |
180
- | `permit_revoke` |
181
- | `permit_offer_create` |
182
- | `permit_offer_accept` |
183
- | `permit_offer_decline` |
184
- | `permit_offer_retract` |
185
- | `permit_offer_expire` |
186
- | `permit_offer_supersede` |
187
- | `invite_create` |
188
- | `invite_delete` |
189
- | `app_settings_update` |
274
+ | Event type |
275
+ | ---------------------------- |
276
+ | `login` |
277
+ | `logout` |
278
+ | `bootstrap` |
279
+ | `signup` |
280
+ | `password_change` |
281
+ | `session_revoke` |
282
+ | `session_revoke_all` |
283
+ | `token_create` |
284
+ | `token_revoke` |
285
+ | `token_revoke_all` |
286
+ | `role_grant_create` |
287
+ | `role_grant_revoke` |
288
+ | `role_grant_offer_create` |
289
+ | `role_grant_offer_accept` |
290
+ | `role_grant_offer_decline` |
291
+ | `role_grant_offer_retract` |
292
+ | `role_grant_offer_expire` |
293
+ | `role_grant_offer_supersede` |
294
+ | `invite_create` |
295
+ | `invite_delete` |
296
+ | `app_settings_update` |
190
297
 
191
298
  #### Metadata schemas
192
299
 
193
300
  - `AUDIT_METADATA_SCHEMAS` — per-type `z.looseObject`. Notable shapes:
194
- - `permit_grant` — `scope_id`, optional `permit_id` (failed grants
195
- omit — `web_grantable` denial never produces a row), optional
301
+ - `role_grant_create` — `scope_id`, optional `role_grant_id` (failed grants
302
+ omit — admin-grant-path denial never produces a row), optional
196
303
  `source_offer_id`, optional `self_service` (set by
197
304
  `self_service_role_actions.ts`; declared on the schema rather than
198
305
  riding on `z.looseObject` so the field is part of the documented surface).
199
- - `permit_revoke` — `scope_id`, optional `reason`, optional
306
+ - `role_grant_revoke` — `scope_id`, optional `reason`, optional
200
307
  `self_service` (same self-service toggle).
201
- - `permit_offer_create` — optional `offer_id` (failed creates omit).
202
- - `permit_offer_supersede` — `reason: 'sibling_accepted' | 'permit_revoked' | 'scope_destroyed'`
203
- plus `cause_id` (accepted offer id, revoked permit id, or destroyed
308
+ - `role_grant_offer_create` — optional `offer_id` (failed creates omit).
309
+ - `role_grant_offer_supersede` — `reason: 'sibling_accepted' | 'role_grant_revoked' | 'scope_destroyed'`
310
+ plus `cause_id` (accepted offer id, revoked role_grant id, or destroyed
204
311
  parent scope row id respectively). The `scope_destroyed` variant is
205
- emitted by callers of `query_permit_revoke_for_scope` when a polymorphic
312
+ emitted by callers of `query_role_grant_revoke_for_scope` when a polymorphic
206
313
  parent scope row is deleted.
207
314
  - `AuditLogEvent` (row); `AuditLogInput<T extends string = AuditEventType>`
208
315
  (narrow metadata when `T` is builtin, generic record otherwise);
209
316
  `AuditLogListOptions` (supports `since_seq` for SSE reconnection gap fill);
210
317
  `AUDIT_LOG_DEFAULT_LIMIT = 50` (default page size, lives on the schema
211
318
  side so client codegen can import it without dragging in the query layer).
319
+ `target_actor_id` lives parallel to `target_account_id` on both row
320
+ and input. **Rule** — `target_actor_id` is populated when the event
321
+ subject is bound to a specific actor. Concretely: `role_grant_revoke`
322
+ and `role_grant_create` (admin direct-grant, self-service toggle, and
323
+ in-tx accept all populate both target columns — the grantee is the
324
+ subject regardless of initiator), in-tx `role_grant_offer_accept` on
325
+ accept, and `role_grant_offer_decline` always populate both target
326
+ columns (decline joins `from_account_id` into the RETURNING so the
327
+ "both populated → same account" invariant holds uniformly).
328
+ Offer-shape events (`role_grant_offer_create`, `_expire`, `_retract`,
329
+ `_supersede`) populate `target_actor_id` when the offer was
330
+ actor-targeted at create time (`role_grant_offer.to_actor_id` set),
331
+ null when the offer was account-grain (any actor on
332
+ `to_account_id` may accept). Account-shape events (login, logout,
333
+ signup, bootstrap, password change, session/token revoke,
334
+ app_settings update, invite events) stay account-grain on both
335
+ `target_actor_id` **and** `actor_id` — the operation is performed
336
+ by the account, and a multi-actor user must be able to log out
337
+ (or change password, or revoke sessions) without first picking an
338
+ acting actor. Role-grant/admin/offer events keep recording the
339
+ initiator's actor in `actor_id`.
340
+ SSE/WS socket-close keys on `target_account_id ?? account_id`
341
+ (sessions stay account-grain at the routing layer even though
342
+ they bind to a specific actor at request-context resolution time —
343
+ see request_context.ts).
344
+ - **Actor-targetable offers** — `role_grant_offer.to_actor_id` is the
345
+ optional column that flips an offer from account-grain (null,
346
+ default) to actor-grain (non-null). `query_role_grant_offer_create`
347
+ validates the actor↔account binding in one SELECT and rejects with
348
+ `RoleGrantOfferActorAccountMismatchError` when the supplied actor isn't
349
+ on `to_account_id`. `query_accept_offer` rejects wrong-actor accepts
350
+ on actor-targeted offers with `RoleGrantOfferActorMismatchError` —
351
+ surfaced to RPC callers as `role_grant_offer_actor_mismatch`. Closes the
352
+ audit hole where offer-shape events left `target_actor_id` null even
353
+ when the recipient binding was known at offer time.
354
+ - **`AuditEmitter.emit_role_grant_target` method** — the canonical entry
355
+ point for role-grant-shape audit emissions. Takes
356
+ `(ctx, auth, {event_type, target_account_id, target_actor_id, metadata, outcome?})`
357
+ and lifts the `actor_id` / `account_id` / `ip` boilerplate that every
358
+ `role_grant_*` audit emit site repeats. Use this instead of
359
+ `deps.audit.emit` for any event populating one of the
360
+ `target_*_id` columns; reach for the lower-level `emit` only when the
361
+ event is non-role-grant-shape (e.g., `app_settings_update`, bootstrap,
362
+ signup).
212
363
  - Client-safe: `AuditLogEventJson`, `AuditLogEventWithUsernamesJson`,
213
- `PermitHistoryEventJson`, `AdminSessionJson`.
364
+ `RoleGrantHistoryEventJson`, `AdminSessionJson`.
214
365
  - `get_audit_metadata(event)` type-narrows after checking `event_type`.
215
366
  - DDL: `AUDIT_LOG_SCHEMA` (includes monotonically-increasing `seq SERIAL`
216
367
  for cursor-based gap fill), `AUDIT_LOG_INDEXES`.
@@ -218,14 +369,15 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
218
369
  builds an `AuditLogConfig` merging builtins with consumer event-type
219
370
  strings keyed to a Zod schema (validates metadata) or `null` (registers
220
371
  without validation). Pass the result to `create_app_backend({audit_log_config})`
221
- — it lands on `AppDeps.audit_log_config` and `audit_log_fire_and_forget`
222
- reads it off the deps bundle automatically (defaults to
372
+ — it gets captured inside the bound `AppDeps.audit` emitter, and every
373
+ call to `audit.emit` validates against it (defaults to
223
374
  `BUILTIN_AUDIT_LOG_CONFIG` when absent). `query_audit_log` still accepts
224
375
  the trailing `config` positional arg for in-transaction emit sites that
225
- don't have `AppDeps`. Builtin collisions and `AuditEventTypeName`
226
- format failures throw at construction. The DB column is `TEXT NOT NULL`
227
- (no enum), so consumer types round-trip through list queries, the
228
- `audit_log_list` RPC, and SSE identically to builtins.
376
+ hold a transaction-scoped DB only. Builtin collisions and
377
+ `AuditEventTypeName` format failures throw at construction. The DB
378
+ column is `TEXT NOT NULL` (no enum), so consumer types round-trip
379
+ through list queries, the `audit_log_list` RPC, and SSE identically to
380
+ builtins.
229
381
  `AuditLogEvent.event_type` (row interface), `AuditLogEventJson.event_type`,
230
382
  and the `audit_log_list` filter input are all `AuditEventTypeName`
231
383
  (regex-validated string) — widened from the closed enum so consumer rows
@@ -244,67 +396,76 @@ Zod enum; `AuditOutcome` is `'success' | 'failure'`.
244
396
  accidental mutation (bugs, test cross-contamination, cast escapes)
245
397
  into loud TypeErrors — not a security boundary.
246
398
 
247
- ### Permit offer (`permit_offer_schema.ts`)
399
+ ### Role grant offer (`role_grant_offer_schema.ts`)
248
400
 
249
- The consentful-permits surface. Key constants:
401
+ The consentful-role-grants surface. Key constants:
250
402
 
251
- - `PERMIT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
403
+ - `ROLE_GRANT_OFFER_SCOPE_SENTINEL_UUID = '00000000-…'` — all-zeros UUID used
252
404
  inside `COALESCE(scope_id, sentinel)` in partial unique indexes to collapse
253
405
  NULL scopes into a comparable value. Without this, Postgres's NULL-in-
254
406
  unique-index quirk would allow duplicate global pending offers.
255
- - `PERMIT_OFFER_MESSAGE_LENGTH_MAX = 500`.
256
- - `PERMIT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
407
+ - `ROLE_GRANT_OFFER_SCOPE_KIND_GLOBAL_TOKEN = 'GLOBAL'` — index-side token
408
+ for the global case in the partial unique indexes. Uppercase, so it
409
+ cannot collide with consumer-declared `ScopeKindName` values
410
+ (lowercase by regex). Never a column value — both null encodes
411
+ global at the row level.
412
+ - `ROLE_GRANT_OFFER_MESSAGE_LENGTH_MAX = 500`.
413
+ - `ROLE_GRANT_OFFER_DEFAULT_TTL_MS` = 30 days (GitHub org-invite parity).
257
414
 
258
415
  DDL:
259
416
 
260
- - `PERMIT_OFFER_SCHEMA` carries four nullable terminal timestamps:
417
+ - `ROLE_GRANT_OFFER_SCHEMA` carries four nullable terminal timestamps:
261
418
  `accepted_at`, `declined_at`, `retracted_at`, **`superseded_at`** (fourth
262
- terminal — obsoleted by sibling accept or revoke of the resulting permit).
263
- Three CHECK constraints:
264
- - `permit_offer_single_terminal` — at most one terminal timestamp set.
265
- - `permit_offer_permit_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_permit_id IS NOT NULL)`.
266
- - `permit_offer_reason_iff_declined` — `decline_reason` only on declined rows.
267
- - `PERMIT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
268
- `(to_account_id, role, COALESCE(scope_id, sentinel), from_actor_id)`
419
+ terminal — obsoleted by sibling accept or revoke of the resulting role_grant).
420
+ Four CHECK constraints:
421
+ - `role_grant_offer_single_terminal` — at most one terminal timestamp set.
422
+ - `role_grant_offer_role_grant_iff_accepted` — `(accepted_at IS NOT NULL) = (resulting_role_grant_id IS NOT NULL)`.
423
+ - `role_grant_offer_reason_iff_declined` — `decline_reason` only on declined rows.
424
+ - `role_grant_offer_scope_kind_paired` — `(scope_kind IS NULL) = (scope_id IS NULL)`
425
+ (both null = global, both non-null = scoped, mismatch rejected).
426
+ - `ROLE_GRANT_OFFER_PENDING_UNIQUE_INDEX` — partial unique on
427
+ `(to_account_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel), from_actor_id)`
269
428
  where all four terminal timestamps are null. Including `from_actor_id`
270
429
  lets multiple grantors coexist (teacher A and B can both offer the same
271
430
  student role). A same-grantor re-offer upserts the pending row. The
272
- `ON CONFLICT` target in `query_permit_offer_create` must match this
273
- expression literally.
274
- - `PERMIT_OFFER_INBOX_INDEX` `(to_account_id, expires_at)` partial on
431
+ `ON CONFLICT` target in `query_role_grant_offer_create` must match this
432
+ expression literally; the paired-null CHECK keeps the two COALESCE
433
+ expressions in lockstep so global rows collide identically whether the
434
+ scope columns are written or omitted.
435
+ - `ROLE_GRANT_OFFER_INBOX_INDEX` — `(to_account_id, expires_at)` partial on
275
436
  pending rows, soonest-expiry first.
276
437
 
277
438
  Types:
278
439
 
279
- - `PermitOffer` (row), `SupersededOffer` (row + `from_account_id` joined
280
- via `actor` — carried so callers fan out `permit_offer_supersede`
440
+ - `RoleGrantOffer` (row), `SupersededOffer` (row + `from_account_id` joined
441
+ via `actor` — carried so callers fan out `role_grant_offer_supersede`
281
442
  notifications without a second round trip).
282
- - `CreatePermitOfferInput` (`expires_at` is required — query layer applies
443
+ - `CreateRoleGrantOfferInput` (`expires_at` is required — query layer applies
283
444
  no default).
284
- - `PermitOfferJson` (with `.meta({description})` on every field) paired
285
- with `to_permit_offer_json(offer)`.
445
+ - `RoleGrantOfferJson` (with `.meta({description})` on every field) paired
446
+ with `to_role_grant_offer_json(offer)`.
286
447
 
287
- ### WS notifications (`permit_offer_notifications.ts`)
448
+ ### WS notifications (`role_grant_offer_notifications.ts`)
288
449
 
289
450
  Six `RemoteNotificationActionSpec`s fan notifications to affected sockets:
290
451
 
291
- | Method | Fires to | Payload |
292
- | ------------------------ | ---------------------------------- | --------------------------------------------------------------------- |
293
- | `permit_offer_received` | Recipient | `{offer: PermitOfferJson}` |
294
- | `permit_offer_retracted` | Recipient | `{offer: PermitOfferJson}` |
295
- | `permit_offer_accepted` | Grantor | `{offer: PermitOfferJson}` |
296
- | `permit_offer_declined` | Grantor | `{offer: PermitOfferJson}` (decline reason on `offer.decline_reason`) |
297
- | `permit_offer_supersede` | Grantor (sibling / revoked-permit) | `{offer, reason: 'sibling_accepted' \| 'permit_revoked', cause_id}` |
298
- | `permit_revoke` | Revokee | `{permit_id, role, scope_id, reason?}` |
452
+ | Method | Fires to | Payload |
453
+ | ---------------------------- | -------------------------------------- | ------------------------------------------------------------------------ |
454
+ | `role_grant_offer_received` | Recipient | `{offer: RoleGrantOfferJson}` |
455
+ | `role_grant_offer_retracted` | Recipient | `{offer: RoleGrantOfferJson}` |
456
+ | `role_grant_offer_accepted` | Grantor | `{offer: RoleGrantOfferJson}` |
457
+ | `role_grant_offer_declined` | Grantor | `{offer: RoleGrantOfferJson}` (decline reason on `offer.decline_reason`) |
458
+ | `role_grant_offer_supersede` | Grantor (sibling / revoked-role_grant) | `{offer, reason: 'sibling_accepted' \| 'role_grant_revoked', cause_id}` |
459
+ | `role_grant_revoke` | Revokee | `{role_grant_id, role, scope_id, reason?}` |
299
460
 
300
- Method constants: `PERMIT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
461
+ Method constants: `ROLE_GRANT_OFFER_RECEIVED_NOTIFICATION_METHOD`,
301
462
  `_RETRACTED_`, `_ACCEPTED_`, `_DECLINED_`, `_SUPERSEDE_`,
302
- `PERMIT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
303
- exports: `PermitOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
304
- `_DeclinedParams`, `_SupersedeParams`, `PermitRevokeParams`. Notification
305
- builders: `build_permit_offer_received_notification(params)` etc.
463
+ `ROLE_GRANT_REVOKE_NOTIFICATION_METHOD`. Zod params schemas with inferred type
464
+ exports: `RoleGrantOfferReceivedParams`, `_RetractedParams`, `_AcceptedParams`,
465
+ `_DeclinedParams`, `_SupersedeParams`, `RoleGrantRevokeParams`. Notification
466
+ builders: `build_role_grant_offer_received_notification(params)` etc.
306
467
 
307
- `PERMIT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
468
+ `ROLE_GRANT_OFFER_NOTIFICATION_SPECS: Array<EventSpec>` — pass to
308
469
  `create_app_server`'s `event_specs` so the attack surface reflects them
309
470
  and DEV-mode `create_validated_broadcaster` catches payload drift.
310
471
 
@@ -313,7 +474,7 @@ and DEV-mode `create_validated_broadcaster` catches payload drift.
313
474
  structurally satisfies it (its signature accepts the broader
314
475
  `JsonrpcMessageFromServerToClient`, contravariantly compatible). Target
315
476
  account travels via the send argument, not the payload — `revoked_by` is
316
- deliberately not in the `permit_revoke` payload (the revokee doesn't need
477
+ deliberately not in the `role_grant_revoke` payload (the revokee doesn't need
317
478
  to learn the admin's identity).
318
479
 
319
480
  ## Queries
@@ -331,106 +492,131 @@ CRUD + listing:
331
492
  indexes).
332
493
  - `query_account_by_username_or_email(deps, input)` — if `@` in input, tries
333
494
  email first; else username first. Single login field accepting either.
334
- - `query_update_account_password`, `query_delete_account` (cascades to
335
- actors, permits, sessions, tokens).
495
+ - `query_update_account_password(deps, id, new_hash, updated_by, expected_hash) → boolean`
496
+ conditional UPDATE keyed on `password_hash = expected_hash`; closes the
497
+ verify-write race where two concurrent password changes both verify
498
+ against the pre-update hash (loaded by the auth phase outside the
499
+ txn). Returns `false` when the racer already moved the row.
500
+ - `query_delete_account` — cascades to actors, role_grants, sessions, tokens.
336
501
  - `query_account_has_any` — used by bootstrap for belt-and-suspenders check.
337
- - `query_actor_by_account`, `query_actor_by_id`.
338
- - `query_admin_account_list` composes accounts + actors + active permits +
339
- pending inbound offers with **four flat queries** instead of N+1. Pending
340
- offers exclude `message` on purpose (cross-admin visibility). Returns
341
- `Array<AdminAccountEntryJson>`, sorted by `created_at`.
342
-
343
- ### `permit_queries.ts`
344
-
345
- - `query_grant_permit` idempotent; `ON CONFLICT` target and fallback
502
+ - `query_actors_by_account` — list every actor on an account, ordered
503
+ by `created_at`. Used by `resolve_acting_actor` to pick the unique
504
+ actor on single-actor accounts or surface `actor_required` when the
505
+ account has multiple actors.
506
+ - `query_actor_by_id` direct lookup by id; preferred when the caller
507
+ already has an actor id in scope.
508
+ - `query_admin_account_list(deps, options?)` — composes accounts + actors +
509
+ active role_grants + pending inbound offers. Paged (`limit` defaults to
510
+ `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT`; pass `limit: null` for unbounded
511
+ internal use). Two round-trips: 1 (account page) → 3 parallel scoped to
512
+ `account_ids`. The role_grants and offers queries push the page bound
513
+ through to the DB via `actor_id IN (SELECT id FROM actor WHERE
514
+ account_id = ANY(...))` so `actor.id`s never round-trip back to the
515
+ application. Pending offers exclude `message` on purpose (cross-admin
516
+ visibility). Returns `Array<AdminAccountEntryJson>`, sorted by
517
+ `created_at`.
518
+
519
+ ### `role_grant_queries.ts`
520
+
521
+ - `query_create_role_grant` — idempotent; `ON CONFLICT` target and fallback
346
522
  `SELECT` both use `COALESCE(scope_id, sentinel)`. The fallback `SELECT`
347
523
  uses `IS NOT DISTINCT FROM` (plain `=` would miss the NULL-scope conflict
348
524
  case).
349
- - `query_permit_find_active_role_for_actor(deps, permit_id, actor_id)` —
350
- actor-scoped read, so IDOR protection is consistent with revoke. Returns
351
- `{role}` or `null`.
352
- - **`query_revoke_permit(deps, permit_id, actor_id, revoked_by, reason?)`**
353
- actor-scoped IDOR guard (returns `null` if the permit belongs to a
354
- different actor). Supersedes pending offers for the revoked permit's
525
+ - `query_role_grant_find_active_role_for_actor(deps, role_grant_id, actor_id)` —
526
+ actor-scoped read, so IDOR protection is consistent with revoke.
527
+ Returns `{role, account_id}` (the actor's `account_id` joined in) or
528
+ `null`. The `account_id` flows into the audit envelope's
529
+ `target_account_id` and the SSE/WS socket-close fan-out target —
530
+ collapsing what used to be a second `query_actor_by_id` round-trip in
531
+ the revoke handler into one read closes the small TOCTOU window
532
+ where the actor row could be deleted between the IDOR check and the
533
+ actor lookup.
534
+ - **`query_revoke_role_grant(deps, role_grant_id, actor_id, revoked_by, reason?)`** —
535
+ actor-scoped IDOR guard (returns `null` if the role_grant belongs to a
536
+ different actor). Supersedes pending offers for the revoked role_grant's
355
537
  `(to_account, role, scope)` in the **same transaction** via a CTE that
356
538
  joins `actor` to surface each sibling's `from_account_id`. Returns
357
- `RevokePermitResult = {id, role, scope_id, superseded_offers}`. Closes the
539
+ `RevokeRoleGrantResult = {id, role, scope_id, superseded_offers}`. Closes the
358
540
  "accept a pre-revoke offer to bypass the revoke" path — the stale offer
359
541
  becomes terminal at revoke time.
360
- - `query_permit_find_active_for_actor`, `query_permit_list_for_actor`.
361
- - `query_permit_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
542
+ - `query_role_grant_find_active_for_actor`, `query_role_grant_list_for_actor`.
543
+ - `query_role_grant_has_role(deps, actor_id, role, scope_id?)` — `IS NOT DISTINCT FROM`
362
544
  handles the NULL case. Omitted scope matches `scope_id IS NULL` (pre-scope
363
545
  callers keep semantics). Use only when checking an arbitrary `actor_id`
364
546
  that isn't the request actor (e.g., post-mutation verification, scripts,
365
547
  audit-time checks). For the request actor, prefer `has_scoped_role` /
366
- `has_any_scoped_role` on the in-memory `auth.permits` snapshot.
367
- - `query_permit_find_account_id_for_role(deps, role)` — joins
368
- permit → actor → account, returns first match. Used by daemon token
548
+ `has_any_scoped_role` on the in-memory `auth.role_grants` snapshot.
549
+ - `query_role_grant_find_account_id_for_role(deps, role)` — joins
550
+ role_grant → actor → account, returns first match. Used by daemon token
369
551
  middleware to resolve the keeper account.
370
- - `query_permit_revoke_role(deps, actor_id, role, ...)` — revokes every
371
- active permit for `(actor, role)` across all scopes and supersedes all
552
+ - `query_role_grant_revoke_role(deps, actor_id, role, ...)` — revokes every
553
+ active role_grant for `(actor, role)` across all scopes and supersedes all
372
554
  matching pending offers. Returns `RevokeRoleResult = {revoked, superseded_offers}`.
373
- - **`query_permit_revoke_for_scope(deps, scope_id, revoked_by, reason?)`** —
555
+ - **`query_role_grant_revoke_for_scope(deps, scope_id, revoked_by, reason?)`** —
374
556
  parent-scope cascade for polymorphic `scope_id` consumers. Revokes every
375
- active permit at `scope_id` (role-agnostic) and supersedes every pending
557
+ active role_grant at `scope_id` (role-agnostic) and supersedes every pending
376
558
  offer at `scope_id` (tuple-matched and orphan, undifferentiated) in the
377
559
  caller's transaction. Returns `RevokeForScopeResult = {revoked, superseded_offers}`
378
- — `revoked` carries `account_id` for `permit_revoke` fan-out;
379
- `superseded_offers` carries `from_account_id`. Caller emits
380
- `permit_offer_supersede` audits with `reason: 'scope_destroyed'` and
560
+ — `revoked` carries both `actor_id` (drives `target_actor_id` audit
561
+ envelopes) and `account_id` (drives `target_account_id` for socket-close
562
+ fan-out); `superseded_offers` carries `from_account_id`. Caller emits
563
+ `role_grant_offer_supersede` audits with `reason: 'scope_destroyed'` and
381
564
  `cause_id: <destroyed scope row id>` per superseded offer (the cause is
382
- the scope deletion, not any individual permit revoke). Use from a
383
- consumer's parent-row delete handler when `permit.scope_id` /
384
- `permit_offer.scope_id` reference rows in a polymorphic table the
565
+ the scope deletion, not any individual role_grant revoke). Use from a
566
+ consumer's parent-row delete handler when `role_grant.scope_id` /
567
+ `role_grant_offer.scope_id` reference rows in a polymorphic table the
385
568
  consumer is about to drop.
386
569
 
387
- ### `permit_offer_queries.ts`
570
+ ### `role_grant_offer_queries.ts`
388
571
 
389
572
  Error classes (all extend `Error` with stable `.name` — never use
390
573
  `instanceof` against plain messages):
391
574
 
392
- - `PermitOfferSelfTargetError` — grantor offered themselves. Enforced via
393
- cross-row JOIN in `query_permit_offer_create` (rather than CHECK) to avoid
394
- denormalized columns.
395
- - `PermitOfferAlreadyTerminalError` offer exists for the caller but is
575
+ - `RoleGrantOfferSelfTargetError` — grantor offered themselves. Enforced
576
+ via a single SELECT on the grantor's `actor.account_id` in
577
+ `query_role_grant_offer_create` (resolving from the grantor side keeps
578
+ the check multi-actor-correctthe grantor account binding stays
579
+ 1:1 by definition of `actor`, while the recipient account may host
580
+ many actors under multi-actor).
581
+ - `RoleGrantOfferAlreadyTerminalError` — offer exists for the caller but is
396
582
  accepted / declined / retracted / superseded.
397
- - `PermitOfferExpiredError` — pending but past `expires_at` (distinct from
583
+ - `RoleGrantOfferExpiredError` — pending but past `expires_at` (distinct from
398
584
  terminal; different user-facing story: "ask the grantor to re-send").
399
- - `PermitOfferNotFoundError` — not found or belongs to a different recipient
585
+ - `RoleGrantOfferNotFoundError` — not found or belongs to a different recipient
400
586
  (standard 404-over-403 IDOR mask; callers never reveal which).
401
587
 
402
588
  Queries:
403
589
 
404
- - `query_permit_offer_create` — INSERT with upsert-on-pending keyed by
590
+ - `query_role_grant_offer_create` — INSERT with upsert-on-pending keyed by
405
591
  `(to_account, role, scope, from_actor)`. Same-grantor re-offer refreshes
406
592
  `message` + `expires_at` only. A terminal-state row with the same tuple
407
593
  does not block a fresh INSERT.
408
- - `query_permit_offer_decline(deps, id, to_account_id, reason)` — IDOR
594
+ - `query_role_grant_offer_decline(deps, id, to_account_id, reason)` — IDOR
409
595
  guarded by `to_account_id`. `resolve_terminal_or_missing` helper
410
596
  distinguishes "not found / different recipient" from "already terminal".
411
- - `query_permit_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
597
+ - `query_role_grant_offer_retract(deps, id, from_actor_id)` — IDOR guarded by
412
598
  grantor actor.
413
- - `query_permit_offer_list(deps, to_account_id)` — pending + non-expired +
599
+ - `query_role_grant_offer_list(deps, to_account_id)` — pending + non-expired +
414
600
  non-superseded, soonest expiry first.
415
- - `query_permit_offer_history_for_account(deps, account_id, limit?, offset?)` —
601
+ - `query_role_grant_offer_history_for_account(deps, account_id, limit?, offset?)` —
416
602
  both directions (recipient or grantor), includes terminal rows, newest
417
603
  first.
418
- - `query_permit_offer_find_pending`.
419
- - `query_permit_offer_sweep_expired` — returns pending offers past
420
- `expires_at`; the caller emits `permit_offer_expire` audit events
604
+ - `query_role_grant_offer_find_pending`.
605
+ - `query_role_grant_offer_sweep_expired` — returns pending offers past
606
+ `expires_at`; the caller emits `role_grant_offer_expire` audit events
421
607
  per-row (no tombstone — caller is responsible for idempotency).
422
608
  - **`query_accept_offer(deps, input)`** — atomic, must run inside a
423
609
  transaction. Row-locks with `SELECT ... FOR UPDATE` (concurrent callers
424
610
  block until commit / rollback, then branch idempotently). Inserts the
425
- permit with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
426
- `accepted_at` + `resulting_permit_id` in one UPDATE (satisfying the
427
- `permit_offer_permit_iff_accepted` CHECK), supersedes sibling pending
611
+ role_grant with normal idempotency (`ON CONFLICT DO NOTHING`), stamps
612
+ `accepted_at` + `resulting_role_grant_id` in one UPDATE (satisfying the
613
+ `role_grant_offer_role_grant_iff_accepted` CHECK), supersedes sibling pending
428
614
  offers for `(to_account, role, scope)` via CTE joined to `actor` for
429
- grantor `account_id`, and emits `permit_offer_accept` + `permit_grant`
430
- - one `permit_offer_supersede` per sibling. On race, returns the
431
- pre-existing permit with `created: false` and empty `superseded_offers`
432
- / `audit_events`. Error map: `PermitOfferNotFoundError`,
433
- `PermitOfferAlreadyTerminalError`, `PermitOfferExpiredError`. Sibling
615
+ grantor `account_id`, and emits `role_grant_offer_accept` + `role_grant_create`
616
+ - one `role_grant_offer_supersede` per sibling. On race, returns the
617
+ pre-existing role_grant with `created: false` and empty `superseded_offers`
618
+ / `audit_events`. Error map: `RoleGrantOfferNotFoundError`,
619
+ `RoleGrantOfferAlreadyTerminalError`, `RoleGrantOfferExpiredError`. Sibling
434
620
  supersede is what forecloses the "accept a pre-revoke sibling later to
435
621
  get the role back" path.
436
622
 
@@ -486,8 +672,15 @@ Server-side sessions, keyed by blake3 hash of the session token:
486
672
  - `query_invite_find_unclaimed_match(deps, email, username)` — three scoping
487
673
  modes: email-only invite needs signup-email match; username-only invite
488
674
  needs signup-username match; both-field invite requires both to match.
489
- - `query_invite_claim` — sets `claimed_by` + `claimed_at` only if still
490
- unclaimed. Return is a boolean for race-detection.
675
+ - **`query_invite_claim_unscoped`** — sets `claimed_by` + `claimed_at` only
676
+ if still unclaimed. Return is a boolean for race-detection. The
677
+ `_unscoped` suffix is the safety signal — the SQL only checks the row
678
+ state, not whether the claiming account's email/username matches the
679
+ invite. Production scoping is enforced upstream in `signup_routes.ts`
680
+ via `query_invite_find_unclaimed_match`. Mirrors the
681
+ `query_session_revoke_by_hash_unscoped` precedent — there is no scoped
682
+ sibling because scoping is provided by a separate find query, not an
683
+ alternate variant of this query.
491
684
  - `query_invite_list_all`, `query_invite_list_all_with_usernames` (joins to
492
685
  `actor` for `created_by_username` and `account` for `claimed_by_username`).
493
686
  - `query_invite_delete_unclaimed` — IDOR not a concern (admin-only surface),
@@ -515,36 +708,80 @@ run'` if the seed somehow missed (defensive — migrations always seed).
515
708
  - `query_audit_log_list(deps, options?)` — supports `event_type`,
516
709
  `event_type_in`, `account_id` (matches `account_id` OR
517
710
  `target_account_id`), `outcome`, `since_seq`, `limit`, `offset`.
518
- - `query_audit_log_list_with_usernames` joins twice to `account`.
519
- - `query_audit_log_list_for_account`, `query_audit_log_list_permit_history`
520
- (filters to `permit_grant` / `permit_revoke`).
711
+ `target_actor_id` filtering is not yet exposed; will land alongside
712
+ the admin-viewer's actor-grain forensics pass.
713
+ - `query_audit_log_list_with_usernames` joins twice to `account`
714
+ (chains `target_account_id` for the `target_username` field).
715
+ `target_actor_id` is on the row but not currently joined to actor
716
+ for a name; the admin viewer will resolve via `actor_lookup` /
717
+ `actor.name` when the actor-grain forensics pass lands.
718
+ - `query_audit_log_list_role_grant_history` (filters to `role_grant_create` / `role_grant_revoke`).
521
719
  - `query_audit_log_cleanup_before`.
522
- - **`audit_log_fire_and_forget(route, input, deps)`**
523
- writes to `route.background_db` (pool-level), so audit entries persist
524
- even when the request transaction rolls back. `deps` is an
525
- `AuditLogFireAndForgetDeps` bundle (`{log, on_audit_event, audit_log_config?}`)
526
- structurally compatible with `Pick<AppDeps, 'log' | 'on_audit_event' | 'audit_log_config'>`,
527
- so call sites pass the surrounding deps object directly. Bundling
528
- replaces the prior 5-arg positional signature; consumers that forgot
529
- the trailing `config` would silently fall back to
530
- `BUILTIN_AUDIT_LOG_CONFIG`. Write and `on_audit_event` callback
531
- failures are logged separately. Pushes onto `route.pending_effects`
532
- for test flushing.
720
+ - **Audit fan-out runs through `AppDeps.audit`** (the bound emitter built
721
+ by `create_audit_emitter` at backend assembly see §`audit_emitter.ts`).
722
+ `audit.emit(ctx, input)` writes via the captured pool, so audit entries
723
+ persist even when the request transaction rolls back. The emitter
724
+ closes over `on_audit_event` + `audit_log_config` so handlers can never
725
+ silently fall back to the builtin config or a stale callback. Write
726
+ failures and listener-callback failures are logged separately. Pushes
727
+ onto `ctx.pending_effects` for test flushing.
728
+
729
+ ### `audit_emitter.ts`
730
+
731
+ `AuditEmitter` is the bound capability that lives on `AppDeps.audit`,
732
+ built once at `create_app_backend` time.
733
+
734
+ Four methods:
735
+
736
+ - `emit(ctx, input)` — fire-and-forget pool write. Pushes the in-flight
737
+ promise onto `ctx.pending_effects`; errors logged, never thrown.
738
+ Returns `void` (the promise handle is already on `pending_effects`).
739
+ - `emit_role_grant_target(ctx, auth, input)` — wrapper that lifts
740
+ `actor_id` / `account_id` / `ip` boilerplate. Use for any event
741
+ populating one of the `target_*_id` columns; reach for `emit` only on
742
+ non-role-grant-shape events (`app_settings_update`, bootstrap, signup).
743
+ - `emit_pool(input)` — awaitable pool write for code paths without a
744
+ `pending_effects` queue (cleanup sweeps, ad-hoc maintenance scripts).
745
+ Same write-then-notify semantics as `emit`; errors logged + swallowed.
746
+ - `notify(event)` — fan out an already-written audit row to the listener
747
+ chain. Used by `query_accept_offer`'s in-transaction audit batch (see
748
+ the role-grant-offer accept handler) — the row is already in the DB,
749
+ this just walks the chain.
750
+
751
+ Per-call `ctx` shape:
752
+
753
+ - `emit` requires `{pending_effects: Array<Promise<void>>}` — the eager
754
+ queue only. Both `RouteContext` and `ActionContext` satisfy this
755
+ structurally; `audit.emit` pushes its in-flight pool-write promise
756
+ onto the eager queue. See `../http/CLAUDE.md` §Pending Effects for
757
+ the eager / deferred split.
758
+ - `emit_role_grant_target` adds `client_ip: string` (also on `ActionContext`;
759
+ REST handlers pass `{pending_effects, client_ip: get_client_ip(c)}`).
760
+
761
+ `on_event_chain` is the mutable subscriber list. `create_app_server`
762
+ appends `audit_sse.on_audit_event` here when `audit_log_sse` is enabled,
763
+ without rebuilding `AppDeps`.
533
764
 
534
765
  ### `migrations.ts`
535
766
 
536
- - `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed).
767
+ - `AUTH_MIGRATION_NAMESPACE = 'fuz_auth'`, `AUTH_MIGRATION_NS` (pre-composed), `RESERVED_MIGRATION_NAMESPACES: ReadonlyArray<string>` (membership list `create_app_backend` rejects on; consumer-discoverable instead of probing the runtime throw).
537
768
  - `AUTH_MIGRATIONS`:
538
769
  - **v0 `full_auth_schema`** — every table + index + seed for the v1
539
- identity system (account, actor, permit, auth_session, api_token,
770
+ identity system (account, actor, role_grant, auth_session, api_token,
540
771
  audit_log, bootstrap_lock, invite, app_settings). All
541
772
  `IF NOT EXISTS` — idempotent replay.
542
- - **v1 `permit_offer_and_scoped_permits`** — adds `permit_offer` table
543
- plus its two partial indexes; adds `permit.scope_id` /
544
- `permit.source_offer_id` / `permit.revoked_reason`; drops
545
- `permit_actor_role_active_unique` and installs scope-aware
546
- `permit_actor_role_scope_active_unique` using the
547
- `PERMIT_OFFER_SCOPE_SENTINEL_UUID`.
773
+ - **v1 `role_grant_offer_and_scoped_role_grants`** — adds `role_grant_offer` table
774
+ plus its two partial indexes; adds `role_grant.scope_id` /
775
+ `role_grant.scope_kind` / `role_grant.source_offer_id` /
776
+ `role_grant.revoked_reason`; installs the
777
+ `role_grant_scope_kind_paired` CHECK (DO-block guarded for re-runs
778
+ since Postgres has no `ADD CONSTRAINT IF NOT EXISTS` for CHECKs);
779
+ drops `role_grant_actor_role_active_unique` (and the prior
780
+ `role_grant_actor_role_scope_active_unique` if present) and installs the
781
+ scope-kind-aware variant keyed on
782
+ `(actor_id, role, COALESCE(scope_kind, 'GLOBAL'), COALESCE(scope_id, sentinel))`.
783
+ `role_grant_offer` is created with `scope_kind` already in the CREATE
784
+ TABLE (its CHECK + index are inline, not ALTERed).
548
785
  - Forward-only (no down). Migrations are `{name, up}` objects; the name
549
786
  surfaces in error messages.
550
787
 
@@ -566,11 +803,20 @@ by `sequence`, then enforces:
566
803
  3. **Run the pending tail** (`code[applied.length..]`) inside a single
567
804
  chain transaction; each `INSERT` uses `sequence = max(sequence) + 1`.
568
805
 
569
- **Append-only after first publish.** Once a fuz_app version containing a
570
- migration is published, the migration's name and position are frozen.
571
- Pre-publish, anything goes; the cliff is the publish event. Body edits to
572
- a published migration slip past the runner (no content hashing) — schema-
573
- snapshot tests in consumers catch these.
806
+ **Schema is not stabilized yet append-only is NOT the rule today.**
807
+ While fuz_app is pre-stable, migration bodies, names, and positions can
808
+ change freely between versions and consumers upgrading across a schema
809
+ change are expected to drop and re-bootstrap their dev/test databases.
810
+ **No consumer has a stable production DB at the time of writing** —
811
+ vissiones, zap, mageguild, undying, and fuz_template are all dev-mode
812
+ only. The pre-stable contract assumes this; once a consumer ships a
813
+ production DB, the upgrade story changes shape (operator-side
814
+ migrations, double-emit windows, etc.) and the schema-stability
815
+ declaration becomes load-bearing. Bias toward editing existing
816
+ migration entries rather than appending patch migrations until that
817
+ declaration lands. Once the schema is declared stable, a hard
818
+ append-only-after-publish rule will apply (with the cliff called out in
819
+ that release's notes).
574
820
 
575
821
  `MigrationError` is the only error class thrown from `run_migrations` /
576
822
  `baseline`; branch on `.kind` (never on message text). Kinds:
@@ -637,47 +883,140 @@ consciously violate the contract.
637
883
 
638
884
  ## Middleware
639
885
 
640
- Side of the chain ordering (concept-level see the root `../../../CLAUDE.md`
641
- §Middleware Ordering for the canonical assembly order):
642
-
643
- **Session parsing is separate from auth enforcement.** The session /
644
- request-context middleware populates `{account, actor, permits}` from a
645
- cookie but does not 401; `require_auth` / `require_role` / `require_keeper`
646
- enforce. This lets `/login` and `/bootstrap` participate in cookie refresh
647
- without being blocked.
886
+ See the root `../../../CLAUDE.md` §Middleware Ordering for the canonical
887
+ assembly order. Two-phase identity:
888
+
889
+ - **Authentication** runs in middleware (session / bearer / daemon
890
+ token). Sets `c.var.account_id` + `CREDENTIAL_TYPE_KEY` on a valid
891
+ credential. Account-only never loads actor or role_grants, never
892
+ populates `REQUEST_CONTEXT_KEY`. **Production-middleware invariant**:
893
+ no production middleware on the auth path (session / bearer / daemon
894
+ token) populates `REQUEST_CONTEXT_KEY`; identity-related context vars
895
+ it does set are `ACCOUNT_ID_KEY`, `CREDENTIAL_TYPE_KEY`, and (for
896
+ sessions / bearer) `AUTH_SESSION_TOKEN_HASH_KEY` /
897
+ `AUTH_API_TOKEN_ID_KEY`. Other middleware (proxy, app server,
898
+ session-cookie parser) sets unrelated vars like `client_ip`,
899
+ `pending_effects`, and the session-token slot keyed by
900
+ `session_options.context_key` (default `auth_session_id`) — those
901
+ are out of scope for this invariant. Test harnesses pre-populate
902
+ `REQUEST_CONTEXT_KEY` + `TEST_CONTEXT_PRESET_KEY` to bypass DB-backed
903
+ actor resolution; production code that consults
904
+ `REQUEST_CONTEXT_KEY` is reading test escape-hatch state, never live
905
+ middleware output.
906
+ - **Authorization** runs after input validation (matches the dispatcher's
907
+ 401 → 400 → 403 order so unauthenticated callers don't leak
908
+ `invalid_params` for methods with required input, and the authorization
909
+ phase reads `acting` as a typed Zod field rather than the raw body).
910
+ When the route's input declares `acting?: ActingActor` or its auth
911
+ requires role_grants (`role` / `credential_types`), the authorization
912
+ phase calls `resolve_acting_actor` over the validated `acting` value
913
+ and builds the actor-bound `RequestContext`. Account-grain routes
914
+ skip resolution and run with `RequestContext.actor: null`.
915
+ `apply_authorization_phase` is pure data — it takes
916
+ `account_id: string | null` and returns `AuthorizationResult`
917
+ (`{ok: true, request_context: RequestContext | null} | {ok: false, status, body}`)
918
+ without touching the Hono context.
919
+ Public actions and the unauthenticated-optional axis collapse to
920
+ `request_context: null`; resolved actor / account-only contexts set it
921
+ non-null. The REST wrapper (`create_fuz_authorization_handler`) sets
922
+ `REQUEST_CONTEXT_KEY` when `request_context !== null` for downstream
923
+ `require_role` / `require_credential_types`; the HTTP RPC and WS
924
+ dispatchers consume the resolved context directly via `perform_action`.
925
+ Resolution failures surface as `{ok: false, status, body}` — the auth
926
+ domain stops short of constructing a `Response` so each transport
927
+ binds the same failure to its wire shape: REST emits
928
+ `c.json(body, status)`; the WS upgrade does the same; the RPC + WS
929
+ dispatchers fold it into a JSON-RPC envelope inside `perform_action`
930
+ (`{jsonrpc, id, error: {code, message, data}}`) with `error.message`
931
+ carrying the reason string and `error.data: {reason, ...rest}`
932
+ flattening any diagnostic fields (e.g. `available[]` for
933
+ `actor_required`). The 500 reasons stay distinct in `body.error`:
934
+ `no_actors_on_account` (signup invariant violation —
935
+ `resolve_acting_actor` enumerated zero actors); `account_vanished`
936
+ (torn-read race — `build_request_context` / `build_account_context`
937
+ returned null after a successful resolve, meaning the account or
938
+ actor row was deleted between credential validation and the
939
+ follow-up read). The named per-error shape `AuthorizationFailureBody`
940
+ is still exported for callers that want to bind the failure body
941
+ by type. See the root `../../../CLAUDE.md` § Cleanest architecture
942
+ takes priority for the rationale.
943
+
944
+ Session parsing is separate from auth enforcement — login / bootstrap
945
+ participate in cookie refresh without being blocked. `require_auth`,
946
+ `require_role(roles)`, and `require_credential_types(types)` are the
947
+ gates; the keeper case composes the credential-type gate with the role
948
+ gate (no dedicated `require_keeper` helper — see `request_context.ts`).
648
949
 
649
950
  ### `request_context.ts`
650
951
 
651
- - `RequestContext = {account, actor, permits}`.
952
+ - `RequestContext = {account, actor: Actor | null, role_grants}`. `actor`
953
+ is null on account-grain routes (no `acting`, no role_grant-requiring
954
+ auth); `role_grants` is empty in that case.
652
955
  - `REQUEST_CONTEXT_KEY` — Hono context variable name.
653
956
  - **`AUTH_SESSION_TOKEN_HASH_KEY`** — holds the blake3 session hash. Set on
654
957
  successful session lookup; `null` for unauthenticated or non-session
655
958
  credentials. Exposed so SSE endpoints can scope per-session resource
656
959
  identity (the audit-log SSE uses this to close only the revoked session's
657
960
  stream on `session_revoke`).
658
- - `get_request_context(c)`, `require_request_context(c)` (throws on misuse
659
- misconfigured middleware surfaces immediately).
660
- - **In-memory permit predicates** — `has_role(ctx, role, now?)`,
961
+ - `get_request_context(c)`, `require_request_context(c)` (throws on
962
+ misuse handler ran without authorization phase wiring).
963
+ - **In-memory role_grant predicates** — `has_role(ctx, role, now?)`,
661
964
  `has_scoped_role(ctx, role, scope_id, now?)`,
662
965
  `has_any_scoped_role(ctx, roles, scope_id, now?)`. All three take
663
- `RequestContext | null` (null returns `false`) so they drop into
664
- `auth: 'public'` handlers without a manual narrow. `scope_id === null`
665
- matches global permits only; UUID matches that exact scope. Empty
666
- `roles` short-circuits `has_any_scoped_role` to `false`. Decide-time
667
- predicates only — the predicate / mutation race window is the same as
668
- the SQL `query_permit_has_role` style and only a transactional re-check
669
- inside the UPDATE/INSERT closes it.
670
- - `build_request_context(deps, account_id)`shared helper used by
671
- session, bearer, and daemon token middleware; does
672
- `account actor permits` and returns `null` if either lookup misses.
673
- - `refresh_permits(ctx, deps)` — reloads permits without mutating the
674
- original (concurrent-safe). Useful for long-lived WebSocket connections.
966
+ `RequestContext | null` and return `false` for null ctx and for
967
+ account-grain ctx (`actor: null`, empty `role_grants`); they drop into
968
+ public (`{account: 'none', actor: 'none'}`) and account-grain
969
+ (`{account: 'required', actor: 'none'}`) handlers without a manual
970
+ narrow.
971
+ `scope_id === null` matches global role_grants only; UUID matches that
972
+ exact scope. Empty `roles` short-circuits `has_any_scoped_role` to
973
+ `false`. Decide-time predicates onlythe predicate / mutation
974
+ race window is the same as the SQL `query_role_grant_has_role` style and
975
+ only a transactional re-check inside the UPDATE/INSERT closes it.
976
+ - `build_request_context(deps, account_id, actor_id)` — loads
977
+ `account` + the named `actor` + active role_grants. Verifies
978
+ `actor.account_id === account.id`; returns `null` when the account
979
+ or actor is missing, or when they don't bind to each other. Called
980
+ by the authorization phase after `resolve_acting_actor` succeeds —
981
+ a null return there is a torn read (account/actor deleted mid-request)
982
+ rather than the missing-actor invariant `resolve_acting_actor` would
983
+ have caught upstream, so the phase surfaces `ERROR_ACCOUNT_VANISHED`
984
+ on null. Not called from middleware.
985
+ - `resolve_acting_actor(deps, account_id, acting_actor_id)` — uniform
986
+ resolver. Resolves to `{ok: true, actor_id}` for 1 actor (any
987
+ `acting`) or matching supplied id; `actor_required` with the
988
+ available list when multi-actor and `acting` is missing;
989
+ `actor_not_on_account` when supplied id doesn't belong; `no_actors`
990
+ defensively.
991
+ - `refresh_role_grants(ctx, deps)` — reloads role_grants without mutating the
992
+ original (concurrent-safe). Useful for long-lived WebSocket
993
+ connections that have an acting actor.
675
994
  - `create_request_context_middleware(deps, log, session_context_key?)` —
676
- reads session token from context, hashes, validates, loads context, sets
677
- `CREDENTIAL_TYPE_KEY = 'session'`, fires `session_touch_fire_and_forget`.
678
- - `require_auth` 401 (`ERROR_AUTHENTICATION_REQUIRED`) on no context.
679
- - `require_role(role)` — 401 on no context, 403 (`ERROR_INSUFFICIENT_PERMISSIONS`
680
- - `required_role`) on missing role.
995
+ validates the session and sets `c.var.account_id` +
996
+ `CREDENTIAL_TYPE_KEY = 'session'` + `AUTH_SESSION_TOKEN_HASH_KEY`.
997
+ Touches the session fire-and-forget. Does not load actor / role_grants.
998
+ - `require_auth` — 401 (`ERROR_AUTHENTICATION_REQUIRED`) when
999
+ `account_id` is null. Does not require an acting actor.
1000
+ - `require_role(roles: ReadonlyArray<string>)` — 401 on no auth, 403
1001
+ (`ERROR_INSUFFICIENT_PERMISSIONS` + `required_roles: ReadonlyArray<string>`)
1002
+ when role_grants don't carry any of `roles` at **global / unscoped**
1003
+ scope. Implies the authorization phase ran (a role-gated route always
1004
+ resolves an actor). Implemented via `has_any_scoped_role(ctx, roles, null)`
1005
+ — a scoped role_grant (`{role: 'admin', scope_id: <uuid>}`) does **not**
1006
+ unlock unscoped role gates. Single-role specs pass `[role_name]`;
1007
+ multi-role specs pass `[r1, r2, ...]` for any-of disjunction. The
1008
+ same scope-aware semantics are mirrored in the HTTP RPC dispatcher
1009
+ (`actions/action_rpc.ts`), the WS dispatcher
1010
+ (`actions/register_action_ws.ts`), and the admin bypasses inside
1011
+ `role_grant_offer_actions.ts` so all four sites agree.
1012
+ - `require_credential_types(types: ReadonlyArray<string>)` — 401 on no
1013
+ auth, 403 (`ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types`
1014
+ echoing the spec's allowlist — symmetric with the role gate's
1015
+ `required_roles`) when `c.var.credential_type` is not in `types`.
1016
+ Composed with `require_role` for keeper specs (credential gate runs
1017
+ before role gate per `auth_guard_resolver.ts`). Replaces the deleted
1018
+ `require_keeper` helper — keeper is now a composable shape:
1019
+ `{roles: ['keeper'], credential_types: ['daemon_token']}`.
681
1020
 
682
1021
  ### `bearer_auth.ts`
683
1022
 
@@ -694,32 +1033,27 @@ without being blocked.
694
1033
  - Rate limiter: `record` before async DB work to close the TOCTOU window;
695
1034
  `reset` on valid token.
696
1035
 
697
- ### `require_keeper.ts`
698
-
699
- Two-part type guard:
700
-
701
- 1. `credential_type` must be `'daemon_token'` (not session, not API token).
702
- A session cookie from the bootstrap account still fails this check.
703
- 2. Active `keeper` permit.
1036
+ ### Keeper auth (no dedicated module)
704
1037
 
705
- Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
706
- `ERROR_INSUFFICIENT_PERMISSIONS`) otherwise.
1038
+ Keeper is a composable `RouteAuth` shape, not a dedicated guard:
1039
+ `{account: 'required', actor: 'required', roles: ['keeper'],
1040
+ credential_types: ['daemon_token']}`. The two-part check is
1041
+ `require_credential_types(['daemon_token'])` (403
1042
+ `ERROR_CREDENTIAL_TYPE_REQUIRED` + `required_credential_types: ['daemon_token']`)
1043
+ followed by `require_role(['keeper'])` (403
1044
+ `ERROR_INSUFFICIENT_PERMISSIONS`).
707
1045
 
708
- ### `session_middleware.ts` + `session_lifecycle.ts`
709
-
710
- `session_middleware.ts`:
1046
+ ### `session_middleware.ts`
711
1047
 
712
1048
  - `get_session_cookie`, `set_session_cookie`, `clear_session_cookie`.
713
1049
  - `create_session_middleware(keyring, options)` — always sets the
714
1050
  identity on context (null when invalid/missing) for type-safe reads.
715
1051
  Acts on `process_session_cookie`'s `action` (`'clear'` / `'refresh'` /
716
1052
  `'none'`).
717
-
718
- `session_lifecycle.ts` — shared by login and bootstrap:
719
-
720
1053
  - `create_session_and_set_cookie({keyring, deps, c, account_id, session_options, max_sessions?})` —
721
- generates token, hashes, persists `auth_session`, optionally enforces
722
- per-account cap, signs the cookie.
1054
+ shared by login, signup, and bootstrap: generates token, hashes,
1055
+ persists `auth_session`, optionally enforces per-account cap, signs
1056
+ the cookie.
723
1057
 
724
1058
  ### `daemon_token_middleware.ts`
725
1059
 
@@ -728,7 +1062,7 @@ Returns 401 on no context, 403 (`ERROR_KEEPER_REQUIRES_DAEMON_TOKEN` or
728
1062
  or `null` if `$HOME` unset.
729
1063
  - `write_daemon_token(runtime, path, token)` — atomic (temp + rename);
730
1064
  `chmod 0600` if available.
731
- - `resolve_keeper_account_id(deps)` — wraps `query_permit_find_account_id_for_role(ROLE_KEEPER)`.
1065
+ - `resolve_keeper_account_id(deps)` — wraps `query_role_grant_find_account_id_for_role(ROLE_KEEPER)`.
732
1066
  - `start_daemon_token_rotation(runtime, deps, options, log)` — writes initial
733
1067
  token, resolves keeper, sets up interval. Returns `{state, stop}`. The
734
1068
  interval guard `writing` skips the next rotation if the prior write is
@@ -780,18 +1114,17 @@ Session-based auth route specs. Factory: `create_account_route_specs(deps, optio
780
1114
  `account_verify` RPC action — that surface carries the typed
781
1115
  `SessionAccountJson` payload.
782
1116
  - `create_account_status_route_spec(options?)` — `GET /api/account/status`
783
- returns `{account, actor, permits}` on 200 or 401 with optional
1117
+ returns `{account, actor, role_grants}` on 200 or 401 with optional
784
1118
  `bootstrap_available` flag. `actor` is the caller's own
785
1119
  `ActorSummaryJson` so clients don't need to derive `actor_id` from
786
- the permit list. Lets the frontend fetch both session state
1120
+ the role_grant list. Lets the frontend fetch both session state
787
1121
  and bootstrap availability in one request (eliminates a separate `/health`
788
1122
  round trip).
789
1123
 
790
- Post-2026-04-23 RPC migration: session listing/revoke + revoke-all
791
- and API token CRUD live in `account_actions.ts` (see
792
- `account_session_list` / `_revoke` / `_revoke_all`,
793
- `account_token_create` / `_list` / `_revoke` below). Each keeps its
794
- guards (IDOR via `query_session_revoke_for_account` /
1124
+ Session listing/revoke + revoke-all and API token CRUD live in
1125
+ `account_actions.ts` (see `account_session_list` / `_revoke` /
1126
+ `_revoke_all`, `account_token_create` / `_list` / `_revoke` below).
1127
+ Each keeps its guards (IDOR via `query_session_revoke_for_account` /
795
1128
  `query_revoke_api_token_for_account`; `Blake3Hash` on session ids;
796
1129
  `ApiTokenId` regex on token ids; `max_tokens` enforcement via
797
1130
  `query_api_token_enforce_limit`).
@@ -828,31 +1161,34 @@ Constants:
828
1161
  - `POST /signup` — `transaction: false` (manages its own). When
829
1162
  `app_settings.open_signup` is false, requires a matching unclaimed invite.
830
1163
  On `open_signup: true` path, no invite check.
831
- - Transaction body: `query_create_account_with_actor` → `query_invite_claim`
1164
+ - Transaction body: `query_create_account_with_actor` → `query_invite_claim_unscoped`
832
1165
  (if invite present; throws `SignupConflictError` on race — another claim
833
1166
  won) → `create_session_and_set_cookie`. Catches
834
1167
  `is_pg_unique_violation(e)` → 409 `ERROR_SIGNUP_CONFLICT` (username or
835
1168
  email already exists).
836
1169
  - Error shapes: 403 `ERROR_NO_MATCHING_INVITE`, 409 `ERROR_SIGNUP_CONFLICT`.
837
1170
 
838
- ### `route_guards.ts`
1171
+ ### `auth_guard_resolver.ts`
839
1172
 
840
- `fuz_auth_guard_resolver: AuthGuardResolver` — maps `RouteAuth` discriminants
841
- (`'none'` | `'authenticated'` | `'role'` | `'keeper'`) to middleware arrays.
1173
+ `fuz_auth_guard_resolver: AuthGuardResolver` — maps the four-axis
1174
+ `RouteAuth` shape to two-phase middleware arrays. `pre_validation`
1175
+ gets `require_auth` when `account === 'required'` or `actor === 'required'`;
1176
+ `post_authorization` gets `require_credential_types(types)` when
1177
+ `credential_types?.length` and `require_role(roles)` when `roles?.length`.
842
1178
  Injected into `apply_route_specs` so the generic HTTP framework stays
843
1179
  auth-agnostic (see `../http/CLAUDE.md` §Validation pipeline for where it plugs in).
844
1180
 
845
- ### `audit_log_routes.ts` (post-RPC-migration state)
1181
+ ### `audit_log_routes.ts`
846
1182
 
847
- The 2026-04-22 RPC migration moved audit-log list + permit-history reads
848
- (plus admin session listing) to `admin_actions.ts`. The sole remaining
849
- REST concern is the optional SSE stream:
1183
+ Audit-log list + role_grant-history reads (plus admin session listing)
1184
+ live on the RPC surface in `admin_actions.ts`. The REST surface this
1185
+ module produces is now just the optional SSE stream:
850
1186
 
851
1187
  - **`GET /audit/stream`** — optional, wired only when
852
1188
  `AuditLogRouteOptions.stream` is passed. Streams aren't an RPC concern.
853
1189
  Uses `AUTH_SESSION_TOKEN_HASH_KEY` for SSE `scope` identity (so
854
1190
  `session_revoke` can close only that session's stream); `groups: [account_id]`
855
- for coarse close on `permit_revoke` / `session_revoke_all` / `password_change`.
1191
+ for coarse close on `role_grant_revoke` / `session_revoke_all` / `password_change`.
856
1192
 
857
1193
  `create_audit_log_route_specs(options?)` — returns an empty array when
858
1194
  `options.stream` is not set; `required_role` defaults to `'admin'`.
@@ -868,7 +1204,7 @@ Each surface is split across two files:
868
1204
  (no per-method `*_METHOD` string constants — read `.method` off the spec),
869
1205
  and `all_*_action_specs: Array<RequestResponseActionSpec>` codegen-ready
870
1206
  registry. Plus any reason-string constants exported to the wire contract
871
- (e.g. `ERROR_OFFER_*` for permit offers).
1207
+ (e.g. `ERROR_ROLE_GRANT_OFFER_*` for role_grant offers).
872
1208
  - `*_actions.ts` — `create_*_actions(deps, options) => Array<RpcAction>` factory
873
1209
  containing handler closures, the `*ActionDeps` / `*ActionOptions` interfaces,
874
1210
  and any handler-only helpers. Imports the specs from its sibling.
@@ -879,25 +1215,29 @@ skips the handler module's transitive query-layer deps.
879
1215
 
880
1216
  ### `admin_action_specs.ts` + `admin_actions.ts` — eleven admin-only RPC actions
881
1217
 
882
- Authorization is **spec-level** (`auth: {role: 'admin'}`) so the dispatcher
883
- enforces admin before the handler runs. `permit_revoke` in
884
- `permit_offer_actions.ts` uses the same spec-level gate even though its
885
- sibling methods are authenticated-but-not-admin the dispatcher checks
886
- auth per-spec, so mixed-auth endpoints compose cleanly.
887
-
888
- | Spec | Side effects | Rate limit | Input | Output |
889
- | -------------------------------------- | ------------ | ----------- | --------------------------------------------------------- | ----------------------------- |
890
- | `admin_account_list_action_spec` | false | | `z.void()` | `{accounts, grantable_roles}` |
891
- | `admin_session_list_action_spec` | false | | `z.void()` | `{sessions}` |
892
- | `admin_session_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
893
- | `admin_token_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
894
- | `audit_log_list_action_spec` | false | | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
895
- | `audit_log_permit_history_action_spec` | false | | `{limit?, offset?}` | `{events}` |
896
- | `invite_create_action_spec` | true | `'account'` | `{email?, username?}` | `{ok, invite}` |
897
- | `invite_list_action_spec` | false | | `z.void()` | `{invites}` |
898
- | `invite_delete_action_spec` | true | `'account'` | `{invite_id}` | `{ok}` |
899
- | `app_settings_get_action_spec` | false | | `z.void()` | `{settings}` |
900
- | `app_settings_update_action_spec` | true | `'account'` | `{open_signup}` | `{ok, settings}` |
1218
+ Authorization is **spec-level** every admin spec declares
1219
+ `auth: {account: 'required', actor: 'required', roles: ['admin']}` so
1220
+ the dispatcher enforces admin before the handler runs. `role_grant_revoke`
1221
+ in `role_grant_offer_actions.ts` uses the same spec-level gate even
1222
+ though its sibling methods are authenticated-but-not-admin the
1223
+ dispatcher checks auth per-spec, so mixed-auth endpoints compose
1224
+ cleanly. Every admin input declares `acting?: ActingActor` per
1225
+ registry-time invariant 2 (the `actor !== 'none' input declares
1226
+ acting?: ActingActor` biconditional).
1227
+
1228
+ | Spec | Side effects | Rate limit | Input | Output |
1229
+ | ------------------------------------------ | ------------ | ----------- | --------------------------------------------------------- | ----------------------------- |
1230
+ | `admin_account_list_action_spec` | false | | `{limit?, offset?}` | `{accounts, grantable_roles}` |
1231
+ | `admin_session_list_action_spec` | false | | `z.void()` | `{sessions}` |
1232
+ | `admin_session_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
1233
+ | `admin_token_revoke_all_action_spec` | true | `'account'` | `{account_id}` | `{ok, count}` |
1234
+ | `audit_log_list_action_spec` | false | | `{event_type?, account_id?, limit?, offset?, since_seq?}` | `{events}` |
1235
+ | `audit_log_role_grant_history_action_spec` | false | | `{limit?, offset?}` | `{events}` |
1236
+ | `invite_create_action_spec` | true | `'account'` | `{email?, username?}` | `{ok, invite}` |
1237
+ | `invite_list_action_spec` | false | | `z.void()` | `{invites}` |
1238
+ | `invite_delete_action_spec` | true | `'account'` | `{invite_id}` | `{ok}` |
1239
+ | `app_settings_get_action_spec` | false | | `z.void()` | `{settings}` |
1240
+ | `app_settings_update_action_spec` | true | `'account'` | `{open_signup}` | `{ok, settings}` |
901
1241
 
902
1242
  Mutating admin specs declare `rate_limit: 'account'` — keyed on the
903
1243
  admin's `request_context.actor.id`. The dispatcher's per-action hook
@@ -909,8 +1249,7 @@ per actor — permissive enough for any human admin workflow, slow enough
909
1249
  that scripted oracles surface in audit. Tighten downstream via
910
1250
  `AppServerOptions.action_account_rate_limiter`.
911
1251
 
912
- `AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp (mirrors the former REST
913
- route).
1252
+ `AUDIT_LOG_LIST_LIMIT_MAX = 200` — page size clamp. `ADMIN_ACCOUNT_LIST_DEFAULT_LIMIT = 50` / `ADMIN_ACCOUNT_LIST_LIMIT_MAX = 200` — same shape on `admin_account_list`.
914
1253
 
915
1254
  Error reasons returned via `error.data.reason`:
916
1255
 
@@ -925,9 +1264,9 @@ Audit events fired by handlers (all pass `ip: ctx.client_ip` for
925
1264
  transport-uniform forensics — matches the REST convention and the
926
1265
  self-service `account_actions.ts` surface):
927
1266
 
928
- - `session_revoke_all` / `token_revoke_all` via `audit_log_fire_and_forget`
929
- (mirrors the former REST behavior). Both also emit an
930
- `outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND` 404 path for
1267
+ - `session_revoke_all` / `token_revoke_all` via `deps.audit.emit`. Both
1268
+ also emit an `outcome: 'failure'` row on the `ERROR_ACCOUNT_NOT_FOUND`
1269
+ 404 path for
931
1270
  forensic visibility — `target_account_id` is null (FK to `account`
932
1271
  rejects references to missing ids), and the probed id is preserved
933
1272
  under `metadata.attempted_account_id`. Metadata schema widening in
@@ -938,8 +1277,9 @@ self-service `account_actions.ts` surface):
938
1277
 
939
1278
  Closure state:
940
1279
 
941
- - `grantable_roles` is derived once from `options.roles?.role_options ?? BUILTIN_ROLE_OPTIONS`
942
- (the `web_grantable` subset) and closed over by the `admin_account_list` handler.
1280
+ - `grantable_roles` is derived once from `options.roles?.role_specs ?? BUILTIN_ROLE_SPECS_BY_NAME`
1281
+ via `list_roles_with_grant_path(_, GRANT_PATH_ADMIN)` and closed over
1282
+ by the `admin_account_list` handler.
943
1283
  - `options.app_settings` — when provided, captured by the
944
1284
  `app_settings_get` / `app_settings_update` handlers. Update handler
945
1285
  **mutates the ref** (`open_signup`, `updated_at`, `updated_by`) so
@@ -951,61 +1291,79 @@ Closure state:
951
1291
  `all_admin_action_specs: Array<RequestResponseActionSpec>` — codegen-ready
952
1292
  registry of all eleven specs (always includes the two app-settings specs).
953
1293
 
954
- Deps: `AdminActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'>`. The `audit_log_config` slot flows through to `audit_log_fire_and_forget` so consumer-extended event-type metadata gets validated.
1294
+ Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>` `log` for handler-side reporting, `audit` for the bound emitter (which captures `on_audit_event` + the optional `AuditLogConfig` so consumer-extended event-type metadata gets validated).
955
1295
 
956
- ### `permit_offer_action_specs.ts` + `permit_offer_actions.ts` — seven RPC actions
1296
+ ### `role_grant_offer_action_specs.ts` + `role_grant_offer_actions.ts` — seven RPC actions
957
1297
 
958
- > **Hazard — admin `permit_offer_create` does not auto-accept.** The action
959
- > returns `{offer}` only — no `permit` is inserted. Acceptance is a separate
960
- > RPC call (`permit_offer_accept`); admin-side tests that need to materialize
961
- > a permit synchronously call `query_accept_offer` directly (see the
1298
+ > **Hazard — admin `role_grant_offer_create` does not auto-accept.** The action
1299
+ > returns `{offer}` only — no `role_grant` is inserted. Acceptance is a separate
1300
+ > RPC call (`role_grant_offer_accept`); admin-side tests that need to materialize
1301
+ > a role_grant synchronously call `query_accept_offer` directly (see the
962
1302
  > `offer_and_accept` helper in `testing/admin_integration.ts`). The CHANGELOG
963
- > v0.31 entry "admin grant_permit routes emit offers instead of direct
1303
+ > v0.31 entry "admin create_role_grant routes emit offers instead of direct
964
1304
  > grants" was the first signal of this two-step flow; consumers reading the
965
1305
  > standard admin suite assume auto-accept and have to redesign their tests
966
1306
  > when they discover otherwise. If you need direct grant for a programmatic
967
- > path that already proves consent, reach for `query_grant_permit` rather
1307
+ > path that already proves consent, reach for `query_create_role_grant` rather
968
1308
  > than the RPC action.
969
1309
 
970
- Six offer-lifecycle methods plus `permit_revoke`. Authorization is a mix:
971
-
972
- - `permit_offer_create` `auth: 'authenticated'`. The **`web_grantable`
973
- gate runs first**, then the `PermitOfferCreateAuthorize` callback
974
- (default: caller holds the offered role globally). Consumers can only
975
- tighten, never loosen past `web_grantable`.
976
- - `permit_offer_accept` / `_decline` / `_retract` — `authenticated`; IDOR
977
- guards in the `query_*` layer.
978
- - `permit_offer_list` / `_history` `side_effects: false` so GET-addressable;
979
- **input-dependent elevation** `'authenticated'` at the spec level so
980
- any caller reaches their own inbox, then the handler requires admin
981
- when `{account_id}` refers to another account. The spec can't express
982
- this because auth runs before input parsing.
983
- `permit_offer_history` accepts `limit` (1–500, default 100) + `offset`.
984
- - **`permit_revoke`** — spec-level `auth: {role: 'admin'}`; the RPC
985
- dispatcher rejects non-admin callers before the handler runs. Keys on
986
- **`actor_id`, not `account_id`** permits are actor-scoped and deriving
987
- actor from account collapses under multi-actor accounts.
988
-
989
- | Spec | Input | Output |
990
- | ---------------------------------- | -------------------------------------------- | ------------------------------------------ |
991
- | `permit_offer_create_action_spec` | `{to_account_id, role, scope_id?, message?}` | `{offer}` |
992
- | `permit_offer_accept_action_spec` | `{offer_id}` | `{permit_id, offer, superseded_offer_ids}` |
993
- | `permit_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
994
- | `permit_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
995
- | `permit_offer_list_action_spec` | `{account_id?}` | `{offers}` |
996
- | `permit_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
997
- | `permit_revoke_action_spec` | `{actor_id, permit_id, reason?}` | `{ok, revoked}` |
1310
+ Six offer-lifecycle methods plus `role_grant_revoke`. Every input
1311
+ declares `acting?: ActingActor` so every spec maps to
1312
+ `{account: 'required', actor: 'required', ...}` per registry-time
1313
+ invariant 2. Authorization tier is the differentiator:
1314
+
1315
+ - `role_grant_offer_create` `auth: {account: 'required', actor: 'required'}`.
1316
+ The **admin-grant-path gate runs first** (the offered role's
1317
+ `RoleSpec.grant_paths` must include `'admin'` /
1318
+ `GRANT_PATH_ADMIN`), then the `RoleGrantOfferCreateAuthorize`
1319
+ callback (default: caller holds the offered role globally).
1320
+ Consumers can only tighten, never loosen past the admin-grant-path
1321
+ gate.
1322
+ - `role_grant_offer_accept` / `_decline` / `_retract`
1323
+ `{account: 'required', actor: 'required'}`; IDOR guards in the
1324
+ `query_*` layer.
1325
+ - `role_grant_offer_list` / `_history` `side_effects: false` so GET-addressable;
1326
+ **input-dependent elevation**`{account: 'required', actor: 'required'}`
1327
+ at the spec level so any caller reaches their own inbox, then the
1328
+ handler requires admin when `{account_id}` refers to another account.
1329
+ The spec can't express this because auth runs before input parsing.
1330
+ `role_grant_offer_history` accepts `limit` (1–500, default 100) + `offset`.
1331
+ - **`role_grant_revoke`** spec-level
1332
+ `auth: {account: 'required', actor: 'required', roles: ['admin']}`;
1333
+ the RPC dispatcher rejects non-admin callers before the handler runs.
1334
+ Keys on **`actor_id`, not `account_id`** — role_grants are
1335
+ actor-scoped and deriving actor from account collapses under
1336
+ multi-actor accounts.
1337
+
1338
+ Every input row below also carries the shared `acting?: ActingActor`
1339
+ field that the dispatcher's authorization phase reads off the raw
1340
+ params (omitted from the table for brevity).
1341
+
1342
+ | Spec | Input | Output |
1343
+ | -------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------- |
1344
+ | `role_grant_offer_create_action_spec` | `{to_account_id, to_actor_id?, role, scope_id?, message?}` | `{offer}` |
1345
+ | `role_grant_offer_accept_action_spec` | `{offer_id}` | `{role_grant_id, offer, superseded_offer_ids}` |
1346
+ | `role_grant_offer_decline_action_spec` | `{offer_id, reason?}` | `{ok}` |
1347
+ | `role_grant_offer_retract_action_spec` | `{offer_id}` | `{ok}` |
1348
+ | `role_grant_offer_list_action_spec` | `{account_id?}` | `{offers}` |
1349
+ | `role_grant_offer_history_action_spec` | `{account_id?, limit?, offset?}` | `{offers}` |
1350
+ | `role_grant_revoke_action_spec` | `{actor_id, role_grant_id, reason?}` | `{ok, revoked}` |
998
1351
 
999
1352
  Error reason constants (exported as `as const` literals):
1000
1353
 
1001
- - `ERROR_OFFER_SELF_TARGET` (`'offer_self_target'`)
1002
- - `ERROR_OFFER_TERMINAL` (`'offer_terminal'`)
1003
- - `ERROR_OFFER_EXPIRED` (`'offer_expired'`)
1004
- - `ERROR_OFFER_NOT_FOUND` (`'offer_not_found'` — 404-over-403 IDOR mask)
1005
- - `ERROR_OFFER_ROLE_NOT_GRANTABLE` (`'offer_role_not_grantable'`)
1006
- - `ERROR_OFFER_NOT_AUTHORIZED` (`'offer_not_authorized'`)
1007
-
1008
- Plus re-uses from `../http/error_schemas.ts`: `ERROR_PERMIT_NOT_FOUND`,
1354
+ - `ERROR_ROLE_GRANT_OFFER_SELF_TARGET` (`'role_grant_offer_self_target'`)
1355
+ - `ERROR_ROLE_GRANT_OFFER_TERMINAL` (`'role_grant_offer_terminal'`)
1356
+ - `ERROR_ROLE_GRANT_OFFER_EXPIRED` (`'role_grant_offer_expired'`)
1357
+ - `ERROR_ROLE_GRANT_OFFER_NOT_FOUND` (`'role_grant_offer_not_found'` — 404-over-403 IDOR mask)
1358
+ - `ERROR_ROLE_GRANT_OFFER_ROLE_NOT_GRANTABLE` (`'role_grant_offer_role_not_grantable'`)
1359
+ - `ERROR_ROLE_GRANT_OFFER_NOT_AUTHORIZED` (`'role_grant_offer_not_authorized'`)
1360
+ - `ERROR_ROLE_GRANT_OFFER_ACTOR_ACCOUNT_MISMATCH` (`'role_grant_offer_actor_account_mismatch'` —
1361
+ `role_grant_offer_create` was called with a `to_actor_id` that does not
1362
+ belong to `to_account_id`)
1363
+ - `ERROR_ROLE_GRANT_OFFER_ACTOR_MISMATCH` (`'role_grant_offer_actor_mismatch'` —
1364
+ actor-targeted offer was accepted by an actor other than `to_actor_id`)
1365
+
1366
+ Plus re-uses from `../http/error_schemas.ts`: `ERROR_ROLE_GRANT_NOT_FOUND`,
1009
1367
  `ERROR_ROLE_NOT_WEB_GRANTABLE`, `ERROR_INSUFFICIENT_PERMISSIONS`,
1010
1368
  `ERROR_ACCOUNT_NOT_FOUND`.
1011
1369
 
@@ -1014,73 +1372,85 @@ Each spec declares the reason codes its handler may surface (see
1014
1372
  domain reasons returned via `error.data.reason` are listed; standard
1015
1373
  transport errors (validation, auth, rate-limit) stay implicit. Drift
1016
1374
  between declared reasons and handler throws is caught by
1017
- `../../test/auth/permit_offer_actions.error_reasons.test.ts`.
1375
+ `../../test/auth/role_grant_offer_actions.error_reasons.test.ts`.
1018
1376
 
1019
1377
  Failure-outcome audit events emitted (success and failure rows both carry
1020
1378
  `ip: ctx.client_ip` — uniform with the admin and self-service surfaces):
1021
1379
 
1022
- - `permit_offer_create` failure — `web_grantable` denial, `authorize`
1023
- denial, self-target rejection (all three denial paths emit the same
1024
- audit row with `target_account_id`).
1025
- - `permit_revoke` failure — `web_grantable` denial after IDOR / role
1380
+ - `role_grant_offer_create` failure — admin-grant-path denial, `authorize`
1381
+ denial, self-target rejection, and actor-account mismatch all emit
1382
+ the same audit row via `emit_create_failure_audit`. `target_account_id`
1383
+ carries `input.to_account_id`; `target_actor_id` echoes
1384
+ `input.to_actor_id` when supplied so failure rows match the
1385
+ success-shape envelope of actor-targeted offers (null on
1386
+ account-grain offers — see audit_log_schema rule).
1387
+ - `role_grant_revoke` failure — admin-grant-path denial after IDOR / role
1026
1388
  lookup succeeded. The admin-role-denied path (pre-IDOR) emits no audit,
1027
- matching the middleware auth-guard precedent.
1389
+ matching the middleware auth-guard precedent. `target_account_id` +
1390
+ `target_actor_id` both populated (the IDOR-passing branch resolves
1391
+ the target actor before the gate; the subject is an actor-bound
1392
+ role_grant).
1028
1393
 
1029
1394
  WS notifications (post-commit via `emit_after_commit` from
1030
1395
  `../http/pending_effects.js` — swallows exceptions so one failed send
1031
1396
  can't starve others; see `../http/CLAUDE.md` §Pending Effects):
1032
1397
 
1033
- - Create → `permit_offer_received` to recipient.
1034
- - Retract → `permit_offer_retracted` to recipient.
1035
- - Accept → `permit_offer_accepted` to grantor + one
1036
- `permit_offer_supersede` per superseded sibling to that sibling's grantor.
1037
- - Decline → `permit_offer_declined` to grantor.
1038
- - Revoke → `permit_revoke` to revokee + one `permit_offer_supersede` per
1398
+ - Create → `role_grant_offer_received` to recipient.
1399
+ - Retract → `role_grant_offer_retracted` to recipient.
1400
+ - Accept → `role_grant_offer_accepted` to grantor + one
1401
+ `role_grant_offer_supersede` per superseded sibling to that sibling's grantor.
1402
+ - Decline → `role_grant_offer_declined` to grantor.
1403
+ - Revoke → `role_grant_revoke` to revokee + one `role_grant_offer_supersede` per
1039
1404
  superseded sibling.
1040
1405
 
1041
- Deps: `PermitOfferActionDeps extends Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'> & {notification_sender?: NotificationSender | null}`.
1042
- Notification sender is optional — when absent, WS fan-out is silently
1043
- skipped (DB-only side effects still happen).
1406
+ Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'> & {notification_sender?: NotificationSender | null}`
1407
+ inline on the param. Notification sender is optional — when absent, WS
1408
+ fan-out is silently skipped (DB-only side effects still happen).
1044
1409
 
1045
1410
  Options:
1046
1411
 
1047
- - `roles?: RoleSchemaResult` — drives `web_grantable` lookup (defaults to
1048
- `BUILTIN_ROLE_OPTIONS`).
1412
+ - `roles?: RoleSchemaResult` — drives the admin-grant-path lookup
1413
+ (`role_has_grant_path(_, role, GRANT_PATH_ADMIN)`); defaults to
1414
+ `BUILTIN_ROLE_SPECS_BY_NAME`.
1049
1415
  - `default_ttl_ms?: number` — applied to new offers (defaults to
1050
- `PERMIT_OFFER_DEFAULT_TTL_MS`).
1051
- - `authorize?: PermitOfferCreateAuthorize` — custom policy for
1052
- `permit_offer_create`. Signature:
1416
+ `ROLE_GRANT_OFFER_DEFAULT_TTL_MS`).
1417
+ - `authorize?: RoleGrantOfferCreateAuthorize` — custom policy for
1418
+ `role_grant_offer_create`. Signature:
1053
1419
  `(auth, input: {to_account_id, role, scope_id}, deps: Pick<RouteFactoryDeps, 'log'>, ctx: ActionContext) => boolean | Promise<boolean>`.
1054
1420
  Pre-built option: `authorize_admin_or_holder` admits any admin and
1055
1421
  otherwise falls back to the symmetric default (caller must hold the
1056
1422
  offered role globally). Drop into
1057
- `create_permit_offer_actions({authorize: authorize_admin_or_holder})`
1423
+ `create_role_grant_offer_actions({authorize: authorize_admin_or_holder})`
1058
1424
  or any factory that forwards `authorize` (e.g. `create_standard_rpc_actions`)
1059
- for the common "admins offer anything web_grantable; users offer what
1060
- they hold" pattern.
1425
+ for the common "admins offer anything on the admin grant path; users
1426
+ offer what they hold" pattern.
1061
1427
 
1062
- `all_permit_offer_action_specs: Array<RequestResponseActionSpec>` —
1428
+ `all_role_grant_offer_action_specs: Array<RequestResponseActionSpec>` —
1063
1429
  codegen-ready registry.
1064
1430
 
1065
- ### `standard_rpc_actions.ts` — combined admin + permit-offer + account factory
1431
+ ### `standard_rpc_actions.ts` — combined admin + role-grant-offer + account factory
1066
1432
 
1067
1433
  `create_standard_rpc_actions(deps, options)` spreads
1068
- `create_admin_actions`, `create_permit_offer_actions`, and
1434
+ `create_admin_actions`, `create_role_grant_offer_actions`, and
1069
1435
  `create_account_actions` into a single `Array<RpcAction>` — the
1070
1436
  canonical fuz_app "standard" RPC surface (25 actions with
1071
1437
  `app_settings` wired, 23 without). Consumers that want a narrower
1072
1438
  surface drop down to the per-domain factories directly.
1073
1439
 
1074
- Option routing: `roles` is shared between admin and permit-offer;
1440
+ Option routing: `roles` is shared between admin and role-grant-offer;
1075
1441
  `app_settings` flows to admin only; `default_ttl_ms` and `authorize`
1076
- flow to permit-offer only; `max_tokens` flows to account only;
1077
- `notification_sender` is wired through to permit-offer (admin +
1442
+ flow to role-grant-offer only; `max_tokens` flows to account only;
1443
+ `notification_sender` is wired through to role-grant-offer (admin +
1078
1444
  account ignore it).
1079
1445
 
1080
1446
  `StandardRpcActionsOptions` composes `AdminActionOptions` +
1081
- `PermitOfferActionOptions` + `AccountActionOptions`.
1082
- `StandardRpcActionsDeps` is the same shape as `PermitOfferActionDeps`
1083
- `log`, `on_audit_event`, optional `notification_sender`.
1447
+ `RoleGrantOfferActionOptions` + `AccountActionOptions`.
1448
+ `StandardRpcActionsDeps extends Pick<RouteFactoryDeps, 'log' | 'audit'>`
1449
+ plus optional `notification_sender` consumed only by the
1450
+ role-grant-offer sub-factory; admin and account sub-factories ignore it.
1451
+ The interface is declared inline rather than aliased so future
1452
+ role-grant-offer-internal deps additions can't silently widen the
1453
+ standard surface.
1084
1454
 
1085
1455
  Pair this with `create_app_server`'s `rpc_endpoints` factory form
1086
1456
  (`(ctx) => Array<RpcEndpointSpec>`) so the combined action list gets
@@ -1090,7 +1460,7 @@ again in `create_route_specs`. See `../../../docs/usage.md` §Server
1090
1460
  Assembly.
1091
1461
 
1092
1462
  Pre-bundle consumers spread `create_admin_actions` and
1093
- `create_permit_offer_actions` separately, then also
1463
+ `create_role_grant_offer_actions` separately, then also
1094
1464
  `create_account_actions`. The bundled helper replaces all three —
1095
1465
  bundling account actions into the "standard" surface is deliberate:
1096
1466
  the admin integration suite exercises `account_token_create` /
@@ -1100,7 +1470,7 @@ consumer wiring the admin surface without account actions will hit
1100
1470
 
1101
1471
  Frontend mirror: `all_standard_action_specs` (in
1102
1472
  `./standard_action_specs.ts`) bundles `all_admin_action_specs +
1103
- all_permit_offer_action_specs + all_account_action_specs` into one
1473
+ all_role_grant_offer_action_specs + all_account_action_specs` into one
1104
1474
  `ReadonlyArray<RequestResponseActionSpec>` for typed-client codegen
1105
1475
  and `create_frontend_rpc_client({specs})` wiring. Self-service role
1106
1476
  specs are not included (opt-in, app-specific `eligible_roles`) —
@@ -1117,8 +1487,10 @@ that was `/api/account/*` is on the RPC endpoint.
1117
1487
  status-only probe, the RPC action returns `SessionAccountJson` for
1118
1488
  programmatic callers.
1119
1489
 
1120
- Authorization is **spec-level** (`auth: 'authenticated'`). Revoke operations
1121
- are account-scoped via `query_session_revoke_for_account` /
1490
+ Authorization is **spec-level**
1491
+ `auth: {account: 'required', actor: 'none'}` (no `acting` on input, so
1492
+ the actor axis stays `'none'` per registry-time invariant 2). Revoke
1493
+ operations are account-scoped via `query_session_revoke_for_account` /
1122
1494
  `query_revoke_api_token_for_account` — passing another account's session
1123
1495
  or token id returns `revoked: false` rather than revealing whether the id
1124
1496
  exists.
@@ -1136,12 +1508,12 @@ exists.
1136
1508
  `session_id` validates as `Blake3Hash`; `token_id` validates as
1137
1509
  `ApiTokenId` (`tok_[A-Za-z0-9_-]{12}`).
1138
1510
 
1139
- Audit events emitted (via `audit_log_fire_and_forget` with `ip: ctx.client_ip`):
1511
+ Audit events emitted (via `deps.audit.emit` with `ip: ctx.client_ip`):
1140
1512
  `session_revoke`, `session_revoke_all`, `token_create`, `token_revoke`. The
1141
1513
  IP is the resolved trusted-proxy value from `ActionContext.client_ip`,
1142
1514
  matching the REST handler convention.
1143
1515
 
1144
- Deps: `AccountActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'>`.
1516
+ Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
1145
1517
  Options: `{max_tokens?: number | null}` — defaults to `DEFAULT_MAX_TOKENS`
1146
1518
  from `account_routes.ts`; `null` disables the cap.
1147
1519
 
@@ -1157,45 +1529,50 @@ Zod schemas, the `satisfies RequestResponseActionSpec` literal, the
1157
1529
  `*_actions.ts` factory imports the spec and pairs it with the handler.
1158
1530
 
1159
1531
  One static `request_response` action — `self_service_role_set` — that
1160
- takes `{role, enabled: boolean}` and toggles a global permit on the
1532
+ takes `{role, enabled: boolean}` and toggles a global role_grant on the
1161
1533
  caller. Idempotent in both directions: `changed: false` when the
1162
1534
  post-call state already matched the request (already-held when
1163
1535
  enabling; not-held when disabling). Output is `{ok, enabled, changed}` —
1164
1536
  `enabled` echoes the post-call state for self-describing responses.
1165
1537
  Audit metadata carries `self_service: true` so admin reviewers can
1166
- distinguish self-toggled permits from admin grants/offers. The
1167
- `permit_grant` / `permit_revoke` metadata schemas declare
1538
+ distinguish self-toggled role_grants from admin grants/offers. The
1539
+ `role_grant_create` / `role_grant_revoke` metadata schemas declare
1168
1540
  `self_service: z.boolean().optional()` explicitly, so the field is
1169
1541
  part of the documented surface rather than riding on `z.looseObject`
1170
1542
  permissiveness.
1171
1543
 
1172
1544
  Method name is static — `role` lives in the input, not the method
1173
- name. Mirrors the `permit_offer_create({role})` precedent. Per-role
1545
+ name. Mirrors the `role_grant_offer_create({role})` precedent. Per-role
1174
1546
  parameterized methods would break the `satisfies RequestResponseActionSpec`
1175
1547
  codegen invariant and grow the surface linearly per role.
1176
1548
 
1177
1549
  `create_self_service_role_actions(deps, options)`:
1178
1550
 
1179
- - `eligible_roles: ReadonlyArray<string>` — required allowlist. Roles
1180
- outside the list are rejected with `forbidden` + reason
1551
+ - `eligible_roles?: ReadonlyArray<string>` — optional override
1552
+ allowlist. When omitted, eligibility is derived from
1553
+ `roles.role_specs` (or `BUILTIN_ROLE_SPECS_BY_NAME` when `roles` is
1554
+ also omitted) by selecting every role whose `RoleSpec.grant_paths`
1555
+ includes `'self_service'` (`GRANT_PATH_SELF_SERVICE`). Roles outside
1556
+ the eligible set are rejected with `forbidden` + reason
1181
1557
  `role_not_self_service_eligible` (exported as
1182
1558
  `ERROR_ROLE_NOT_SELF_SERVICE_ELIGIBLE`). The eligibility check fires
1183
1559
  before the `enabled` branch — same rejection regardless of direction.
1184
- - `roles?: RoleSchemaResult` — optional. When supplied, every entry in
1185
- `eligible_roles` is checked against `roles.role_options` at factory
1186
- time so typos throw at startup instead of at first call.
1560
+ - `roles?: RoleSchemaResult` — drives default-eligibility derivation
1561
+ from `RoleSpec.grant_paths`. When `eligible_roles` is also supplied,
1562
+ every entry is checked against `roles.role_specs` at factory time so
1563
+ typos throw at startup instead of at first call.
1187
1564
 
1188
1565
  Grant branch uses `has_scoped_role(auth, role, null)` for a
1189
1566
  benign-TOCTOU pre-check (distinguishes new grant from idempotent
1190
- re-grant) — reads from the in-memory `auth.permits` snapshot, no DB
1191
- roundtrip — then `query_grant_permit` for the actual insert. Revoke branch filters
1192
- `query_permit_find_active_for_actor` in JS for the matching
1567
+ re-grant) — reads from the in-memory `auth.role_grants` snapshot, no DB
1568
+ roundtrip — then `query_create_role_grant` for the actual insert. Revoke branch filters
1569
+ `query_role_grant_find_active_for_actor` in JS for the matching
1193
1570
  `(actor, role, scope_id IS NULL)` row before calling
1194
- `query_revoke_permit`. Bundle is **not** included in
1571
+ `query_revoke_role_grant`. Bundle is **not** included in
1195
1572
  `create_standard_rpc_actions` — `eligible_roles` is app-specific, opt-in,
1196
1573
  spread alongside the standard bundle when needed.
1197
1574
 
1198
- Deps: `SelfServiceRoleActionDeps = Pick<RouteFactoryDeps, 'log' | 'on_audit_event' | 'audit_log_config'>`.
1575
+ Deps: `Pick<RouteFactoryDeps, 'log' | 'audit'>`.
1199
1576
 
1200
1577
  `all_self_service_role_action_specs: ReadonlyArray<RequestResponseActionSpec>` —
1201
1578
  codegen-ready registry of the single unified spec.
@@ -1204,47 +1581,54 @@ codegen-ready registry of the single unified spec.
1204
1581
 
1205
1582
  `cleanup.ts` — periodic auth maintenance:
1206
1583
 
1207
- - `AuthCleanupDeps = QueryDeps & {log, on_audit_event?}`.
1208
- - `cleanup_expired_permit_offers(deps)` wraps `query_permit_offer_sweep_expired`,
1209
- emits one `permit_offer_expire` audit row per expired offer. Per-row
1210
- `on_audit_event` exceptions are logged and swallowed; one failed callback
1211
- does not starve siblings. Audit-write failures are also logged and skipped
1212
- (not re-thrown) so sibling sweeps still complete.
1584
+ - `AuthCleanupDeps = QueryDeps & {log, audit: AuditEmitter}`.
1585
+ Requiredproduction wiring always has a bound emitter (built once
1586
+ at `create_app_backend`); tests that need a no-op pass
1587
+ `create_test_audit_emitter()` from `testing/stubs.ts`. Single slot
1588
+ carries both row persistence and SSE/WS fan-out.
1589
+ - `cleanup_expired_role_grant_offers(deps)` wraps `query_role_grant_offer_sweep_expired`,
1590
+ emits one `role_grant_offer_expire` audit row per expired offer
1591
+ through `audit.emit_pool` (the captured pool + config + chain). Both
1592
+ write errors and per-listener throws are logged + swallowed inside
1593
+ `emit_pool`, so a single bad row never starves sibling sweeps.
1213
1594
  - `run_auth_cleanup(deps)` — one-shot consumer entry point: expired
1214
1595
  sessions + expired offers. Returns `{expired_sessions, expired_offers}`.
1215
1596
  **Re-throws sweep errors** so the caller's scheduler can log / alert.
1216
1597
  Call from `setInterval` / cron / similar.
1217
1598
 
1218
- Idempotency: the audit log has no tombstone on `permit_offer_expire`, so
1599
+ Idempotency: the audit log has no tombstone on `role_grant_offer_expire`, so
1219
1600
  concurrent sweep runs double-audit. Deploy a single scheduled invocation
1220
1601
  per instance — matches `query_session_cleanup_expired`'s expected pattern.
1221
1602
  Expired offer rows are **preserved** (not deleted) — they carry audit value
1222
1603
  for the history view, and accepted rows are the provenance for the
1223
- resulting permit.
1604
+ resulting role_grant.
1224
1605
 
1225
1606
  ## Deps
1226
1607
 
1227
1608
  `deps.ts` defines:
1228
1609
 
1229
- - **`AppDeps`** — the stateless capabilities bundle. Eight members:
1610
+ - **`AppDeps`** — the stateless capabilities bundle. Seven members:
1230
1611
  - `stat`, `read_text_file`, `delete_file` — filesystem.
1231
1612
  - `keyring: Keyring` — HMAC-SHA256 signing.
1232
1613
  - `password: PasswordHashDeps` — use `argon2_password_deps` in production.
1233
1614
  - `db: Db` — pool-level instance (middleware uses this; route handlers
1234
1615
  get a transaction-scoped `Db` via `RouteContext`).
1235
1616
  - `log: Logger`.
1236
- - `on_audit_event: (event) => void` — fires after every successful audit
1237
- INSERT. Wire to SSE broadcast for realtime audit streams. Defaults to
1238
- noop when unwired. Flows automatically through every factory that
1239
- receives `deps` / `RouteFactoryDeps`.
1240
- - `audit_log_config?: AuditLogConfig` optional consumer-extended audit
1241
- config from `create_audit_log_config({extra_events})`. Wired into
1242
- `audit_log_fire_and_forget` via the deps bundle so consumer event-type
1243
- metadata gets validated. Absent defaults to `BUILTIN_AUDIT_LOG_CONFIG`.
1244
- Pass at the backend via `create_app_backend({audit_log_config})`.
1617
+ - `audit: AuditEmitter` — bound emitter built once at `create_app_backend`
1618
+ via `create_audit_emitter`. Closes over the pool, the
1619
+ `on_audit_event` subscriber chain, and the optional
1620
+ `AuditLogConfig` so handlers reach `audit.emit(ctx, input)` /
1621
+ `audit.emit_role_grant_target(ctx, auth, input)` and never see the
1622
+ pool. Pass `on_audit_event` and `audit_log_config` to
1623
+ `create_app_backend` both fold into `audit`'s closure and the slot
1624
+ is the single seam for SSE/WS fan-out (additional listeners append
1625
+ via `audit.on_event_chain.push(...)` at server assembly).
1245
1626
  - **`RouteFactoryDeps = Omit<AppDeps, 'db'>`** — for route factories. Route
1246
1627
  handlers receive DB access via `RouteContext`, so factories don't capture
1247
1628
  a pool-level `Db`.
1248
1629
 
1630
+ Action factories take `Pick<RouteFactoryDeps, 'log' | 'audit'>` directly
1631
+ (role-grant-offer adds `notification_sender?` inline).
1632
+
1249
1633
  See root `../../../CLAUDE.md` §AppDeps Vocabulary for the
1250
1634
  capability / options / runtime-state split across the whole project.