gina 0.7.2-alpha.1 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (628) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +39 -44
  3. package/ROADMAP.md +3 -0
  4. package/framework/v0.7.2/VERSION +1 -0
  5. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/js/gina.js +351 -24
  6. package/framework/v0.7.2/core/asset/plugin/dist/vendor/gina/js/gina.min.js +726 -0
  7. package/framework/v0.7.2/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
  8. package/framework/v0.7.2/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
  9. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sqlite/lib/session-store.js +24 -6
  10. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.js +90 -9
  11. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-nunjucks.js +12 -5
  12. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-xml.js +10 -2
  13. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/gna.js +15 -1
  14. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/validator/src/main.js +301 -12
  15. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/server.isaac.js +95 -13
  16. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/server.js +232 -7
  17. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/index.js +2 -1
  18. package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/templates.json +1 -0
  19. package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/inc/name.js +1 -1
  20. package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/inc/name.js +1 -1
  21. package/framework/v0.7.2/lib/error-ref/package.json +7 -0
  22. package/framework/v0.7.2/lib/error-ref/src/main.js +70 -0
  23. package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/index.js +12 -0
  24. package/framework/v0.7.2/lib/lane/package.json +7 -0
  25. package/framework/v0.7.2/lib/lane/src/main.js +1440 -0
  26. package/framework/v0.7.2/lib/sri/src/main.js +518 -0
  27. package/framework/{v0.7.2-alpha.1 → v0.7.2}/package.json +1 -1
  28. package/gna.js +4 -4
  29. package/llms.txt +11 -9
  30. package/package.json +6 -6
  31. package/schema/routing.json +7 -1
  32. package/types/index.d.ts +28 -0
  33. package/framework/v0.7.2-alpha.1/VERSION +0 -1
  34. package/framework/v0.7.2-alpha.1/core/asset/plugin/dist/vendor/gina/js/gina.min.js +0 -720
  35. package/framework/v0.7.2-alpha.1/core/asset/plugin/dist/vendor/gina/js/gina.min.js.br +0 -0
  36. package/framework/v0.7.2-alpha.1/core/asset/plugin/dist/vendor/gina/js/gina.min.js.gz +0 -0
  37. package/framework/v0.7.2-alpha.1/lib/sri/src/main.js +0 -237
  38. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/AUTHORS +0 -0
  39. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/LICENSE +0 -0
  40. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/html/nolayout.html +0 -0
  41. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/html/static.html +0 -0
  42. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/android-chrome-192x192.png +0 -0
  43. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/android-chrome-512x512.png +0 -0
  44. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/apple-touch-icon.png +0 -0
  45. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/favicon-16x16.png +0 -0
  46. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/favicon-32x32.png +0 -0
  47. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/img/favicon.ico +0 -0
  48. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/README.md +0 -0
  49. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.css +0 -0
  50. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/beemaster/beemaster.js +0 -0
  51. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/beemaster/index.html +0 -0
  52. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/css/gina.min.css +0 -0
  53. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.br +0 -0
  54. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/css/gina.min.css.gz +0 -0
  55. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/html/statusbar.html +0 -0
  56. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.br +0 -0
  57. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/html/statusbar.html.gz +0 -0
  58. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/inspector/have_heart_one-webfont.woff2 +0 -0
  59. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/inspector/index.html +0 -0
  60. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/inspector/inspector.css +0 -0
  61. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/inspector/inspector.js +0 -0
  62. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/inspector/logo.svg +0 -0
  63. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js +0 -0
  64. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.br +0 -0
  65. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/asset/plugin/dist/vendor/gina/js/gina.onload.min.js.gz +0 -0
  66. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/config.js +0 -0
  67. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/config.requirements-anchor.js +0 -0
  68. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/ai/index.js +0 -0
  69. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/ai/lib/connector.js +0 -0
  70. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/index.js +0 -0
  71. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/connector.js +0 -0
  72. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/connector.v3.js +0 -0
  73. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/connector.v4.js +0 -0
  74. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/n1ql.js +0 -0
  75. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/session-store.js +0 -0
  76. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/session-store.v3.js +0 -0
  77. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/session-store.v4.js +0 -0
  78. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/couchbase/lib/storage-store.js +0 -0
  79. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/duckdb/index.js +0 -0
  80. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/duckdb/lib/connector.js +0 -0
  81. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mongodb/index.js +0 -0
  82. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mongodb/lib/connector.js +0 -0
  83. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mongodb/lib/job-store.js +0 -0
  84. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mongodb/lib/pipeline-loader.js +0 -0
  85. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mongodb/lib/session-store.js +0 -0
  86. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mysql/index.js +0 -0
  87. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/mysql/lib/connector.js +0 -0
  88. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/param-redact.js +0 -0
  89. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/postgresql/index.js +0 -0
  90. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/postgresql/lib/connector.js +0 -0
  91. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/index.js +0 -0
  92. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/lib/connector.js +0 -0
  93. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/lib/job-store.js +0 -0
  94. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/lib/kv-store.js +0 -0
  95. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/lib/render-cache-store.js +0 -0
  96. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/redis/lib/session-store.js +0 -0
  97. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/scylladb/index.js +0 -0
  98. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/scylladb/lib/connector.js +0 -0
  99. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/scylladb/lib/session-store.js +0 -0
  100. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/settle-once.js +0 -0
  101. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sql-parser.js +0 -0
  102. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sqlite/index.js +0 -0
  103. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sqlite/lib/connector.js +0 -0
  104. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sqlite/lib/job-store.js +0 -0
  105. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/connectors/sqlite/lib/kv-store.js +0 -0
  106. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/content.encoding +0 -0
  107. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.framework.js +0 -0
  108. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-json.js +0 -0
  109. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-nunjucks-async.js +0 -0
  110. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-stream.js +0 -0
  111. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-swig-async.js +0 -0
  112. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-swig.js +0 -0
  113. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/controller.render-v1.js +0 -0
  114. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/index.js +0 -0
  115. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/inline-script.js +0 -0
  116. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/inspector-window-emit.js +0 -0
  117. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/controller/release-banner.js +0 -0
  118. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/dev/index.js +0 -0
  119. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/dev/lib/class.js +0 -0
  120. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/dev/lib/factory.js +0 -0
  121. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/dev/lib/tools.js +0 -0
  122. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/README.md +0 -0
  123. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/currency.json +0 -0
  124. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/dist/language/en.json +0 -0
  125. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/dist/language/fr.json +0 -0
  126. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/dist/region/en.json +0 -0
  127. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/dist/region/fr.json +0 -0
  128. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/locales/index.js +0 -0
  129. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/mime.types +0 -0
  130. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/model/entity.js +0 -0
  131. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/model/index.js +0 -0
  132. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/model/template/entityFactory.js +0 -0
  133. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/model/template/index.js +0 -0
  134. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/README.md +0 -0
  135. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/index.js +0 -0
  136. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/csrf/README.md +0 -0
  137. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/csrf/package.json +0 -0
  138. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/csrf/src/main.js +0 -0
  139. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/README.md +0 -0
  140. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coep/README.md +0 -0
  141. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coep/package.json +0 -0
  142. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coep/src/main.js +0 -0
  143. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coop/README.md +0 -0
  144. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coop/package.json +0 -0
  145. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/coop/src/main.js +0 -0
  146. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/corp/README.md +0 -0
  147. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/corp/package.json +0 -0
  148. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/corp/src/main.js +0 -0
  149. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/csp/README.md +0 -0
  150. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/csp/package.json +0 -0
  151. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/csp/src/main.js +0 -0
  152. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hide-powered-by/README.md +0 -0
  153. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hide-powered-by/package.json +0 -0
  154. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hide-powered-by/src/main.js +0 -0
  155. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hsts/README.md +0 -0
  156. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hsts/package.json +0 -0
  157. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/hsts/src/main.js +0 -0
  158. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/origin-agent-cluster/README.md +0 -0
  159. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/origin-agent-cluster/package.json +0 -0
  160. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/origin-agent-cluster/src/main.js +0 -0
  161. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/package.json +0 -0
  162. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/referrer-policy/README.md +0 -0
  163. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/referrer-policy/package.json +0 -0
  164. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/referrer-policy/src/main.js +0 -0
  165. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/src/main.js +0 -0
  166. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-content-type-options/README.md +0 -0
  167. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-content-type-options/package.json +0 -0
  168. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-content-type-options/src/main.js +0 -0
  169. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-dns-prefetch-control/README.md +0 -0
  170. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-dns-prefetch-control/package.json +0 -0
  171. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-dns-prefetch-control/src/main.js +0 -0
  172. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-download-options/README.md +0 -0
  173. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-download-options/package.json +0 -0
  174. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-download-options/src/main.js +0 -0
  175. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-frame-options/README.md +0 -0
  176. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-frame-options/package.json +0 -0
  177. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-frame-options/src/main.js +0 -0
  178. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/README.md +0 -0
  179. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/package.json +0 -0
  180. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-permitted-cross-domain-policies/src/main.js +0 -0
  181. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-xss-protection/README.md +0 -0
  182. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-xss-protection/package.json +0 -0
  183. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/security-headers/x-xss-protection/src/main.js +0 -0
  184. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/session/README.md +0 -0
  185. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/session/package.json +0 -0
  186. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/session/src/main.js +0 -0
  187. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/storage/README.md +0 -0
  188. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/storage/build.json +0 -0
  189. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/storage/package.json +0 -0
  190. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/storage/src/main.js +0 -0
  191. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/validator/README.md +0 -0
  192. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/validator/build.json +0 -0
  193. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/validator/package.json +0 -0
  194. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/plugins/lib/validator/src/form-validator.js +0 -0
  195. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/router.js +0 -0
  196. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/server.express.js +0 -0
  197. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/server.isaac.rapid-reset.js +0 -0
  198. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/server.route-candidates.js +0 -0
  199. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/status.codes +0 -0
  200. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/_gitignore +0 -0
  201. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/app.json +0 -0
  202. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/connectors.json +0 -0
  203. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/routing.json +0 -0
  204. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/settings.json +0 -0
  205. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/settings.server.json +0 -0
  206. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/templates.json +0 -0
  207. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/config/watchers.json +0 -0
  208. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/controllers/controller.content.js +0 -0
  209. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/controllers/controller.js +0 -0
  210. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/controllers/setup.js +0 -0
  211. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle/locales/en.json +0 -0
  212. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_namespace/controllers/controller.js +0 -0
  213. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/css/default.css +0 -0
  214. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/css/home.css +0 -0
  215. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/css/vendor/readme.md +0 -0
  216. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/favicon.ico +0 -0
  217. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/js/components/x-checklist.js +0 -0
  218. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/js/vendor/readme.md +0 -0
  219. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/manifest.webmanifest +0 -0
  220. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/readme.md +0 -0
  221. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_public/sw.js +0 -0
  222. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/handlers/main.js +0 -0
  223. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/html/content/homepage.html +0 -0
  224. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/html/includes/error-msg-noscript.html +0 -0
  225. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/html/includes/error-msg-outdated-browser.html +0 -0
  226. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/html/includes/x-checklist.html +0 -0
  227. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/boilerplate/bundle_templates/html/layouts/main.html +0 -0
  228. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/command/gina.bat.tpl +0 -0
  229. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/command/gina.tpl +0 -0
  230. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/env.json +0 -0
  231. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/manifest.json +0 -0
  232. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/package.json +0 -0
  233. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/settings.json +0 -0
  234. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/conf/statics.json +0 -0
  235. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/client/json/401.json +0 -0
  236. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/client/json/403.json +0 -0
  237. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/client/json/404.json +0 -0
  238. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/server/html/50x.html +0 -0
  239. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/server/json/500.json +0 -0
  240. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/error/server/json/503.json +0 -0
  241. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/core/template/extensions/logger/config.json +0 -0
  242. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/console.js +0 -0
  243. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/context.js +0 -0
  244. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/data/LICENSE +0 -0
  245. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/data/README.md +0 -0
  246. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/data/package.json +0 -0
  247. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/data/src/main.js +0 -0
  248. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/dateFormat.js +0 -0
  249. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/index.js +0 -0
  250. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/json/LICENSE +0 -0
  251. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/json/README.md +0 -0
  252. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/json/package.json +0 -0
  253. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/json/src/main.js +0 -0
  254. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/path.js +0 -0
  255. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/plugins/README.md +0 -0
  256. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/plugins/package.json +0 -0
  257. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/plugins/src/api-error.js +0 -0
  258. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/plugins/src/main.js +0 -0
  259. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/prototypes.js +0 -0
  260. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/task.js +0 -0
  261. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/helpers/text.js +0 -0
  262. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/admin/package.json +0 -0
  263. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/admin/src/main.js +0 -0
  264. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/archiver/README.md +0 -0
  265. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/archiver/build.json +0 -0
  266. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/archiver/package.json +0 -0
  267. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/archiver/src/dep/jszip.min.js +0 -0
  268. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/archiver/src/main.js +0 -0
  269. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/async/package.json +0 -0
  270. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/async/src/main.js +0 -0
  271. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/audit/package.json +0 -0
  272. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/audit/src/main.js +0 -0
  273. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/audit-store.js +0 -0
  274. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authn/package.json +0 -0
  275. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authn/src/lockout.js +0 -0
  276. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authn/src/main.js +0 -0
  277. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authn/src/totp.js +0 -0
  278. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authz-gate/package.json +0 -0
  279. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/authz-gate/src/main.js +0 -0
  280. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cache/README.md +0 -0
  281. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cache/build.json +0 -0
  282. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cache/package.json +0 -0
  283. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cache/src/main.js +0 -0
  284. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/aliases.json +0 -0
  285. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/audit/arguments.json +0 -0
  286. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/audit/help.txt +0 -0
  287. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/audit/verify.js +0 -0
  288. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/add.js +0 -0
  289. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/arguments.json +0 -0
  290. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/build.js +0 -0
  291. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/copy.js +0 -0
  292. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/cp.js +0 -0
  293. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/help.js +0 -0
  294. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/help.txt +0 -0
  295. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/inc/boot-lines.js +0 -0
  296. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/inc/name-rewrite.js +0 -0
  297. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/list.js +0 -0
  298. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/man.js +0 -0
  299. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/mcp-start.js +0 -0
  300. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/mcp.js +0 -0
  301. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/oas.js +0 -0
  302. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/openapi.js +0 -0
  303. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/remove.js +0 -0
  304. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/rename.js +0 -0
  305. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/restart.js +0 -0
  306. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/rm.js +0 -0
  307. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/start.js +0 -0
  308. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/status.js +0 -0
  309. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/stop.js +0 -0
  310. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/bundle/types.js +0 -0
  311. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/cache/arguments.json +0 -0
  312. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/cache/clear.js +0 -0
  313. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/cache/help.txt +0 -0
  314. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/cache/stats.js +0 -0
  315. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/add.js +0 -0
  316. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/arguments.json +0 -0
  317. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/help.js +0 -0
  318. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/help.txt +0 -0
  319. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/infer.js +0 -0
  320. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/list.js +0 -0
  321. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/migrate.js +0 -0
  322. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/models.js +0 -0
  323. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/remove.js +0 -0
  324. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/rm.js +0 -0
  325. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/connector/test.js +0 -0
  326. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/arguments.json +0 -0
  327. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/help.js +0 -0
  328. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/help.txt +0 -0
  329. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/man.js +0 -0
  330. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/ps.js +0 -0
  331. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/container/stop.js +0 -0
  332. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/add.js +0 -0
  333. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/arguments.json +0 -0
  334. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/help.txt +0 -0
  335. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/inc/args.js +0 -0
  336. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/inc/namespace.js +0 -0
  337. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/inc/reference-rewrite.js +0 -0
  338. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/inc/reference-scan.js +0 -0
  339. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/inc/scaffold.js +0 -0
  340. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/remove.js +0 -0
  341. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/rename.js +0 -0
  342. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/controller/rm.js +0 -0
  343. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/add.js +0 -0
  344. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/get.js +0 -0
  345. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/help.js +0 -0
  346. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/help.txt +0 -0
  347. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/inc/name.js +0 -0
  348. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/link-dev.js +0 -0
  349. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/list.js +0 -0
  350. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/remove.js +0 -0
  351. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/rm.js +0 -0
  352. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/set.js +0 -0
  353. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/unset.js +0 -0
  354. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/env/use.js +0 -0
  355. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/add.js +0 -0
  356. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/arguments.json +0 -0
  357. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/build.js +0 -0
  358. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/dot.js +0 -0
  359. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/get.js +0 -0
  360. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/help.js +0 -0
  361. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/help.txt +0 -0
  362. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/inc/ps-titles.js +0 -0
  363. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/init.js +0 -0
  364. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/link-node-modules.js +0 -0
  365. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/link.js +0 -0
  366. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/list.js +0 -0
  367. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/man.js +0 -0
  368. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/msg.json +0 -0
  369. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/open.js +0 -0
  370. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/remove.js +0 -0
  371. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/reset.js +0 -0
  372. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/restart.js +0 -0
  373. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/set.js +0 -0
  374. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/start.js +0 -0
  375. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/status.js +0 -0
  376. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/stop.js +0 -0
  377. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/tail.js +0 -0
  378. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/update.js +0 -0
  379. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/framework/version.js +0 -0
  380. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/gina-dev.1.md +0 -0
  381. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/gina-framework.1.md +0 -0
  382. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/gina.1.md +0 -0
  383. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/helper.js +0 -0
  384. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/add.js +0 -0
  385. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/arguments.json +0 -0
  386. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/export.js +0 -0
  387. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/help.js +0 -0
  388. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/help.txt +0 -0
  389. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/import.js +0 -0
  390. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/i18n/scan.js +0 -0
  391. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/_host.js +0 -0
  392. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/arguments.json +0 -0
  393. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/build.js +0 -0
  394. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/help.js +0 -0
  395. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/help.txt +0 -0
  396. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/list.js +0 -0
  397. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/man.js +0 -0
  398. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/rm.js +0 -0
  399. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/image/run.js +0 -0
  400. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/index.js +0 -0
  401. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/inspector/help.js +0 -0
  402. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/inspector/help.txt +0 -0
  403. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/inspector/open.js +0 -0
  404. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/man-render.js +0 -0
  405. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/minion/arguments.json +0 -0
  406. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/minion/help.js +0 -0
  407. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/minion/help.txt +0 -0
  408. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/minion/kill.js +0 -0
  409. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/minion/list.js +0 -0
  410. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/msg.json +0 -0
  411. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/help.js +0 -0
  412. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/help.txt +0 -0
  413. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/inc/scan.js +0 -0
  414. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/list.js +0 -0
  415. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/reset.js +0 -0
  416. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/port/set.js +0 -0
  417. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/add.js +0 -0
  418. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/arguments.json +0 -0
  419. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/backup.js +0 -0
  420. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/build.js +0 -0
  421. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/help.js +0 -0
  422. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/help.txt +0 -0
  423. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/import.js +0 -0
  424. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/list.js +0 -0
  425. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/man.js +0 -0
  426. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/move.js +0 -0
  427. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/remove.js +0 -0
  428. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/rename.js +0 -0
  429. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/restart.js +0 -0
  430. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/restore.js +0 -0
  431. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/rm.js +0 -0
  432. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/start.js +0 -0
  433. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/status.js +0 -0
  434. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/project/stop.js +0 -0
  435. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/arguments.json +0 -0
  436. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/help.js +0 -0
  437. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/help.txt +0 -0
  438. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/list.js +0 -0
  439. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/remove.js +0 -0
  440. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/protocol/set.js +0 -0
  441. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/add.js +0 -0
  442. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/help.js +0 -0
  443. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/help.txt +0 -0
  444. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/inc/name.js +0 -0
  445. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/link-local.js +0 -0
  446. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/link-production.js +0 -0
  447. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/list.js +0 -0
  448. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/remove.js +0 -0
  449. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/rm.js +0 -0
  450. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/scope/use.js +0 -0
  451. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/secrets/arguments.json +0 -0
  452. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/secrets/check.js +0 -0
  453. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/secrets/help.js +0 -0
  454. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/secrets/help.txt +0 -0
  455. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/secrets/scan.js +0 -0
  456. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/service/help.js +0 -0
  457. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/service/help.txt +0 -0
  458. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/service/list.js +0 -0
  459. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/service/man.js +0 -0
  460. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/service/start.js +0 -0
  461. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/storage/arguments.json +0 -0
  462. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/storage/gc.js +0 -0
  463. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/storage/help.txt +0 -0
  464. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/storage/stats.js +0 -0
  465. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/storage/verify.js +0 -0
  466. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd/view/add.js +0 -0
  467. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd-status-format/package.json +0 -0
  468. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cmd-status-format/src/main.js +0 -0
  469. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/collection/README.md +0 -0
  470. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/collection/build.json +0 -0
  471. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/collection/package.json +0 -0
  472. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/collection/src/main.js +0 -0
  473. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/conf-view/package.json +0 -0
  474. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/conf-view/src/main.js +0 -0
  475. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/config.js +0 -0
  476. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-config/package.json +0 -0
  477. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-config/src/main.js +0 -0
  478. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-error/package.json +0 -0
  479. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-error/src/main.js +0 -0
  480. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-registry/package.json +0 -0
  481. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/connector-registry/src/main.js +0 -0
  482. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cron/README.md +0 -0
  483. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cron/package.json +0 -0
  484. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/cron/src/main.js +0 -0
  485. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/domain/LICENSE +0 -0
  486. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/domain/README.md +0 -0
  487. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/domain/package.json +0 -0
  488. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/domain/src/main.js +0 -0
  489. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto/package.json +0 -0
  490. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto/src/main.js +0 -0
  491. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto-pipe/package.json +0 -0
  492. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto-pipe/src/main.js +0 -0
  493. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto-types/package.json +0 -0
  494. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/dto-types/src/main.js +0 -0
  495. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/duration/package.json +0 -0
  496. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/duration/src/main.js +0 -0
  497. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/generator/index.js +0 -0
  498. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/i18n/package.json +0 -0
  499. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/i18n/src/main.js +0 -0
  500. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/idempotency/package.json +0 -0
  501. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/idempotency/src/main.js +0 -0
  502. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/image-build/package.json +0 -0
  503. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/image-build/src/main.js +0 -0
  504. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inherits/LICENSE +0 -0
  505. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inherits/README.md +0 -0
  506. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inherits/package.json +0 -0
  507. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inherits/src/main.js +0 -0
  508. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inspector-events/package.json +0 -0
  509. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inspector-events/src/main.js +0 -0
  510. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inspector-redact/package.json +0 -0
  511. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/inspector-redact/src/main.js +0 -0
  512. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/instrument/package.json +0 -0
  513. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/instrument/src/main.js +0 -0
  514. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/job/package.json +0 -0
  515. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/job/src/main.js +0 -0
  516. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/job-store.js +0 -0
  517. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/json-config-header/package.json +0 -0
  518. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/json-config-header/src/main.js +0 -0
  519. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/kv/package.json +0 -0
  520. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/kv/src/main.js +0 -0
  521. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/kv-store.js +0 -0
  522. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/loading-state/package.json +0 -0
  523. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/loading-state/src/main.js +0 -0
  524. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/README.md +0 -0
  525. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/package.json +0 -0
  526. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/containers/default/index.js +0 -0
  527. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/containers/file/index.js +0 -0
  528. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/containers/mq/index.js +0 -0
  529. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/containers/mq/listener.js +0 -0
  530. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/containers/mq/speaker.js +0 -0
  531. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/helper.js +0 -0
  532. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/main.js +0 -0
  533. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/logger/src/redact.js +0 -0
  534. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/maintenance/package.json +0 -0
  535. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/maintenance/src/main.js +0 -0
  536. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/math/index.js +0 -0
  537. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-dispatch/package.json +0 -0
  538. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-dispatch/src/main.js +0 -0
  539. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-http/package.json +0 -0
  540. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-http/src/main.js +0 -0
  541. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-server/package.json +0 -0
  542. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/mcp-server/src/main.js +0 -0
  543. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/merge/README.md +0 -0
  544. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/merge/package.json +0 -0
  545. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/merge/src/main.js +0 -0
  546. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/message-validator/package.json +0 -0
  547. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/message-validator/src/main.js +0 -0
  548. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/metrics/package.json +0 -0
  549. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/metrics/src/main.js +0 -0
  550. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/model.js +0 -0
  551. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/money/package.json +0 -0
  552. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/money/src/main.js +0 -0
  553. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/multipart/package.json +0 -0
  554. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/multipart/src/main.js +0 -0
  555. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/net-locality/package.json +0 -0
  556. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/net-locality/src/main.js +0 -0
  557. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/nunjucks-filters/README.md +0 -0
  558. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/nunjucks-filters/package.json +0 -0
  559. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/nunjucks-filters/src/main.js +0 -0
  560. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/nunjucks-resolver/package.json +0 -0
  561. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/nunjucks-resolver/src/main.js +0 -0
  562. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/priority/package.json +0 -0
  563. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/priority/src/main.js +0 -0
  564. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/proc.js +0 -0
  565. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/push/package.json +0 -0
  566. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/push/src/main.js +0 -0
  567. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/rate-limit/package.json +0 -0
  568. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/rate-limit/src/main.js +0 -0
  569. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/release-watch/package.json +0 -0
  570. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/release-watch/src/main.js +0 -0
  571. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/render-cache/package.json +0 -0
  572. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/render-cache/src/main.js +0 -0
  573. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/render-cache-store.js +0 -0
  574. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing/README.md +0 -0
  575. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing/build.json +0 -0
  576. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing/package.json +0 -0
  577. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing/src/main.js +0 -0
  578. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing-introspect/package.json +0 -0
  579. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/routing-introspect/src/main.js +0 -0
  580. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/package.json +0 -0
  581. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/backends/env.js +0 -0
  582. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/backends/exec.js +0 -0
  583. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/backends/file.js +0 -0
  584. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/declaration.js +0 -0
  585. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/env-file.js +0 -0
  586. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/main.js +0 -0
  587. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/secrets/src/sources.js +0 -0
  588. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/security-headers-emitter/package.json +0 -0
  589. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/security-headers-emitter/src/main.js +0 -0
  590. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/session-lifetime/package.json +0 -0
  591. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/session-lifetime/src/main.js +0 -0
  592. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/session-store.js +0 -0
  593. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/shell.js +0 -0
  594. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/sqlite-driver.js +0 -0
  595. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/sri/package.json +0 -0
  596. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/state.js +0 -0
  597. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/package.json +0 -0
  598. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/local-cas.js +0 -0
  599. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/local-stream.js +0 -0
  600. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/local.js +0 -0
  601. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/main.js +0 -0
  602. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/meta-store.js +0 -0
  603. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/s3.js +0 -0
  604. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage/src/util.js +0 -0
  605. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/storage-store.js +0 -0
  606. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/swig-filters/README.md +0 -0
  607. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/swig-filters/package.json +0 -0
  608. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/swig-filters/src/main.js +0 -0
  609. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/swig-resolver/package.json +0 -0
  610. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/swig-resolver/src/main.js +0 -0
  611. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/template-loaders/package.json +0 -0
  612. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/template-loaders/src/loaders/http.js +0 -0
  613. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/template-loaders/src/loaders/memory.js +0 -0
  614. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/template-loaders/src/main.js +0 -0
  615. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/url/README.md +0 -0
  616. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/url/index.js +0 -0
  617. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/url/routing.json +0 -0
  618. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/uuid/package.json +0 -0
  619. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/uuid/src/main.js +0 -0
  620. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/validator.js +0 -0
  621. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/watcher/package.json +0 -0
  622. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/watcher/src/main.js +0 -0
  623. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-framing/package.json +0 -0
  624. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-framing/src/main.js +0 -0
  625. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-query/package.json +0 -0
  626. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-query/src/main.js +0 -0
  627. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-session/package.json +0 -0
  628. /package/framework/{v0.7.2-alpha.1 → v0.7.2}/lib/ws-session/src/main.js +0 -0
package/llms.txt CHANGED
@@ -284,6 +284,7 @@ SELECT * FROM users WHERE id = $1 AND t._scope = $scope
284
284
 
285
285
  Connectors supported: `couchbase` (v3/v4), `mysql` (mysql2), `postgresql` (pg), `redis` (store backend only: sessions, jobs, render-cache L2 — no ORM), `sqlite` (v2 ORM + session store), `scylladb` (cassandra-driver, ORM + session store), `mongodb` (official driver, ORM via `pipelines/<Entity>/*.json` + session store via TTL index), `ai` (LLM providers).
286
286
  All connector clients (`mysql2`, `pg`, `ioredis`, `couchbase`, `mongodb`, `cassandra-driver`, `openai`, `@anthropic-ai/sdk`) are loaded from the project's `node_modules` — zero framework runtime dependency. The driver-name → npm-package mapping lives in `lib/connector-registry/src/main.js` (single source of truth for `connector:add` / `connector:list` install hints).
287
+ SQLite paths go in `file`, never in `database`: the model layer opens a connector for EVERY `connectors.json` entry at boot, store-only entries included, and reads `database` as a database NAME under the gina home (`<home>/<database>.sqlite`), so a filesystem path in `database` stops the boot. The SQLite session, job and kv stores all read their path, or `":memory:"`, from `file` — the session store since 0.7.2 (#D47; before that it read only `database`, so a persistent session store needed both keys set to the same path). With neither key the session store uses `<gina home>/sessions-<bundle>.db`.
287
288
 
288
289
  ## AI connector
289
290
 
@@ -701,7 +702,7 @@ gina-init # Bootstrap ~/.gina/ from env vars (cont
701
702
  ## Dev mode behaviour
702
703
 
703
704
  - `NODE_ENV_IS_DEV=true` enables hot-reload.
704
- - `WatcherService` starts automatically in dev mode and watches `controller.js`, `controller.render-swig.js`, and the bundle's `controllers/` directory.
705
+ - `WatcherService` starts in dev mode only when the bundle's `index.js` calls `onStarted()` (the scaffolded `index.js` leaves that call commented out); it watches `controller.js`, `controller.render-swig.js`, the bundle's `controllers/` directory and its lane directories. Without it, dev mode takes the per-request fallback below.
705
706
  - `require.cache` eviction is split: `refreshCoreDependencies()` (`router.js`) evicts and re-requires the controller pair only when the watcher has marked a watched file dirty (file-change-triggered, not per-request; falls back to per-request eviction when the watcher context is unavailable), while `refreshCore()` (`server.isaac.js` — isaac engine only) re-requires `lib/index.js` + `plugins/index.js` on EVERY request.
706
707
  - SQL files are re-read from disk on every entity call (Couchbase/SQLite connectors).
707
708
  - Static files are served with `cache-control: no-cache, no-store, must-revalidate` — 304 is never sent; browser always re-fetches.
@@ -830,7 +831,7 @@ Dev-mode query instrumentation captures every database query tied to the current
830
831
 
831
832
  ## Common gotchas
832
833
 
833
- 12. **Static file caching — production vs dev**: In production (`NODE_ENV_IS_DEV` not set), `handleStatics` sends `ETag` (`"<size>-<mtime>"`) and `Last-Modified` on every 200 response. If the browser resends `If-None-Match` or `If-Modified-Since`, a **304 Not Modified** (no body) is returned. `If-None-Match` takes precedence. In dev mode (`isCacheless=true`), 304 is never sent — all statics are served fresh with `cache-control: no-cache, no-store, must-revalidate`. The ETag format matches Express/serve-static: `"<byteSize>-<mtimeMs>"`. Both HTTP/1.x and HTTP/2 paths implement the same logic. **Directory-to-index redirect (former #218, #B72, 2026-07-06):** a static request whose resolved filename is a DIRECTORY containing an `index.html` redirects to `<url>index.html` with an unconditional **301 on BOTH protocols, dev or not** — the HTTP/1.x branch used to issue its `writeHead(301)` only inside the dev gate, so outside dev it answered 200 with a `Location` header browsers ignore (a blank page instead of the index; the HTTP/2 sibling always sent `:status` 301, and http/1.1 + http IS the scaffold default). The no-cache header set stays dev-only. The branch requires the directory to CONTAIN an index.html — without one the request 403s before the redirect, which is why a probe against an asset directory with no index misses it. Static dispatch is engine-agnostic (the built-in engine serves only its own endpoints and cache-hits itself; everything else, statics included, flows through the shared pipeline). **HTTP/2 statics are served by `handleStatics` on the request's OWN `response.stream` (#B566, 0.6.32):** the former once-per-instance `'stream'` listener — registered from inside the FIRST HTTP/2 static request and serving every later static through that request's captured `response`, so its headers (`Set-Cookie`, request id, CORS) were folded onto every other client's stream, an unseen HTML static was served as a raw binary, and a directory's index URL died for the process lifetime once the directory itself had been requested — and its server-push branch were REMOVED. A static now carries its own request's headers plus the same dev/prod cache headers as HTTP/1.x; HTTP/2 server push is not implemented (browsers never used it; `pushAllowed` reflects the CLIENT's SETTINGS_ENABLE_PUSH, so the server-side `enablePush: false` advertisement never stopped it for push-capable clients).
834
+ 12. **Static file caching — production vs dev**: In production (`NODE_ENV_IS_DEV` not set), `handleStatics` sends `ETag` (`"<size>-<mtime>"`) and `Last-Modified` on every 200 response. If the browser resends `If-None-Match` or `If-Modified-Since`, a **304 Not Modified** (no body) is returned. `If-None-Match` takes precedence. In dev mode (`isCacheless=true`), 304 is never sent — all statics are served fresh with `cache-control: no-cache, no-store, must-revalidate`. The ETag format matches Express/serve-static: `"<byteSize>-<mtimeMs>"`. Both HTTP/1.x and HTTP/2 paths implement the same logic. **Directory-to-index redirect (former #218, #B72, 2026-07-06):** a static request whose resolved filename is a DIRECTORY containing an `index.html` redirects to `<url>index.html` with an unconditional **301 on BOTH protocols, dev or not** — the HTTP/1.x branch used to issue its `writeHead(301)` only inside the dev gate, so outside dev it answered 200 with a `Location` header browsers ignore (a blank page instead of the index; the HTTP/2 sibling always sent `:status` 301, and http/1.1 + http IS the scaffold default). The no-cache header set stays dev-only. The branch requires the directory to CONTAIN an index.html — without one the request 403s before the redirect, which is why a probe against an asset directory with no index misses it. Static dispatch is engine-agnostic (the built-in engine serves only its own endpoints and cache-hits itself; everything else, statics included, flows through the shared pipeline). **HTTP/2 statics are served by `handleStatics` on the request's OWN `response.stream` (#B566, 0.6.32):** the former once-per-instance `'stream'` listener — registered from inside the FIRST HTTP/2 static request and serving every later static through that request's captured `response`, so its headers (`Set-Cookie`, request id, CORS) were folded onto every other client's stream, an unseen HTML static was served as a raw binary, and a directory's index URL died for the process lifetime once the directory itself had been requested — and its server-push branch were REMOVED. A static now carries its own request's headers plus the same dev/prod cache headers as HTTP/1.x; HTTP/2 server push is not implemented (browsers never used it; `pushAllowed` reflects the CLIENT's SETTINGS_ENABLE_PUSH, so the server-side `enablePush: false` advertisement never stopped it for push-capable clients). **Versioned asset URLs (#P48, 0.7.2):** in production — never in dev, never for a render without a layout — the `<link>` / `<script>` tags built from `templates.json` and their HTTP/2 preload hints (the `link` header and 103 Early Hints) carry `?v=<first 10 hex of the file's SHA-384>` (`&v=` after an existing query). Fail-open: an unresolvable file, an external URL or a file over 8 MiB keeps its plain URL; a js `isExternalPlugin` tag stays plain (it is spliced into the layout the render cache compiles, where a token could outlive its file); `templates.json > _common > assetVersioningEnabled: false` turns the feature off (default on). `handleStatics` treats only a 10-hex `v` as a token: the file's current token → `cache-control: public, max-age=31536000, immutable` on the 200 and on the HTTP/2 304 (the HTTP/1.x 304 carries no Cache-Control — it is decided before a precompressed sibling is picked, and a 304 without it keeps what the browser stored — but it carries the Vary below); any other 10-hex token → `no-cache` + the ETag; no `v`, or an author's own non-token `v`, → the headers above, unchanged. Over HTTP/1.x a precompressed sibling (`.br` / `.gz`) is `immutable` only when it is not older than its source IN WHOLE SECONDS — compressors that keep their source's timestamp truncate it, so a millisecond compare read every sibling of the same build as stale. A sibling goes out labelled with its coding's NAME, `Content-Encoding: br` / `gzip` (up to 0.7.1 a `.gz` copy said `gz`, its extension, which browsers do not decode — #B742), and every production HTTP/1.x static, sibling or not, carries `Vary: Accept-Encoding` on the 200 AND the 304 (#B743): a configured `server.response.header` `vary` is merged before the conditional-GET decision (completeHeaders() sets it on the 200 only) and restored after completeHeaders(), which replaces it; isaac's routing table appends it to its `Vary: Origin` (both protocols, production); HTTP/2 statics serve the file itself and carry none. The client routing table is versioned the same way: gina's own script tag carries `data-gina-routing-v` (the token of the table variant that request is served, rendered per request — never in the loader, which the render cache can freeze), `core.js` appends it as `?v=`, and both engines answer `(public|private), max-age=31536000, immutable` only to the token of the variant they serve; a routing change mints a new token, a restart with the same routes keeps it. The client's script and style dedup (`bindRegion`, the popin) compares URLs without the token. A front server that serves the statics should send requests carrying a 10-hex `v` to the bundle, which checks the token: in nginx, inside each `location ^~` serving those files, `error_page 418 = @named; if ($tok) { return 418; }` with `map $arg_v $tok { "~^[0-9a-f]{10}$" 1; default 0; }` (the regex QUOTED: unquoted braces fail `nginx -t`) and a named location that only `proxy_pass`es (an `add_header` there duplicates `Cache-Control` and drops the inherited server headers; an `error_page` in the location replaces the server's, so repeat them); a regex `location` never applies beside a `^~` prefix. Keying `Cache-Control` on the token PATTERN alone answers any 10-hex token with the current bytes for a year: a page rendered before a deploy pins the new bytes under its old token, and a revert revives that token with the reverted-away file (measured). Both engines match the table on the url's PATH only (the #B712 rule): a page whose query string ends in `/_gina/assets/routing.json` is answered by the page; the isaac fast path looks the file up by the query-free path's last segment, lower-cased, and hands a miss to the shared handler.
834
835
  33. **`query()` callback — `false` is the success sentinel** — `self.query()` calls `callback(false, data)` on success, not `callback(null, data)`. `data` is the upstream body as parsed, the same over HTTP/1.1 and HTTP/2: a JSON body without `status` arrives without one (since `0.7.0`; the HTTP/2 client used to add `status: 200` and log `Response status code is undefined: switching to 200` on every such call — #P47 F6). Use `if (err)` as the error guard — it works because `false` is falsy. Never check `err === null` (always false on success). Never use `err = merge(err, {...})` on a real Error object — `merge()` destroys `message` and `stack`. Assign extra properties directly: `err.session = userSession`.
835
836
 
836
837
  50. **Leak-scan & public-surface discipline — tooling, sidecar pattern, recovery** — covers four leak surfaces (commit messages, changie YAML bodies, JSDoc in shipped source, tracked-script body content); six layered defenses (`.gitignore` → `.npmignore` → pre-commit → commit-msg → CI scan → publish prepack) — the commit-MESSAGE surface was listed but unguarded until 0.6.32: pre-commit scans staged CONTENT and has since 0.3.6, but no hook ever received the message, and 13 messages on the public branches picked up a local-configuration-path mention between 2026-05 and 2026-08 as a result. `.githooks/commit-msg` closes it, shares pre-commit's leak/exception patterns byte-for-byte (extracted, not retyped) and adds a message-only pattern for the prose forms the content scan deliberately does not match — the bare token is also the AI connector's provider key, so widening the CONTENT scan would reject product functionality the rule allows. ⚠️ The exclusion list is MIRRORED in `.github/workflows/security.yml`: adding a file to the hook alone leaves CI red on the very commit that adds it (measured 2026-09-18). `test/lib/commit-msg-hook.test.js` pins both copies and the parity, four arms validated red-first; `.gitignore` and `.npmignore` are independent files and patterns are NOT auto-synced (every new gitignored file must be cross-checked against both); `package.json files` whitelist bypasses `.npmignore` for nested paths (#S4 audit, 80MB → 6.9MB tarball decision); sidecar pattern (`script/.private-tokens.json` + shared loader) for tooling that names what it scans for; `git filter-repo` operational gotchas (prior-run prompt, origin removal, committer-date SHA churn, local-only-tag false positive, `--replace-text` ≠ `--replace-message`); GitHub branch protection `allow_force_pushes: false` blocks ALL force-pushes including admin (subfield endpoint returns 404; web UI toggle has been observed to silently no-op); cwd persists across the local-tool harness's Bash tool calls (one chained `cd` affects subsequent calls — silent wrong-repo trap); doc-rule → harness hook escalation when a documented HARD RULE leaks twice on the same surface within weeks. **A SCAFFOLDED project's ignore file is a separate surface from the framework's own, and it was empty until 0.6.9 (#B258/#B291):** `core/template/_gitignore` shipped in every tarball with ZERO consumers — the `_`→`.` rename its prefix implies was never implemented — so `gina project:add` produced projects with no `.gitignore` at all, and the `.env`/`.env.*`/`*.env` globs that template carries protected nothing. `project:add` now copies it to `<project>/.gitignore` **only when the project has none** (skip-if-exists, so a user's own file is never replaced or appended to, and re-running the command is a no-op), mode `0o644`, and never fatally: a template read failure warns and the scaffold still completes. It deliberately does NOT reuse `lib.generator.createFileFromTemplateSync`, which unlinks the target first (so it cannot express skip-if-exists) and chmods `0755`. Note the glob shape is the point — a bare `.env` matches neither `secrets.env` nor `.env.production`, which is why all three forms plus the `!.env.example` negations ship together.
@@ -916,11 +917,11 @@ Dev-mode query instrumentation captures every database query tied to the current
916
917
 
917
918
  151. **`templates.json` pre-process pass in `core/config.js` (right after the `hasViews` line, BEFORE the routing↔template GET auto-vivify) expands two additive section-key shapes once per bundle — gina-io/gina#8 comma-separated keys + #10 `_common.config`.** #8: a comma-separated section key (`"a, b": {…}`) is split on `/\s*,\s*/` (each name `.trim()`med, empty segments skipped) and the block is replicated under each named section, MERGING into any section that already exists so a section's own keys win — `merge(existing, JSON.clone(block))`, and `lib/merge` keeps its FIRST argument on a leaf collision (`override=false` default). #10: an optional `_common.config` block is `merge(_common, _common.config)`-flattened back into `_common` then deleted, so the existing `_common.*` read sites are unchanged and a direct `_common.X` overrides `_common.config.X`. **Placement is load-bearing:** the pass must run before the GET auto-vivify `files['templates'][rule.toLowerCase()] = {}` (which keys off CLEAN route names from routing.json) — otherwise a comma key leaves the real route names "missing" → empty `{}` sections get minted AND the comma key survives to produce a dead `"a, b@bundle"` route. **Both are no-ops when absent** (no comma → single-element split → identical; no `_common.config` → untouched), so existing bundles are byte-identical — verified zero comma keys across known consumer + gina fixture `templates.json` before shipping. **#7 (a Swig-like `{ "inherit": … }` directive) was closed un-shipped**, so #8 is NOT redundant: `_common` shares to ALL routes, #8 shares a chosen SUBSET — a gap nothing else fills. Collect-then-mutate (gather comma keys first) avoids changing the object mid-`for…in`. Tests: `test/core/config-templates-preprocess.test.js` (source pins incl. placement-before-auto-vivify + a real-`merge` pure-logic replica: split / union-merge / own-keys-win / trim / empty-segment / flatten / no-op). Established 2026-06-05.
918
919
 
919
- 174. **`throwError` — call signatures, status-code preservation, render-error interception, and fail-closed error-response `stack` hygiene** (replaces individual entries #10, #21, #26, #105, #110, #132) — `self.throwError` accepts four shapes: `(errorObj|Error)` 1-arg; `(code, Error|string)` 2-arg, dispatched via a function-top normalization shift that preserves the explicit code (`throwError(404, new Error('not found'))` sends 404 — earlier releases fell back to 500, and the `new Error(...)` wrapping workaround from that era still works unchanged); `(code, errorObj)` 2-arg, intentionally NOT shifted — it flows through the `arguments.length < 3` branch, which reaches the explicit code — that branch passes an OBJECT through untouched (so the errorObj resolution inside the JSON branch below can unpack it; collapsing it there silently disables the #CE1 transient-503 upgrade, measured) and validates a SCALAR, and including errorObj in the shift detection would break exactly that case; `(res, code, string|Error)` 3-arg (explicit code preserved). Always `return self.throwError(...)` immediately in a controller action — it is a terminal response, and any later response-API call runs against a released response (guarded to warn + no-op since 0.5.1-alpha.2 instead of crashing the bundle). Calling `self.render(err)` with a non-2xx `data.page.data.status` and a defined `data.page.data.error` is intercepted before template rendering and routed through `throwError` automatically; object-valued `error`/`message` fields are normalised to strings first (no `[object Object]`), and the same normalized string feeds the server-side `console.error('[render] ...')` log, so wire and log carry identical readable text. Error responses are scope-gated FAIL-CLOSED: unless `NODE_SCOPE_IS_LOCAL` is explicitly `true`, the server-side `stack` is stripped from BOTH the JSON error body AND the fallback HTML error page's `<pre class="stack">` block — the gate is strip-unless-local, not strip-only-if-prod, so an unset scope on a fresh deployment still strips and internals (file paths, frames, library versions) never leak; local scope keeps the stack on the wire for the dev toolbar's data-xhr panel. Custom error templates remain consumer-owned (a view rendering the error object's `stack` is the consumer's call). A stack passed AS the message gets the same scope treatment since #B670 (0.7.0): `throwError(res, 500, err.stack)`, `throwError(500, err.stack)`, or an errorObj whose `title` / `message` / `error` holds one used to reach the client whole — in `message` (or `error` for the 1-arg errorObj), and on the fallback HTML page — because only the `stack` FIELD was stripped. Outside local scope such a value now keeps its first line on the JSON body, the fallback page and the data a custom error page receives (`page.data`, `req.params.errorObject`); the detector is #B131's `/\n\s+at\s/`, so a human message with a line starting `at ` is cut too, nested objects are not walked, and text built from raw V8 CallSite objects (the `__stack` global, whose `toString()` carries no `at ` prefix) is NOT recognised — write such frames V8-style (`' at ' + callSite`), as `redirect()` now does. The full text goes to the #ERRREF line, which on the HTML path now logs the error's own text instead of the throwError CALLSITE and also fires for the `(res, code, errorObj)` shape (it emitted no line for that ref before). A caller's `msg` object is cut on a copy; an errorObj the build merges into (`lib/merge` returns its first argument) is edited in place, as the `stack` strip already did. Local scope is unchanged on the wire. **The sibling `renderJSON()` status surface had an HTTP/2-only hole (#B172, fixed 2026-07-30): the `status` key in the payload correctly resolves onto `response.statusCode`, but the HTTP/2 body path hand-builds its header frame for the raw `stream.respond()` and hardcoded `':status': 200` — and the pending-header merge below can never supply the real code, since `setHeader(':status', …)` throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED — so every `renderJSON({status: 4xx})` on a genuine HTTP/2 stream was served as 200 with the error payload in the body (HTTP/1.1, the HEAD branch, and the HTML delegates were already correct). Fixed to `':status': response.statusCode || 200`, matching the HEAD branch. The `errno` half of the status branch is now guarded like the swig/v1 delegates (enter only when `statusCodes[jsonObj.status]` is defined): an `errno`-only payload used to assign `statusCode = undefined`, which HTTP/1.1's `response.end()` rejects with the throw swallowed by an empty catch (the response was never sent — the client hangs) and HTTP/2's compat setter rejects at assignment (→ 500); guarded, such a payload is served as a normal 200 with the payload in the body — measured wire-identical to dropping the errno clause on every input. Rule: any hand-built HTTP/2 header frame must carry `response.statusCode || 200`, never a literal — the compat layer's pending-header merge structurally cannot repair a pseudo-header. Tests: `render-json.test.js §07-§08` (extract-and-execute on the real branch bytes).** **Body field semantics — which key carries the human sentence (docs-corrected 2026-07-30): on a known status code `error` carries the STATUS TEXT (`statusCodes[code]`) and the caller's text lands in `message` (the Error argument's `.message`, or the trailing string), so a client wanting the sentence reads `message`, NOT `error`; only for an unknown status code (warned) can the caller's text land in `error` instead. The sibling `ApiError` + `renderJSON` path cannot carry a message on the wire AT ALL: the server-error forms (`new ApiError(msg)`, `new ApiError(msg, code)`) return a real `Error` whose `message` is a non-enumerable own property that `renderJSON`'s single `JSON.stringify` skips (readable server-side, absent from the body — and reassigning `e.message` does NOT make it enumerable, only `defineProperty` would), while the field forms (`new ApiError(msg, field)`, `new ApiError(msg, field, code)`, the array form) return a PLAIN OBJECT built by `merge({tag,fields,path}, e)`, which copies enumerable properties only — so `message` is absent outright there and the text travels in `fields[field]` (those bodies also carry `tag` + `path`). To place a sentence in the body use `throwError` or hand-build the payload.** **The custom-error-PAGE path had the same inert-status class (#B190, fixed 2026-08-01): `renderCustomError` — the `req.routing.param.error` / `errorFiles` path — never set the response status; the swig and v1 delegates recompute it downstream from `data.page.data.status`, but the nunjucks and both async delegates only read the already-set `res.statusCode || 200` at their write sites, so a configured custom error page was served as HTTP 200 there (live-measured on the same fixture: nunjucks 200 vs swig 500 pre-fix; nunjucks 500 post-fix). Fixed with a guarded stamp in `renderCustomError` immediately before the render dispatch — `res.statusCode = data.status`, gated on `!res.headersSent` + `statusCodes[data.status]` membership, mirroring the swig delegate's own downstream semantics — so every delegate serves the configured page with its real status, and a transient-failure 503 upgrade (#CE1) rides this path with a meaningful `Retry-After`. Tests: `test/core/controller-custom-error-status.test.js` (source pins + behavioral drive of the real bytes via `createTestInstance` with a stubbed `render()`; red-first).** **The same path could DISCARD its resolved template outright (#B191, fixed 2026-08-01, consumer-reported against 0.6.0): `renderCustomError` built its `errOptions` only under the `isLocalOptionResetNeeded` flag, but the server-side throwError twin sets `param.file` WITHOUT that flag (and the flag rode a shared routing object the dispatch mutated per error), so a falsy read left `errOptions` null and the swig delegate fell back to the FAILING route's own `file` — a bare, un-rooted `<file>.html` ENOENT plus a "check the following rule in your routing.json" dump naming a rule that was correct by construction (an upstream outage misread as a routing misconfiguration; the built-in fallback page still served, so end users saw an error page throughout). Fixed threefold: the resolved `param.file` now reaches `errOptions` unconditionally (with `path: null` so the namespace stays ignored — heals nunjucks too, which read the same `localOptions.file`); the controller-side dispatch works on a `JSON.clone` of the injected route instead of mutating `content.routing[<rule>]` (lib/routing `getRoute()` already clones for the server twin, which is also why a clean router dispatch self-rescued); and render-swig's template-not-found diagnostic, when rendering a custom error page, names the custom error template and hands off to throwError's re-entry guard (built-in page, no loop) — never the routing-rule dump. Rule: anything the error dispatch resolves must not depend on optional dispatch flags, and the dispatch must never mutate shared routing state. Tests: `test/core/controller-custom-error-options.test.js` (source pins + real-bytes behavioral for BOTH the file channel and the shared-config non-mutation; red-first validated on pre-fix bytes in a detached worktree).** **Every status the method resolves is VALIDATED before it can reach `writeHead` (#B466, 2026-09-04): an unvalidated status previously reached `res.writeHead()` verbatim, so a payload whose top-level `status` was a domain string (`{status:'draft', error:'…'}` — a field name any page vocabulary may legitimately own) threw `RangeError [ERR_HTTP_INVALID_STATUS_CODE]` and replaced the intended error page with an unhandled 500. The failure was invisible on every SUCCESS render (the swig success path already guarded with a 200 fallback) and fatal only on the error path, i.e. exactly when the error page was needed. The guard is a module-level `_isValidHttpStatus` predicate encoding node's OWN rule — integer 100-999 — deliberately NOT `statusCodes` membership (that table carries a `_comment` key, so a membership test accepts the status `"_comment"`) and NOT `/^\d{3}$/` (it accepts `"099"`, which node rejects; both measured). The `statusCodes` lookup stays as the `[ ApiValidator ]` diagnostic warn — table for diagnosis, range for correctness. A numeric `status` still sets the HTTP status exactly as documented; an invalid one now degrades to a 500 error page instead of throwing. Tests: `test/core/throwerror-status-validation.test.js` (15; red-first 6/13 → green, driven through `createTestInstance` against real bytes).** **The same method's five `writeHead` EGRESS sites had a headers-argument twin (#B467, 2026-09-04): two of them — the MSIE override and the no-`user-agent` else branch — passed the literal string `"content-type"` as node's statusMessage (`writeHead(statusCode, statusMessage, headers)`), so the MIME string landed in the headers slot, while the two sibling branches beside them already used the correct 2-arg object form. Node does NOT throw on this — measured on both transports: over HTTP/1.1 it iterates `Object.keys()` of the MIME STRING, so the response carries ~10-31 junk headers named `"0"`,`"1"`,… , a reason phrase reading `content-type`, and NO content-type at all; over HTTP/2 the headers argument is dropped and a one-time `UnsupportedWarning` (status message unsupported, RFC 7540 §8.1.2.4) is emitted. So the defect is SILENT corruption of the error response, never a crash — nothing appears in logs. Reachability was measured by driving the real method, not inferred: the else branch fires for any caller that sends no `user-agent` header at all, the common shape for machine callers. Both sites now pass an object, and the MSIE branch declares `'text/plain; charset='+ bundleConf.encoding` rather than a bare `text/plain` — every branch here ends at `res.end(JSON.stringify(errorObject))`, whose caller-supplied `error`/`message` text goes out as UTF-8 (`JSON.stringify` does not escape non-ASCII), so a charset-less `text/plain` invites a single-byte guess that corrupts it (`refusé` → `refusé`, measured); this matches the `'text/plain' + '; charset='+ conf.encoding` form the same file already builds on its text-render path. Rule: `writeHead`'s 2nd POSITIONAL argument is a statusMessage, never a header name — headers go in an object. Tests: `test/core/throwerror-writehead-headers.test.js` (13; red-first 6/13 → green; both defective branches driven through `createTestInstance`, both correct siblings pinned as controls, and every source pin validated red against the pre-fix bytes).** **A custom-error render shares the request's template object and re-seeds its per-response accumulators (#B497, 0.6.28):** the render delegates resolve their view config as `errOptions || local.options` and read `localOptions.template.h2Links` (the final-200 `link` preload prefix, render-swig) and `localOptions.template.externalPlugins` (the head) through it, while `getNodeRes()` writes both to `local.options.template`. `renderCustomError` built `errOptions` with `lib/merge`, which grafts `template` as a NEW shallow copy (scalars, empty arrays and arrays of primitives are copied — only arrays of objects are shared, measured), so a custom error page had never carried the config-declared preloads nor — with `javascriptsDeferEnabled: false`, the only mode in which external plugins are injected into the head — its `isExternalPlugin` scripts. Live (isolated prod http/2 boots, pre-fix bytes as the control): an action throwing before render answered 500 with NO `link` header and no plugin tag, and now carries the same 319-byte header and one tag as the 200; a route whose template fails to compile, with the share but WITHOUT the re-seed, carried every preload twice and the plugin tag four times (the head-injection loop inserts the whole array once per entry — #B501), and once each with the fix. Since 0.6.28 `renderCustomError` points `errOptions.template` at `local.options.template` and re-seeds `h2Links = ''` (only where the router seeded it, i.e. http/2) and `externalPlugins = []` before dispatching: a custom-error render is a SECOND top-level render on the same response, and an error struck after the failing route's `setResources()` ran (a template compilation error) would otherwise `+=` a second copy of every preload and splice every plugin twice. (On a route whose template fails to compile, two 103 responses go out — one per render; measured identically on the pre-fix bytes, so this fix neither introduces nor changes it.) Scope as measured: the only live 200-header reader of `h2Links` is render-swig (`render-v1` is unreachable from `render()`; nunjucks and the async delegates emit no final-200 `link` header and rely on the 103). Test: `test/core/controller-custom-error-preload.test.js` (16 arms; 10 red-first against the pre-fix bytes). **The `helpers/context.js` twin answered the WRONG REQUEST under concurrency (#B534, 0.6.30).** That twin — the one behind the implicit-global `getConfig()` / `getLib()`, reachable from request code whenever either throws — resolved its response through `getContext('router')`, a PROCESS-WIDE slot `core/router.js` fills on every routed request and NEVER clears (1 writer, 1 reader, measured). So with request A in flight and B routed behind it, A's callback resuming after an await read B's response, wrote A's failure to B's client and returned — leaving A unanswered until the caller's timeout, which presents as an upstream fault rather than a render error. The #ERRREF pairing line made diagnosis WORSE, not better: it derived `_req` from `res.req`, i.e. from that same wrong response, so it printed B's request id beside A's incident ref — a confidently mislabelled correlation line, which is worse than an absent one, since the ref exists precisely to correlate. Fixed by preferring the per-request `process.gina._reqALS` store (`server.js handle()` establishes it for EVERY request on both engines and it propagates across `await`; the store literal now carries `req`/`res`/`next` alongside `requestId`/`startMs`/`proxy` — purely additive, nothing in the tree enumerates an ALS store and `_renderALS` already carried a `req`/`res` pair), with `res` and `next` taken from the SAME source so a store response is never paired with the slot's stale `next`, and `_req` re-derived alongside `res`. The slot REMAINS as the fallback for a caller with no store at all — boot, CLI, cron, worker — which is what it was for; ⚠️ that fallback still answers a stranger for a boot-armed timer invoking application code (`lib/cron` calls arbitrary bundle code and `getConfig`'s catch writes the wire then returns normally, so the cron's own try/catch never sees it), tracked separately as the residual. Rule: a process-wide slot holding per-request state is a concurrency bug waiting for its second request — resolve per-request state from the request's own ALS store and keep the global only for callers that genuinely have none. Server-side only, NO dist rebuild. Test: `test/core/context-throwerror-request-scope-b534.test.js` (10; a two-interleaved-context drive plus a frozen PRE-FIX copy through the SAME scene as the subtract control, which must still answer B and mislabel the line — without that arm the passing assertions could not be distinguished from a harness that never reproduced the defect). **Every value the built-in error pages render is HTML-ESCAPED (#B554, 2026-09-18 — SECURITY; reflected XSS in every released version).** `redirect()` USED TO treat a request-supplied `error` key as an instruction to `throwError` (in `SuperController::redirect`, reading `req[method]` i.e. the parsed query/body), so `GET /<any-route-whose-action-redirects>?error=<img src=x onerror=…>` answered a 500 whose body carried the tag RAW and executed it in the application's origin — DRIVEN on a booted bundle (`content-type: text/html`, payload present, `&lt;img` absent) and **not scope-gated**: only the two `stack` blocks carry `_isLocalScope`, which is the control for its absence on the others. ⚠️ **A 1-arg call whose PAYLOAD is FALSY is not a late call (#B560, 2026-09-18, own commit).** The `#B44` guard bails on `!res`, written for the 2-arg/3-arg shapes where `res` IS a possibly-released `local.res`; but in the 1-arg `throwError(err)` shape that slot holds the CALLER'S payload, so `''`, `null`, `undefined`, `0` and `false` were all read as late calls and SWALLOWED — no writeHead, no end, request never answered, and the only trace a warning claiming the response had been released followed by NO error text (its interpolation reads `msg`/`code`, never `res`). Reachable from CONSUMER code, not just the framework: an app calling `self.throwError(err)` with a falsy err hung that request. Fixed by normalising a falsy 1-arg payload to a minimal errorObj BEFORE the guard. ⛔ Do NOT 'simplify' that to `arguments.length !== 1` on the bail itself: the errorObject build derefs `res.error`/`res.message` unguarded, so letting a null through turns a silent hang into a TypeError crash. Genuine 2-arg/3-arg late calls and 0-arg calls still bail unchanged; measured red-first, `''`/`null`/`0` went `ret=false`+no-egress → `writeHead:500`+`end`, with a non-empty payload egressing on BOTH sides as the control. Twins measured UNAFFECTED: `core/server.js`'s has no bare `!res` bail, and `helpers/context.js`'s shifts by ARITY, never truthiness. **That gate is now DELETED (#B559, 2026-09-18 — SECURITY, present in every released version back to `v0.1.6`):** a request parameter must not steer control flow. `req[method]` is CLIENT-populated (`server.js` assigns `request.get = request.query`, and `request.post` from the parsed body), so ANY unauthenticated request to ANY route whose action redirects could force a 500 by adding the key — both engines, no route opt-in — and an EMPTY value was worse: the empty string is falsy, so throwError's `#B44` late-call guard mistook it for a call on an already-released response, logged `ignoring late error:` with NOTHING after the colon, and returned with NO egress, leaving the request UNANSWERED for as long as the client held the socket; nothing reaps that (`server.timeout` defaults to 0, deliberately, so SSE/WS are not cut off), and the hang limb needs that guard so it dates from `v0.5.3`. DRIVEN live before/after on a booted bundle, every arm paired with a control that had to keep its pre-fix value: GET `?error=X` 500→302, GET `?error=` HANG→302, POST body `error=X` 500→303, POST body `error=` HANG→303, while plain GET stayed 302 and plain POST stayed 303. The key is now an ordinary parameter and rides `inheritedData` like every other one — which is what the published `controller.md` "carrying request data across the redirect" example always documented; that example could NOT work as written while the gate stood, since it assigns the key on the request and redirects one line later. Origin: no design — the condition was written in 2021 to `delete` the key so it would not ride the target URL, was removed, and came back in 2022 with a `throwError` where the `delete` had been, inside an unrelated omnibus commit with no comment. ⚠️ Cite this by SYMBOL, never by line: the original `controller.js:3540-3543` citation rotted ~33 lines inside a single release and misdirected a later session. Reach is narrower than "any URL" and was measured: the route must EXIST and its action must call `redirect()` (a 404, a different query key, and a non-redirecting 200 route all reflect nothing), but a newly scaffolded bundle declares no `errorFiles` (`core/template/boilerplate/bundle/config/templates.json`) so it hits precisely this inline page. Fixed by escaping at the emission point — `& < > " '`, null/undefined ⇒ `''` — across ALL SEVEN `msgString` sites in `controller.js` (the five non-stack plus both gated `stack` blocks, gates untouched), BOTH transports of the engine-attached twin in `core/server.js` (whose `msg` its 404/403/500 callers build from `req.url` / `headers[':path']` — raw for any client that can send a literal `<`, which a browser cannot in a URL, so that half is hardening rather than a driven vector), and the THREE error-document fallbacks in `controller.render-nunjucks.js` (`_absErrTemplate`, `readErr.message`, `renderErr.message`, plus each `<title>` status, which #B466 showed can be a caller-supplied value). Three byte-identical `_escapeHtml` local copies, the deliberate `_mintErrorRef` duplication discipline (controller.js is evicted per request in dev, so a shared home would churn), pinned identical by the test. **Consumer-visible behaviour change:** an application that deliberately passed HTML in an error title/message now sees it escaped — that reflection WAS the vulnerability, so there is no opt-out; of 196 framework-side `throwError(` call sites in `core/`, zero pass markup (needle validated against a synthetic positive). ⚠️ A consumer custom error template rendering `{{ data.message }}` remains a SEPARATE unescaped sink, because `settings.swig.autoescape` defaults to false — consumer-owned, unchanged by this fix. Server-side only, NO dist rebuild (measured: none of the three files appears in the browser `build.json`, controls `lib/merge`/`lib/routing` at 1 each; pickup is a bundle restart). Tests: `test/core/throwerror-html-escape-b554.test.js` (14 arms — source pins across all three files + the helper executed as shipped bytes + the page DRIVEN through `createTestInstance`; red-first 11/3). Two authoring traps this paid for, both documented shapes firing on the fix's OWN comments: the replace-code `// was:` lines reproduced the `\n\nref '+ ref +'` tail that `error-ref.test.js` counts over raw source expecting exactly 2 (own-comment trap ⇒ the comments elide the unchanged tail), and a 5-line note pushed two message literals outside `render-engine-dispatch.test.js`' 700/500-char distance windows (⇒ the in-window notes are one line each).
920
+ 174. **`throwError` — call signatures, status-code preservation, render-error interception, and fail-closed error-response `stack` hygiene** (replaces individual entries #10, #21, #26, #105, #110, #132) — `self.throwError` accepts four shapes: `(errorObj|Error)` 1-arg; `(code, Error|string)` 2-arg, dispatched via a function-top normalization shift that preserves the explicit code (`throwError(404, new Error('not found'))` sends 404 — earlier releases fell back to 500, and the `new Error(...)` wrapping workaround from that era still works unchanged); `(code, errorObj)` 2-arg, intentionally NOT shifted — it flows through the `arguments.length < 3` branch, which reaches the explicit code — that branch passes an OBJECT through untouched (so the errorObj resolution inside the JSON branch below can unpack it; collapsing it there silently disables the #CE1 transient-503 upgrade, measured) and validates a SCALAR, and including errorObj in the shift detection would break exactly that case; `(res, code, string|Error)` 3-arg (explicit code preserved). Always `return self.throwError(...)` immediately in a controller action — it is a terminal response, and any later response-API call runs against a released response (guarded to warn + no-op since 0.5.1-alpha.2 instead of crashing the bundle). Calling `self.render(err)` with a non-2xx `data.page.data.status` and a defined `data.page.data.error` is intercepted before template rendering and routed through `throwError` automatically; object-valued `error`/`message` fields are normalised to strings first (no `[object Object]`), and the same normalized string feeds the server-side `console.error('[render] ...')` log, so wire and log carry identical readable text. Error responses are scope-gated FAIL-CLOSED: unless `NODE_SCOPE_IS_LOCAL` is explicitly `true`, the server-side `stack` is stripped from BOTH the JSON error body AND the fallback HTML error page's `<pre class="stack">` block — the gate is strip-unless-local, not strip-only-if-prod, so an unset scope on a fresh deployment still strips and internals (file paths, frames, library versions) never leak; local scope keeps the stack on the wire for the dev toolbar's data-xhr panel. Custom error templates remain consumer-owned (a view rendering the error object's `stack` is the consumer's call). A stack passed AS the message gets the same scope treatment since #B670 (0.7.0): `throwError(res, 500, err.stack)`, `throwError(500, err.stack)`, or an errorObj whose `title` / `message` / `error` holds one used to reach the client whole — in `message` (or `error` for the 1-arg errorObj), and on the fallback HTML page — because only the `stack` FIELD was stripped. Outside local scope such a value now keeps its first line on the JSON body, the fallback page and the data a custom error page receives (`page.data`, `req.params.errorObject`); the detector is #B131's `/\n\s+at\s/`, so a human message with a line starting `at ` is cut too, nested objects are not walked, and text built from raw V8 CallSite objects (the `__stack` global, whose `toString()` carries no `at ` prefix) is NOT recognised — write such frames V8-style (`' at ' + callSite`), as `redirect()` now does. The full text goes to the #ERRREF line, which on the HTML path now logs the error's own text instead of the throwError CALLSITE and also fires for the `(res, code, errorObj)` shape (it emitted no line for that ref before). A caller's `msg` object is cut on a copy; an errorObj the build merges into (`lib/merge` returns its first argument) is edited in place, as the `stack` strip already did. Local scope is unchanged on the wire. **The sibling `renderJSON()` status surface had an HTTP/2-only hole (#B172, fixed 2026-07-30): the `status` key in the payload correctly resolves onto `response.statusCode`, but the HTTP/2 body path hand-builds its header frame for the raw `stream.respond()` and hardcoded `':status': 200` — and the pending-header merge below can never supply the real code, since `setHeader(':status', …)` throws ERR_HTTP2_PSEUDOHEADER_NOT_ALLOWED — so every `renderJSON({status: 4xx})` on a genuine HTTP/2 stream was served as 200 with the error payload in the body (HTTP/1.1, the HEAD branch, and the HTML delegates were already correct). Fixed to `':status': response.statusCode || 200`, matching the HEAD branch. The `errno` half of the status branch is now guarded like the swig/v1 delegates (enter only when `statusCodes[jsonObj.status]` is defined): an `errno`-only payload used to assign `statusCode = undefined`, which HTTP/1.1's `response.end()` rejects with the throw swallowed by an empty catch (the response was never sent — the client hangs) and HTTP/2's compat setter rejects at assignment (→ 500); guarded, such a payload is served as a normal 200 with the payload in the body — measured wire-identical to dropping the errno clause on every input. Rule: any hand-built HTTP/2 header frame must carry `response.statusCode || 200`, never a literal — the compat layer's pending-header merge structurally cannot repair a pseudo-header. Tests: `render-json.test.js §07-§08` (extract-and-execute on the real branch bytes).** **#B749 (fixed for 0.7.2) — the converse: a raw HTTP/2 answer must also give the compat response the code its frame carries.** `stream.respond()` never touches `res.statusCode`, and the #B562 shim swallows `writeHead(code)`, so this server-level throwError's HTTP/2 errors reached the metrics `finish` hook as `200` (`route="__no_route__"`) while the client got the real code. Both raw send helpers (`__ginaSendErrJSON`, `__ginaSendErrHTML`) now run `try { res.statusCode = code; } catch (e) {}` before the respond: the setter throws on an invalid code (undefined among them) that `respond()` still sends as 200, and the send must not depend on it. The same class outside throwError (16 raw isaac answers, 3 more in server.js) is staked as #B755, unfixed. Tests: `test/core/h2-raw-send-b749-b750.test.js §03`. **Body field semantics — which key carries the human sentence (docs-corrected 2026-07-30): on a known status code `error` carries the STATUS TEXT (`statusCodes[code]`) and the caller's text lands in `message` (the Error argument's `.message`, or the trailing string), so a client wanting the sentence reads `message`, NOT `error`; only for an unknown status code (warned) can the caller's text land in `error` instead. The sibling `ApiError` + `renderJSON` path cannot carry a message on the wire AT ALL: the server-error forms (`new ApiError(msg)`, `new ApiError(msg, code)`) return a real `Error` whose `message` is a non-enumerable own property that `renderJSON`'s single `JSON.stringify` skips (readable server-side, absent from the body — and reassigning `e.message` does NOT make it enumerable, only `defineProperty` would), while the field forms (`new ApiError(msg, field)`, `new ApiError(msg, field, code)`, the array form) return a PLAIN OBJECT built by `merge({tag,fields,path}, e)`, which copies enumerable properties only — so `message` is absent outright there and the text travels in `fields[field]` (those bodies also carry `tag` + `path`). To place a sentence in the body use `throwError` or hand-build the payload.** **The custom-error-PAGE path had the same inert-status class (#B190, fixed 2026-08-01): `renderCustomError` — the `req.routing.param.error` / `errorFiles` path — never set the response status; the swig and v1 delegates recompute it downstream from `data.page.data.status`, but the nunjucks and both async delegates only read the already-set `res.statusCode || 200` at their write sites, so a configured custom error page was served as HTTP 200 there (live-measured on the same fixture: nunjucks 200 vs swig 500 pre-fix; nunjucks 500 post-fix). Fixed with a guarded stamp in `renderCustomError` immediately before the render dispatch — `res.statusCode = data.status`, gated on `!res.headersSent` + `statusCodes[data.status]` membership, mirroring the swig delegate's own downstream semantics — so every delegate serves the configured page with its real status, and a transient-failure 503 upgrade (#CE1) rides this path with a meaningful `Retry-After`. Tests: `test/core/controller-custom-error-status.test.js` (source pins + behavioral drive of the real bytes via `createTestInstance` with a stubbed `render()`; red-first).** **The same path could DISCARD its resolved template outright (#B191, fixed 2026-08-01, consumer-reported against 0.6.0): `renderCustomError` built its `errOptions` only under the `isLocalOptionResetNeeded` flag, but the server-side throwError twin sets `param.file` WITHOUT that flag (and the flag rode a shared routing object the dispatch mutated per error), so a falsy read left `errOptions` null and the swig delegate fell back to the FAILING route's own `file` — a bare, un-rooted `<file>.html` ENOENT plus a "check the following rule in your routing.json" dump naming a rule that was correct by construction (an upstream outage misread as a routing misconfiguration; the built-in fallback page still served, so end users saw an error page throughout). Fixed threefold: the resolved `param.file` now reaches `errOptions` unconditionally (with `path: null` so the namespace stays ignored — heals nunjucks too, which read the same `localOptions.file`); the controller-side dispatch works on a `JSON.clone` of the injected route instead of mutating `content.routing[<rule>]` (lib/routing `getRoute()` already clones for the server twin, which is also why a clean router dispatch self-rescued); and render-swig's template-not-found diagnostic, when rendering a custom error page, names the custom error template and hands off to throwError's re-entry guard (built-in page, no loop) — never the routing-rule dump. Rule: anything the error dispatch resolves must not depend on optional dispatch flags, and the dispatch must never mutate shared routing state. Tests: `test/core/controller-custom-error-options.test.js` (source pins + real-bytes behavioral for BOTH the file channel and the shared-config non-mutation; red-first validated on pre-fix bytes in a detached worktree).** **Every status the method resolves is VALIDATED before it can reach `writeHead` (#B466, 2026-09-04): an unvalidated status previously reached `res.writeHead()` verbatim, so a payload whose top-level `status` was a domain string (`{status:'draft', error:'…'}` — a field name any page vocabulary may legitimately own) threw `RangeError [ERR_HTTP_INVALID_STATUS_CODE]` and replaced the intended error page with an unhandled 500. The failure was invisible on every SUCCESS render (the swig success path already guarded with a 200 fallback) and fatal only on the error path, i.e. exactly when the error page was needed. The guard is a module-level `_isValidHttpStatus` predicate encoding node's OWN rule — integer 100-999 — deliberately NOT `statusCodes` membership (that table carries a `_comment` key, so a membership test accepts the status `"_comment"`) and NOT `/^\d{3}$/` (it accepts `"099"`, which node rejects; both measured). The `statusCodes` lookup stays as the `[ ApiValidator ]` diagnostic warn — table for diagnosis, range for correctness. A numeric `status` still sets the HTTP status exactly as documented; an invalid one now degrades to a 500 error page instead of throwing. Tests: `test/core/throwerror-status-validation.test.js` (15; red-first 6/13 → green, driven through `createTestInstance` against real bytes).** **The same method's five `writeHead` EGRESS sites had a headers-argument twin (#B467, 2026-09-04): two of them — the MSIE override and the no-`user-agent` else branch — passed the literal string `"content-type"` as node's statusMessage (`writeHead(statusCode, statusMessage, headers)`), so the MIME string landed in the headers slot, while the two sibling branches beside them already used the correct 2-arg object form. Node does NOT throw on this — measured on both transports: over HTTP/1.1 it iterates `Object.keys()` of the MIME STRING, so the response carries ~10-31 junk headers named `"0"`,`"1"`,… , a reason phrase reading `content-type`, and NO content-type at all; over HTTP/2 the headers argument is dropped and a one-time `UnsupportedWarning` (status message unsupported, RFC 7540 §8.1.2.4) is emitted. So the defect is SILENT corruption of the error response, never a crash — nothing appears in logs. Reachability was measured by driving the real method, not inferred: the else branch fires for any caller that sends no `user-agent` header at all, the common shape for machine callers. Both sites now pass an object, and the MSIE branch declares `'text/plain; charset='+ bundleConf.encoding` rather than a bare `text/plain` — every branch here ends at `res.end(JSON.stringify(errorObject))`, whose caller-supplied `error`/`message` text goes out as UTF-8 (`JSON.stringify` does not escape non-ASCII), so a charset-less `text/plain` invites a single-byte guess that corrupts it (`refusé` → `refusé`, measured); this matches the `'text/plain' + '; charset='+ conf.encoding` form the same file already builds on its text-render path. Rule: `writeHead`'s 2nd POSITIONAL argument is a statusMessage, never a header name — headers go in an object. Tests: `test/core/throwerror-writehead-headers.test.js` (13; red-first 6/13 → green; both defective branches driven through `createTestInstance`, both correct siblings pinned as controls, and every source pin validated red against the pre-fix bytes).** **A custom-error render shares the request's template object and re-seeds its per-response accumulators (#B497, 0.6.28):** the render delegates resolve their view config as `errOptions || local.options` and read `localOptions.template.h2Links` (the final-200 `link` preload prefix, render-swig) and `localOptions.template.externalPlugins` (the head) through it, while `getNodeRes()` writes both to `local.options.template`. `renderCustomError` built `errOptions` with `lib/merge`, which grafts `template` as a NEW shallow copy (scalars, empty arrays and arrays of primitives are copied — only arrays of objects are shared, measured), so a custom error page had never carried the config-declared preloads nor — with `javascriptsDeferEnabled: false`, the only mode in which external plugins are injected into the head — its `isExternalPlugin` scripts. Live (isolated prod http/2 boots, pre-fix bytes as the control): an action throwing before render answered 500 with NO `link` header and no plugin tag, and now carries the same 319-byte header and one tag as the 200; a route whose template fails to compile, with the share but WITHOUT the re-seed, carried every preload twice and the plugin tag four times (the head-injection loop inserts the whole array once per entry — #B501), and once each with the fix. Since 0.6.28 `renderCustomError` points `errOptions.template` at `local.options.template` and re-seeds `h2Links = ''` (only where the router seeded it, i.e. http/2) and `externalPlugins = []` before dispatching: a custom-error render is a SECOND top-level render on the same response, and an error struck after the failing route's `setResources()` ran (a template compilation error) would otherwise `+=` a second copy of every preload and splice every plugin twice. (On a route whose template fails to compile, two 103 responses go out — one per render; measured identically on the pre-fix bytes, so this fix neither introduces nor changes it.) Scope as measured: the only live 200-header reader of `h2Links` is render-swig (`render-v1` is unreachable from `render()`; nunjucks and the async delegates emit no final-200 `link` header and rely on the 103). Test: `test/core/controller-custom-error-preload.test.js` (16 arms; 10 red-first against the pre-fix bytes). **The `helpers/context.js` twin answered the WRONG REQUEST under concurrency (#B534, 0.6.30).** That twin — the one behind the implicit-global `getConfig()` / `getLib()`, reachable from request code whenever either throws — resolved its response through `getContext('router')`, a PROCESS-WIDE slot `core/router.js` fills on every routed request and NEVER clears (1 writer, 1 reader, measured). So with request A in flight and B routed behind it, A's callback resuming after an await read B's response, wrote A's failure to B's client and returned — leaving A unanswered until the caller's timeout, which presents as an upstream fault rather than a render error. The #ERRREF pairing line made diagnosis WORSE, not better: it derived `_req` from `res.req`, i.e. from that same wrong response, so it printed B's request id beside A's incident ref — a confidently mislabelled correlation line, which is worse than an absent one, since the ref exists precisely to correlate. Fixed by preferring the per-request `process.gina._reqALS` store (`server.js handle()` establishes it for EVERY request on both engines and it propagates across `await`; the store literal now carries `req`/`res`/`next` alongside `requestId`/`startMs`/`proxy` — purely additive, nothing in the tree enumerates an ALS store and `_renderALS` already carried a `req`/`res` pair), with `res` and `next` taken from the SAME source so a store response is never paired with the slot's stale `next`, and `_req` re-derived alongside `res`. The slot REMAINS as the fallback for a caller with no store at all — boot, CLI, cron, worker — which is what it was for; ⚠️ that fallback still answers a stranger for a boot-armed timer invoking application code (`lib/cron` calls arbitrary bundle code and `getConfig`'s catch writes the wire then returns normally, so the cron's own try/catch never sees it), tracked separately as the residual. Rule: a process-wide slot holding per-request state is a concurrency bug waiting for its second request — resolve per-request state from the request's own ALS store and keep the global only for callers that genuinely have none. Server-side only, NO dist rebuild. Test: `test/core/context-throwerror-request-scope-b534.test.js` (10; a two-interleaved-context drive plus a frozen PRE-FIX copy through the SAME scene as the subtract control, which must still answer B and mislabel the line — without that arm the passing assertions could not be distinguished from a harness that never reproduced the defect). **Every value the built-in error pages render is HTML-ESCAPED (#B554, 2026-09-18 — SECURITY; reflected XSS in every released version).** `redirect()` USED TO treat a request-supplied `error` key as an instruction to `throwError` (in `SuperController::redirect`, reading `req[method]` i.e. the parsed query/body), so `GET /<any-route-whose-action-redirects>?error=<img src=x onerror=…>` answered a 500 whose body carried the tag RAW and executed it in the application's origin — DRIVEN on a booted bundle (`content-type: text/html`, payload present, `&lt;img` absent) and **not scope-gated**: only the two `stack` blocks carry `_isLocalScope`, which is the control for its absence on the others. ⚠️ **A 1-arg call whose PAYLOAD is FALSY is not a late call (#B560, 2026-09-18, own commit).** The `#B44` guard bails on `!res`, written for the 2-arg/3-arg shapes where `res` IS a possibly-released `local.res`; but in the 1-arg `throwError(err)` shape that slot holds the CALLER'S payload, so `''`, `null`, `undefined`, `0` and `false` were all read as late calls and SWALLOWED — no writeHead, no end, request never answered, and the only trace a warning claiming the response had been released followed by NO error text (its interpolation reads `msg`/`code`, never `res`). Reachable from CONSUMER code, not just the framework: an app calling `self.throwError(err)` with a falsy err hung that request. Fixed by normalising a falsy 1-arg payload to a minimal errorObj BEFORE the guard. ⛔ Do NOT 'simplify' that to `arguments.length !== 1` on the bail itself: the errorObject build derefs `res.error`/`res.message` unguarded, so letting a null through turns a silent hang into a TypeError crash. Genuine 2-arg/3-arg late calls and 0-arg calls still bail unchanged; measured red-first, `''`/`null`/`0` went `ret=false`+no-egress → `writeHead:500`+`end`, with a non-empty payload egressing on BOTH sides as the control. Twins measured UNAFFECTED: `core/server.js`'s has no bare `!res` bail, and `helpers/context.js`'s shifts by ARITY, never truthiness. **That gate is now DELETED (#B559, 2026-09-18 — SECURITY, present in every released version back to `v0.1.6`):** a request parameter must not steer control flow. `req[method]` is CLIENT-populated (`server.js` assigns `request.get = request.query`, and `request.post` from the parsed body), so ANY unauthenticated request to ANY route whose action redirects could force a 500 by adding the key — both engines, no route opt-in — and an EMPTY value was worse: the empty string is falsy, so throwError's `#B44` late-call guard mistook it for a call on an already-released response, logged `ignoring late error:` with NOTHING after the colon, and returned with NO egress, leaving the request UNANSWERED for as long as the client held the socket; nothing reaps that (`server.timeout` defaults to 0, deliberately, so SSE/WS are not cut off), and the hang limb needs that guard so it dates from `v0.5.3`. DRIVEN live before/after on a booted bundle, every arm paired with a control that had to keep its pre-fix value: GET `?error=X` 500→302, GET `?error=` HANG→302, POST body `error=X` 500→303, POST body `error=` HANG→303, while plain GET stayed 302 and plain POST stayed 303. The key is now an ordinary parameter and rides `inheritedData` like every other one — which is what the published `controller.md` "carrying request data across the redirect" example always documented; that example could NOT work as written while the gate stood, since it assigns the key on the request and redirects one line later. Origin: no design — the condition was written in 2021 to `delete` the key so it would not ride the target URL, was removed, and came back in 2022 with a `throwError` where the `delete` had been, inside an unrelated omnibus commit with no comment. ⚠️ Cite this by SYMBOL, never by line: the original `controller.js:3540-3543` citation rotted ~33 lines inside a single release and misdirected a later session. Reach is narrower than "any URL" and was measured: the route must EXIST and its action must call `redirect()` (a 404, a different query key, and a non-redirecting 200 route all reflect nothing), but a newly scaffolded bundle declares no `errorFiles` (`core/template/boilerplate/bundle/config/templates.json`) so it hits precisely this inline page. Fixed by escaping at the emission point — `& < > " '`, null/undefined ⇒ `''` — across ALL SEVEN `msgString` sites in `controller.js` (the five non-stack plus both gated `stack` blocks, gates untouched), BOTH transports of the engine-attached twin in `core/server.js` (whose `msg` its 404/403/500 callers build from `req.url` / `headers[':path']` — raw for any client that can send a literal `<`, which a browser cannot in a URL, so that half is hardening rather than a driven vector), and the THREE error-document fallbacks in `controller.render-nunjucks.js` (`_absErrTemplate`, `readErr.message`, `renderErr.message`, plus each `<title>` status, which #B466 showed can be a caller-supplied value). Three byte-identical `_escapeHtml` local copies, the deliberate `_mintErrorRef` duplication discipline (controller.js is evicted per request in dev, so a shared home would churn), pinned identical by the test. **Consumer-visible behaviour change:** an application that deliberately passed HTML in an error title/message now sees it escaped — that reflection WAS the vulnerability, so there is no opt-out; of 196 framework-side `throwError(` call sites in `core/`, zero pass markup (needle validated against a synthetic positive). ⚠️ A consumer custom error template rendering `{{ data.message }}` remains a SEPARATE unescaped sink, because `settings.swig.autoescape` defaults to false — consumer-owned, unchanged by this fix. Server-side only, NO dist rebuild (measured: none of the three files appears in the browser `build.json`, controls `lib/merge`/`lib/routing` at 1 each; pickup is a bundle restart). Tests: `test/core/throwerror-html-escape-b554.test.js` (14 arms — source pins across all three files + the helper executed as shipped bytes + the page DRIVEN through `createTestInstance`; red-first 11/3). Two authoring traps this paid for, both documented shapes firing on the fix's OWN comments: the replace-code `// was:` lines reproduced the `\n\nref '+ ref +'` tail that `error-ref.test.js` counts over raw source expecting exactly 2 (own-comment trap ⇒ the comments elide the unchanged tail), and a 5-line note pushed two message literals outside `render-engine-dispatch.test.js`' 700/500-char distance windows (⇒ the in-window notes are one line each).
920
921
 
921
922
  175. **Dev-mode hot-reload & module lifecycle — what reloads, what doesn't, and the `require.cache` poisoning antipattern** (replaces individual entries #22, #25, #28, #34, #104) — in dev mode (`NODE_ENV_IS_DEV` set; `isCacheless()` true) the framework hot-reloads code via two functions with DIFFERENT triggers: `refreshCoreDependencies()` (`core/router.js`) evicts and re-requires the controller pair ONLY when the dev watcher has marked a watched file dirty (`__hotReload` flags; watched: `controller.js`, `controller.render-swig.js`, and the bundle's `controllers/` directory; falls back to per-request eviction when the watcher context is absent), while `refreshCore()` (`core/server.isaac.js`, isaac engine only) re-exports core-path modules and re-requires `lib/index.js` + `plugins/index.js` on EVERY request. Consequence for controllers: module-level state (`var store = {}`) resets whenever a watched file changes (not per request) — for in-process state that survives hot-reloads (but resets on `bundle:restart`) attach to `global` (`if (!global.__myStore) global.__myStore = {}; var store = global.__myStore;`); for durable state use a database or file. NOT hot-reloaded (a full `gina bundle:stop` + `bundle:start` or `docker restart` is required): `server.js` / `server.isaac.js` / `server.express.js` (loaded once at process start, never evicted); connector code (`core/connectors/*/index.js`, loaded once via entity registration, outside the refresh scope); and bundle-registered plugin middleware — the `onInitialize` → `app.use(gina.plugins.X(...))` factories run ONCE at bootstrap, so a plugin config change needs a bundle restart in BOTH dev and prod, despite the misleading per-request refresh cue. Correctness invariant for any eviction code: `require.cache[path]` must hold a `Module` instance — `require.cache[path] = require(path)` poisons the slot by storing the bare exports object (no `.exports` key), so the next plain `require()` of that path returns `undefined`, surfacing as `Cannot read properties of undefined (reading '<X>')` after a hot reload; use `delete require.cache[require.resolve(path)]` + the `require()` return value, or swap `require.cache[c].exports = require(path)` on the existing Module — never the bare assignment. **The eviction cycles also leaked the whole module graph (#B32, folds former #173):** Node pushes every cache-miss require's fresh Module onto the REQUIRING module's `children` array and dedupes only on cache hits, so the per-request delete-and-re-require cycles accumulated one dead Module per eviction on long-lived parents — each pinning its entire evaluated exports graph (~1.8 MB post-GC live heap per request on a minimal dev bundle; heap-limit OOM/SIGABRT at ~2400 requests, presenting upstream as HTTP/2 PING timeouts, then ECONNREFUSED, then a supervisor respawn loop that keeps every process cold). Fixed by a `pruneDeadModuleChildren()` sweep at the end of BOTH eviction cycles — `children` is diagnostic metadata (nothing in Node resolution reads it), so pruning never unloads a module still referenced elsewhere; prod was never affected (no eviction + cache-hit dedup). The sweep walks `require.cache` keys ONLY, so a second residual of the same class existed OFF-cache: hot-evicted leaf/singleton libs captured at gen-0 by load-once modules and re-required per request pushed dead children onto the evicted-but-retained gen-0 parent, prune-blind — those libs are plain-`require`d now (never evicted → cache-hit → children deduped), and a completeness audit closed the gen-0-binding class. Rules: any new delete-`require.cache` + re-require cycle in a long-lived process must end with the prune sweep; a hot-evicted lib captured as a gen-0 binding by a load-once module leaks PAST the prune — plain-require leaf/singleton libs that don't need hot reload. **`requireJSON` has NO cache in ANY mode** — every call is `fs.readFileSync` + comment strip + `JSON.parse` (its dev-mode `require.cache` eviction touches a cache it never fills), so never call it per request in bundle code; read a config once at module load or in `onReady`. The framework's own env template (`core/template/conf/env.json`) is parsed once per process and shared read-only by every `Config` instance since #B610 (0.6.33) — before that, `new Config()` (which runs three times per request) re-read it twice per construction, six disk reads per request.
922
923
 
923
- 176. **Inspector & `/_gina/*` built-in endpoints — in-process architecture, admin IP-allowlist, agent-stream auth, live index coverage** (replaces individual entries #31, #32, #38, #111, #134, #138) — the dev Inspector (formerly Beemaster) is a built-in SPA served at `/_gina/inspector/` inside the bundle's own process: no project registration, no separate port, no auto-start spawn; dev-mode only (production bundles never expose it), and same-origin with the monitored bundle so `window.opener.__ginaData` always works. Every `/_gina/*` route (healthcheck, assets, cache/stats, info, inspector, logs, agent, indexes, reveal, instrument, metrics) is a handler in the same HTTP server process; the Isaac engine is the source of truth and may carry fast-paths, but base functionality belongs in the engine-agnostic dispatcher so Express bundles get the same endpoints. Admin-grade endpoints exposing process/cache internals (`/_gina/info` — memory/uptime/version/HTTP-2 session counters; `/_gina/cache/stats` — full cache contents) are IP-allowlisted via the `admin.allowFrom` block in `app.json`: the client IP is read from the socket only (the spoofable `X-Forwarded-For` is never trusted), `::ffff:`-mapped IPv4 is normalised, the list defaults to loopback (`127.0.0.1`, `::1`) when omitted, an empty list denies everyone, and denied callers get a 403 JSON error; `/_gina/health/check` stays deliberately open for liveness probes, and `/_gina/metrics` keeps its own separate `metrics.allowFrom` axis. **#B709 (fixed for 0.7.1) — a loopback entry admits only a DIRECT caller.** The default list is loopback, and loopback is also the address a reverse proxy on the bundle's own host connects from, so the address-only gate admitted every client such a proxy relayed — maintenance on (a site-wide 503), a cache flush, a storage gc, the process and cache state — with no browser and no credential; an edge block of `/_gina/` did not close it either, because the unanchored handlers also matched a nested endpoint path (`/_gina/health/check/_gina/maintenance`, which a PREFIX proxy location forwards), the endpoint path in a query string, another letter case, or `//_gina/…`. Now (1) `lib.admin.isClientAllowed` and `lib.metrics.isClientAllowed` refuse a listed LOOPBACK caller whose request carries a proxy signal — the classifier the maintenance bypass already used (`lib.maintenance.isProxiedRequest`: any `x-forwarded-*` header name, RFC 7239 `Forwarded`, a `true` #B65 stamp, or a port-less `Host`/`:authority` unless `server.proxy.requireForwardedHeaders` is true); a listed NON-loopback address (a proxy on another host) still admits what it relays, and the first refusal per list is logged once per process. (2) That classification is stamped ONCE per `/_gina/*` request from the pristine headers at both engine tops (`request._ginaAdminProxied`, first-seer): isaac rewrites an h1 `Host` port-less before it hands a request to server.js, so a live read there would take every direct caller of a server.js-only handler (`storage/*`) for a proxied one. (3) `info`, `cache/stats`, `cache/clear`, `maintenance` and `metrics` match EXACTLY on `lib.admin.controlPath(url, webroot)` — the query-free path, in the root form or the bundle's own webroot form, nothing else (a query on these endpoints is now ignored, where it used to 404 some of them); `storage/*` and `release/*` stay `^`-anchored on the url and become case-sensitive; `health/check` and the dev/key-gated family are unchanged. Residual, pinned: a proxy that forwards a port-bearing `Host` and adds no forwarding header is byte-identical to a direct client and stays admitted — call the bundle's port directly from the host or the pod, and block `/_gina/` at the edge in any case and anywhere in the path. False positive: a bundle bound directly to port 80/443 receives a port-less Host from every direct client, so set `server.proxy.requireForwardedHeaders: true` there. Pickup = bundle restart, no re-bake. **#B712 (fixed for 0.7.1) — the handlers #B709 left out match the url's PATH.** `health/check`, `jobs/:id`, `instrument` and the dev/key-gated `inspector`, `logs`, `agent`, `indexes`, `reveal` tested the whole url with no start anchor, so a page whose QUERY ended in an endpoint path (`/web/?next=/_gina/health/check`) got the endpoint instead of the page — inside a maintenance window too, where the page's 503 was due — and a WebSocket upgrade to such a url was taken by the agent. Each matcher now starts `^[^?]*` (the endpoint path must end the url's path) and the dev endpoints' `$` became `(?:\?|$)`; prefix tolerance stays (the Inspector puts the opener page's full pathname before `/_gina/`, and a liveness probe may call the health check under the bundle's webroot), as do letter case, methods and gates. The agent WebSocket upgrade listeners test the query-free path, and the Inspector reads its asset path from it. Residual: `/_gina/assets/routing.json` still matches its path at the end of a query string (`/x?y=/_gina/assets/routing.json` gets the routing map); its handler is reworked separately. **#B707 (fixed for 0.7.1) — a GET of the routing table in another letter case no longer ends an isaac bundle.** isaac's fast path tested the url without regard to case, then looked the asset up by the requested spelling through an exact-match `findOne`: `Routing.json` found nothing and the `localAsset.mime` read threw an uncaughtException that ended the process — one unauthenticated request, before routing, with any path prefix. The looked-up name is now lower-cased. A request classified as proxied was served the host-stripped table and escaped it; the express engine serves the table from memory and was not affected. **#B722 (fixed for 0.7.1) — isaac's own event streams answer HTTP/2 clients.** `/_gina/logs`, `/_gina/agent` and `/_gina/release/events` passed `connection: keep-alive` to node's HTTP/2 `stream.respond()`, which throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on any connection-specific header (`connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`, `upgrade`); lib/proc.js logged the throw as a warn and the client never got headers, so an `http/2.0` bundle reached directly over HTTP/2 streamed none of them (0.3.0 through 0.7.0; release events since 0.5.18). The header now goes to HTTP/1.1 only. Reflex: a header set shared by an h1 `writeHead()` and a raw h2 `stream.respond()` must carry no HTTP/1-only header — the compat `setHeader('connection', …)` merely drops it with an `UnsupportedWarning`, while a raw `respond()` throws. Pickup for both: bundle restart, no re-bake. The `/_gina/agent` stream (combined data + log events) is dev-only by default but can be enabled outside dev behind an API key (`settings.json > inspector.agent.{enabled, key}`, `${secret:KEY}`-capable, constant-time compared, fail-closed when no key is configured); browsers pass `?key=` as a query param because `EventSource` and WebSocket handshakes cannot set custom headers — and any `$`-anchored endpoint gate regex must become `(?:\?|$)` the moment its endpoint accepts a query param, or the handler silently stops matching the query'd URL. A time-boxed, separately-keyed production instrumentation window (`POST /_gina/instrument`, hard-capped at one hour) can stream per-request query + flow capture over that authenticated channel — channel AUTH, not redaction, is what protects raw query text (redaction masks only secret-NAMED fields, never the statement or its positional params). The Query tab computes live index coverage client-side, so bundles WITHOUT an `indexes.sql` get a correct "no index for filter" badge on the first render too (cached live-index descriptors are cloned per query before stamping coverage — the cache is shared across queries that filter different columns). The live-index refetch's success re-render lands in `#tree-query` — the container the tab renderer owns and silently bails on when missing — never the scroll wrapper around it (#B222: replacing the wrapper's children destroyed `#tree-query`, so after the first render carrying an `indexes: null` query every later payload — navigation, XHR, the refresh button — froze the Query pane and badge until the Inspector window itself was reloaded; the ⟳ button re-armed the freeze via its forced refetch). Inspector toolbar CSS: native macOS `<select>` ignores `line-height` — use explicit vertical padding, and keep `select` (sans font) and `input` (mono font) on separate CSS rules. **SPA + statusbar hardening (folds former #165/#190/#191/#196):** every per-bundle `/_gina/*` consumer in the SPA derives its base URL via the shared 3-fallback `resolveBundleBase()` (`?target=` → opener pathname → path strip) — a bare `window.location.pathname` strip misroutes to the proxy's default bundle in reverse-proxy multi-bundle setups. A `?target=` value loses its own query string and fragment before `/_gina/…` is appended (`normaliseTarget()`, read by `resolveBundleBase()` and both agent builders; `gina inspector:open` does the same to a positional URL — #B719, fixed for 0.7.1): a pasted page URL (`http://host/page?x=1`) otherwise built `http://host/page?x=1/_gina/agent`, the endpoint path inside the query, which the endpoints ignore since #B712. The Inspector binds to its opener tab via a per-tab `BroadcastChannel` (`?ch=<tabId>`): pages sending COOP `same-origin` sever `window.opener` for the popup, and the bundle-global fallbacks (the shared localStorage slot + the worker-wide agent SSE) reflect whichever render last touched them — a diagnostic channel that must track ONE page needs a per-tab transport (the statusbar publishes that tab's data at its existing write points and answers a request/reply handshake; no `?ch=` keeps the prior behaviour, and `?target=` agent mode keeps priority). Structured-localStorage reads need a SHAPE check on top of the try/catch parse (a tampered key holding a JSON primitive otherwise breaks the un-guarded consumer), and every value interpolated into `innerHTML` goes through an HTML-escape helper — untrusted model/app text renders via `.textContent` instead. Server data serialised into those inline scripts goes through the shared helper `core/controller/inline-script.js` (`safeInlineJson` / `safeInlineString` / `escapeForInlineScript`), applied at every emission site in both renderers (4 swig + 3 nunjucks): `<` → `\u003c` (the earlier literal-`</script>`+`<!--` blocklist was bypassable — per the HTML5 script-data end-tag-name state `</script >`, `</script/>` and `</SCRIPT >` all terminated the block; #B451, 0.6.23), U+2028/U+2029 (JS line terminators that are legal in JSON), and — inside JSON string literals only — `{`/`}` → `\u007b`/`\u007d` (#B463): render-swig splices the `__ginaData`/`__ginaLogs` scripts into the layout BEFORE `swig.compile` (the per-request nonce there is a swig conditional), so unescaped `{{ }}`/`{% %}`/`{# #}` in stored data were evaluated as template source — `A{{ 7*7 }}B` rendered `A49B`, measured on fork 2.0.0/2.4.0/2.8.0; nunjucks splices post-render and was never exposed. Structural braces are never touched (an escape outside a literal is neither JSON nor JS), every value parses back byte-identical; pins: `test/lib/inline-script-xss.test.js` (jsdom, 11 spellings) + `test/lib/inline-script-template-injection.test.js` (renders through the real fork, with a firing control). Both server-side only ⇒ pickup = restart, no re-bake. Dev inline-script splices before `</body>` use FUNCTION replacers: `String.replace(/re/, str)` expands `$`-sequences in a STRING replacement (`` $` ``/`$'`/`$&`), so dynamic content carrying a stray dollar-sequence spliced the whole document into the statusbar `<script>` (SyntaxError → statusbar and launch link vanish) on content-heavy pages while a near-empty smoke page stayed falsely green — any `String.replace(re, dynamicX)` with dynamic content must use a function replacer or escape `$`. Inspector SPA files are copied VERBATIM to dist (no minification) — an inspector-only edit rebuilds only those dist files, never the main bundle artifacts. Bound-window data fidelity (#B225): a preload-consumed popin open sets the dev toolbar's XHR overlay on BOTH consume branches exactly like a cold click (dev-gated `updateToolbar(body)` immediately before each dispatch — in consumePreload, never in the shared dispatcher, which would double-fire the cold path); the statusbar-bound Inspector window's refresh button never reopens the passive `/_gina/agent` stream (`source !== 'broadcast'` on the reopen gate — bound mode takes page-scoped data from the statusbar publisher, and a leaked stream applied bundle-wide payloads over it, blinking the Query badge at every XHR overlay); and every Query-pane re-render (filter/search/show-all plus the live-index refetch success path) derives from the live payload via renderTab('query')'s own preference (reveal swap included) instead of the module query cache, which the empty render path never clears and which survives overlay eras ending while the Query tab is inactive — a refresh no longer resurrects a closed popin's badge count. A `?ch=`-less embedded Inspector (direct URL, bookmark, a window predating bound mode) no longer silently runs those bundle-global channels (#B231): every dev page's statusbar advertises its per-tab channel id in localStorage (`__gina_last_tab_ch`) on each publish, a `?ch=`-less Inspector adopts the most-recently-published tab's channel at boot behind a liveness handshake (the bind's request must be answered by a data frame within 1.5 s — a stale advert from a closed tab tears the bound mode down and falls back to the legacy acquisition, closing the bound-mode `/_gina/logs` stream so log entries are not double-delivered once the passive agent stream attaches), and a footer badge names the active data-source mode (`bound` / `agent` / warn-tinted `global` with a tooltip pointing at the statusbar link) so a degraded bundle-global mode is visible instead of silent. That badge renders from `updateSourceModeBadge()`, which init must call on BOTH acquisition branches (#B306): it previously ran only on the non-agent branch and from `pollData()`, and since the poll timer is deliberately never scheduled under `?target=` (agent data is SSE-pushed), a URL-entered standalone Inspector kept the element's shipped `hidden` attribute and named no mode at all — while the identical `agent` mode DID render when reached through the connect form, whose timer is already ticking when `source` flips. Badging depended on how the window was opened rather than on the mode itself, which is exactly what the badge exists to prevent. The timer stays unscheduled in agent mode on purpose, and a future reader must not "reconcile" that guard to the comment that once claimed otherwise: `pollData()` is NOT a no-op under `source === 'agent'` — it repaints the active tab from cache before returning — so putting it on a timer would repaint every `pollDataMs` and fight scroll position, expanded folds and text selection; the refresh button calls `pollData()` directly and needs no timer at all. The footer memory gauge sits its unfilled track on `--bg3` with a theme-scoped inset groove shadow (#B237) — the track previously shared `.bm-footer`'s own `--bg2` background token, so the empty portion rendered invisible in both themes and a low fill read as a floating green dot; a gauge track must contrast the surface it sits on, and an inset box-shadow paints under the fill child, so the groove shades only the empty region. The Forms tab shows the page's DOM forms enriched with runtime validation state (the statusbar merge writes `u.forms[<id>]` per form), while the whole-bundle forms catalog the server seeds into `user.forms` (`page.forms` = the walked `<bundle>/forms/` directory groups — `rules`, `mocks`, `validators`, open-ended) renders as one collapsed, muted "Bundle catalog" card at the bottom (#B343): catalog keys are recognized by their presence in the pristine `gina.forms` half of the payload (which runtime merges never write), so app-custom group directories demote too, departed runtime forms (a closed popin's form) keep their own card, and a payload with no gina half falls back to the legacy per-key rendering — nothing is ever hidden. **The page whisper excludes `mocks` (#B344, every env):** `page.environment.forms` — the RFC5987-encoded export the client loader parses into `gina.forms` — ships a SHALLOW COPY of the catalog minus the `mocks` group in every environment (dev fixture data has zero client consumers: the client bundle reads `gina.forms.rules` and `gina.forms.validators` only), trimming page weight and keeping the client contract uniform across envs; server-side `conf.forms`/`page.forms` — and therefore this catalog card — keep the full set, and the shared catalog object is never mutated. **The localStorage fallback channel is re-synced by the late-bind patch (#B386, fixed 2026-08-16, shipped in 0.6.11).** `statusbar.html` mirrors `window.__ginaData` into `localStorage.__ginaData` at statusbar-execution time, which is BEFORE the render delegates append their late-bind patch script above `</body>` — and that patch mutates the IN-MEMORY object only. Without a re-sync the mirror keeps the EMIT-TIME payload forever: `metrics.weightBytes` null (null at emit by construction, since the body length is unknown until the render finishes) and the late flow entries absent. Measured live on a dev bundle: mirror `{serverMs:2339, weightBytes:null}` with 17 flow entries against an in-page `{serverMs:2860, weightBytes:628192}` with 21 — the four missing being `swig-compile`, `swig-execute`, `response-write`, `total`. Two visible symptoms, and the asymmetry between them IS the diagnostic: the View tab drops its weight badge (both weight legs falsy) while the load badge survives, because `serverMs` IS set at emit and `weightBytes` is not — so a fallback-channel Inspector shows 2 badges instead of 3, and only until something else re-syncs; and the Flow tab loses its template-compile/execute/response-write/total bars. All three patch sites (render-swig cache-hit and cache-miss, render-nunjucks) now end their patch script by refreshing BOTH channels the Inspector can read, placed AFTER the metrics assignments — ordering is load-bearing, since syncing first would re-mirror the stale payload. **localStorage alone was NOT enough, and that was the first fix's mistake:** it is only the FALLBACK channel, while a statusbar-launched Inspector runs in BOUND mode on a per-tab `BroadcastChannel`. Measured: all SEVEN `_ginaPublish()` call sites sit BEFORE the patch (`publishesAfterPatch: 0`), and a surrogate bound listener held across a real navigation received exactly ONE frame carrying `weightBytes: null` with `serverMs` set — the 2-badges-instead-of-3 symptom, reproduced on the transport itself. The patch now also republishes there, keying the channel from `sessionStorage.__gina_tab_id` (per-tab, race-free) and NEVER from the `__gina_last_tab_ch` localStorage advert, which is last-writer-wins across tabs. The subscriber applies it immediately (`setupBoundChannel`'s `onmessage` → `_bcLatest = payload; pollData()`). ⚠️ The convention `'gina-inspector-' + tabId` now spans FOUR files (statusbar publisher, SPA subscriber, both delegates) and is pinned by a test, since a rename in one strands the Inspector on a channel nobody publishes to — silently, with no error. **Generalisable: verifying the PRODUCER and inferring the CONSUMER is not verification** — the page data measured correct on all four reported URLs while the bug was fully present. The nunjucks site was also converted to a FUNCTION replacer, which both swig sites already used. **Cross-origin WRITE guard (#B384, fixed 2026-08-16 — a CSRF that was live in every release up to 0.6.9).** The admin endpoints authenticate with an AMBIENT credential (the client IP via `lib.admin.isClientAllowed`), which a browser attaches automatically, so an operator browsing from an allowlisted address — loopback by DEFAULT, i.e. the machine running the bundle — could be lured to a page that silently wrote to `/_gina/storage/gc`, `/_gina/cache/clear`, `/_gina/release/rebuild` or `/_gina/maintenance`. The first three read their whole input from the QUERY STRING and no body, so the attack needed no `fetch` and no CORS reasoning at all: an auto-submitting `<form>` sufficed. `lib.admin.isCrossOriginWrite(req)` now fronts the WHOLE family from ONE site per engine, placed above every `/_gina/*` handler so current and future handlers inherit it — the body reader `_readInstrumentBody` was the WRONG seam (it fronts only 2 of the family), and folding the check into `isClientAllowed` would have silently widened a function whose name promises an IP check. Signals, in order: `Sec-Fetch-Site` (browser-computed, a forbidden header name so page script cannot forge it, and independent of a proxy rewriting `Host`) — `same-origin`/`none` pass, `same-site`/`cross-site` refused; else `Origin` vs `:authority`/`Host`, built NEVER from `X-Forwarded-*` (#B367), with `Origin: null` refused (#CSRF3). NO browser signal ⇒ ALLOWED, since curl / the gina CLI / a deploy script carry no ambient credential. SAFE methods (GET/HEAD/OPTIONS/TRACE) are untouched, so the Inspector's deliberately cross-origin GET/SSE channels keep working — measured: the plugin source issues ZERO POSTs to `/_gina/*`, control firing at 23 hits on `/_gina/agent`. `/_gina/instrument` was NOT the worst of the family as first filed: it is key-gated (`_instrumentKeyValid`), so an attacker without the key cannot invoke it; its `access-control-allow-origin: *` is set BEFORE the key check, making only the 401 cross-origin readable. Verified live: 5 attack arms (incl. the query-param form vector and an `X-Forwarded-Host` spoof) all 403 with state never flipping, 5 control arms all allowed incl. a real browser's `no-cors` POST refused where it previously flipped the toggle. Residual, pinned: a browser sending NEITHER signal on a POST would pass — legacy-only, and narrower than the IP allowlist already fronting these endpoints. **#B708 (fixed for 0.7.1) — placement was not enough: the guard's URL test must MATCH WHEREVER A WRITE HANDLER MATCHES.** It tested `/^\/_gina\//`, while the unanchored handlers (`cache/clear`, `maintenance`, `instrument`) test the FULL url — so a leading segment (`/web/_gina/…`, `//_gina/…`) and the endpoint path at the END of the query string (`/?next=/_gina/maintenance`) reached them — and `cache/clear`, `storage/*` and `release/*` match in any case (`/_GINA/storage/gc`). Driven live on isaac (dev and prod): a cross-site `text/plain` POST to each shape got 200 and turned maintenance on (a site-wide 503). The guard now tests `/\/_gina\//i` on the full url, a superset of every write handler on both engines, locked by a source-extraction test that derives the write handlers from each condition's methods (a looser future handler turns it red) and a live boot test. Side effect: a cross-origin browser request with an unsafe method to an app URL merely containing `/_gina/` (query included) is refused too; same-origin and non-browser clients are unaffected. #B679 — the isaac `X-Forwarded-Prefix` handler in this same forwarded-header block ran its trailing-slash trim (`/\/+$/`, which backtracks quadratically on a long run of slashes) on the RAW header BEFORE the length+charset gate, on every request: a 15 KB header cost ~100–170 ms CPU (measured on a booted bundle; the regex alone ~8 s at the 64 KB an HTTP/2 header list admits). The gate now runs BEFORE the trim, so a >255 value is zeroed before any regex sees it; a legitimate ≤255 mount path is unchanged. Reflex: any per-request regex over an attacker-controllable header caps length first, then matches.
924
+ 176. **Inspector & `/_gina/*` built-in endpoints — in-process architecture, admin IP-allowlist, agent-stream auth, live index coverage** (replaces individual entries #31, #32, #38, #111, #134, #138) — the dev Inspector (formerly Beemaster) is a built-in SPA served at `/_gina/inspector/` inside the bundle's own process: no project registration, no separate port, no auto-start spawn; dev-mode only (production bundles never expose it), and same-origin with the monitored bundle so `window.opener.__ginaData` always works. Every `/_gina/*` route (healthcheck, assets, cache/stats, info, inspector, logs, agent, indexes, reveal, instrument, metrics) is a handler in the same HTTP server process; the Isaac engine is the source of truth and may carry fast-paths, but base functionality belongs in the engine-agnostic dispatcher so Express bundles get the same endpoints. Admin-grade endpoints exposing process/cache internals (`/_gina/info` — memory/uptime/version/HTTP-2 session counters; `/_gina/cache/stats` — full cache contents) are IP-allowlisted via the `admin.allowFrom` block in `app.json`: the client IP is read from the socket only (the spoofable `X-Forwarded-For` is never trusted), `::ffff:`-mapped IPv4 is normalised, the list defaults to loopback (`127.0.0.1`, `::1`) when omitted, an empty list denies everyone, and denied callers get a 403 JSON error; `/_gina/health/check` stays deliberately open for liveness probes, and `/_gina/metrics` keeps its own separate `metrics.allowFrom` axis. **#B709 (fixed for 0.7.1) — a loopback entry admits only a DIRECT caller.** The default list is loopback, and loopback is also the address a reverse proxy on the bundle's own host connects from, so the address-only gate admitted every client such a proxy relayed — maintenance on (a site-wide 503), a cache flush, a storage gc, the process and cache state — with no browser and no credential; an edge block of `/_gina/` did not close it either, because the unanchored handlers also matched a nested endpoint path (`/_gina/health/check/_gina/maintenance`, which a PREFIX proxy location forwards), the endpoint path in a query string, another letter case, or `//_gina/…`. Now (1) `lib.admin.isClientAllowed` and `lib.metrics.isClientAllowed` refuse a listed LOOPBACK caller whose request carries a proxy signal — the classifier the maintenance bypass already used (`lib.maintenance.isProxiedRequest`: any `x-forwarded-*` header name, RFC 7239 `Forwarded`, a `true` #B65 stamp, or a port-less `Host`/`:authority` unless `server.proxy.requireForwardedHeaders` is true); a listed NON-loopback address (a proxy on another host) still admits what it relays, and the first refusal per list is logged once per process. (2) That classification is stamped ONCE per `/_gina/*` request from the pristine headers at both engine tops (`request._ginaAdminProxied`, first-seer): isaac rewrites an h1 `Host` port-less before it hands a request to server.js, so a live read there would take every direct caller of a server.js-only handler (`storage/*`) for a proxied one. (3) `info`, `cache/stats`, `cache/clear`, `maintenance` and `metrics` match EXACTLY on `lib.admin.controlPath(url, webroot)` — the query-free path, in the root form or the bundle's own webroot form, nothing else (a query on these endpoints is now ignored, where it used to 404 some of them); `storage/*` and `release/*` stay `^`-anchored on the url and become case-sensitive; `health/check` and the dev/key-gated family are unchanged. Residual, pinned: a proxy that forwards a port-bearing `Host` and adds no forwarding header is byte-identical to a direct client and stays admitted — call the bundle's port directly from the host or the pod, and block `/_gina/` at the edge in any case and anywhere in the path. False positive: a bundle bound directly to port 80/443 receives a port-less Host from every direct client, so set `server.proxy.requireForwardedHeaders: true` there. Pickup = bundle restart, no re-bake. **#B712 (fixed for 0.7.1) — the handlers #B709 left out match the url's PATH.** `health/check`, `jobs/:id`, `instrument` and the dev/key-gated `inspector`, `logs`, `agent`, `indexes`, `reveal` tested the whole url with no start anchor, so a page whose QUERY ended in an endpoint path (`/web/?next=/_gina/health/check`) got the endpoint instead of the page — inside a maintenance window too, where the page's 503 was due — and a WebSocket upgrade to such a url was taken by the agent. Each matcher now starts `^[^?]*` (the endpoint path must end the url's path) and the dev endpoints' `$` became `(?:\?|$)`; prefix tolerance stays (the Inspector puts the opener page's full pathname before `/_gina/`, and a liveness probe may call the health check under the bundle's webroot), as do letter case, methods and gates. The agent WebSocket upgrade listeners test the query-free path, and the Inspector reads its asset path from it. `/_gina/assets/routing.json`, left out then (`/x?y=/_gina/assets/routing.json` got the routing map), matches its path only since #P48 (0.7.2): that url now gets the page. **#B707 (fixed for 0.7.1) — a GET of the routing table in another letter case no longer ends an isaac bundle.** isaac's fast path tested the url without regard to case, then looked the asset up by the requested spelling through an exact-match `findOne`: `Routing.json` found nothing and the `localAsset.mime` read threw an uncaughtException that ended the process — one unauthenticated request, before routing, with any path prefix. The looked-up name is now lower-cased. A request classified as proxied was served the host-stripped table and escaped it; the express engine serves the table from memory and was not affected. **#B722 (fixed for 0.7.1) — isaac's own event streams answer HTTP/2 clients.** `/_gina/logs`, `/_gina/agent` and `/_gina/release/events` passed `connection: keep-alive` to node's HTTP/2 `stream.respond()`, which throws `ERR_HTTP2_INVALID_CONNECTION_HEADERS` on any connection-specific header (`connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`, `upgrade`); lib/proc.js logged the throw as a warn and the client never got headers, so an `http/2.0` bundle reached directly over HTTP/2 streamed none of them (0.3.0 through 0.7.0; release events since 0.5.18). The header now goes to HTTP/1.1 only. Reflex: a header set shared by an h1 `writeHead()` and a raw h2 `stream.respond()` must carry no HTTP/1-only header — the compat `setHeader('connection', …)` merely drops it with an `UnsupportedWarning`, while a raw `respond()` throws. Pickup for both: bundle restart, no re-bake. The `/_gina/agent` stream (combined data + log events) is dev-only by default but can be enabled outside dev behind an API key (`settings.json > inspector.agent.{enabled, key}`, `${secret:KEY}`-capable, constant-time compared, fail-closed when no key is configured); browsers pass `?key=` as a query param because `EventSource` and WebSocket handshakes cannot set custom headers — and any `$`-anchored endpoint gate regex must become `(?:\?|$)` the moment its endpoint accepts a query param, or the handler silently stops matching the query'd URL. A time-boxed, separately-keyed production instrumentation window (`POST /_gina/instrument`, hard-capped at one hour) can stream per-request query + flow capture over that authenticated channel — channel AUTH, not redaction, is what protects raw query text (redaction masks only secret-NAMED fields, never the statement or its positional params). The Query tab computes live index coverage client-side, so bundles WITHOUT an `indexes.sql` get a correct "no index for filter" badge on the first render too (cached live-index descriptors are cloned per query before stamping coverage — the cache is shared across queries that filter different columns). The live-index refetch's success re-render lands in `#tree-query` — the container the tab renderer owns and silently bails on when missing — never the scroll wrapper around it (#B222: replacing the wrapper's children destroyed `#tree-query`, so after the first render carrying an `indexes: null` query every later payload — navigation, XHR, the refresh button — froze the Query pane and badge until the Inspector window itself was reloaded; the ⟳ button re-armed the freeze via its forced refetch). Inspector toolbar CSS: native macOS `<select>` ignores `line-height` — use explicit vertical padding, and keep `select` (sans font) and `input` (mono font) on separate CSS rules. **SPA + statusbar hardening (folds former #165/#190/#191/#196):** every per-bundle `/_gina/*` consumer in the SPA derives its base URL via the shared 3-fallback `resolveBundleBase()` (`?target=` → opener pathname → path strip) — a bare `window.location.pathname` strip misroutes to the proxy's default bundle in reverse-proxy multi-bundle setups. A `?target=` value loses its own query string and fragment before `/_gina/…` is appended (`normaliseTarget()`, read by `resolveBundleBase()` and both agent builders; `gina inspector:open` does the same to a positional URL — #B719, fixed for 0.7.1): a pasted page URL (`http://host/page?x=1`) otherwise built `http://host/page?x=1/_gina/agent`, the endpoint path inside the query, which the endpoints ignore since #B712. The Inspector binds to its opener tab via a per-tab `BroadcastChannel` (`?ch=<tabId>`): pages sending COOP `same-origin` sever `window.opener` for the popup, and the bundle-global fallbacks (the shared localStorage slot + the worker-wide agent SSE) reflect whichever render last touched them — a diagnostic channel that must track ONE page needs a per-tab transport (the statusbar publishes that tab's data at its existing write points and answers a request/reply handshake; no `?ch=` keeps the prior behaviour, and `?target=` agent mode keeps priority). Structured-localStorage reads need a SHAPE check on top of the try/catch parse (a tampered key holding a JSON primitive otherwise breaks the un-guarded consumer), and every value interpolated into `innerHTML` goes through an HTML-escape helper — untrusted model/app text renders via `.textContent` instead. Server data serialised into those inline scripts goes through the shared helper `core/controller/inline-script.js` (`safeInlineJson` / `safeInlineString` / `escapeForInlineScript`), applied at every emission site in both renderers (4 swig + 3 nunjucks): `<` → `\u003c` (the earlier literal-`</script>`+`<!--` blocklist was bypassable — per the HTML5 script-data end-tag-name state `</script >`, `</script/>` and `</SCRIPT >` all terminated the block; #B451, 0.6.23), U+2028/U+2029 (JS line terminators that are legal in JSON), and — inside JSON string literals only — `{`/`}` → `\u007b`/`\u007d` (#B463): render-swig splices the `__ginaData`/`__ginaLogs` scripts into the layout BEFORE `swig.compile` (the per-request nonce there is a swig conditional), so unescaped `{{ }}`/`{% %}`/`{# #}` in stored data were evaluated as template source — `A{{ 7*7 }}B` rendered `A49B`, measured on fork 2.0.0/2.4.0/2.8.0; nunjucks splices post-render and was never exposed. Structural braces are never touched (an escape outside a literal is neither JSON nor JS), every value parses back byte-identical; pins: `test/lib/inline-script-xss.test.js` (jsdom, 11 spellings) + `test/lib/inline-script-template-injection.test.js` (renders through the real fork, with a firing control). Both server-side only ⇒ pickup = restart, no re-bake. Dev inline-script splices before `</body>` use FUNCTION replacers: `String.replace(/re/, str)` expands `$`-sequences in a STRING replacement (`` $` ``/`$'`/`$&`), so dynamic content carrying a stray dollar-sequence spliced the whole document into the statusbar `<script>` (SyntaxError → statusbar and launch link vanish) on content-heavy pages while a near-empty smoke page stayed falsely green — any `String.replace(re, dynamicX)` with dynamic content must use a function replacer or escape `$`. Inspector SPA files are copied VERBATIM to dist (no minification) — an inspector-only edit rebuilds only those dist files, never the main bundle artifacts. Bound-window data fidelity (#B225): a preload-consumed popin open sets the dev toolbar's XHR overlay on BOTH consume branches exactly like a cold click (dev-gated `updateToolbar(body)` immediately before each dispatch — in consumePreload, never in the shared dispatcher, which would double-fire the cold path); the statusbar-bound Inspector window's refresh button never reopens the passive `/_gina/agent` stream (`source !== 'broadcast'` on the reopen gate — bound mode takes page-scoped data from the statusbar publisher, and a leaked stream applied bundle-wide payloads over it, blinking the Query badge at every XHR overlay); and every Query-pane re-render (filter/search/show-all plus the live-index refetch success path) derives from the live payload via renderTab('query')'s own preference (reveal swap included) instead of the module query cache, which the empty render path never clears and which survives overlay eras ending while the Query tab is inactive — a refresh no longer resurrects a closed popin's badge count. A `?ch=`-less embedded Inspector (direct URL, bookmark, a window predating bound mode) no longer silently runs those bundle-global channels (#B231): every dev page's statusbar advertises its per-tab channel id in localStorage (`__gina_last_tab_ch`) on each publish, a `?ch=`-less Inspector adopts the most-recently-published tab's channel at boot behind a liveness handshake (the bind's request must be answered by a data frame within 1.5 s — a stale advert from a closed tab tears the bound mode down and falls back to the legacy acquisition, closing the bound-mode `/_gina/logs` stream so log entries are not double-delivered once the passive agent stream attaches), and a footer badge names the active data-source mode (`bound` / `agent` / warn-tinted `global` with a tooltip pointing at the statusbar link) so a degraded bundle-global mode is visible instead of silent. That badge renders from `updateSourceModeBadge()`, which init must call on BOTH acquisition branches (#B306): it previously ran only on the non-agent branch and from `pollData()`, and since the poll timer is deliberately never scheduled under `?target=` (agent data is SSE-pushed), a URL-entered standalone Inspector kept the element's shipped `hidden` attribute and named no mode at all — while the identical `agent` mode DID render when reached through the connect form, whose timer is already ticking when `source` flips. Badging depended on how the window was opened rather than on the mode itself, which is exactly what the badge exists to prevent. The timer stays unscheduled in agent mode on purpose, and a future reader must not "reconcile" that guard to the comment that once claimed otherwise: `pollData()` is NOT a no-op under `source === 'agent'` — it repaints the active tab from cache before returning — so putting it on a timer would repaint every `pollDataMs` and fight scroll position, expanded folds and text selection; the refresh button calls `pollData()` directly and needs no timer at all. The footer memory gauge sits its unfilled track on `--bg3` with a theme-scoped inset groove shadow (#B237) — the track previously shared `.bm-footer`'s own `--bg2` background token, so the empty portion rendered invisible in both themes and a low fill read as a floating green dot; a gauge track must contrast the surface it sits on, and an inset box-shadow paints under the fill child, so the groove shades only the empty region. The Forms tab shows the page's DOM forms enriched with runtime validation state (the statusbar merge writes `u.forms[<id>]` per form), while the whole-bundle forms catalog the server seeds into `user.forms` (`page.forms` = the walked `<bundle>/forms/` directory groups — `rules`, `mocks`, `validators`, open-ended) renders as one collapsed, muted "Bundle catalog" card at the bottom (#B343): catalog keys are recognized by their presence in the pristine `gina.forms` half of the payload (which runtime merges never write), so app-custom group directories demote too, departed runtime forms (a closed popin's form) keep their own card, and a payload with no gina half falls back to the legacy per-key rendering — nothing is ever hidden. **The page whisper excludes `mocks` (#B344, every env):** `page.environment.forms` — the RFC5987-encoded export the client loader parses into `gina.forms` — ships a SHALLOW COPY of the catalog minus the `mocks` group in every environment (dev fixture data has zero client consumers: the client bundle reads `gina.forms.rules` and `gina.forms.validators` only), trimming page weight and keeping the client contract uniform across envs; server-side `conf.forms`/`page.forms` — and therefore this catalog card — keep the full set, and the shared catalog object is never mutated. **The localStorage fallback channel is re-synced by the late-bind patch (#B386, fixed 2026-08-16, shipped in 0.6.11).** `statusbar.html` mirrors `window.__ginaData` into `localStorage.__ginaData` at statusbar-execution time, which is BEFORE the render delegates append their late-bind patch script above `</body>` — and that patch mutates the IN-MEMORY object only. Without a re-sync the mirror keeps the EMIT-TIME payload forever: `metrics.weightBytes` null (null at emit by construction, since the body length is unknown until the render finishes) and the late flow entries absent. Measured live on a dev bundle: mirror `{serverMs:2339, weightBytes:null}` with 17 flow entries against an in-page `{serverMs:2860, weightBytes:628192}` with 21 — the four missing being `swig-compile`, `swig-execute`, `response-write`, `total`. Two visible symptoms, and the asymmetry between them IS the diagnostic: the View tab drops its weight badge (both weight legs falsy) while the load badge survives, because `serverMs` IS set at emit and `weightBytes` is not — so a fallback-channel Inspector shows 2 badges instead of 3, and only until something else re-syncs; and the Flow tab loses its template-compile/execute/response-write/total bars. All three patch sites (render-swig cache-hit and cache-miss, render-nunjucks) now end their patch script by refreshing BOTH channels the Inspector can read, placed AFTER the metrics assignments — ordering is load-bearing, since syncing first would re-mirror the stale payload. **localStorage alone was NOT enough, and that was the first fix's mistake:** it is only the FALLBACK channel, while a statusbar-launched Inspector runs in BOUND mode on a per-tab `BroadcastChannel`. Measured: all SEVEN `_ginaPublish()` call sites sit BEFORE the patch (`publishesAfterPatch: 0`), and a surrogate bound listener held across a real navigation received exactly ONE frame carrying `weightBytes: null` with `serverMs` set — the 2-badges-instead-of-3 symptom, reproduced on the transport itself. The patch now also republishes there, keying the channel from `sessionStorage.__gina_tab_id` (per-tab, race-free) and NEVER from the `__gina_last_tab_ch` localStorage advert, which is last-writer-wins across tabs. The subscriber applies it immediately (`setupBoundChannel`'s `onmessage` → `_bcLatest = payload; pollData()`). ⚠️ The convention `'gina-inspector-' + tabId` now spans FOUR files (statusbar publisher, SPA subscriber, both delegates) and is pinned by a test, since a rename in one strands the Inspector on a channel nobody publishes to — silently, with no error. **Generalisable: verifying the PRODUCER and inferring the CONSUMER is not verification** — the page data measured correct on all four reported URLs while the bug was fully present. The nunjucks site was also converted to a FUNCTION replacer, which both swig sites already used. **Cross-origin WRITE guard (#B384, fixed 2026-08-16 — a CSRF that was live in every release up to 0.6.9).** The admin endpoints authenticate with an AMBIENT credential (the client IP via `lib.admin.isClientAllowed`), which a browser attaches automatically, so an operator browsing from an allowlisted address — loopback by DEFAULT, i.e. the machine running the bundle — could be lured to a page that silently wrote to `/_gina/storage/gc`, `/_gina/cache/clear`, `/_gina/release/rebuild` or `/_gina/maintenance`. The first three read their whole input from the QUERY STRING and no body, so the attack needed no `fetch` and no CORS reasoning at all: an auto-submitting `<form>` sufficed. `lib.admin.isCrossOriginWrite(req)` now fronts the WHOLE family from ONE site per engine, placed above every `/_gina/*` handler so current and future handlers inherit it — the body reader `_readInstrumentBody` was the WRONG seam (it fronts only 2 of the family), and folding the check into `isClientAllowed` would have silently widened a function whose name promises an IP check. Signals, in order: `Sec-Fetch-Site` (browser-computed, a forbidden header name so page script cannot forge it, and independent of a proxy rewriting `Host`) — `same-origin`/`none` pass, `same-site`/`cross-site` refused; else `Origin` vs `:authority`/`Host`, built NEVER from `X-Forwarded-*` (#B367), with `Origin: null` refused (#CSRF3). NO browser signal ⇒ ALLOWED, since curl / the gina CLI / a deploy script carry no ambient credential. SAFE methods (GET/HEAD/OPTIONS/TRACE) are untouched, so the Inspector's deliberately cross-origin GET/SSE channels keep working — measured: the plugin source issues ZERO POSTs to `/_gina/*`, control firing at 23 hits on `/_gina/agent`. `/_gina/instrument` was NOT the worst of the family as first filed: it is key-gated (`_instrumentKeyValid`), so an attacker without the key cannot invoke it; its `access-control-allow-origin: *` is set BEFORE the key check, making only the 401 cross-origin readable. Verified live: 5 attack arms (incl. the query-param form vector and an `X-Forwarded-Host` spoof) all 403 with state never flipping, 5 control arms all allowed incl. a real browser's `no-cors` POST refused where it previously flipped the toggle. Residual, pinned: a browser sending NEITHER signal on a POST would pass — legacy-only, and narrower than the IP allowlist already fronting these endpoints. **#B708 (fixed for 0.7.1) — placement was not enough: the guard's URL test must MATCH WHEREVER A WRITE HANDLER MATCHES.** It tested `/^\/_gina\//`, while the unanchored handlers (`cache/clear`, `maintenance`, `instrument`) test the FULL url — so a leading segment (`/web/_gina/…`, `//_gina/…`) and the endpoint path at the END of the query string (`/?next=/_gina/maintenance`) reached them — and `cache/clear`, `storage/*` and `release/*` match in any case (`/_GINA/storage/gc`). Driven live on isaac (dev and prod): a cross-site `text/plain` POST to each shape got 200 and turned maintenance on (a site-wide 503). The guard now tests `/\/_gina\//i` on the full url, a superset of every write handler on both engines, locked by a source-extraction test that derives the write handlers from each condition's methods (a looser future handler turns it red) and a live boot test. Side effect: a cross-origin browser request with an unsafe method to an app URL merely containing `/_gina/` (query included) is refused too; same-origin and non-browser clients are unaffected. #B679 — the isaac `X-Forwarded-Prefix` handler in this same forwarded-header block ran its trailing-slash trim (`/\/+$/`, which backtracks quadratically on a long run of slashes) on the RAW header BEFORE the length+charset gate, on every request: a 15 KB header cost ~100–170 ms CPU (measured on a booted bundle; the regex alone ~8 s at the 64 KB an HTTP/2 header list admits). The gate now runs BEFORE the trim, so a >255 value is zeroed before any regex sees it; a legitimate ≤255 mount path is unchanged. Reflex: any per-request regex over an attacker-controllable header caps length first, then matches.
924
925
 
925
926
  177. **Couchbase connector — install-derived SDK resolution (v2 removed), `connectors.json` semantics, `getCluster()`, dev-mode index reporting** (replaces individual entries #29, #40, #41, #57, #136, #153) — connectors are keyed in `schema/connectors.json` by LOGICAL name (`primary`, `sessionStore`, `cache`, …) with the driver selected by the `connector` enum field (`couchbase`/`mysql`/`postgresql`/`sqlite`/`redis`/`ai`/…) — never introduce a separate `driver` field; the optional `version` field carries a semver range used by `connector:add --driver-version=…` for the npm-install hint. The Couchbase SDK major is derived from the project's INSTALLED `couchbase` npm version — the leading major of `dependencies.couchbase` selects `connector.v<major>.js`, which stamps `conn.sdk = { version: N }` — never from a config key, so migrating SDK majors is a driver bump (`npm install couchbase@^4`), not a config edit. SDK v2 is REMOVED as of 0.4.0: the resolver now throws a clear "SDK v2 is no longer supported — upgrade couchbase@^3/^4" error when the installed major is ≤ 2 or the connector file is missing (previously a silent fallback that crashed later with an opaque MODULE_NOT_FOUND); the v3-vs-v4 split remains for param shaping. Generalises: when a connector's behavior-version derives from an installed dependency rather than config, fail fast once the installed major drops below the supported floor. Couchbase entities expose a public `getCluster()` (on both the model-entity and N1QL-entity prototypes) returning the underlying SDK `Cluster` handle for features the ORM doesn't wrap — chiefly multi-document ACID transactions (`cluster.transactions().run(...)`, needs SDK 3.2+/4.x) — without touching private `_*` internals; it throws a coded `GINA_COUCHBASE_CLUSTER_UNRESOLVED` error when neither connection shape resolves. Dev-mode index reporting: the SDK v4 C++ binding never populates `meta.profile` despite `profile: 'timings'` being sent (confirmed on v4.6.0), so an async `EXPLAIN <statement>` fallback with a per-process per-statement cache supplies the plan instead (the first request for a new statement may show N/A; subsequent requests hit the cache), and `USE KEYS` plans surface as "KV lookup" via `ExpressionScan`/`KeyScan` operator detection. Three historical traps locked by tests: `conn._cluster.query()` must receive the full `queryOptions` object, not the raw params array (the raw form silently dropped `profile`/`scanConsistency`/`adhoc` from every query); of the connector's two `register()` dispatch paths, Option B (`!_isRegisteredFromProto`) is the ALWAYS-active one — instrumentation or logging added to Option A never executes; and (#B193, 0.6.3) the plan walker must visit the NESTED scan containers, not just `~child`/`~children` — the multi-index operators (`IntersectScan`/`UnionScan`/`OrderedIntersectScan`) put their child `IndexScan3` nodes under a `scans` array and `DistinctScan` under a singular `scan`, so before the fix any plan the planner served with more than one index extracted `[]` and the Inspector rendered the red "no index — full bucket scan" badge/banner for a fully-indexed query — a false negative inviting a pointless (write-amplifying) index build. Both extraction paths (SDK `meta.profile` and the EXPLAIN fallback) share the walker, so one fix covers both; index names still dedupe, and the Query tab already renders one chip per index so multi-index plans display correctly with no client change. A consumer-runnable SDK soak harness ships at `script/soak/couchbase-soak.js` (#CN12): it scaffolds a fully isolated throwaway project, installs a candidate `couchbase` SDK into it (`--sdk=<version>` / `--sdk-path=<dir>` — the install IS the version selector, since the connector resolves the SDK from the project node_modules and derives v3/v4 dispatch from its dependency pin), builds + boots prod, and drives N1QL (incl. a `request_plus` arm) + entity-handle KV (promise AND 4-arg callback forms) + the couchbase session store under sustained concurrent load for `--duration`, FAILING on premature process exit (a clean exit 0 counts as failure — the silent-death class it screens for), unbounded RSS growth, error-rate drift, or a dead arm. A screen, not proof: run it as the first filter on an SDK-bump candidate, ahead of a workload-shaped soak. Pure parts (arg parsing, RSS slope, verdict) are unit-tested in `test/lib/couchbase-soak-evaluator.test.js`; the live harness needs a real cluster and never runs in CI. Named scopes/collections stance (2026-08-02): document-field partitioning (`_scope`/`_collection` fields, one bucket, default collection) IS the data model; the `useScopeAndCollections` option (+ `scope`/`collection` defaults) is accepted but INERT — declared and merged, consumed by nothing — and `schema/connectors.json` says so honestly; named-collection KV is reachable per call via `entity.getConnection(scope, collection)`; native scope/collection routing is deliberately not built (demand-gated). #B203 (2026-08-02): both SDK-major resolver twins derive the major as the dependency pin's FIRST integer — the former caret-only strip mangled range pins (`~4.5.0` → `~4`, `>=4.5` → `>=4`), which slipped the v2 floor (parseInt NaN) into a misdirecting existsSync "supported majors are 3 and 4" error; a digit-less pin (`*`, `latest`) now refuses naming the pin, a package.json without a `dependencies` key no longer TypeErrors, and the v2 floor fires for range v2 pins too (`~2.5.0`). #B243 (2026-08-04): a query parameter the SDK cannot serialize — a bare `undefined`, a function, or a Symbol — was PROCESS-FATAL rather than throwable: the SDK maps `JSON.stringify` over the parameter list, those three types yield no string at all, the native binding coerces that to `""`, and the C++ core's JSON parse of `""` throws on an internal thread reaching `std::terminate()`/`abort()` — uncatchable by `try/catch`, `uncaughtException` or `unhandledRejection`, so the whole bundle died instead of the request 500-ing (measured against a live cluster on SDK 4.1.3 AND 4.7.1; 4.2.0+ maps a bare `undefined` to null but still aborts on functions and Symbols). Reachable with NO misuse of the driver: the cursor-style assembly branch (queries matching `\w+\.($|%)`) fills `queryParams[i]` for every `i < params.length` while guarding only `undefined`, so a call one argument SHORT with a trailing callback puts the CALLBACK into a parameter slot — the arity check cannot catch that shape because it only fires when the last argument is not a function. `getUnserializableParamError()` now gates the single `queryOptions.parameters` assignment (so both assembly branches and any future one are covered) and surfaces a `TypeError` coded `GINA_COUCHBASE_UNSERIALIZABLE_PARAM` naming the offending position, routed through the query callback when there is one and thrown otherwise. Serializable values are untouched: `null`, `0`, `''`, `false` and objects carrying `undefined` PROPERTIES still reach the SDK. An object whose own `toJSON()` returns undefined is deliberately left unguarded — detecting it costs a full `JSON.stringify` per parameter on every query for a shape no realistic call site produces. **`.sql` `@options` annotation — the exact contract, and the consistency gate that silently dropped keys (former #282, #B155; warns since 0.5.26):** the parser accepts ONLY `@options { … }` — a brace-delimited JS-object-literal after exactly ONE space (unquoted keys fine: a key-quoting normalisation runs before `JSON.parse`; a malformed body warns and is skipped). The historical docs-taught brace-less form (`@options consistency=request_plus`) and a double-space-before-brace both MISS the parse regex entirely. The passthrough into the SDK's query options is GATED on the `consistency` key: any other key (`adhoc`, `timeout`, `profile`, …) applies ONLY when `consistency` is present alongside it — alone it is parsed then dropped. Since 0.5.26 BOTH failure shapes warn (was silent): an unparseable `@options` mention warns with the exact working form; a gate-shut drop warns naming the ignored keys + the `"consistency": "not_bounded"` remedy; an empty `@options {}` drops nothing and stays quiet. An UNKNOWN consistency VALUE warns and falls back to `not_bounded` but STILL opens the gate — the gate keys on presence, not validity. Query-path defaults: `adhoc: false` (statement plans cached — `true` disables that) + `not_bounded`; user keys WIN over framework values (direct assignment — including dev-mode's `profile: 'timings'`). `bulkInsert` is a SEPARATE surface: a JS `options` argument, UNGATED, merged caller-wins-on-conflict / defaults-fill-missing (its default is `adhoc: true`). The gate itself deliberately stays: un-gating would make historically-inert options suddenly live for every consumer, and a bare un-gate CRASHES (the next statement dereferences `options.consistency`) — a restructure gated on field evidence, which the warns now collect. Tests: `test/core/couchbase-connector.test.js §09` drives the SHIPPED parse+gate bytes (unique-text-anchored slices — line numbers rot and the parse block's regex holds an unbalanced `{` that defeats brace-matching). **#B204 (0.6.24) — the `gina.onError` reconnect classifier is guarded; it used to THROW under every supported SDK.** `lib/connector.v3.js`/`lib/connector.v4.js` tested `err instanceof couchbase.Error`, but no supported couchnode exports a bare `Error` class (measured on a real 4.1.3 `dist/errors.js`: `Error` undefined, `CouchbaseError` + 81 siblings present, and the classifier condition executed against that surface throws `TypeError: Right-hand side of 'instanceof' is not an object`). Because that operand is evaluated FIRST, the shutdown-bucket message arm — which needs no SDK class — was unreachable too, so the classifier could not classify anything by any route. **Dispatch topology, measured:** `gina.onError` listeners fire ONLY under 4-arg (Express-engine) error-middleware invocation; Isaac's dispatchers invoke middlewares 3-arg, so the arity shim sets `error = false` and the emit branch never runs — on Isaac the listener, broken or fixed, never executes. **Fix = guard, deliberately NOT an activation:** each `instanceof` is prefixed `typeof(couchbase.Error) == 'function' &&` (4 live sites, both files). Modern SDK errors carry no numeric `.code`, so the guarded arms stay inert; the SDK-2 message string greps 0 in the 4.1.3 dist JS (`timeout` firing as control); on Express the real error now reaches the handler's designed terminal (log + JSON 500 / `next(err)`) instead of being replaced by the TypeError. ⚠️ **Do NOT "modernize" this into an active reconnect without a live-cluster gate:** gina's own `connect()` mints a NEW cluster handle per call (`couchbase.connect` at `connector.v4.js:265`) and the only `disconnect()` sits inside the code-23 arm — an activated retry loop would stack unclosed handles. Also do NOT remove the listener: `e` (`gna.js:81`) is a plain EventEmitter, and an `'error'` emit with no listener THROWS (measured), so the registration is load-bearing on Express. Tests: `§16` (both files' real condition bytes extracted and driven — no-`Error` SDK must not throw and the message arm must be evaluable, with an SDK-2-shape control green pre- and post-fix; red-first 3 red / 2 green). **#B509 (0.6.29) — a failed statement's RESULT ROWS no longer ride the synthesized query error.** Both onError sites (`register()` and `bulkInsert`) build `new Error(cause.first_error_message)` from the N1QL `cause` envelope and used to set `error.stack = trigger + '\n' + cause.http_body` and `error.cause = cause` VERBATIM — and `http_body` is the query service's WHOLE response, whose `results` is non-empty whenever the failing statement had already produced rows (a `RETURNING` DML losing a CAS race, 12009; a SELECT timing out part-way, 1080). So the rows reached every sink that printed the error: the controller error path prints `.stack` in ALL scopes; any `console.error(err)` / `logger.error(err)` prints enumerable `cause` through `util.inspect` (the logger's `inspectError` IS `util.inspect`; Node's default `maxStringLength` lets 10,000 chars of the body through); the JSON error response carries `.stack` in LOCAL scope only (stripped elsewhere, #B131 on both engines); a custom error template receives `stack` ungated. NOT sinks: the Inspector query log (`error: err.message` only) and `lib/connector-error` (reads exactly `first_error_code` + `retry`). A consumer measured 17 of 18 logged query errors in 16 h carrying 1–7 full documents each, with no interception point (the controller prints the stack before any bundle `onError` runs). Fix: `param-redact.redactResultRows(httpBody)` — the #B350 module, same fail-safe contract on the OUTPUT side — parses the envelope, replaces `results` with `[N result rows redacted]`, keeps `errors`/`requestID`/`status`/`signature`/metrics, and FAILS CLOSED (a non-string or unparseable body becomes a byte-count marker, never the raw text; it never throws); both sites set `.stack` from the redacted body and `error.cause = Object.assign({}, cause, { http_body: redacted })` — a shallow copy, so the SDK's object is never mutated and the classifier's two inputs survive. The #B153 empty-message guard is untouched. Consumer-visible: nothing reads rows out of a query error's stack any more. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-b509.test.js` (helper arms incl. fail-closed and a `__proto__`-key body; both-site source pins with an anti-vacuity control; a replica proving every channel clean, with the pre-fix branch as the subtract control); `couchbase-connector.test.js §08`'s fixed replica realigned. Generalises: an error object that carries a foreign payload leaks through EVERY generic printer, not just the one you instrumented — redact at the point the payload is attached, and copy foreign objects rather than mutate them. **#B541 (0.6.31) — a connector that cannot CONNECT at boot fails loudly instead of hanging, and one failed attempt reports once instead of twice.** Consumers wait on a ONE-SHOT `ready` (`onReady` registers `self.once('ready', cb)`), and every failure path in `connect()` routes to `onError`, which only re-arms a retry — it never emits. So an unreachable cluster at boot settled nothing: `lib/model.js`'s all-or-nothing ready gate never closed, the bundle never printed the two flags `bundle:start` waits on, and the CLI killed it at ~64s (`lib/cmd/bundle/start.js`: maxRetry 15 × maxTimeout 4000ms) with no error anywhere. The loud path — `console.error` + `process.exit(1)` in `onModelReady` — was already correct and is how every OTHER connector reports a dead database (the other eight invoke the caller's callback on every path, failure included); it was simply unreachable here. Fix, both files: `init()` arms a readiness deadline and, if nothing has settled by it, emits `ready(err, null)` itself so that path runs. ⚠️ Do NOT "fix" this by un-commenting the disabled `self.emit('ready', bErr, null)` — the same 2023 hunk that commented it ADDED the retry, so un-commenting restores the loud path and destroys the retry. Tunable per `connectors.json` entry via `readyTimeout` (MILLISECONDS, default 50000, in `schema/connectors.json`); a value at or above the ~64s start budget cannot take effect because the CLI terminates first, and a non-positive/non-numeric one falls back to the default. Two deliberate shapes: the deadline is NOT unref'd (were it the only handle holding the event loop, unref would let node exit 0 silently instead of reporting), and `_markSettled()` only records the settle + disarms the timer — it does NOT gate the emit, because `core/model/index.js` attaches a persistent `.on('ready')` bridging reconnect churn onto the Inspector event signal while both `onReady` consumers use `once`, which already drops a late delivery. Retry is untouched: uncapped after a settle, so a serving bundle still survives a blip. **Same arc, the double report:** on failure the SDK settles BOTH channels — the `onBucketOpened` callback AND the awaited promise — so one failed attempt called `onError` twice, arming TWO retry chains that each armed two more (2→4→8…) and double-counting `_reconnectAttempts` so the backoff hit its 60s cap after five real attempts instead of ten; `onError` now goes through `settleOnce` minted PER `connect()` invocation (driven: counter 2 → 1). That guard also fixes a shape it was masking — `onError` dereferences `self.instance`, which is never initialised at construction and is assigned only by the callback channel, so an SDK rejection that never calls back THREW at `onError`'s first statement, arming no retry and emitting nothing, visible only as an unhandled rejection of the bare `self.connect(dbString)` (driven: counter `undefined` → 1) — a silent death, which is why it went unnoticed. v3 matters as much as v4: it is the DEFAULT when a project pins no `couchbase` dependency. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-boot-deadline.test.js` (boots the REAL connector against a planted project-side SDK stub — `connector.v*.js` requires the SDK at `getPath('project') + '/node_modules/couchbase'`, so seeding the project dir IS the seam; mocked timers, because `onError` does not retain its retry handle and a real chain would re-arm forever and hang the file; a SUCCESS arm as the firing control, since `init()` reads `getConfig()` inside a try whose catch emits `ready(err)` and an unwired harness therefore makes the FAIL arm settle and read as no-defect). Generalises: a connector that reports readiness by EVENT rather than by invoking the caller's callback has no failure path unless one is built — an error-only retry loop is indistinguishable, from the gate's side, from a connector still trying. **#B608 (0.6.33) — no caller or configuration value reaches N1QL statement TEXT unvalidated.** Three splice sites, three fixes. (1) `SEARCH()`: the 2021 block matched `(search\(|search\s+\().*\)` — greedy, so the span ran to the statement's LAST `)` — and rewrote every `$N` inside it to `'"' + value + '"'` through a STRING replacement: no escaping (a search term carrying `"` made the statement malformed), `$&` expanded, `$1` also rewrote `$10`, value params that merely followed the call were re-typed as string literals, and every distinct term compiled its own prepared plan (`adhoc:false`). Its premise ("N1QL parameters are not interpolated into SEARCH()") was wrong: Couchbase documents a parameter as SEARCH()'s query argument when it resolves to a string or an object (docs 6.5 → 8.0; the engine's `ValidOperands()` accepts a static parameter since 6.5.1, source-read), and it was measured on Server 8.0 CE — ad-hoc AND prepared, a complete search-request object with the parameter nested inside, a `"`-carrying term bound without error, the plan still `IndexFtsSearch`. The block is commented out and `$N` stays bound; behaviour changes: a non-string term is no longer stringified, a value param after SEARCH() keeps its type, one prepared plan per statement. (2) Field-path `$N` (`doc.flags.$2`, which cannot be bound): `getInvalidFieldPathError()` admits only `identifier(.identifier)*` (`[A-Za-z_][A-Za-z0-9_]*`, `$` excluded) and the call is refused BEFORE dispatch with a `TypeError` coded `GINA_COUCHBASE_INVALID_FIELD_PATH`, delivered like #B243 (callback when there is one, else thrown — the Promise form throws synchronously). (3) `$scope`: `resolveScope(infos.scope, process.env.NODE_SCOPE)` resolves it ONCE at factory load (same `||` precedence; the factory runs again when a couchbase reconnect rebuilds the models through `reloadModels`, which passes the entry's `scope` since #B624 — the rebuild used to omit it and fall back to `NODE_SCOPE`) against `^[A-Za-z0-9_./-]+$` (`/` stays admitted so that a scope registered under the retired `scope:add <bundle>/<scope>` form keeps booting; since #B626 `scope:add` checks the whole name and refuses `/`, pointing at the `manifest.json` `bundles.<name>.scopes` allow-list, the real per-bundle mechanism), stamps both prototypes with it and feeds `/\$scope\b/g` through a function replacer; a refusal ends the boot through the EXPLICIT terminal (`console.emerg` → `fs.writeSync(2)` → `process.exit(1)`, placed just before `return init()` so every factory-level `var x = function` is assigned) — never a bare throw: the factory runs inside the model layer's ready handler, which this connector reaches from inside the SDK's connect callback, and the SDK 4.x hands a throw from that callback back to the same callback as a connection error — logged as a failure to connect and retried, the bundle never listening (read from the SDK 4.1.3 source). Since #B617 `lib/model.js`'s `done()` catches such a throw too; the explicit terminal keeps the refusal's own message and code. Server-side, load-once ⇒ bundle RESTART, no re-bake. Tests: `test/core/couchbase-b608.test.js` — both helpers extracted from the shipped bytes and executed; the REAL connector against a recording cluster stub; the boot terminal in a CHILD process (a `process.exit(1)` in the runner takes the whole file down); comment-stripped source pins with raw-text anti-vacuity checks; the env seam `GINA_COUCHBASE_CONNECTOR_SRC` runs the whole file against pre-fix bytes (8 controls green, 32 fix arms red). Generalises: a comment asserting "the driver cannot bind X" is a claim to re-measure against the vendor docs and a live server before it may justify a splice. **#B616 (0.6.33) — the fourth splice site #B608 missed:** `bulkInsert` wrote the bucket name (the entry's `database`) bare into `INSERT INTO <bucket> (KEY, VALUE)` and `RETURNING <bucket>.*`, so a legal dashed bucket such as `beer-sample` (N1QL needs it escaped) could not be bulk-inserted into. One keyspace string, backtick-quoted with any embedded backtick doubled, now serves both clauses and the redacted log copy; no load-time refusal was added, since Couchbase bucket names may contain `.` and `%`, which the storage store's `IDENTIFIER_RE` excludes. The statement text changes for every bucket; the query means the same. Load-once ⇒ bundle RESTART. Pin: the self-contained `#B616` arm in `test/core/couchbase-concurrency.test.js` §06 (the real factory on a `beer-sample` bucket). The REST query transport is RETIRED (#B634, #B623): `useRestApi: true` used to route every N1QL query through a hand-written plain-http `http.request()` to the query service — `Authorization: Basic` in cleartext even on `couchbases://`, every `'` in the statement rewritten to `"`, parameters spliced in unescaped — so the option is now ignored with ONE warning per connector, and queries always go through the SDK (`conn._cluster.query`).
926
927
 
@@ -934,7 +935,7 @@ Dev-mode query instrumentation captures every database query tied to the current
934
935
 
935
936
  206. **Observable application events surface in the dev Inspector via a per-request signal that mirrors #AISTREAM — `self.emitEvent(name, metadata)` (controller) or `lib/inspector-events.emit()` (model/service code) pushes a `{type:'event',id,name,t[,meta]}` entry and emits a live `inspector#event` frame, delivered as an `event: event` SSE/WS frame plus a `user.events` end-of-request snapshot, shown in a new SPA "Events" tab (#EVTBUS, 2026-06-29).** The per-request buffer `_devEventLog` is a THIRD key on the shared `process.gina._queryALS` store (sibling of `_devQueryLog`/`_devAiLog`, seeded at `controller.js` `setOptions`); `emit()` reaches it via `getStore()` so the snapshot is captured from any code in the request's async context (outside one — a background job / lifecycle hook — the live frame still fires but no snapshot is pushed, per Slice 2b below; a closed gate or invalid name is a no-op). Capture is gated on `NODE_ENV_IS_DEV` OR an open `process.gina._inspectorWindowUntil` (identical to the query/AI gates). The event NAME + framework stamps always ride the wire; the caller's `metadata` VALUES ride ONLY when `settings.inspector.events.captureArgs` is true (default off, seeded onto `process.gina._inspectorEventsCaptureArgs` at boot) — `lib/inspector-redact` matches secret-NAMED keys only and cannot sanitise arbitrary arg VALUES, so the gate + opt-in + authenticated channel are the protection, never redaction (same contract as #AISTREAM `captureText`). Delivery: a `server.isaac.js` SSE forwarder (`event: event`) + a `server.js` WS forwarder (`{event:'event'}`), registered/deregistered beside the data/log/token listeners; the `user.events` snapshot is attached at the SAME 5 render sites as `user.aiStream` (render-json + inspector-window-emit on `_gdUser`, render-swig cache-hit/miss + render-nunjucks on `data.page.events`), gated on `local._eventLog.length`. **Three carry-forward facts:** (1) the SPA `appendAppEvent` ACCUMULATES a capped rolling buffer (NOT single-slot-reset like `appendTokenDelta` — events are discrete, not one token stream), renders via `.textContent` (untrusted app text), and the `renderTab` `case 'events':` prefers the live buffer then falls back to the request snapshot; (2) the live `event: event` frame rides isaac-SSE + server.js-WS, and (#AISTREAM/#EVTBUS parity gap CLOSED 2026-06-29) the server.js HTTP/1 SSE `/_gina/agent` handler now forwards token + event too via `response.write` (mirroring isaac's `_agWrite` shape) — all three live transports now carry all four frame types and `event-inspection.test.js §08` is a positive pin; (3) `event` is NOT an EventSource-reserved name (unlike open/message/error) so `event: event` is collision-free. Adding the 8th SPA tab updates the 3 `TAB_LAYOUTS` presets → trips `inspector.test.js §44`'s exact-tab-set pins (now 8 tabs, updated with approval). `lib/inspector-events` registered via `_require` (stateless). There are zero pre-existing app/domain events in the framework (the only `process.emit` namespaces are `inspector#`/`logger#`/`gina#`, all internal), so the emit API is the headline — without it the signal surfaces nothing. Slice 2a (SHIPPED 2026-06-29): a curated allow-list (`settings.inspector.events.topics`, default `[]`) bridges entity-trigger emits onto the live signal via a gated block in `entity.js`'s `emit` chokepoint — skips `error`, gated on a non-empty allow-list FIRST (the opt-in IS the flood control), ships a framework-controlled `{ok,error}` summary (never raw rows; captureArgs-gated like all metadata — name+source always ride, the `{ok,error}` summary reaches the wire only when captureArgs is on, default off) tagged `source:'framework'` (a new optional 3rd arg to `inspector-events.emit` + an exported `matchTopics` helper: exact / single leading-or-trailing `*`); reached via `require('lib/inspector-events')`; request-scoped so it reuses the MVP live+snapshot delivery (no new transport/ALS key/tab). The chokepoint catches every entity emit with ZERO per-instance `.on()` / `ENTITY_MAX_LISTENERS` use (the original deferred note had conflated flood with listener-exhaustion). Only the couchbase connector emits named CRUD (`N1QL:<entity>#<method>`); the other 5 don't, and CRUD is largely redundant with the Query tab — so 2a's genuinely-new value is custom (non-query) entity methods. Slice 2b (SHIPPED 2026-06-29): an emit refactor SPLITS the store-gated buffer push from an always-on live emit — once the gate passes the live `inspector#event` frame fires store-or-not (a no-store background-job / lifecycle caller now reaches the stream; only the per-request snapshot needs a store), return `true`⟺live-frame-emitted / `false` only for gate-closed or invalid-name; this flipped §01 (`:58-63`) and CHANGED the no-op-outside-request contract, deliberately diverging from #AISTREAM (whose live `inspector#token` emit is itself store-gated at `ai/index.js` `if(_aiLog)`). On top of it a connector-lifecycle bridge surfaces a connector's recurring `ready` emit (re-fired on every reconnect) via a single additive `connector.on('ready')` at the construct-once site in `core/model/index.js this.connect` (attached once in the cache-miss branch → no per-reconnect accumulation; the existing `onReady` consumer is a one-shot `once('ready')`; couchbase is the only connector emitting `ready` today, the other 5 use a direct onReady callback; redis session-store connect/disconnect — the only other recurring pair — has no framework seam so it is deferred), live-only (lifecycle events have no request context → no snapshot, exactly the no-store path the refactor unblocked), reusing the same `_inspectorEventTopics` allow-list + `matchTopics` + `source:'framework'` tag + `{ok,error}` summary (no new config key). Tests: `test/core/event-inspection.test.js` (§01 real-module behaviour incl. the no-store live-emit, §02-§09 server wiring, §10-§11 SPA + dist propagation, §12-§14 Slice 2a: source/matchTopics + entity-bridge source-pins/replica + topics seed, §15 Slice 2b connector-lifecycle bridge: model/index.js source-pins + replica). Slices A1a `b2157113` / A1b `bb3103f1` / A1c `4f844bd6`; Slice 2b emit refactor `ea55a489` + connector-lifecycle bridge. Slices 2a + the 2b always-on-emit substrate LIVE-VERIFIED e2e 2026-06-30 (entity op → `inspector#event` frame + `user.events` snapshot; a no-store background emit → live frame), which also corrected the captureArgs note above; the couchbase-specific `couchbase#ready` reconnect→frame e2e is source + unit-replica only (no self-controlled couchbase env).
936
937
 
937
- 209. **FormValidator — engine disambiguation, string inputs, a11y reflection, and live-check message visibility (consolidates former #42/#130/#150/#186/#197).** The live form/data rule engine is `core/plugins/lib/validator/src/form-validator.js` (single source, `isGFFCtx`-branched: runs server-side via `backendInit` AND compiled into the browser bundle) with the client orchestration in `validator/src/main.js` — `framework/v*/lib/validator.js` is a DEAD standalone fluent validator with overlapping `is*` rule names; never edit it for form-rule work (tell them apart fast: the live engine's `isRequired` rejects whitespace-only input, the dead one passes it). Rule bodies must handle STRING inputs — the two DECLARATIVE contexts feed strings (`.value` + urlencoded bodies) — so a typed/numeric rule coerces or parses explicit components, never assumes a typed JS value; but "always a string" is NOT true of every path, and reading it that way shipped #B198 (see below): a JSON request body keeps real Numbers (`JSON.parse` → `req.body`/`req.post`, which the `validator::{}` routing path MERGES into the validated data before spreading array bounds through `apply()`), and `toInteger` leaves `Math.round()`'s real Number on `this.value`, so a `toInteger` → `is*` chain hands the next rule a Number even in the browser — a rule must therefore be correct for a typed value too, not merely tolerant of strings: `isFloat` coerces via `Number()` (#B46); `isDate` builds from explicit mask components + a round-trip check so non-ISO slash masks aren't US-misparsed and impossible dates still reject (#B47); `isDate` returns the FIELD again on its valid path (#B48, 0.5.4 — parsed `Date` preserved on the field's `.value`, the `isDate(mask).format(...)` idiom unchanged), so rule chaining works. ⚠️ **The FIELD keeps the `Date`; the PAYLOAD takes a `yyyy-mm-dd` string, always (#B558, 0.6.32-alpha.2).** Every validator normalises the payload to the validated canonical form (`isEmail` lowercases, `isBoolean` → boolean, `isNumber` → `Number`), so `isDate` normalising is consistent — but `Date` is the one normalised type that does NOT round-trip through JSON: `JSON.stringify` renders it via `toISOString()`, a UTC **instant**, and local midnight at any positive UTC offset is the PREVIOUS day in UTC. So a browser in Paris submitting `2026-09-18` sent `2026-09-17T22:00:00.000Z` and every reader that sliced the date part stored the 17th — silent (the instant is well-formed) and invisible to a server or CI running in UTC. The written shape is mask-INDEPENDENT: the mask governs INPUT parsing, while both `requirementToSchema()` and `dto.date()` already published the field as `{type:'string', format:'date'}` (RFC 3339 full-date, explicitly NOT `date-time`), so the payload now matches the schema gina was already advertising. Consumer-visible: `req.body.<field>` is a string, not a `Date`, and `new Date('2026-09-18')` on a date-only string is UTC midnight, not local midnight. An empty value is adjudicated by `isRequired` ALONE (#B78): the per-rule empty-bypass became unconditional on empty (`if (this.value == '')`, its old `!errors['isRequired']` gate dropped) for `isEmail`/`isJsonWebToken`/`isFloat`/`isInList` — each regating `this.valid = isValid && !errors['isRequired']` — and `isString` keeps the field invalid without recording a second message, so a required-empty field shows ONE message (`is required`) not two, optional empty fields still pass, a filled-but-invalid value still reports its own error, and custom `is` was deliberately excluded by #B78 and re-declined by #B82 — an exclusion REVERSED by #B233 (2026-08-03, 0.6.3, `307721f2`): `is` now carries the same canonical strict bypass (`if ( this.value === '' ) { isValid = true; }`) and the same regate, taking the Shape-A population from four rules to FIVE, so a required+EMPTY field carrying an `is` condition records `isRequired` ALONE instead of also collecting a second `Condition not satisfied`. `isBoolean` joined the same contract at #B235 (2026-08-03, 0.6.3, `aa1c2035`), taking that population to SIX: its pre-switch rescue `errors['isRequired'] && this.value == false` was LOOSE (`'' == false`), so a required+EMPTY boolean field LOST its isRequired error and reported `Must be a valid boolean` instead of `Cannot be left empty`; the rule now takes the canonical strict `=== ''` self-pass, and the rescue moves AFTER the accept-set switch gated on the value having been ACCEPTED (`val !== null`) — which keeps the documented unchecked-but-required-toggle case working, since a recognized `false`/`0` is a present answer, while emptiness returns to `isRequired` alone. Paired in the same commit with #B236, the SERVER-side half: the plugin's `getCastedValue` funneled EVERY value on an isBoolean-ruled field through `/^true$/i ? true : false` BEFORE the engine ran (client AND server — `validate` calls `formatFields` unconditionally), so on the server auto path junk validated CLEAN and PERSISTED as `false` — `nope`, the HTML checkbox default `on` (a CHECKED box storing UNchecked), the strings `1`/`0`, `TRUE`/`True` — and the NUMBER 1 stored `false` where the engine reads it as `true`. The pre-cast now survives ONLY in dynamised-rules mode, where a referenced boolean field must splice into a stringified `is` condition as an unquoted operand (measured NECESSARY: deleting it outright breaks a server `$flag === true` condition); the ENGINE is the single adjudicator on every surface, which is what the routing `validator::` surface always enforced and what the published reference already promised. Disclosed both directions: values that silently stored `false` now ERROR, the number 1 flips its stored value `false`→`true` on a verdict that was already valid, an optional blank boolean field now PASSES instead of erroring, and a required blank field's message changes from isBoolean to isRequired. A sibling server-path crash in the same plugin is fixed by #B234 (2026-08-03, 0.6.3, `7c56565d`): `getDynamisedRules` substitutes in two passes, and the SECOND is a DOM fallback re-deriving each splice value from the live element (`$fields[...].value`) — which `backendInit` calls with `$fields = null`, so it threw `TypeError: Cannot read properties of null` on its FIRST iteration for ANY `$` surviving pass 1: a regex end-anchor in an `is` condition, a `$` inside a human-readable message string, or a `$` in any array-rule element after the first. Plain cross-field `$peer === $me` never crashed, because pass 1 consumes tokens that NAME fields. The loop is now gated `$fields && ...`, joining the #B127 precedent one function later; `validate`'s same-text gate is deliberately left UNGUARDED, being reachable only with a live DOM. Residual, disclosed — and since FIXED (#B239): a `$` token in an ARRAY rule's FIRST argument that names no field (`isInList: ['$100']`) threw one site later at `checkFieldAgainstRules`' `d[<token>].value` — NOT DOM-dependent, so it reached the client too. The substitution is now gated on the token resolving to a REAL field (an existing `d` key with a defined `.value` — two clauses, both load-bearing: an engine-METHOD-name collision like `'$isValid'` resolves to a defined key with no `.value`, and pre-fix spliced the string "undefined" into the rule for a silent wrong verdict rather than a crash); anything else stays LITERAL so strict comparison applies (`'$100'` matches its own literal, rejects non-members with the rule's own error; bare-`$` and mixed elements covered). `$` is therefore the engine's RESERVED cross-field sigil: whether an authored `$` stays literal depends on a runtime field-name collision — a token naming a sibling field is consumed UPSTREAM by getDynamisedRules loop 1, substituted with quoting fit for `is`-condition splices, not array elements (`"yes"` with quotes can never match `yes`), so real cross-field refs in array-rule elements are always-invalid, fail-closed, never-worked, undocumented (the reference scopes `$name` to `is` expressions) — tracked as #B240 (demand-gated; the fix is relocating array-element substitution into checkFieldAgainstRules, whose guarded loop is deliberately preserved as the substrate). This reserved-sigil model is also the #DTO2 `$` guard's CURRENT rationale (the crash rationale is retired — deterministic literal semantics are impossible for any `$`, so toRules() refuses at boot rather than validate collision-dependently). The reversal is measured rather than re-argued: the old bypass was gated on `!errors['isRequired']` — off exactly when #B78 wants it on — beside a two-disjunct guard that was DEAD CODE (`x == '' && x != 0` has no witness), optional+empty ALREADY self-passed through the live else-if, and on required+empty the condition is VERDICT-IRRELEVANT (form validity is `getErrors().count()` and `isRequired` has already errored), so form validity and the request payload are identical in both directions and only the message list changes; the "coercion-sensitive" premise had already been retired by #B199's strict test, which leaves `0`/`false`/`null` as operands that still evaluate the condition. #B82 is neither regressed nor retired — its root `getCastedValue` quoting and its `is()` grammar guard stay necessary and reachable with a FILLED host; #B233 only closes that crash path a second time for an empty HOST, whose condition is no longer compiled at all. Same commit drops the dead `_defaultErrorLabels['isApiError']` entry (zero consult sites: the API path assigns the server's message directly and never calls `replace()`) - but a cross-field `is` (`"$a === $b"`) no longer THROWS when the referenced field is empty (#B82): the client dynamised-rules substitution (`getCastedValue` in `main.js`) now renders an empty referenced operand as a quoted `""` (it was spliced RAW, leaving a dangling `"7654321" === ` that `is()`'s binary-comparison grammar `_SCS_BINARY_RE` rejected -> an uncaught throw that aborted the whole-form validity pass and left the submit trigger ungated on an invalid form, breaking the documented `is`+`isRequired` value-confirmation pattern while the confirm field was blank), mirroring `getDynamisedRules`' own sibling substitution default (`: '\"\"'`); `null`/`undefined` stay raw (already valid operands). Hardening: `is()`'s grammar mismatch now FAILS-THE-FIELD (`console.warn`+`isValid=false`) instead of throwing, so a per-keystroke live check can never abort the gate on a residually-unparseable condition (e.g. a field literally valued `"NaN"`, which the root fix leaves raw). Browser-bundled -> prod dist rebuilt; the `#SCS1e`/`#SCS1h` eval-safety pins target the untouched `_SCS_BINARY_RE`/`_scsParseOperand`/regex-literal constructs, so the hardening flips none of them. **The splice itself was UNESCAPED until #B600-#B602 (gh#77, 0.6.33):** `getDynamisedRules` pastes every referenced value into the STRINGIFIED rule set and parses it back, so a `"`, `\` or control character in a referenced value (a textarea, a password) threw out of the whole pass (client: dead submit; server: the error escaped the plugin), at FOUR sites - `getCastedValue`'s string return AND its number-rule branch, the loop-1 default for a referenced field with no rule, the loop-2 DOM default - each through a STRING replacement that also expanded `$&`/`$'`/`$$`. Every splice is now `quoteForDynamisedRules(v)`, i.e. an escaped quote + `escapeForJsonString(escapeForJsonString(v))` + an escaped quote (the escaper touches ONLY `"`, `\` and U+0000-U+001F, so the splice is byte-identical to the old one for every value that did not throw), through function replacers, with the closing parse guarded (fallback: the rules as declared, which `is()` resolves or fails closed). A number-ruled referenced value splices raw only when it IS a number (typed, or text matching `/^ *-?\d+(?:\.\d+)? *$/` - the padding keeps `" 12"` vs `12` valid as before); other text compares as a string. Note the engine's `isNumber` is LENIENT (`parseInt`: `1"2` and `12abc` pass as the leading number), so a number field's own verdict never discriminates such values - compare `1"2` vs `1"3` instead. `is()`: the string operand is `"(?:[^"\\]|\\.)*"`, decoded with `JSON.parse` (an AUTHORED literal that is not valid JSON, `"C:\dir"`, is read verbatim as before; one that cannot be read at all fails the field, never throws); its own `$` substitution splices `JSON.stringify(value)` through a function replacer; the `(`/`)`/`return` strip runs OUTSIDE string literals only (#B601 - it made `ab(cd` equal `ab)cd`, also on route requirements); and a condition no longer needs an ASCII alphanumeric run to be evaluated (#B602 - `!!!`/`é€` never matched; through `$` tokens the gate saw the token NAMES, so it bit the plugin path and literal conditions only). The client `query` body: a late-resolved token splices twice-escaped and `decodeSplicedQuotes()` restores each spliced literal instead of deleting every escaped quote - byte-identical for any body whose strings carry no backslash (token-wise, so key order survives); an AUTHORED quoted segment holding a valid JSON escape is the one divergence (decoded). **One `$`-token grammar at every site (0.6.33, #B603/#B604/#B606):** `FormValidatorUtil.substituteFieldTokens(text, names, resolve)` — a token is `$` + a field name in scope, the LONGEST name wins in one pass over the original text, a token ends where its name ends provided the next character is outside `[A-Za-z0-9_-]` (so `($a) === ($b)`, `$a===$b`, `$a,` resolve while `$passwordX` leaves `password` alone), names are case-sensitive and RegExp-escaped (`$pw[0]`, `$a+b` resolve), a replacement is never scanned again, and a `$` naming no field stays literal. Before it: the plugin's `getDynamisedRules` replaced one name at a time and re-read the text after each splice, so a value holding `$<otherField>` was substituted twice (#B603 — and the two-argument `is` form re-read the resolved condition through the array-rule scan, which now skips `is`/`is<N>`); its DOM-fallback second loop, which could only act on a `$` a spliced value carried in, is retired with #B234's gate; the engine's `is()` resolved a token only when whitespace or the end followed it and interpolated names unescaped (#B604) — it now resolves OUTSIDE string literals only and never inside a regex-literal condition, so the plugin path's already-spliced literals are not resolved a second time; the client `query` body scanned `\$[-_\[\]a-z 0-9]+` — lowercase-only with a space in the class — so `$passwordConfirm` resolved `$password` + `Confirm` (another field's value on the wire), a prefix pair resolved by body order, and an unknown `$` was sent as the string `null` (#B606). Value semantics per site are unchanged (`getCastedValue`/`quoteForDynamisedRules`, `JSON.stringify`, the twice-escaped `query` splice). The form's validity comes from `getErrors().count()` (the surviving `isRequired` error), never the per-field `.valid` flag (whose only error-dropping reader, `setErrors`, is dead). Length bounds are ARITY-sensitive, and the source JSDoc was WRONG about it until 0.6.3: `"isString": [N]` (same for `isInteger`/`isNumber`) supplies `minLength` ONLY — identical in effect to the scalar `N` — because the exact-length branch fires only when `minLength === maxLength`, so an exact length needs `[N, N]`; the stale comment had propagated verbatim into the published reference page, so correct BOTH surfaces when one is found. Those bounds measure the value's STRING FORM (`val.toString().length`) — until 0.6.3 `isInteger` alone measured a bare `val.length`, which is `undefined` on a real Number, so BOTH its bounds were silently inert on every numeric value: no error, no warn, field left `valid` (#B198, a fail-OPEN bypass reachable from a JSON body, a `validator::{}` requirement, or a preceding `toInteger` — the browser included). `isString` reads the same bare `val.length` at two sites and is CORRECT there because a `typeof(val) == 'string'` guard precedes it, so this class of fix is line-scoped: a whole-file replace of the bound expression hits four sites, two of which must not change. One consequence of measuring the string form, intended: a negative number counts its sign toward the length (parity with the same value arriving as a string). The zero-swallow residual #B198 initially left open is CLOSED by #B199 (0.6.3): loose `== ''` emptiness tests conflated `0`/`-0`/`false`/`[]` with the empty string at FIVE sites — the isInteger/isNumber bounds gates AND the isEmail/isJsonWebToken/isFloat empty-bypasses, where a JSON body's `{"email": 0}` validated as a correct email — all five now compare strictly, so only the literal `''` bypasses (the designed empty-is-adjudicated-by-isRequired contract, preserved byte-exactly); `isString` stays loose behind its typeof guard (operators identical for strings), `isInList` was already strict, and `isDate`'s broader `!val` swallow (a silent half-state: `valid` false, NO error recorded, so the form passes) is deliberately untouched. STILL OPEN sibling (#B200): a TRUTHY non-string in an isEmail/isJsonWebToken field (`{"email": 123}`) hits an unguarded `.toLowerCase()` and the rule driver RE-THROWS, killing the whole validation run — the falsy/truthy non-string space is partitioned between the fixed bug and this one. Custom validators (`bundle/validators/<name>/main.js`) are a BROWSER-ONLY affordance, NEVER a server-side guarantee: the server gate reads `getContext('gina').forms` while the loop it guards reads a bare `gina` that is undefined in Node, so a custom rule never attaches server-side and the engine then silently skips the unknown rule name with no warn — re-validate such constraints in the action. (Publishing that context without also fixing the loop would make EVERY validator construction throw, including for bundles shipping no custom validators.) Since #M21d (2026-09-26) the browser compiles a custom validator as an inline `<script>` — `gina.forms.compiledValidators[<name>]`, one compile per validator per page, carrying the page's CSP nonce when one is set, `//# sourceURL=<name>.js` in DevTools — with NO `eval`, NO `Function` and no `'unsafe-eval'` needed; the scope contract is unchanged (the spliced prologue hands the body `self`/`local`/`isGFFCtx`/`replace` through `this.getValidationContext()`), a file the browser cannot compile throws `[UserFormValidator] Could not evaluate` pointing at the console's SyntaxError, and server-side a FUNCTION registered on the context is attached as it is while a source-shaped one is refused naming the browser-only contract. The bundle build (`core/asset/plugin/lib/js/no-dynamic-code.js`, run by `build` after r.js and again after Closure) also rewrites the two unreachable vendored calls the r.js output inlines — RequireJS `req.exec` (the `load.fromText` transpiler-plugin path) and engine.io-client's `Function("return this")()` global shim — by EXACT match, failing the build if a dependency bump moves them, so the published bundle carries no dynamic-code call at all: Socket's `Uses eval` verdict, which flipped the package score 62 ↔ 42 on identical bytes, has nothing left to fire on (pinned by `test/lib/validator-m21d.test.js` + `test/core/bundle-no-dynamic-code.test.js`). A rule-body edit needs a prod dist rebuild AND flips the section-locked characterization tests by design. Editing trap: `form-validator.js` embeds hidden NO-BREAK SPACE bytes (U+00A0) where a normal space appears inside several `||`/ternary sequences, so a literal-space find/replace spanning one silently fails — patch such regions with a byte-scoped script over clean-ASCII substrings, not a space-spanning match. The blur-time global validation pass sets submit-button state but renders errors ONLY for the touched field — untouched invalid fields stay quiet until interacted-with or submit. Accessibility (#A11Y1): the rule-agnostic chokepoint `handleErrorsDisplay` reflects committed errors into `aria-invalid="true"` (gated on committed-not-warning; `"false"` on clear mirrors native `ValidityState` so it agrees with `:user-invalid`; hidden fields skipped), auto-wires `aria-errormessage` to a gina-owned message div UNLESS the consumer provided their own, focuses the first DOM-order invalid field on a failed submit, and announces blur-time errors via a per-form visually-hidden `aria-live="polite"` region; the per-field aria passes fire only under live-check — the always-on submit pass covers every bound form regardless. Live-region LIFECYCLE (#A11Y2): creation is split out of the announcer into `ensureA11yLiveRegion($form)` and called from `bindForm` — the chokepoint every registration path funnels through — so the region is in the a11y tree from BIND time. It previously created, inserted AND populated the region in one synchronous tick, which reaches assistive tech as a single mutation batch on a node it has never observed, so the FIRST announcement per form (the one that matters most) was the one least likely to be spoken while every later one worked. The region is a CHILD OF THE FORM on purpose: a popin renders its form inside a native `<dialog>` opened with `showModal()`, which leaves everything outside the top layer inert, so a body-level region would go unspoken for exactly the forms that live in popins — the placement is an accessibility constraint, not a convenience. The price of that choice is that a subtree replacement (`$el.innerHTML =` on a popin re-render, a nav fragment swap) destroys it, so `ensureA11yLiveRegion` is create-OR-RECOVER and also re-homes a region whose form node was replaced; any region created or re-homed at announce time is marked fresh and defers its first write one macrotask, so insertion and mutation land in different ticks — that deferral is what stops a recovery from silently repeating the defect, and it is load-bearing because a re-render does NOT always re-bind (`validateFormById` early-returns on an already-registered id, and a multi-form popin's teardown loop splices while iterating so it skips every odd-indexed form). An already-bound region writes synchronously, unchanged; while a deferred write is pending a newer error replaces the pending text so the LATEST message wins, and the timer re-enters the announcer, keeping exactly ONE `textContent` write site. Rule: a live region must be observable BEFORE it is written — separate creation from announcement, and when the same call must do both, put a tick between them. **Submit LIFECYCLE exposure (#A11Y4):** the accessibility signals for an in-flight submit hang off the request window inside `send()` — the only place reached once an XHR genuinely exists — and that placement is the fix for a timing trap, not an accident: the loading state (`data-gina-loading`) is armed at CLICK time, BEFORE validation runs, so a signal hung there would arm-then-disarm on every rejected submit and announce a spurious busy/not-busy pair. Three effects, all scoped to that window. (1) The trigger's focus is captured before gina natively disables it and restored once the request settles: a natively `disabled` control cannot hold focus, so the browser drops focus to `<body>` and re-enabling does NOT bring it back (both measured), which silently cost every keyboard submit its place. The restore is deliberately conservative — only when focus is still on `<body>` and the trigger is still in the document — so a response that opened a popin, redirected, or focused the first invalid field keeps its own focus decision; gina restores what gina took, never more. The `<a>` branch is excluded from the capture: an anchor gets `aria-disabled` from the in-flight lock (since #B312 the framework's only `aria-disabled` write), which does not blur (measured control). (2) The trigger carries `aria-busy` for the request's duration — on the TRIGGER, never the form, because the live region is a CHILD of the form and an ancestor marked busy MAY be treated as "defer announcements in this subtree", which would silence the very channel used to announce; ARIA 1.2 defines no normative behaviour here, so that is a cheap hedge, and the same reading is why `aria-busy` can never substitute for an announcement (it produces none of its own). ⚠️ **Do NOT restate this as "assistive tech commonly defers"** — that wording shipped once and was WRONG: measured 2026-08-05 on VoiceOver/Chrome, an announcement made from inside an `aria-busy="true"` ancestor was spoken normally, so at least that pairing does not defer. The placement costs nothing and still guards ATs that might, but it is a precaution, never a claim about implementations. (3) The start is announced ONCE through the #A11Y2 region; completion announces NOTHING by design, because an errored response is already announced field-by-field by `handleErrorsDisplay` and a second status write over the same polite region in the same beat can truncate it. Announced strings are the framework's own, so they resolve through `gina.config.a11y` with English defaults (e.g. `{ submitting: 'Envoi…' }`) — deliberately separate from `setErrorLabels`, which is keyed by RULE name and owns rule messages. Rule: put a state signal where the state actually begins; a signal armed on intent rather than on the operation announces work that may never happen. **Error-association integrity (#A11Y5):** three fixes sharing one theme — an ARIA assertion is only worth what its target is worth. (a) Hiding the message div used gina's `.hidden` helper (`display: none !important`), which removes it from the accessibility tree entirely — fine for a soft warning, wrong at the TWO hide paths that coexist with an asserted `aria-invalid` (`refreshWarning`'s focus-driven hide, and the refresh re-create), where the field was announced invalid while `aria-errormessage` pointed at an unreachable target. Both now CLIP instead: inline declarations that hide visually but keep the node in the tree, with `display` carrying `!important` because that is what outranks the class's own `!important` (measured — a plain inline `display:block` loses to it); the class is deliberately left in place so consumer CSS keyed on `.hidden` keeps matching. (b) A polite region is announced on CHANGE, so re-writing byte-identical text is commonly not spoken — which is precisely the repeat-error case (blur a field that still fails the same rule). A trailing no-break space now makes the content differ without altering what is read out, and it self-cancels on the next write. NOTE this half is source-derived: the string demonstrably changes, but no assistive-technology pass has confirmed the re-announcement. ⚠️ The same caveat NO LONGER applies to #A11Y2's first-announcement fix — **V1 was CONFIRMED 2026-08-05 on VoiceOver/Chrome** by an A/B whose two arms differ only by a `setTimeout(0)`: the same-tick create-and-write was NOT spoken, the deferred write WAS. So the deferral is load-bearing in practice, not merely defensible in theory; the region-lifecycle discipline above is measured, not inferred. (c) `focusFirstInvalidField` (and its deliberately-duplicated inline twin) gated focusability on `typeof $field.focus == 'function'`, which is TRUE for every HTMLElement — a custom element with neither `tabindex` nor `delegatesFocus` passes it and its `focus()` is a silent no-op (measured), so a failed submit whose first invalid control was such a host focused nothing AND stopped searching. Both loops now confirm `document.activeElement` actually moved before stopping, which is also what makes the JSDoc's "skips unfocusable controls" claim true rather than aspirational. Rule: a capability probe (`typeof x.focus == 'function'`, `'foo' in el`) tests the API's PRESENCE, never its EFFECT — when the effect is what matters, assert the resulting state. Error-MESSAGE visibility has THREE write paths (create / refreshWarning's un-hide / the refresh re-create) and the re-create runs LAST in the live-check pass, so it owns the steady state: it is focus-aware — message hidden while the edited field is the active element, revealed on blur (soft warning border while typing). Rule: when an element is written by multiple paths in a single validation pass, guard the LAST writer — an earlier-writer fix is silently overridden. #B319 (0.6.5-alpha.2): the focus-driven hide is EXEMPT during the framework's own ANSWER focus — both refused-submit paths (the #B246/#B308 display-only reveal via `focusFirstInvalidField`, and the `validate.<id>` failure branch's inline focus twin) render errors then focus the first invalid field, and that focus's synchronous `focusin` re-entered the live-check listener and hid the just-rendered message: a refused submit explained itself only to a screen reader (the #A11Y5 clip kept the node resolvable) while sighted users saw nothing — despite the render itself being VISIBLE (no fieldName ⇒ the live-check branch is skipped, and activeElement is still the trigger/BODY at render time), which is also why an async `query` rule in a repro is incidental (the suppressing dispatch is synchronous inside `focus()`). Fix: a one-shot module flag raised around BOTH focus loops (try/finally, cleared on every exit) gates the focusin arm's `refreshWarning` call; the message stays visible with the hard `form-item-error` styling, aria state untouched, and the first later keystroke re-engages the mid-typing suppression unchanged (in that answered-then-typed configuration the border stays `form-item-error` — the answer focus re-registered the field so `lastFocused` reads it twice and the isWarning heuristic keeps the hard border while the active-element ternary hides the message; the hidden message is the contract, the border there is heuristic). Behavioral lock: `test/e2e/validator-submit-answer-visibility.spec.js` (4 arms, red-first against the pre-fix bundle); browserless pins: `test/core/validator-answer-focus.test.js`. #B387 (shipped in 0.6.11): the one-shot flag covers only that synchronous window — on an async-`query` form with a committed error, a refused submit whose click lands inside the UNDRAINED completion tail of the previous settle had its answer delivered, focused, then re-hidden ~0.2ms later: a stale live-check waiter (woken inside the click's cascade by the reveal pass's deferred release) or the trailing silent global re-validation ran the display-refresh pair AFTER the flag's `finally` had cleared it, and BOTH hide sites key on the same heuristic — "the field is the active element, so the user is editing it" — which cannot tell answer-placed focus from user-placed focus (`refreshWarning`'s error→warning downgrade appends ` hidden`; `handleErrorsDisplay`'s refresh branch re-creates the message born-hidden via its active-element ternary; the occurrence gate is click-inside-the-tail, which is why full-suite/CI runs flaked ~1/20 while standalone runs stayed green). Fix: focus PROVENANCE — a single module slot `answerFocusHold` ({formId, elName}; one slot is exact, only one active element exists) recorded at both confirmed answer-focus points (inside the #B319 windows), consulted by BOTH hide sites for ANY caller however late (provenance beats a pass-staleness latch: the trailing re-validation is a FRESH pass spawned inside the click cascade and would sail through any staleness check), and released on the first genuine user interaction — any TRUSTED native event reaching one of the seven form proxy handlers while the one-shot flag is down (framework `triggerEvent` dispatches are untrusted and cannot release it; the answer's own trusted synchronous focusin is excluded by the flag) — so the deliberate mid-typing suppression re-engages the moment the user actually edits; no timers. Locked by `test/core/validator-answer-focus-hold.test.js` (17 — source pins on every edit site, comment-stripped extracted-real-bytes behavioral arms for both hide sites + the release helper, red-first against the pre-fix bytes; gina.js dist pins) and the §02 e2e arm (25/25 post-fix vs the ~1/20 pre-fix CI red whose signature was `Received: 1` + msgClass `hidden` — a recurrence of that signature is a NEW defect, not #B387). #B348 (shipped in 0.6.11): `revealValidationState`'s completion STARVED on any form whose async `query` field was not declared last — the reveal pass is un-latched, writes neither `isSubmitting` nor `isValidating`, and on a valid form its verdict is clean, so its waiter completion matched NO dispatch branch (terminal errors>0 / the latched dispatch / last-field / the display-only live-check arm): `onDisabledTriggerReveal` never ran, the stale `data-gina-form-submit-gated` marker never re-synced, and a fully valid form ate every later click until reload (measured live: 2 post-settle clicks, 0 POSTs — field declaration order decided whether the documented self-heal worked). Fix: the reveal's callback carries a completion identity (`onDisabledTriggerReveal.isRevealCompletion = true`, the engine's own `cb._data`/`cb._errors` property idiom) and the waiter chain gains ONE else-if chained after the display-only arm, gated on the SAME terminal condition the errors>0 block uses (`hasParsedAllRules && asyncCount <= 0` — the guard that stops a multi-query-field early wake, where the first waiter fires at asyncCount 1): a terminal reveal completion that matched no other branch dispatches `validated.<formId>` with its own cb. Every previously-working shape is byte-identical (errored reveals keep completing via the terminal branch, query-last via last-field, latched submits via the latched dispatch, live-check stays display-only), and the un-latched programmatic-submit starve (#B347) is deliberately untouched — its cb carries no marker and its fix is gated on its own repro. Known residual, pre-existing on EVERY branch: a query field whose own rule object continues past `query` never sets the terminal flag and still starves — same guard as the existing dispatch, no new asymmetry. Locked by `test/e2e/validator-reveal-starve.spec.js` (red-first: the starve arm failed on the pre-fix bundle while the query-LAST control arm passed on the same bytes, pinning the defect to field order; the scene manufactures [valid values + stale gate + stale committed error] deterministically via a prototype-setter silent fill after an errored reveal, with every precondition an explicit expect so an impossible scene voids loudly) and `test/core/validator-reveal-completion.test.js` (9 — stamp/consult/placement pins, extracted-real-bytes reveal arm asserting the stamp as a runtime value, gina.js verbatim pins AND a gina.min.js exact-count pin — unlike a local flag, the property name survives Closure). The not-ready submit trigger is marked `data-gina-form-submit-gated="true"` + the class `gina-form-submit-disabled` (#B312 retired `aria-disabled` from this marker — its contract says not-operable while the #B246 gate deliberately answers the click with the error reveal; authored `aria-disabled` remains enforced by the gates and is never auto-cleared), NEVER native `disabled` (#B76 — a natively-disabled button emits no click, so the validate-render-focus guard could never run); `isValid()` is the real send gate, and **the framework now ships a default not-ready look (cursor `not-allowed` + dim; deliberately no `pointer-events`, which would swallow the click the reveal answers) that consumer CSS overrides**. Form-associated custom elements (FACEs) participate in binding + live-check (#CC2 — hyphenated members of `form.elements`; their own `.value` accessor is honoured, live-check rides the composed bubbling `change`; author contract: `static formAssociated`, a `name` attribute, a `.value` getter, composed `change` on commit). **Radio-group collection (#B221):** an unchecked non-boolean radio group whose rule declares a truthy `isRequired` is collected as an EMPTY value by BOTH collectors (`getFormValidationInfos` + the native-submit inline copy) so `isRequired` adjudicates it via the standard emptiness test — pre-fix no collection arm admitted the shape (each required `.checked`, a `true|false`-shaped value, or an `isBoolean` rule), the DOM handle was held in `$fields` but the VALUE never entered `fields`, so no rule ran against the group and a radio-group-only form short-circuited BOTH submit guards (field count 0 reads as nothing-to-validate → synthetic `isValid() === true`) and submitted its XHR with zero client-side validation. Other unchecked groups stay absent-when-unchecked (native parity: no rule / `isRequired: false` unchanged; `isBoolean`-declared groups keep the force-false arm), checked members post exactly as before, and on the auto path a required-empty form is invalid and never sends — so the wire only changes for the newly-gated shape. Enforcement-tightening: forms that silently submitted with nothing picked now gate on the pick (trigger marked not-ready at bind under default-on live-check — `data-gina-form-submit-gated` + class since #B312 — message on submit attempt, re-enabled after picking). **The re-enable is real only since #B228:** the radio live-check listener was registered under a `changed.<id>` event name nothing dispatches on a user pick — the form-level click proxy short-circuits into the radio state updater (which never dispatches any gina event), and the change proxy dispatches ONLY names present in the event registry, which radios never registered (checkboxes have that registration via their state-updater relay; radios' equivalent relay is keyed on the bare element id, which nothing triggers) — so the whole-form silent pass never re-ran after a pick and the trigger kept its bind-time disabled state indefinitely, while submit-time validation (a separate call chain) accepted the checked group and let the click-guard send: flows completed, only the trigger state was wrong (announced disabled to assistive tech; automation actionability checks refuse `aria-disabled`). Radios now ALSO register the proxy-dispatched `change.<id>` name alongside `changed.<id>` — the handler's radio arm accepted `change.`-typed events all along, so one registration line closes the loop: mouse, label and keyboard picks all re-run the field + whole-form passes (single delivery per pick — native `change` fires only on real state changes; the legacy `changed.<id>` name stays registered for the relay/programmatic path; checkboxes byte-identical). Latent since the live-check's introduction, invisible until #B221 armed it. **A field that DRIVES its own conditional block lost its BASE rules until #B229:** `forEachField`'s per-field tail read `if (isInCase || caseName == field) continue;`, and `caseName` is assigned inside the `_case_` scan loop that re-runs in full on EVERY field iteration, so it always held the LAST scanned `_case_` key's driver name — when the iterated field WAS that driver the `continue` skipped the rest of the iteration, base-rule check included. A rule shape `{ "group": { "isRequired": true }, "_case_group": { "conditions": [...] } }` therefore never adjudicated `group`'s own `isRequired` on the bind pass, the live-check global pass OR the submit pass: the form never gated and an empty submit went out with zero client-side validation — the silent-submit class above, resurfacing for the self-driving shape and structurally DOWNSTREAM of the collection fix (the group IS collected as `''`; only adjudication was missing). The tail is now split: `isInCase` keeps its own `continue` (it is dead code — never assigned truthy — and is preserved as such), and the `caseName == field` arm runs the base-rule check before continuing, restoring the driver's collected value around the call (the check deletes the field from the object it is handed, and that object is where the scan block re-reads the case VALUE on every later field iteration; a deleted entry re-seeds from the DOM, which for a radio group is the FIRST member's value regardless of `.checked`). Which conditions apply is unchanged — the direct-case block is never entered for a self-driving case, pre- or post-fix (measured) — and the fix is order-independent: a driver declared BEFORE another `_case_` block was already adjudicated (the tail's comparison never matched it), so the post-fix union is every driver carrying base rules. Client-only: the server form-body path throws earlier on any `_case_`-bearing rule set (conditional rules are unsupported there). Enforcement-tightening: a form built on this shape starts gating where it silently submitted. Known interplay, pre-existing: on a rule set with NO `$` tokens, a pick whose value matches a `_case_` condition lets the case machinery PERSISTENTLY replace injected fields' rules in the live store (the site-B replacement), so a later `reBind()` can re-arm the gate from the mutated store — `$`-bearing rule sets are immune (the dynamised-rules path clones). **A conditional driver's collected VALUE survives every full-form pass since #B230:** the base-rule check deletes each adjudicated field from the object it is handed, and until #B230 only the last-declared driver's entry was restored (the #B229 arm above) — any OTHER field that both carries base rules and drives a `_case_` lost its stored case value the moment its own rules were adjudicated, so later field iterations re-read it from the DOM (a radio group's FIRST member regardless of `.checked`) and matched conditions against a value the user never picked — spuriously requiring the wrong flow's fields (a correctly-completed picked flow could not submit), or with excluding condition rules under-validating the picked flow — while the driver's own direct-case block read `undefined` in the same pass and matched nothing. The entry is now backed up and restored around the base-rule check for any field driving a `_case_` in the live rules OR the pass-entry rule clone; the union matters because inside a direct-case recursion the pass's rule set is the condition's own rules, which carry no `_case_` keys, so the live-rules test alone is blind there. Non-driver fields keep the deletion untouched (the condition pull-in gate, the direct-case exclude injection and the async-`query` re-validation input all read those absences today), and a driver with no rules of its own is byte-identical — including the legitimate first-scan DOM seed for rule-less unchecked groups, which is preserved. **`setFlash` `[null, "message"]` works client-side since #B226 (the form the reference documents):** it previously lost its custom message in the browser ONLY — `lib/merge` classified a `null` array element as an object (`typeof null`) and dropped it on every no-override merge, and the client rules path re-merges the whispered rules (the `data-gina-form-rule` bind merge, the `gina.hasValidator` instance re-merge, the `_case_` merges), so the engine received a one-element array, bound the message to the ignored first `regex` argument, and rendered the built-in label; `["", "message"]` always survived (empty strings, `false` and `0` are primitives — `null` was the only casualty), and the server was unaffected (it reads the boot-loaded rules without those hops). The fix is in `lib/merge` itself, so no-override merges now preserve `null` array elements as VALUES framework-wide (and the index-merge branch stops manufacturing `{}` from a `null` source element) — a merge consumer relying on the silent compaction sees the `null` slots preserved. **Bracket-notation and nested-authored rule KEYS enforce on the SERVER form-body path since #B241:** the rule parser canonicalizes every rule key to a dotted path (`account[username]` becomes `account.username`; a nested rule tree flattens to its dotted leaves) while the server's fields map kept the RAW posted keys, so such rules never joined — the field was silently skipped with no warning, fail-open for every rule-keyed directive alike: checks (`isRequired`, `isEmail`, ...), the `exclude` drop, and value transforms — on BOTH production wire shapes (flat bracket keys: the client posts its name-keyed data as JSON and the JSON body path deliberately does no bracket expansion; and nested objects: the multipart and urlencoded parsers expand bracket names). The server now synthesizes dotted-canon field aliases ALONGSIDE the raw keys (originals kept, so `$name` cross-field tokens keep resolving off the raw posted names, and an all-flat payload synthesizes nothing — byte-identical behaviour), then folds alias outcomes back at egress: error keys return under the DOM-name bracket form the client renders against, and the validated data output keeps its materialized shape with exclusions and transforms applied (a parent object emptied by an exclusion is pruned along that alias's path only — a posted empty object survives). The client join was always bracket-on-both-sides (a named rule set passes through with its authored keys; nested-authored sets are reconstructed to bracket names at bind time), so this brings the server to parity — quirks included: a caller that posts the dotted key form keeps its own addressing, and the no-rules path still returns the payload verbatim. Behaviour change by design: a bracket-keyed or nested-authored rule that never fired before now enforces — anything relying on the old silent skip starts rejecting or dropping those fields. **Upload previews carry a text alternative (#A11Y7/U1, 0.6.4).** The staged-upload client layer builds its preview `<img>` in two MUTUALLY EXCLUSIVE branches of `onUpload` — one for a file with no server-side `preview` object, one for a returned preview variant — and neither set `alt` at all (not even `alt=""`), so assistive tech fell back to reading the temp URI aloud, once per staged file (WCAG 1.1.1). Both now set `alt` from `files[f].originalFilename`: the name the USER chose, deliberately NOT the sibling `files[f][key].originalFilename` of the preview variant, which is a server-generated artefact that means nothing to the person listening — the same distinction the adjacent `data-upload-original-filename` / `data-upload-preview-original-filename` pair already encodes. The fallback is `''` (a properly ignored image) rather than letting a missing name be spoken as a placeholder. The preview is INFORMATIVE, not decorative: it is the only signal telling a user which file is staged, and everything else in the upload layer is still silent — progress is attribute+`textContent` with no `role="progressbar"`/`aria-value*`, upload errors are an `innerHTML` write with no `role="alert"` that never calls the polite region the plugin already owns, and the reset control is an `<a href="#">` (U2/U3/U5, tracked in the accessibility audit, not fixed here). Maintainer gotcha: the two branches are per-file exclusive, so a one-site fix silently misses every upload whose server returns a preview object — fix both or neither. **A not-ready submit trigger really refuses the send — and its marker is aria-free (#B246 + #B312, 0.6.5).** `updateSubmitTriggerState()` marks an invalid form's trigger with `data-gina-form-submit-gated="true"` + the `gina-form-submit-disabled` class and deliberately never native-`disabled` (a natively-disabled button emits no click at all, so nothing could tell the user WHY it is dead) — and, since #B312, never `aria-disabled`: the gate ANSWERS the click with the error reveal, which that contract forbids for a control announced disabled, so the attribute belongs to consumers (authored marks the gates enforce and never auto-clear) and to the anchor in-flight lock. #B246's origin: NOTHING READ the marker — a click ran the entire submit cycle (collect → validate → `validate.<id>`) and only the `isValid()` gate stopped the send: the trigger was inert in appearance ONLY. `clickProxyHandler` now intercepts a disabled-or-gated trigger BEFORE the `submit.<id>` dispatch — so `bindSubmitEl`'s handler never runs, `isSubmitting` is never latched, and no send path is reachable by construction — and answers the click with a display-only `revealValidationState()` pass that renders every invalid field, focuses the first, and re-syncs the trigger state so a STALE not-ready marker on a form that has since become valid heals itself — the heal touches only the marker + class; authored `aria-disabled` and the in-flight lock survive it (#B313 closed by construction). The predicate `isTriggerDisabled()` reads the CLICKED element rather than `$formInstance.submitTrigger` (a form may carry several submit buttons while only one registers) and mirrors the popin plugin's existing trigger gate on the shared channels (authored `aria-disabled` + native `disabled`), so both subsystems agree there; since #B312 it ALSO fires on the validator-owned not-ready marker `data-gina-form-submit-gated="true"`. **#B293 (0.6.5) NARROWED that predicate: the native `disabled` ATTRIBUTE now counts only where `disabled` is not a real IDL property (`!('disabled' in $el)`).** As first shipped it accepted native `disabled` on ANY element, which silently killed the near-universal double-submit guard: a click listener on the submit button that sets `disabled` to block a second submit is bound to the button and therefore runs BEFORE gina's delegated form-level proxy reaches the gate, so the gate saw the attribute, cancelled the click, and `send()` never ran — then the consumer's own handler cleared the attribute again, leaving NOTHING marked, a normal-looking button, and every subsequent click equally dead. Measured: a natively-disabled `<button>` delivers **no click to JS at all** (the browser suppresses it), so on a real form control that arm could only ever fire on an attribute set DURING the dispatch — it protected nothing and cost everything; on an `<a>` or a custom element the browser enforces nothing, so there the attribute is still the only honest signal and is still read (`'disabled' in $el` measured: button/input `true`, anchor/custom-element/span `false`). Regression cover is an **e2e** (`test/e2e/validator-submit-native-disabled.spec.js`, 4 arms), deliberately NOT a replica: the defect is an ordering interaction between a consumer's own listener and gina's delegated proxy, which only the real bundle handling a real click can exhibit — `test/core/validator-submit-trigger-state.test.js` covers the same gate with replicas and stayed GREEN throughout, including two tests named as controls for the working case. Gotcha for maintainers: `focusFirstInvalidField()` deliberately DUPLICATES the inline focus loop inside the `validate.<id>` guard — that inline shape (`_a11yErrs` / `_aField`) is locked by source pins, so collapsing the two would break tests unrelated to this fix; keep them in step by hand if the focus rule ever changes. **Staged uploads speak (#A11Y7/U2+U3+U5, 0.6.4).** Three defects in one client layer, fixed together because they share one plumbing change. (a) `updateUploadProgressIndicator` wrote `textContent` plus two `data-gina-upload-progress*` attributes and NO ARIA, so any indicator that is not a native `<progress>` was unlabelled text; it now carries `role="progressbar"` + `aria-valuemin="0"` / `aria-valuemax="100"` / `aria-valuenow`, with `aria-valuenow` tracking the percent attribute EXACTLY — absent while `preparing`/`indeterminate` and on `error`, because an absent value is how a progressbar signals "unknown" while a stale or zeroed one reads as real, stalled progress. `reset` strips the ARIA too (it strips everything this layer ever set); `processing` advances the state only, preserving the last value so a determinate bar stays full through the server-side window. A native `<progress>` is deliberately untouched — it already exposes all of this, and an explicit role risks overriding the implicit one. (b) The TRANSITIONS are announced through the polite region `bindForm` already stands up: `uploadStarted` at the selection kickoff — deliberately NOT gated on the indicator being present, since opt-in-by-presence is right for a visual and wrong for an announcement — and `uploadComplete` in the success branch only. Per-tick progress is NEVER announced: `aria-live="polite"` coalesces but does not throttle, so one announcement per `onprogress` event would bury every other message on the page. There is deliberately NO generic `uploadError` key, because the error path announces the server's own message and a generic second write in the same beat would clobber it (the region is latest-wins — the same reasoning that keeps `releaseSubmitA11y` silent). (c) The upload error container is written via `innerHTML` while `display:none` and revealed only by a fade-in — exactly where a `role="alert"` on it is unreliable — so the error routes through that same region, announcing `$error.textContent` (the RENDERED text: a message can carry markup, and the url-action checker substitutes `<br>`). Note the container being consumer-owned is NOT the discriminator: the progress indicator is equally consumer-supplied and gina already writes attributes into both; the hidden-then-revealed shape is what rules out `alert`. (d) The generated reset control was `<a href="#">Reset</a>` — announced as a link, not activatable with Space, and identical across every staged file. It now carries `role="button"` plus an `aria-label` of `<visible label> <filename>`, the visible label kept as a PREFIX so the accessible name still contains it (WCAG 2.5.3 Label in Name) and reusing the consumer's own `data-gina-form-upload-reset-label` so it is translated wherever that already is. The role is stamped ONLY on an anchor (strict `/^a$/i`, unlike the loose `/a/i` guarding `href` a few lines above, which also matches TEXTAREA/CANVAS/LABEL), so a consumer supplying their own `<button>` keeps its implicit role. A `role="button"` that cannot be operated by Space would be worse than no role at all, so the binder adds a `keydown` handler mapping Space to the removal — anchors only, since a native button already fires click on Space and would otherwise run the removal twice. (e) That control is REMOVED from the DOM while holding focus (`$resetLink.remove()`, which its own comment requires be last so the click listener dies with the node). The finding described this as a `display:none`; both happen, and the removal is the decisive one — a fix aimed only at the hide would not have worked. Focus now moves to the file input BEFORE the removal, guarded on the control actually holding focus (a programmatic reset must not steal focus from elsewhere) and CONFIRMED afterwards rather than assumed, since `typeof focus == 'function'` is true for every HTMLElement and `focus()` is a silent no-op on one that cannot take it. The removal is then announced (`fileRemoved`, default `'%s removed'`) using a FUNCTION replacer — never a string — because a file name may contain `$` and a string replacement would expand `$&`/`$1` patterns inside it. All three new strings live in `A11Y_LABELS`, so `gina.config.a11y` overrides them. **Maintainer gotcha worth the line:** `test/core/validator-upload-reset-delete.test.js`'s `fnSlice` takes a FIXED 12000-char window from its declaration anchor, and this fix grew `onUploadResetOrDelete` by ~2.6k chars — pushing the `#R8` strip and the callback dispatch outside it and reddening four pins whose code was present and correctly ordered the whole time. Widened to 16000 (the function ends ~14.2k past its anchor), but it is still a fixed window and will bite again; the durable fix is to brace-walk to the real function end, as the sibling `validator-upload-progress.test.js` already does. **Not verified against assistive technology** — source-derived plus a served-bytes check, like the rest of this arc. **#B295 (2026-08-06) — an async `query` rule left the form's validity state STALE once it settled.** `onasyncCompleted` derives its verdict from `d.getErrors(field)`, which `form-validator.js` scopes to a SINGLE field, so its `isFormValid` means "this field passed", never "the form is valid" — and the entire update block sits behind `if (!isFormValid && …)`. A form that had just become valid therefore received NO update: `updateSubmitTriggerState` was never called, so the bind-time not-ready mark (then `aria-disabled="true"`; the `data-gina-form-submit-gated` marker since #B312) survived on a valid form and `$forms[id].errors` kept listing the field. Cosmetic until #B246 made the click gate read that marker — then the first click after the query settled was silently eaten, and only the second went through (`revealValidationState`'s re-sync self-heals it). Fixed by handing the verdict to the fresh whole-form pass the same function ALREADY runs for the not-last-field case (`needsGlobalReValidation`), whose unconditional `updateSubmitTriggerState($currentForm, gResult.isValid())` settles the marker while its `handleErrorsDisplay` empty-errors branch clears the stale record; measured to add NO extra round-trip, because the query rule's already-registered-listener guard short-circuits the re-run. **The measurement that CHOSE the fix, and the reason the two cheaper shapes are wrong:** with a second required field left invalid, that fresh pass reports its error while BOTH the field-scoped `cb._errors` AND the in-pass `d.getErrors()` report none — so un-gating the existing call, or letting `handleErrorsDisplay`'s empty-errors branch auto-enable via its own `updateSubmitTriggerState(..., true)`, would each have ENABLED submit on an invalid form and re-opened #B246's hole. Both were rejected on that arm, not on reading. Covered by `test/e2e/validator-async-query-revalidation.spec.js` (4 arms, red-first: 01+02 RED on the pre-fix dist, 03 "another field invalid ⇒ still blocked" and 04 "no query rule ⇒ sends" GREEN throughout); arm 01 deliberately waits on the QUERY settling rather than polling the trigger's not-ready marker, because polling the guard is exactly what let the pre-existing FACE e2e stay green through #B293. Browser-bundled ⇒ consumer pickup is restart AND re-bake. **#B294 (2026-08-06) — a submit trigger whose DOM NODE is REPLACED after binding (the AJAX / popin re-render shape) was PERMANENTLY and SILENTLY dead.** Submit binding is **two-stage and only the first stage is delegated**: the native click proxy is registered on the FORM (`:8090`, "Form-level proxies: capture bubbled events from in-tree controls") and survives any re-render, but the listener that does the work is attached to the trigger NODE by `bindSubmitEl` (`:8231`). `gina.events` is a **name → id-STRING** registry (`utils/events.js:42`), so the `submit.<id>` key outlives the node: after a `cloneNode(true)`+`replaceChild` the `:8037` dispatch gate still passes, `cancelEvent` suppresses the native submit, and `triggerEvent` fires at a node with no listener — so the form does not even fall back to a normal submit. Nothing self-heals (`bindForm` latches `binded` at `:8580` behind `:585`, cleared only by `unbindForm`; there is no `MutationObserver` and no `isConnected` check in the bind/dispatch path). **Pre-existing, NOT a 0.6.4 regression** — measured identically on published 0.6.3. Fixed by marking the bound node with a **JS expando** (`__ginaSubmitBoundFor`) and re-binding the live node at the dispatch gate when the marker is absent — measured: `cloneNode(true)` copies `data-*` attributes but NOT expandos, which is exactly why the inherited `dataset.ginaFormSubmitTriggerFor` is useless as a fresh-node test and was a red herring in the original filing. **The rejected alternative is worth recording:** delegating the custom `submit.<id>` event to the form also fixes it (measured), but was measured to RETAIN the `gina.events` key past `unbind` (`registryKeyDeleted:false` vs stock `true`), which would force an edit inside `unbindForm`'s removal loop — six guard variants all keyed on `gina.events[name] == element.id`. Covered by `test/e2e/validator-submit-trigger-rebind.spec.js` (4 arms, red-first: 01 single replacement and 03 double replacement RED pre-fix; 02 untouched-sends and 04 replaced-but-invalid-still-refuses GREEN throughout, so re-binding provably does not bypass validation). **Diagnostic reflex for any "it stopped working after we re-rendered" report: the question is not whether the click listener survived — it did, it is on the form — but whether the node carrying the stage-2 listener is still the node in the document.** Browser-bundled ⇒ restart AND re-bake. **The submit proxy's gesture gate reads the LIVE trigger (#B308, 0.6.5).** The per-form `submit` proxy enforced its disabled gate on a DOMParser copy of the form's innerHTML, testing only native `.disabled` on the copy — blind to the gate's own marker (then `aria-disabled`, `data-gina-form-submit-gated` since #B312, written by `updateSubmitTriggerState`) while SIGHTED on a native `disabled` written mid-dispatch by a double-submit guard (the exact attribute #B293 ruled must not count on a real control — so wrapped-label + double-submit guard was a dead submit, the #B293 signature alive on the submit path). A gated trigger's trusted gesture therefore ran the full collect → validate cycle and SENT whenever the values validated. Now an `e.isTrusted` submit on a form whose REGISTERED trigger is `isTriggerDisabled()` is cancelled and answered with the same display-only `revealValidationState()` as the #B246 click path (errors rendered, first invalid field focused, stale marker re-synced); programmatic `$forms[id].submit()` arrives untrusted (triggerEvent CustomEvent) and deliberately keeps the fresh-validate path — a programmatic submit is not operating the control, the fresh pass is the honest gate there, and cancelling on a cached marker would break fill-then-submit flows. Two gesture shapes reach this gate (probe-measured, Chromium): a wrapped-label click (`<button type="submit"><span>` — the span has no `.type`, so the click proxy never fires and the gesture surfaces as a native submit event), and Enter in a form with NO native submit button (e.g. an `<a data-gina-form-submit="true">` trigger — implicit submission fires a DIRECT trusted submit; with a native button present, Enter instead synthesizes a click on the default button that the #B246 CLICK guard already refuses while the marker stands, so that shape never needed this gate). Covered by `test/e2e/validator-submit-proxy-disabled-gate.spec.js` (5 arms, red-first BOTH directions via the committed `B308_PREFIX_BUNDLE` route-swap hook: label-click and A-tag-Enter RED pre-fix on an EVENT discriminator — the pre-fix cycle fires `validate.<id>` from the no-`on('submit')` branch, while the post-fix reveal is display-only and fires nothing; POSTs cannot discriminate, since an invalid form never sends on either side). Scene lesson for e2e authors: stale-marker scenes are racy BY CONSTRUCTION — a page-level `.value =` write on a bound field is heard SYNCHRONOUSLY (the value property is instrumented and the silent live validation repairs the marker inside the write), and a delayed global silent pass can heal a protocol-level fill's stale marker mid-arm — so pin gate behaviour on a genuinely-invalid form and discriminate on event emission. Browser-bundled ⇒ restart AND re-bake. **The anchor in-flight lock owns `aria-disabled` alone; the settle releases remove it unconditionally (#B309 → #B312, 0.6.5).** On an `<a data-gina-form-submit="true">` trigger send()'s in-flight lock and the not-ready gate historically SHARED `aria-disabled` with opposite lifecycles — the settle releases (the `loadend` fail-safe + the readyState-4 twin) removed it unconditionally, so a mid-flight gate re-mark was silently erased at settle (#B309), and the reveal's valid-form heal erased the LOCK mid-flight the same way (#B313). #B312's single-writer split dissolves the class structurally: the gate writes only `data-gina-form-submit-gated` + the class, the lock is the sole framework `aria-disabled` writer on anchors (buttons lock via native `disabled`), both settle releases are back to the plain unconditional `removeAttribute` (the interim #B309 verdict-stamp replay retired unreleased, its 18-test file with it), and the reveal's show branch never touches aria — so authored `aria-disabled` survives every framework pass (enforced by the gates, never auto-cleared: consumers relying on the old auto-clear-on-valid must migrate) and #B313 is closed by construction. Scene fact that shaped the design: live-check is DELIBERATELY quiet during a real in-flight submit (the #B192 valid branch holds the submit latch until settle), so keystrokes cannot re-mark mid-flight — the reachable mid-flight writer was the #B246/#B308 reveal itself. Covered by `test/core/validator-submit-gated-marker.test.js` (17 — block-scoped single-writer negatives immune to right-extension, authored-aria + lock survival behaviour, the shipped `isTriggerDisabled` EXTRACTED and EXECUTED, terminator-anchored release-shape pins, dist-fidelity pins validated red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. **isRequired's emptiness is anchored at both ends, and trim strips both sides (#B245, 0.6.5).** The emptiness conjunct was `!/^\s+/.test(value)` — leading-anchor only — so ANY leading-whitespace-padded value read as empty (" x" rejected, "x " accepted), and under the documented isRequired-first ordering a paired `trim` healed `local.data` AFTER the error was recorded: a rejected field carried the trimmed non-empty value in the same result. Now `/^\s+$/`: whitespace-ONLY is empty, padded values pass, all-whitespace and the empty string still fail. Same fix: `trim`'s replace gained its missing `g` flag (first-match-only rewriting left the trailing run whenever a leading run matched — " x " → "x "). The validation-rules reference is corrected (it had documented the leading-space rejection as intended while prescribing the isRequired-then-trim pairing that could not work under it). Covered by `test/core/validator-required-whitespace-trim.test.js` (block-scoped comment-stripped source pins + the heal matrix on the REAL plugin over the server auto path + dist-fidelity counts red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. #B345 (issue #59, shipped in 0.6.7): the #SCS1e paren/`return` strip used to run BEFORE the regex-vs-comparison branch split, so a parenthesized `is` regex literal compiled from MANGLED text — groups destroyed and anchors rebound (`/^(a|b)$/` behaved as `/^a|b$/`: substring-permissive for middle alternatives), quantified groups requantified (`(#TAG)?` → literal `#TA` + optional `G`), and a literal `return` inside a pattern deleted — all silent, since the stripped text still compiled as a valid regex (the `Invalid regex literal` throw structurally cannot catch it). The strip now lives inside the binary-comparison branch only (grammar lock + authored-paren tolerance unchanged — `("a") === ("a")` still strips to a valid comparison); a regex literal compiles exactly as authored, that branch never evaluating the condition as JS. Behavior change (changelog ACTION REQUIRED): paren-carrying `is` patterns now match as authored, i.e. stricter; and the paren-wrapped-regex edge `(/foo/)` now fails closed in the comparison branch instead of being unwrapped into the regex branch. Locked by `test/lib/validator-is-regex-parens.test.js` (14 — red-first source ORDER pins, the reported reproduction, anchors/quantifier/`return` arms, comparison-branch controls, gina.js + wrap-immune gina.min.js order pins). #B389 (gh issue #63, shipped in 0.6.11): the Safari-autocomplete keydown interception (`handleAutoComplete` — REAL-Safari-only per #B135; preventDefault + programmatic value rebuild behind a transient readonly) restored the caret only two setTimeout(0) hops after each rebuild, while a `.value` assignment parks the selection at the END of the field (measured on WebKit AND Chromium) — so a fast second keystroke read a stale `selectionStart` and composed scrambled text ("AXB" where the user typed "ABX"; deterministic whenever two keydowns share one task, which live-check work between keystrokes makes routine). Fixed with a synchronous caret commit after every rebuild (`commitCaret` — measured to stick, and to survive the readonly toggle, on both engines) plus an element-recorded desired-caret tracker (`_ginaAcCaret`/`_ginaAcPending`) the handler trusts while a restore is in flight; the deferred restore (`queueCaretRestore` — the autofill-suppression readonly dance, mechanism-identical) re-asserts the LATEST committed position instead of its own stale capture, and arrows commit through the same tracker so a pending restore cannot undo them. By-catches in the same switch, all fixed to native behavior: #B390 Backspace at position 0 deleted the FIRST character (native no-op), #B391 Delete with a selection starting at 0 ate one char MORE than the selection, #B392 ArrowLeft at position 0 wrapped `setSelectionRange(-1)` to the unsigned maximum and teleported the caret to the END (now floored at 0). Browser-bundled ⇒ pickup is bundle restart AND re-bake. Tests: `test/core/validator-autocomplete-caret.test.js` (red-first: extraction controls, source pins, extracted-real-bytes behavioral arms on a caret-to-end element model, dist pins) + the webkit-only e2e `test/e2e/validator-autocomplete-caret.spec.js` (full-stack real-WebKit reproduction, red-first "AXB" → green "ABX"; chromium/firefox skip — #B135 gates them out; runs in the on-demand cross-engine job). **#B200 (shipped in 0.6.12): a NON-STRING field value no longer aborts the whole validation pass.** `isEmail` and `isJsonWebToken` opened with a TRUTHY-only coercion (`(this.value) ? this.value.toLowerCase() : this.value`), so a truthy non-string — `123` / `true` / `[]` from a JSON body, or a checkbox boolean on the client — reached `.toLowerCase()` and threw; `trim` was WIDER still, its type guard present but COMMENTED OUT and no truthy guard either, so EVERY non-string threw, a falsy `0` included. In all three the rule driver's catch RE-THROWS (`[ ginaFormValidator ] could not evaluate …`), so one bad field kills every remaining one: server-side the request goes unvalidated, client-side the boot-time binding loop dies and later forms silently lose validation AND CSRF injection. Fixed by type-guarding each site against a precedent already in the file — the #B87 `query` coercion for the two rules, isFloat's identical guarded `.replace()` for `trim`. **Measured, and the load-bearing check: the guard does NOT open a #B199-style silent bypass** — a non-string passes through untouched and the rule's own regex then rejects it, so `isEmail`/`isJsonWebToken` record a normal rule-keyed error (`isValid === false`), while `trim`, being a transform, leaves it untransformed (the #B245 both-ends strip on real strings is unchanged). **Reusable rule: type-guard BEFORE a string coercion, never truthy-guard** — a truthy check admits every non-string except the falsy ones (the authn.md §4 lesson, and the same split that sent falsy values to #B199 and truthy ones here). Deliberately NOT fixed in that pass: `isDate` (its throw is a purpose-built catch — an error-contract design call, #B397). `toFloat`/`format` — plus the same-family `set` — were later fixed by #B398 (context-safe rules; `test/core/validator-context-safe-rules.test.js`): `toFloat` reads the live DOM value only when one is reachable and otherwise uses the submitted `this.value` (server-side that IS the raw value, so the rule is fully functional in both contexts; the missing comma that leaked `isFloatingWithCommas` as an implicit global is fixed with it); `set` assigns the value everywhere and guards its DOM write (`isGFFCtx && target`); `format` always worked in BOTH contexts after `isDate` — the filed "dateFormat prototype extension absent server-side" mechanism was REFUTED by measurement (helpers/index installs `Date.prototype.format` unconditionally, server included) — and a non-Date value now throws a NAMED authoring error (`apply isDate(mask) before format(mask)`) instead of the opaque `val.format is not a function` (`toFloat`/`format` were first misread as this same class by a probe whose STRING CONTROL also threw, which is what exposed them). Browser-bundled ⇒ pickup is bundle restart AND re-bake. Tests: `test/core/validator-nonstring-value-guards.test.js` (comment-stripped source pins red-first vs the pre-fix blob, behavioural arms asserting the VERDICT not merely the absence of a throw, wrap-agnostic dist pins red-first vs the pre-fix artifact, plus untouched-rule controls incl. one labelled INVARIANT because it cannot go red). **The form-level keydown proxy defers the native-cancel decision to the namespaced handler (#B444, gh issue #67):** `addListener(gina, $el, 'keydown.<id>', fn)` registers a CUSTOM event type a native keydown can never fire; the form-level `keydownProxyHandler` bridges the two by re-dispatching - and it used to `cancelEvent()` the NATIVE keydown unconditionally BEFORE that dispatch, so on real-Safari UAs (where the autocomplete interception registers exactly such a handler) EVERY modifier chord on a live-checked autocomplete-suppressed field was dead - paste, select-all, copy, cut, undo - with NO paste/beforeinput event observable anywhere (preventDefault on a native keydown suppresses the browser editing command itself), while the interception handler's own chord bail sat one layer too low to help. The proxy now dispatches FIRST and cancels the native keydown only when the handler prevented the synthetic event (which the interception already does on the paths it re-implements and already does not on chords); `triggerEvent` returns the dispatched event to make that decision readable, undefined on the element-less path - treated fail-open to native. The keyup proxy is untouched (no `keyup.<id>` registrar exists). Measured live on WebKit: chords restored, typing interception + caret integrity + autofill suppression unchanged. Tests: `test/core/validator-keydown-proxy.test.js` (extract-and-execute on both changed functions) + `test/e2e/validator-autocomplete-paste.spec.js` (webkit project, cross-engine job). **#FIN4 (`isIban` / `isBic`, 0.6.x):** IBAN and BIC join the built-in rule set. `isIban` validates ISO 13616 in three ordered checks - shape (`^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$`), official per-country length via the constructor-scope `_ibanLengths` registry (87 countries; unknown country codes deliberately pass on shape + checksum so a registry gap never hard-fails a well-formed IBAN), and the ISO 7064 MOD 97-10 checksum folded modulo 97 in <=7-digit chunks of plain Number math (no BigInt in bundled source - build-chain constraint). Tolerant read: validation runs on an uppercased copy with spaces/hyphens stripped; the stored value and the DOM are NEVER mutated (unlike `isEmail`'s lowercase write-back). The type guard folds into `isValid` (`typeof(candidate) == 'string' && rgx.test(...)`) so a toString-spoofing object records an invalid instead of throwing into the rule driver (#B200 posture, tightened). `isBic` validates the ISO 9362 8/11-char shape case-insensitively by regex - no case mutation. Both follow Shape A (`this.valid = isValid && !errors['isRequired']`), the strict #B199 empty bypass, and register English defaults in `_defaultErrorLabels` ('A valid IBAN is required' / 'A valid BIC is required'), culture-localisable like every built-in label. Behavioral + pin coverage: `test/lib/validator-isiban-isbic.test.js` (real-engine; fixtures verified against an independent BigInt oracle). **Autofill signal (#B478, 0.6.27):** the live check re-runs only on the proxied native `change`/`keyup`/`focusin`/`focusout`, and a browser or password-manager fill produces NONE of them - on Chrome not even at fill time, and the filled VALUE stays withheld from script (`.value` reads `''` while `:-webkit-autofill` matches) until the first trusted gesture, which then releases it and fires a keydown/input/change/keyup burst per field (measured, Chrome 152 - which is also why a "gated" trigger always submitted on the first click). So a form filled without keystrokes kept its bind-time gated look on a visibly complete form, and an autofilled INVALID value showed no error. Fix: the default stylesheet (`src/vendor/gina/autofill/sass/autofill.scss`, FUNCTIONAL so deliberately outside `@layer gina`; its keyframe carries a declaration because csso strips an EMPTY `@keyframes` block) gives every `:autofill` / `:-webkit-autofill` control a 1ms keyframe named `gina-autofill-start`; the validator's form-level `animationstart` proxy (`autofillProxyHandler`, also on reassociated controls) dispatches the field's own `change.<id>` live check when the value is readable, and calls `revalidateSilently()` when the browser withholds it. `excludeWithheldAutofill()` narrows the five LIVE-CHECK collectors (bind-time, live global, select global, reveal, silent) so a withheld field neither gates nor renders a false "required" error; the SUBMIT collectors stay strict, so `isValid()` still refuses to post a withheld (empty) credential. A consumer rule setting `animation-name` on those pseudo-classes replaces the hook - keep `gina-autofill-start`. Pins: `test/core/validator-autofill-livecheck.test.js` (extract-and-execute, dist + stylesheet contract) and `test/e2e/validator-autofill.spec.js` (the served keyframe applied through a harness class - real autofill is invisible to automation and to fresh CI profiles). **Honest label for a withheld autofill at submit (#B341, 0.6.27):** the submit collectors are strict by design, so a control the browser autofilled but still withholds fails `isRequired` at click time; that verdict is right, but the plain required label named a problem the user could not see. `isRequired` now consults the plugin's single withheld predicate (exported as `gina.validator.isAutofillValueWithheld`) and, when true, composes the message from the new built-in key `isRequiredAutofill` (default `Filled in by your browser: click the field to confirm it`) - the errors key stays `isRequired`, the verdict and the gate are untouched - and marks the control `data-gina-form-autofill-withheld="true"` until its next adjudication (a CSS/JS hook and the locale-agnostic test signal). `_labelAliasFill` copies an app-supplied `isRequired` onto `isRequiredAutofill`, so a localized catalog keeps its own wording until it translates the new key: never key a test on the English text. Pins: `test/core/validator-autofill-honest-label.test.js` (the rule, `replace()` and the alias fill executed from the shipped engine bytes; red-first through `GINA_FORM_VALIDATOR`) + `test/e2e/validator-autofill-honest-label.spec.js` (chromium + webkit; the plain label read off the page first, the honest render asserted to differ and to carry the marker). **Same-name pairs with a hidden twin (#B510, 0.6.29):** the painter walks `$form.elements` in document order and, on the per-field path, exits on the first control whose name matches — and a BARE `type="hidden"` control (no `form-item-wrapper`) can never be painted (every paint gate skips it). It used to consume the error anyway (box class set, no message, no `aria-invalid`, walk stopped), so the recommended disabled-control shape — a visible control plus a hidden twin carrying `"exclude": false` — lost its server-side field error whenever the hidden twin came first; on a shared box the pre-marked class blocked the visible twin's first-error branch even without the exit. A bare hidden control is now skipped when another control of the same name can be painted (`hasPaintableTwin`); a lone bare hidden keeps the former behaviour, a wrapped hidden is still painted after its wrapper. Browser-bundled: pickup is a bundle restart AND a re-bake. Tests: `test/e2e/validator-hidden-first-paint.spec.js` (real bytes, seven shapes) + `test/core/validator-hidden-first-paint.test.js`. **Binding scope (#B549, 2026-09-17):** the boot scan binds ONLY opted-in forms — a `data-gina-form-*` attribute, an existing id naming a registered rule (`-` read as `.`), or a virtual `gina-upload-*` id; any other `<form>` is left untouched (no minted id, no `$forms` entry, no submit proxy) even on a rules-bearing page, matching the forms guide. Explicit `validateFormById()` / `getFormById()` stay ungated opt-ins. Before the fix the scan's `else` arm bound every form after minting it a generated id, so declaring ONE rule file turned every plain login/signup form on the site into an always-XHR submit — and on isaac HTTP/2 that XHR-answered `req.login()` then lost its session cookie (#B550).
938
+ 209. **FormValidator — engine disambiguation, string inputs, a11y reflection, and live-check message visibility (consolidates former #42/#130/#150/#186/#197).** The live form/data rule engine is `core/plugins/lib/validator/src/form-validator.js` (single source, `isGFFCtx`-branched: runs server-side via `backendInit` AND compiled into the browser bundle) with the client orchestration in `validator/src/main.js` — `framework/v*/lib/validator.js` is a DEAD standalone fluent validator with overlapping `is*` rule names; never edit it for form-rule work (tell them apart fast: the live engine's `isRequired` rejects whitespace-only input, the dead one passes it). Rule bodies must handle STRING inputs — the two DECLARATIVE contexts feed strings (`.value` + urlencoded bodies) — so a typed/numeric rule coerces or parses explicit components, never assumes a typed JS value; but "always a string" is NOT true of every path, and reading it that way shipped #B198 (see below): a JSON request body keeps real Numbers (`JSON.parse` → `req.body`/`req.post`, which the `validator::{}` routing path MERGES into the validated data before spreading array bounds through `apply()`), and `toInteger` leaves `Math.round()`'s real Number on `this.value`, so a `toInteger` → `is*` chain hands the next rule a Number even in the browser — a rule must therefore be correct for a typed value too, not merely tolerant of strings: `isFloat` coerces via `Number()` (#B46); `isDate` builds from explicit mask components + a round-trip check so non-ISO slash masks aren't US-misparsed and impossible dates still reject (#B47); `isDate` returns the FIELD again on its valid path (#B48, 0.5.4 — parsed `Date` preserved on the field's `.value`, the `isDate(mask).format(...)` idiom unchanged), so rule chaining works. ⚠️ **The FIELD keeps the `Date`; the PAYLOAD takes a `yyyy-mm-dd` string, always (#B558, 0.6.32-alpha.2).** Every validator normalises the payload to the validated canonical form (`isEmail` lowercases, `isBoolean` → boolean, `isNumber` → `Number`), so `isDate` normalising is consistent — but `Date` is the one normalised type that does NOT round-trip through JSON: `JSON.stringify` renders it via `toISOString()`, a UTC **instant**, and local midnight at any positive UTC offset is the PREVIOUS day in UTC. So a browser in Paris submitting `2026-09-18` sent `2026-09-17T22:00:00.000Z` and every reader that sliced the date part stored the 17th — silent (the instant is well-formed) and invisible to a server or CI running in UTC. The written shape is mask-INDEPENDENT: the mask governs INPUT parsing, while both `requirementToSchema()` and `dto.date()` already published the field as `{type:'string', format:'date'}` (RFC 3339 full-date, explicitly NOT `date-time`), so the payload now matches the schema gina was already advertising. Consumer-visible: `req.body.<field>` is a string, not a `Date`, and `new Date('2026-09-18')` on a date-only string is UTC midnight, not local midnight. An empty value is adjudicated by `isRequired` ALONE (#B78): the per-rule empty-bypass became unconditional on empty (`if (this.value == '')`, its old `!errors['isRequired']` gate dropped) for `isEmail`/`isJsonWebToken`/`isFloat`/`isInList` — each regating `this.valid = isValid && !errors['isRequired']` — and `isString` keeps the field invalid without recording a second message, so a required-empty field shows ONE message (`is required`) not two, optional empty fields still pass, a filled-but-invalid value still reports its own error, and custom `is` was deliberately excluded by #B78 and re-declined by #B82 — an exclusion REVERSED by #B233 (2026-08-03, 0.6.3, `307721f2`): `is` now carries the same canonical strict bypass (`if ( this.value === '' ) { isValid = true; }`) and the same regate, taking the Shape-A population from four rules to FIVE, so a required+EMPTY field carrying an `is` condition records `isRequired` ALONE instead of also collecting a second `Condition not satisfied`. `isBoolean` joined the same contract at #B235 (2026-08-03, 0.6.3, `aa1c2035`), taking that population to SIX: its pre-switch rescue `errors['isRequired'] && this.value == false` was LOOSE (`'' == false`), so a required+EMPTY boolean field LOST its isRequired error and reported `Must be a valid boolean` instead of `Cannot be left empty`; the rule now takes the canonical strict `=== ''` self-pass, and the rescue moves AFTER the accept-set switch gated on the value having been ACCEPTED (`val !== null`) — which keeps the documented unchecked-but-required-toggle case working, since a recognized `false`/`0` is a present answer, while emptiness returns to `isRequired` alone. Paired in the same commit with #B236, the SERVER-side half: the plugin's `getCastedValue` funneled EVERY value on an isBoolean-ruled field through `/^true$/i ? true : false` BEFORE the engine ran (client AND server — `validate` calls `formatFields` unconditionally), so on the server auto path junk validated CLEAN and PERSISTED as `false` — `nope`, the HTML checkbox default `on` (a CHECKED box storing UNchecked), the strings `1`/`0`, `TRUE`/`True` — and the NUMBER 1 stored `false` where the engine reads it as `true`. The pre-cast now survives ONLY in dynamised-rules mode, where a referenced boolean field must splice into a stringified `is` condition as an unquoted operand (measured NECESSARY: deleting it outright breaks a server `$flag === true` condition); the ENGINE is the single adjudicator on every surface, which is what the routing `validator::` surface always enforced and what the published reference already promised. Disclosed both directions: values that silently stored `false` now ERROR, the number 1 flips its stored value `false`→`true` on a verdict that was already valid, an optional blank boolean field now PASSES instead of erroring, and a required blank field's message changes from isBoolean to isRequired. A sibling server-path crash in the same plugin is fixed by #B234 (2026-08-03, 0.6.3, `7c56565d`): `getDynamisedRules` substitutes in two passes, and the SECOND is a DOM fallback re-deriving each splice value from the live element (`$fields[...].value`) — which `backendInit` calls with `$fields = null`, so it threw `TypeError: Cannot read properties of null` on its FIRST iteration for ANY `$` surviving pass 1: a regex end-anchor in an `is` condition, a `$` inside a human-readable message string, or a `$` in any array-rule element after the first. Plain cross-field `$peer === $me` never crashed, because pass 1 consumes tokens that NAME fields. The loop is now gated `$fields && ...`, joining the #B127 precedent one function later; `validate`'s same-text gate is deliberately left UNGUARDED, being reachable only with a live DOM. Residual, disclosed — and since FIXED (#B239): a `$` token in an ARRAY rule's FIRST argument that names no field (`isInList: ['$100']`) threw one site later at `checkFieldAgainstRules`' `d[<token>].value` — NOT DOM-dependent, so it reached the client too. The substitution is now gated on the token resolving to a REAL field (an existing `d` key with a defined `.value` — two clauses, both load-bearing: an engine-METHOD-name collision like `'$isValid'` resolves to a defined key with no `.value`, and pre-fix spliced the string "undefined" into the rule for a silent wrong verdict rather than a crash); anything else stays LITERAL so strict comparison applies (`'$100'` matches its own literal, rejects non-members with the rule's own error; bare-`$` and mixed elements covered). `$` is therefore the engine's RESERVED cross-field sigil: whether an authored `$` stays literal depends on a runtime field-name collision — a token naming a sibling field is consumed UPSTREAM by getDynamisedRules loop 1, substituted with quoting fit for `is`-condition splices, not array elements (`"yes"` with quotes can never match `yes`), so real cross-field refs in array-rule elements are always-invalid, fail-closed, never-worked, undocumented (the reference scopes `$name` to `is` expressions) — tracked as #B240 (demand-gated; the fix is relocating array-element substitution into checkFieldAgainstRules, whose guarded loop is deliberately preserved as the substrate). This reserved-sigil model is also the #DTO2 `$` guard's CURRENT rationale (the crash rationale is retired — deterministic literal semantics are impossible for any `$`, so toRules() refuses at boot rather than validate collision-dependently). The reversal is measured rather than re-argued: the old bypass was gated on `!errors['isRequired']` — off exactly when #B78 wants it on — beside a two-disjunct guard that was DEAD CODE (`x == '' && x != 0` has no witness), optional+empty ALREADY self-passed through the live else-if, and on required+empty the condition is VERDICT-IRRELEVANT (form validity is `getErrors().count()` and `isRequired` has already errored), so form validity and the request payload are identical in both directions and only the message list changes; the "coercion-sensitive" premise had already been retired by #B199's strict test, which leaves `0`/`false`/`null` as operands that still evaluate the condition. #B82 is neither regressed nor retired — its root `getCastedValue` quoting and its `is()` grammar guard stay necessary and reachable with a FILLED host; #B233 only closes that crash path a second time for an empty HOST, whose condition is no longer compiled at all. Same commit drops the dead `_defaultErrorLabels['isApiError']` entry (zero consult sites: the API path assigns the server's message directly and never calls `replace()`) - but a cross-field `is` (`"$a === $b"`) no longer THROWS when the referenced field is empty (#B82): the client dynamised-rules substitution (`getCastedValue` in `main.js`) now renders an empty referenced operand as a quoted `""` (it was spliced RAW, leaving a dangling `"7654321" === ` that `is()`'s binary-comparison grammar `_SCS_BINARY_RE` rejected -> an uncaught throw that aborted the whole-form validity pass and left the submit trigger ungated on an invalid form, breaking the documented `is`+`isRequired` value-confirmation pattern while the confirm field was blank), mirroring `getDynamisedRules`' own sibling substitution default (`: '\"\"'`); `null`/`undefined` stay raw (already valid operands). Hardening: `is()`'s grammar mismatch now FAILS-THE-FIELD (`console.warn`+`isValid=false`) instead of throwing, so a per-keystroke live check can never abort the gate on a residually-unparseable condition (e.g. a field literally valued `"NaN"`, which the root fix leaves raw). Browser-bundled -> prod dist rebuilt; the `#SCS1e`/`#SCS1h` eval-safety pins target the untouched `_SCS_BINARY_RE`/`_scsParseOperand`/regex-literal constructs, so the hardening flips none of them. **The splice itself was UNESCAPED until #B600-#B602 (gh#77, 0.6.33):** `getDynamisedRules` pastes every referenced value into the STRINGIFIED rule set and parses it back, so a `"`, `\` or control character in a referenced value (a textarea, a password) threw out of the whole pass (client: dead submit; server: the error escaped the plugin), at FOUR sites - `getCastedValue`'s string return AND its number-rule branch, the loop-1 default for a referenced field with no rule, the loop-2 DOM default - each through a STRING replacement that also expanded `$&`/`$'`/`$$`. Every splice is now `quoteForDynamisedRules(v)`, i.e. an escaped quote + `escapeForJsonString(escapeForJsonString(v))` + an escaped quote (the escaper touches ONLY `"`, `\` and U+0000-U+001F, so the splice is byte-identical to the old one for every value that did not throw), through function replacers, with the closing parse guarded (fallback: the rules as declared, which `is()` resolves or fails closed). A number-ruled referenced value splices raw only when it IS a number (typed, or text matching `/^ *-?\d+(?:\.\d+)? *$/` - the padding keeps `" 12"` vs `12` valid as before); other text compares as a string. Note the engine's `isNumber` is LENIENT (`parseInt`: `1"2` and `12abc` pass as the leading number), so a number field's own verdict never discriminates such values - compare `1"2` vs `1"3` instead. `is()`: the string operand is `"(?:[^"\\]|\\.)*"`, decoded with `JSON.parse` (an AUTHORED literal that is not valid JSON, `"C:\dir"`, is read verbatim as before; one that cannot be read at all fails the field, never throws); its own `$` substitution splices `JSON.stringify(value)` through a function replacer; the `(`/`)`/`return` strip runs OUTSIDE string literals only (#B601 - it made `ab(cd` equal `ab)cd`, also on route requirements); and a condition no longer needs an ASCII alphanumeric run to be evaluated (#B602 - `!!!`/`é€` never matched; through `$` tokens the gate saw the token NAMES, so it bit the plugin path and literal conditions only). The client `query` body: a late-resolved token splices twice-escaped and `decodeSplicedQuotes()` restores each spliced literal instead of deleting every escaped quote - byte-identical for any body whose strings carry no backslash (token-wise, so key order survives); an AUTHORED quoted segment holding a valid JSON escape is the one divergence (decoded). **One `$`-token grammar at every site (0.6.33, #B603/#B604/#B606):** `FormValidatorUtil.substituteFieldTokens(text, names, resolve)` — a token is `$` + a field name in scope, the LONGEST name wins in one pass over the original text, a token ends where its name ends provided the next character is outside `[A-Za-z0-9_-]` (so `($a) === ($b)`, `$a===$b`, `$a,` resolve while `$passwordX` leaves `password` alone), names are case-sensitive and RegExp-escaped (`$pw[0]`, `$a+b` resolve), a replacement is never scanned again, and a `$` naming no field stays literal. Before it: the plugin's `getDynamisedRules` replaced one name at a time and re-read the text after each splice, so a value holding `$<otherField>` was substituted twice (#B603 — and the two-argument `is` form re-read the resolved condition through the array-rule scan, which now skips `is`/`is<N>`); its DOM-fallback second loop, which could only act on a `$` a spliced value carried in, is retired with #B234's gate; the engine's `is()` resolved a token only when whitespace or the end followed it and interpolated names unescaped (#B604) — it now resolves OUTSIDE string literals only and never inside a regex-literal condition, so the plugin path's already-spliced literals are not resolved a second time; the client `query` body scanned `\$[-_\[\]a-z 0-9]+` — lowercase-only with a space in the class — so `$passwordConfirm` resolved `$password` + `Confirm` (another field's value on the wire), a prefix pair resolved by body order, and an unknown `$` was sent as the string `null` (#B606). Value semantics per site are unchanged (`getCastedValue`/`quoteForDynamisedRules`, `JSON.stringify`, the twice-escaped `query` splice). The form's validity comes from `getErrors().count()` (the surviving `isRequired` error), never the per-field `.valid` flag (whose only error-dropping reader, `setErrors`, is dead). Length bounds are ARITY-sensitive, and the source JSDoc was WRONG about it until 0.6.3: `"isString": [N]` (same for `isInteger`/`isNumber`) supplies `minLength` ONLY — identical in effect to the scalar `N` — because the exact-length branch fires only when `minLength === maxLength`, so an exact length needs `[N, N]`; the stale comment had propagated verbatim into the published reference page, so correct BOTH surfaces when one is found. Those bounds measure the value's STRING FORM (`val.toString().length`) — until 0.6.3 `isInteger` alone measured a bare `val.length`, which is `undefined` on a real Number, so BOTH its bounds were silently inert on every numeric value: no error, no warn, field left `valid` (#B198, a fail-OPEN bypass reachable from a JSON body, a `validator::{}` requirement, or a preceding `toInteger` — the browser included). `isString` reads the same bare `val.length` at two sites and is CORRECT there because a `typeof(val) == 'string'` guard precedes it, so this class of fix is line-scoped: a whole-file replace of the bound expression hits four sites, two of which must not change. One consequence of measuring the string form, intended: a negative number counts its sign toward the length (parity with the same value arriving as a string). The zero-swallow residual #B198 initially left open is CLOSED by #B199 (0.6.3): loose `== ''` emptiness tests conflated `0`/`-0`/`false`/`[]` with the empty string at FIVE sites — the isInteger/isNumber bounds gates AND the isEmail/isJsonWebToken/isFloat empty-bypasses, where a JSON body's `{"email": 0}` validated as a correct email — all five now compare strictly, so only the literal `''` bypasses (the designed empty-is-adjudicated-by-isRequired contract, preserved byte-exactly); `isString` stays loose behind its typeof guard (operators identical for strings), `isInList` was already strict, and `isDate`'s broader `!val` swallow (a silent half-state: `valid` false, NO error recorded, so the form passes) is deliberately untouched. STILL OPEN sibling (#B200): a TRUTHY non-string in an isEmail/isJsonWebToken field (`{"email": 123}`) hits an unguarded `.toLowerCase()` and the rule driver RE-THROWS, killing the whole validation run — the falsy/truthy non-string space is partitioned between the fixed bug and this one. Custom validators (`bundle/validators/<name>/main.js`) are a BROWSER-ONLY affordance, NEVER a server-side guarantee: the server gate reads `getContext('gina').forms` while the loop it guards reads a bare `gina` that is undefined in Node, so a custom rule never attaches server-side and the engine then silently skips the unknown rule name with no warn — re-validate such constraints in the action. (Publishing that context without also fixing the loop would make EVERY validator construction throw, including for bundles shipping no custom validators.) Since #M21d (2026-09-26) the browser compiles a custom validator as an inline `<script>` — `gina.forms.compiledValidators[<name>]`, one compile per validator per page, carrying the page's CSP nonce when one is set, `//# sourceURL=<name>.js` in DevTools — with NO `eval`, NO `Function` and no `'unsafe-eval'` needed; the scope contract is unchanged (the spliced prologue hands the body `self`/`local`/`isGFFCtx`/`replace` through `this.getValidationContext()`), a file the browser cannot compile throws `[UserFormValidator] Could not evaluate` pointing at the console's SyntaxError, and server-side a FUNCTION registered on the context is attached as it is while a source-shaped one is refused naming the browser-only contract. The bundle build (`core/asset/plugin/lib/js/no-dynamic-code.js`, run by `build` after r.js and again after Closure) also rewrites the two unreachable vendored calls the r.js output inlines — RequireJS `req.exec` (the `load.fromText` transpiler-plugin path) and engine.io-client's `Function("return this")()` global shim — by EXACT match, failing the build if a dependency bump moves them, so the published bundle carries no dynamic-code call at all: Socket's `Uses eval` verdict, which flipped the package score 62 ↔ 42 on identical bytes, has nothing left to fire on (pinned by `test/lib/validator-m21d.test.js` + `test/core/bundle-no-dynamic-code.test.js`). A rule-body edit needs a prod dist rebuild AND flips the section-locked characterization tests by design. Editing trap: `form-validator.js` embeds hidden NO-BREAK SPACE bytes (U+00A0) where a normal space appears inside several `||`/ternary sequences, so a literal-space find/replace spanning one silently fails — patch such regions with a byte-scoped script over clean-ASCII substrings, not a space-spanning match. The blur-time global validation pass sets submit-button state but renders errors ONLY for the touched field — untouched invalid fields stay quiet until interacted-with or submit. Accessibility (#A11Y1): the rule-agnostic chokepoint `handleErrorsDisplay` reflects committed errors into `aria-invalid="true"` (gated on committed-not-warning; `"false"` on clear mirrors native `ValidityState` so it agrees with `:user-invalid`; hidden fields skipped), auto-wires `aria-errormessage` to a gina-owned message div UNLESS the consumer provided their own, focuses the first DOM-order invalid field on a failed submit, and announces blur-time errors via a per-form visually-hidden `aria-live="polite"` region; the per-field aria passes fire only under live-check — the always-on submit pass covers every bound form regardless. Live-region LIFECYCLE (#A11Y2): creation is split out of the announcer into `ensureA11yLiveRegion($form)` and called from `bindForm` — the chokepoint every registration path funnels through — so the region is in the a11y tree from BIND time. It previously created, inserted AND populated the region in one synchronous tick, which reaches assistive tech as a single mutation batch on a node it has never observed, so the FIRST announcement per form (the one that matters most) was the one least likely to be spoken while every later one worked. The region is a CHILD OF THE FORM on purpose: a popin renders its form inside a native `<dialog>` opened with `showModal()`, which leaves everything outside the top layer inert, so a body-level region would go unspoken for exactly the forms that live in popins — the placement is an accessibility constraint, not a convenience. The price of that choice is that a subtree replacement (`$el.innerHTML =` on a popin re-render, a nav fragment swap) destroys it, so `ensureA11yLiveRegion` is create-OR-RECOVER and also re-homes a region whose form node was replaced; any region created or re-homed at announce time is marked fresh and defers its first write one macrotask, so insertion and mutation land in different ticks — that deferral is what stops a recovery from silently repeating the defect, and it is load-bearing because a re-render does NOT always re-bind (`validateFormById` early-returns on an already-registered id, and a multi-form popin's teardown loop splices while iterating so it skips every odd-indexed form). An already-bound region writes synchronously, unchanged; while a deferred write is pending a newer error replaces the pending text so the LATEST message wins, and the timer re-enters the announcer, keeping exactly ONE `textContent` write site. Rule: a live region must be observable BEFORE it is written — separate creation from announcement, and when the same call must do both, put a tick between them. **Submit LIFECYCLE exposure (#A11Y4):** the accessibility signals for an in-flight submit hang off the request window inside `send()` — the only place reached once an XHR genuinely exists — and that placement is the fix for a timing trap, not an accident: the loading state (`data-gina-loading`) is armed at CLICK time, BEFORE validation runs, so a signal hung there would arm-then-disarm on every rejected submit and announce a spurious busy/not-busy pair. Three effects, all scoped to that window. (1) The trigger's focus is captured before gina natively disables it and restored once the request settles: a natively `disabled` control cannot hold focus, so the browser drops focus to `<body>` and re-enabling does NOT bring it back (both measured), which silently cost every keyboard submit its place. The restore is deliberately conservative — only when focus is still on `<body>` and the trigger is still in the document — so a response that opened a popin, redirected, or focused the first invalid field keeps its own focus decision; gina restores what gina took, never more. The `<a>` branch is excluded from the capture: an anchor gets `aria-disabled` from the in-flight lock (since #B312 the framework's only `aria-disabled` write), which does not blur (measured control). (2) The trigger carries `aria-busy` for the request's duration — on the TRIGGER, never the form, because the live region is a CHILD of the form and an ancestor marked busy MAY be treated as "defer announcements in this subtree", which would silence the very channel used to announce; ARIA 1.2 defines no normative behaviour here, so that is a cheap hedge, and the same reading is why `aria-busy` can never substitute for an announcement (it produces none of its own). ⚠️ **Do NOT restate this as "assistive tech commonly defers"** — that wording shipped once and was WRONG: measured 2026-08-05 on VoiceOver/Chrome, an announcement made from inside an `aria-busy="true"` ancestor was spoken normally, so at least that pairing does not defer. The placement costs nothing and still guards ATs that might, but it is a precaution, never a claim about implementations. (3) The start is announced ONCE through the #A11Y2 region; completion announces NOTHING by design, because an errored response is already announced field-by-field by `handleErrorsDisplay` and a second status write over the same polite region in the same beat can truncate it. Announced strings are the framework's own, so they resolve through `gina.config.a11y` with English defaults (e.g. `{ submitting: 'Envoi…' }`) — deliberately separate from `setErrorLabels`, which is keyed by RULE name and owns rule messages. Rule: put a state signal where the state actually begins; a signal armed on intent rather than on the operation announces work that may never happen. **Error-association integrity (#A11Y5):** three fixes sharing one theme — an ARIA assertion is only worth what its target is worth. (a) Hiding the message div used gina's `.hidden` helper (`display: none !important`), which removes it from the accessibility tree entirely — fine for a soft warning, wrong at the TWO hide paths that coexist with an asserted `aria-invalid` (`refreshWarning`'s focus-driven hide, and the refresh re-create), where the field was announced invalid while `aria-errormessage` pointed at an unreachable target. Both now CLIP instead: inline declarations that hide visually but keep the node in the tree, with `display` carrying `!important` because that is what outranks the class's own `!important` (measured — a plain inline `display:block` loses to it); the class is deliberately left in place so consumer CSS keyed on `.hidden` keeps matching. (b) A polite region is announced on CHANGE, so re-writing byte-identical text is commonly not spoken — which is precisely the repeat-error case (blur a field that still fails the same rule). A trailing no-break space now makes the content differ without altering what is read out, and it self-cancels on the next write. NOTE this half is source-derived: the string demonstrably changes, but no assistive-technology pass has confirmed the re-announcement. ⚠️ The same caveat NO LONGER applies to #A11Y2's first-announcement fix — **V1 was CONFIRMED 2026-08-05 on VoiceOver/Chrome** by an A/B whose two arms differ only by a `setTimeout(0)`: the same-tick create-and-write was NOT spoken, the deferred write WAS. So the deferral is load-bearing in practice, not merely defensible in theory; the region-lifecycle discipline above is measured, not inferred. (c) `focusFirstInvalidField` (and its deliberately-duplicated inline twin) gated focusability on `typeof $field.focus == 'function'`, which is TRUE for every HTMLElement — a custom element with neither `tabindex` nor `delegatesFocus` passes it and its `focus()` is a silent no-op (measured), so a failed submit whose first invalid control was such a host focused nothing AND stopped searching. Both loops now confirm `document.activeElement` actually moved before stopping, which is also what makes the JSDoc's "skips unfocusable controls" claim true rather than aspirational. Rule: a capability probe (`typeof x.focus == 'function'`, `'foo' in el`) tests the API's PRESENCE, never its EFFECT — when the effect is what matters, assert the resulting state. Error-MESSAGE visibility has THREE write paths (create / refreshWarning's un-hide / the refresh re-create) and the re-create runs LAST in the live-check pass, so it owns the steady state: it is focus-aware — message hidden while the edited field is the active element, revealed on blur (soft warning border while typing). Rule: when an element is written by multiple paths in a single validation pass, guard the LAST writer — an earlier-writer fix is silently overridden. #B319 (0.6.5-alpha.2): the focus-driven hide is EXEMPT during the framework's own ANSWER focus — both refused-submit paths (the #B246/#B308 display-only reveal via `focusFirstInvalidField`, and the `validate.<id>` failure branch's inline focus twin) render errors then focus the first invalid field, and that focus's synchronous `focusin` re-entered the live-check listener and hid the just-rendered message: a refused submit explained itself only to a screen reader (the #A11Y5 clip kept the node resolvable) while sighted users saw nothing — despite the render itself being VISIBLE (no fieldName ⇒ the live-check branch is skipped, and activeElement is still the trigger/BODY at render time), which is also why an async `query` rule in a repro is incidental (the suppressing dispatch is synchronous inside `focus()`). Fix: a one-shot module flag raised around BOTH focus loops (try/finally, cleared on every exit) gates the focusin arm's `refreshWarning` call; the message stays visible with the hard `form-item-error` styling, aria state untouched, and the first later keystroke re-engages the mid-typing suppression unchanged (in that answered-then-typed configuration the border stays `form-item-error` — the answer focus re-registered the field so `lastFocused` reads it twice and the isWarning heuristic keeps the hard border while the active-element ternary hides the message; the hidden message is the contract, the border there is heuristic). Behavioral lock: `test/e2e/validator-submit-answer-visibility.spec.js` (4 arms, red-first against the pre-fix bundle); browserless pins: `test/core/validator-answer-focus.test.js`. #B387 (shipped in 0.6.11): the one-shot flag covers only that synchronous window — on an async-`query` form with a committed error, a refused submit whose click lands inside the UNDRAINED completion tail of the previous settle had its answer delivered, focused, then re-hidden ~0.2ms later: a stale live-check waiter (woken inside the click's cascade by the reveal pass's deferred release) or the trailing silent global re-validation ran the display-refresh pair AFTER the flag's `finally` had cleared it, and BOTH hide sites key on the same heuristic — "the field is the active element, so the user is editing it" — which cannot tell answer-placed focus from user-placed focus (`refreshWarning`'s error→warning downgrade appends ` hidden`; `handleErrorsDisplay`'s refresh branch re-creates the message born-hidden via its active-element ternary; the occurrence gate is click-inside-the-tail, which is why full-suite/CI runs flaked ~1/20 while standalone runs stayed green). Fix: focus PROVENANCE — a single module slot `answerFocusHold` ({formId, elName}; one slot is exact, only one active element exists) recorded at both confirmed answer-focus points (inside the #B319 windows), consulted by BOTH hide sites for ANY caller however late (provenance beats a pass-staleness latch: the trailing re-validation is a FRESH pass spawned inside the click cascade and would sail through any staleness check), and released on the first genuine user interaction — any TRUSTED native event reaching one of the seven form proxy handlers while the one-shot flag is down (framework `triggerEvent` dispatches are untrusted and cannot release it; the answer's own trusted synchronous focusin is excluded by the flag) — so the deliberate mid-typing suppression re-engages the moment the user actually edits; no timers. Locked by `test/core/validator-answer-focus-hold.test.js` (17 — source pins on every edit site, comment-stripped extracted-real-bytes behavioral arms for both hide sites + the release helper, red-first against the pre-fix bytes; gina.js dist pins) and the §02 e2e arm (25/25 post-fix vs the ~1/20 pre-fix CI red whose signature was `Received: 1` + msgClass `hidden` — a recurrence of that signature is a NEW defect, not #B387). #B348 (shipped in 0.6.11): `revealValidationState`'s completion STARVED on any form whose async `query` field was not declared last — the reveal pass is un-latched, writes neither `isSubmitting` nor `isValidating`, and on a valid form its verdict is clean, so its waiter completion matched NO dispatch branch (terminal errors>0 / the latched dispatch / last-field / the display-only live-check arm): `onDisabledTriggerReveal` never ran, the stale `data-gina-form-submit-gated` marker never re-synced, and a fully valid form ate every later click until reload (measured live: 2 post-settle clicks, 0 POSTs — field declaration order decided whether the documented self-heal worked). Fix: the reveal's callback carries a completion identity (`onDisabledTriggerReveal.isRevealCompletion = true`, the engine's own `cb._data`/`cb._errors` property idiom) and the waiter chain gains ONE else-if chained after the display-only arm, gated on the SAME terminal condition the errors>0 block uses (`hasParsedAllRules && asyncCount <= 0` — the guard that stops a multi-query-field early wake, where the first waiter fires at asyncCount 1): a terminal reveal completion that matched no other branch dispatches `validated.<formId>` with its own cb. Every previously-working shape is byte-identical (errored reveals keep completing via the terminal branch, query-last via last-field, latched submits via the latched dispatch, live-check stays display-only), and the un-latched programmatic-submit starve (#B347) is deliberately untouched — its cb carries no marker and its fix is gated on its own repro. Known residual, pre-existing on EVERY branch: a query field whose own rule object continues past `query` never sets the terminal flag and still starves — same guard as the existing dispatch, no new asymmetry. Locked by `test/e2e/validator-reveal-starve.spec.js` (red-first: the starve arm failed on the pre-fix bundle while the query-LAST control arm passed on the same bytes, pinning the defect to field order; the scene manufactures [valid values + stale gate + stale committed error] deterministically via a prototype-setter silent fill after an errored reveal, with every precondition an explicit expect so an impossible scene voids loudly) and `test/core/validator-reveal-completion.test.js` (9 — stamp/consult/placement pins, extracted-real-bytes reveal arm asserting the stamp as a runtime value, gina.js verbatim pins AND a gina.min.js exact-count pin — unlike a local flag, the property name survives Closure). The not-ready submit trigger is marked `data-gina-form-submit-gated="true"` + the class `gina-form-submit-disabled` (#B312 retired `aria-disabled` from this marker — its contract says not-operable while the #B246 gate deliberately answers the click with the error reveal; authored `aria-disabled` remains enforced by the gates and is never auto-cleared), NEVER native `disabled` (#B76 — a natively-disabled button emits no click, so the validate-render-focus guard could never run); `isValid()` is the real send gate, and **the framework now ships a default not-ready look (cursor `not-allowed` + dim; deliberately no `pointer-events`, which would swallow the click the reveal answers) that consumer CSS overrides**. Form-associated custom elements (FACEs) participate in binding + live-check (#CC2 — hyphenated members of `form.elements`; their own `.value` accessor is honoured, live-check rides the composed bubbling `change`; author contract: `static formAssociated`, a `name` attribute, a `.value` getter, composed `change` on commit). **Radio-group collection (#B221):** an unchecked non-boolean radio group whose rule declares a truthy `isRequired` is collected as an EMPTY value by BOTH collectors (`getFormValidationInfos` + the native-submit inline copy) so `isRequired` adjudicates it via the standard emptiness test — pre-fix no collection arm admitted the shape (each required `.checked`, a `true|false`-shaped value, or an `isBoolean` rule), the DOM handle was held in `$fields` but the VALUE never entered `fields`, so no rule ran against the group and a radio-group-only form short-circuited BOTH submit guards (field count 0 reads as nothing-to-validate → synthetic `isValid() === true`) and submitted its XHR with zero client-side validation. Other unchecked groups stay absent-when-unchecked (native parity: no rule / `isRequired: false` unchanged; `isBoolean`-declared groups keep the force-false arm), checked members post exactly as before, and on the auto path a required-empty form is invalid and never sends — so the wire only changes for the newly-gated shape. Enforcement-tightening: forms that silently submitted with nothing picked now gate on the pick (trigger marked not-ready at bind under default-on live-check — `data-gina-form-submit-gated` + class since #B312 — message on submit attempt, re-enabled after picking). **The re-enable is real only since #B228:** the radio live-check listener was registered under a `changed.<id>` event name nothing dispatches on a user pick — the form-level click proxy short-circuits into the radio state updater (which never dispatches any gina event), and the change proxy dispatches ONLY names present in the event registry, which radios never registered (checkboxes have that registration via their state-updater relay; radios' equivalent relay is keyed on the bare element id, which nothing triggers) — so the whole-form silent pass never re-ran after a pick and the trigger kept its bind-time disabled state indefinitely, while submit-time validation (a separate call chain) accepted the checked group and let the click-guard send: flows completed, only the trigger state was wrong (announced disabled to assistive tech; automation actionability checks refuse `aria-disabled`). Radios now ALSO register the proxy-dispatched `change.<id>` name alongside `changed.<id>` — the handler's radio arm accepted `change.`-typed events all along, so one registration line closes the loop: mouse, label and keyboard picks all re-run the field + whole-form passes (single delivery per pick — native `change` fires only on real state changes; the legacy `changed.<id>` name stays registered for the relay/programmatic path; checkboxes byte-identical). Latent since the live-check's introduction, invisible until #B221 armed it. **A field that DRIVES its own conditional block lost its BASE rules until #B229:** `forEachField`'s per-field tail read `if (isInCase || caseName == field) continue;`, and `caseName` is assigned inside the `_case_` scan loop that re-runs in full on EVERY field iteration, so it always held the LAST scanned `_case_` key's driver name — when the iterated field WAS that driver the `continue` skipped the rest of the iteration, base-rule check included. A rule shape `{ "group": { "isRequired": true }, "_case_group": { "conditions": [...] } }` therefore never adjudicated `group`'s own `isRequired` on the bind pass, the live-check global pass OR the submit pass: the form never gated and an empty submit went out with zero client-side validation — the silent-submit class above, resurfacing for the self-driving shape and structurally DOWNSTREAM of the collection fix (the group IS collected as `''`; only adjudication was missing). The tail is now split: `isInCase` keeps its own `continue` (it is dead code — never assigned truthy — and is preserved as such), and the `caseName == field` arm runs the base-rule check before continuing, restoring the driver's collected value around the call (the check deletes the field from the object it is handed, and that object is where the scan block re-reads the case VALUE on every later field iteration; a deleted entry re-seeds from the DOM, which for a radio group is the FIRST member's value regardless of `.checked`). Which conditions apply is unchanged — the direct-case block is never entered for a self-driving case, pre- or post-fix (measured) — and the fix is order-independent: a driver declared BEFORE another `_case_` block was already adjudicated (the tail's comparison never matched it), so the post-fix union is every driver carrying base rules. Client-only: the server form-body path throws earlier on any `_case_`-bearing rule set (conditional rules are unsupported there). Enforcement-tightening: a form built on this shape starts gating where it silently submitted. Known interplay, pre-existing: on a rule set with NO `$` tokens, a pick whose value matches a `_case_` condition lets the case machinery PERSISTENTLY replace injected fields' rules in the live store (the site-B replacement), so a later `reBind()` can re-arm the gate from the mutated store — `$`-bearing rule sets are immune (the dynamised-rules path clones). **A conditional driver's collected VALUE survives every full-form pass since #B230:** the base-rule check deletes each adjudicated field from the object it is handed, and until #B230 only the last-declared driver's entry was restored (the #B229 arm above) — any OTHER field that both carries base rules and drives a `_case_` lost its stored case value the moment its own rules were adjudicated, so later field iterations re-read it from the DOM (a radio group's FIRST member regardless of `.checked`) and matched conditions against a value the user never picked — spuriously requiring the wrong flow's fields (a correctly-completed picked flow could not submit), or with excluding condition rules under-validating the picked flow — while the driver's own direct-case block read `undefined` in the same pass and matched nothing. The entry is now backed up and restored around the base-rule check for any field driving a `_case_` in the live rules OR the pass-entry rule clone; the union matters because inside a direct-case recursion the pass's rule set is the condition's own rules, which carry no `_case_` keys, so the live-rules test alone is blind there. Non-driver fields keep the deletion untouched (the condition pull-in gate, the direct-case exclude injection and the async-`query` re-validation input all read those absences today), and a driver with no rules of its own is byte-identical — including the legitimate first-scan DOM seed for rule-less unchecked groups, which is preserved. **`setFlash` `[null, "message"]` works client-side since #B226 (the form the reference documents):** it previously lost its custom message in the browser ONLY — `lib/merge` classified a `null` array element as an object (`typeof null`) and dropped it on every no-override merge, and the client rules path re-merges the whispered rules (the `data-gina-form-rule` bind merge, the `gina.hasValidator` instance re-merge, the `_case_` merges), so the engine received a one-element array, bound the message to the ignored first `regex` argument, and rendered the built-in label; `["", "message"]` always survived (empty strings, `false` and `0` are primitives — `null` was the only casualty), and the server was unaffected (it reads the boot-loaded rules without those hops). The fix is in `lib/merge` itself, so no-override merges now preserve `null` array elements as VALUES framework-wide (and the index-merge branch stops manufacturing `{}` from a `null` source element) — a merge consumer relying on the silent compaction sees the `null` slots preserved. **Bracket-notation and nested-authored rule KEYS enforce on the SERVER form-body path since #B241:** the rule parser canonicalizes every rule key to a dotted path (`account[username]` becomes `account.username`; a nested rule tree flattens to its dotted leaves) while the server's fields map kept the RAW posted keys, so such rules never joined — the field was silently skipped with no warning, fail-open for every rule-keyed directive alike: checks (`isRequired`, `isEmail`, ...), the `exclude` drop, and value transforms — on BOTH production wire shapes (flat bracket keys: the client posts its name-keyed data as JSON and the JSON body path deliberately does no bracket expansion; and nested objects: the multipart and urlencoded parsers expand bracket names). The server now synthesizes dotted-canon field aliases ALONGSIDE the raw keys (originals kept, so `$name` cross-field tokens keep resolving off the raw posted names, and an all-flat payload synthesizes nothing — byte-identical behaviour), then folds alias outcomes back at egress: error keys return under the DOM-name bracket form the client renders against, and the validated data output keeps its materialized shape with exclusions and transforms applied (a parent object emptied by an exclusion is pruned along that alias's path only — a posted empty object survives). The client join was always bracket-on-both-sides (a named rule set passes through with its authored keys; nested-authored sets are reconstructed to bracket names at bind time), so this brings the server to parity — quirks included: a caller that posts the dotted key form keeps its own addressing, and the no-rules path still returns the payload verbatim. Behaviour change by design: a bracket-keyed or nested-authored rule that never fired before now enforces — anything relying on the old silent skip starts rejecting or dropping those fields. **Upload previews carry a text alternative (#A11Y7/U1, 0.6.4).** The staged-upload client layer builds its preview `<img>` in two MUTUALLY EXCLUSIVE branches of `onUpload` — one for a file with no server-side `preview` object, one for a returned preview variant — and neither set `alt` at all (not even `alt=""`), so assistive tech fell back to reading the temp URI aloud, once per staged file (WCAG 1.1.1). Both now set `alt` from `files[f].originalFilename`: the name the USER chose, deliberately NOT the sibling `files[f][key].originalFilename` of the preview variant, which is a server-generated artefact that means nothing to the person listening — the same distinction the adjacent `data-upload-original-filename` / `data-upload-preview-original-filename` pair already encodes. The fallback is `''` (a properly ignored image) rather than letting a missing name be spoken as a placeholder. The preview is INFORMATIVE, not decorative: it is the only signal telling a user which file is staged, and everything else in the upload layer is still silent — progress is attribute+`textContent` with no `role="progressbar"`/`aria-value*`, upload errors are an `innerHTML` write with no `role="alert"` that never calls the polite region the plugin already owns, and the reset control is an `<a href="#">` (U2/U3/U5, tracked in the accessibility audit, not fixed here). Maintainer gotcha: the two branches are per-file exclusive, so a one-site fix silently misses every upload whose server returns a preview object — fix both or neither. **A not-ready submit trigger really refuses the send — and its marker is aria-free (#B246 + #B312, 0.6.5).** `updateSubmitTriggerState()` marks an invalid form's trigger with `data-gina-form-submit-gated="true"` + the `gina-form-submit-disabled` class and deliberately never native-`disabled` (a natively-disabled button emits no click at all, so nothing could tell the user WHY it is dead) — and, since #B312, never `aria-disabled`: the gate ANSWERS the click with the error reveal, which that contract forbids for a control announced disabled, so the attribute belongs to consumers (authored marks the gates enforce and never auto-clear) and to the anchor in-flight lock. #B246's origin: NOTHING READ the marker — a click ran the entire submit cycle (collect → validate → `validate.<id>`) and only the `isValid()` gate stopped the send: the trigger was inert in appearance ONLY. `clickProxyHandler` now intercepts a disabled-or-gated trigger BEFORE the `submit.<id>` dispatch — so `bindSubmitEl`'s handler never runs, `isSubmitting` is never latched, and no send path is reachable by construction — and answers the click with a display-only `revealValidationState()` pass that renders every invalid field, focuses the first, and re-syncs the trigger state so a STALE not-ready marker on a form that has since become valid heals itself — the heal touches only the marker + class; authored `aria-disabled` and the in-flight lock survive it (#B313 closed by construction). The predicate `isTriggerDisabled()` reads the CLICKED element rather than `$formInstance.submitTrigger` (a form may carry several submit buttons while only one registers) and mirrors the popin plugin's existing trigger gate on the shared channels (authored `aria-disabled` + native `disabled`), so both subsystems agree there; since #B312 it ALSO fires on the validator-owned not-ready marker `data-gina-form-submit-gated="true"`. **#B293 (0.6.5) NARROWED that predicate: the native `disabled` ATTRIBUTE now counts only where `disabled` is not a real IDL property (`!('disabled' in $el)`).** As first shipped it accepted native `disabled` on ANY element, which silently killed the near-universal double-submit guard: a click listener on the submit button that sets `disabled` to block a second submit is bound to the button and therefore runs BEFORE gina's delegated form-level proxy reaches the gate, so the gate saw the attribute, cancelled the click, and `send()` never ran — then the consumer's own handler cleared the attribute again, leaving NOTHING marked, a normal-looking button, and every subsequent click equally dead. Measured: a natively-disabled `<button>` delivers **no click to JS at all** (the browser suppresses it), so on a real form control that arm could only ever fire on an attribute set DURING the dispatch — it protected nothing and cost everything; on an `<a>` or a custom element the browser enforces nothing, so there the attribute is still the only honest signal and is still read (`'disabled' in $el` measured: button/input `true`, anchor/custom-element/span `false`). Regression cover is an **e2e** (`test/e2e/validator-submit-native-disabled.spec.js`, 4 arms), deliberately NOT a replica: the defect is an ordering interaction between a consumer's own listener and gina's delegated proxy, which only the real bundle handling a real click can exhibit — `test/core/validator-submit-trigger-state.test.js` covers the same gate with replicas and stayed GREEN throughout, including two tests named as controls for the working case. Gotcha for maintainers: `focusFirstInvalidField()` deliberately DUPLICATES the inline focus loop inside the `validate.<id>` guard — that inline shape (`_a11yErrs` / `_aField`) is locked by source pins, so collapsing the two would break tests unrelated to this fix; keep them in step by hand if the focus rule ever changes. **Staged uploads speak (#A11Y7/U2+U3+U5, 0.6.4).** Three defects in one client layer, fixed together because they share one plumbing change. (a) `updateUploadProgressIndicator` wrote `textContent` plus two `data-gina-upload-progress*` attributes and NO ARIA, so any indicator that is not a native `<progress>` was unlabelled text; it now carries `role="progressbar"` + `aria-valuemin="0"` / `aria-valuemax="100"` / `aria-valuenow`, with `aria-valuenow` tracking the percent attribute EXACTLY — absent while `preparing`/`indeterminate` and on `error`, because an absent value is how a progressbar signals "unknown" while a stale or zeroed one reads as real, stalled progress. `reset` strips the ARIA too (it strips everything this layer ever set); `processing` advances the state only, preserving the last value so a determinate bar stays full through the server-side window. A native `<progress>` is deliberately untouched — it already exposes all of this, and an explicit role risks overriding the implicit one. (b) The TRANSITIONS are announced through the polite region `bindForm` already stands up: `uploadStarted` at the selection kickoff — deliberately NOT gated on the indicator being present, since opt-in-by-presence is right for a visual and wrong for an announcement — and `uploadComplete` in the success branch only. Per-tick progress is NEVER announced: `aria-live="polite"` coalesces but does not throttle, so one announcement per `onprogress` event would bury every other message on the page. There is deliberately NO generic `uploadError` key, because the error path announces the server's own message and a generic second write in the same beat would clobber it (the region is latest-wins — the same reasoning that keeps `releaseSubmitA11y` silent). (c) The upload error container is written via `innerHTML` while `display:none` and revealed only by a fade-in — exactly where a `role="alert"` on it is unreliable — so the error routes through that same region, announcing `$error.textContent` (the RENDERED text: a message can carry markup, and the url-action checker substitutes `<br>`). Note the container being consumer-owned is NOT the discriminator: the progress indicator is equally consumer-supplied and gina already writes attributes into both; the hidden-then-revealed shape is what rules out `alert`. (d) The generated reset control was `<a href="#">Reset</a>` — announced as a link, not activatable with Space, and identical across every staged file. It now carries `role="button"` plus an `aria-label` of `<visible label> <filename>`, the visible label kept as a PREFIX so the accessible name still contains it (WCAG 2.5.3 Label in Name) and reusing the consumer's own `data-gina-form-upload-reset-label` so it is translated wherever that already is. The role is stamped ONLY on an anchor (strict `/^a$/i`, unlike the loose `/a/i` guarding `href` a few lines above, which also matches TEXTAREA/CANVAS/LABEL), so a consumer supplying their own `<button>` keeps its implicit role. A `role="button"` that cannot be operated by Space would be worse than no role at all, so the binder adds a `keydown` handler mapping Space to the removal — anchors only, since a native button already fires click on Space and would otherwise run the removal twice. (e) That control is REMOVED from the DOM while holding focus (`$resetLink.remove()`, which its own comment requires be last so the click listener dies with the node). The finding described this as a `display:none`; both happen, and the removal is the decisive one — a fix aimed only at the hide would not have worked. Focus now moves to the file input BEFORE the removal, guarded on the control actually holding focus (a programmatic reset must not steal focus from elsewhere) and CONFIRMED afterwards rather than assumed, since `typeof focus == 'function'` is true for every HTMLElement and `focus()` is a silent no-op on one that cannot take it. The removal is then announced (`fileRemoved`, default `'%s removed'`) using a FUNCTION replacer — never a string — because a file name may contain `$` and a string replacement would expand `$&`/`$1` patterns inside it. All three new strings live in `A11Y_LABELS`, so `gina.config.a11y` overrides them. **Maintainer gotcha worth the line:** `test/core/validator-upload-reset-delete.test.js`'s `fnSlice` takes a FIXED 12000-char window from its declaration anchor, and this fix grew `onUploadResetOrDelete` by ~2.6k chars — pushing the `#R8` strip and the callback dispatch outside it and reddening four pins whose code was present and correctly ordered the whole time. Widened to 16000 (the function ends ~14.2k past its anchor), but it is still a fixed window and will bite again; the durable fix is to brace-walk to the real function end, as the sibling `validator-upload-progress.test.js` already does. **Not verified against assistive technology** — source-derived plus a served-bytes check, like the rest of this arc. **#B295 (2026-08-06) — an async `query` rule left the form's validity state STALE once it settled.** `onasyncCompleted` derives its verdict from `d.getErrors(field)`, which `form-validator.js` scopes to a SINGLE field, so its `isFormValid` means "this field passed", never "the form is valid" — and the entire update block sits behind `if (!isFormValid && …)`. A form that had just become valid therefore received NO update: `updateSubmitTriggerState` was never called, so the bind-time not-ready mark (then `aria-disabled="true"`; the `data-gina-form-submit-gated` marker since #B312) survived on a valid form and `$forms[id].errors` kept listing the field. Cosmetic until #B246 made the click gate read that marker — then the first click after the query settled was silently eaten, and only the second went through (`revealValidationState`'s re-sync self-heals it). Fixed by handing the verdict to the fresh whole-form pass the same function ALREADY runs for the not-last-field case (`needsGlobalReValidation`), whose unconditional `updateSubmitTriggerState($currentForm, gResult.isValid())` settles the marker while its `handleErrorsDisplay` empty-errors branch clears the stale record; measured to add NO extra round-trip, because the query rule's already-registered-listener guard short-circuits the re-run. **The measurement that CHOSE the fix, and the reason the two cheaper shapes are wrong:** with a second required field left invalid, that fresh pass reports its error while BOTH the field-scoped `cb._errors` AND the in-pass `d.getErrors()` report none — so un-gating the existing call, or letting `handleErrorsDisplay`'s empty-errors branch auto-enable via its own `updateSubmitTriggerState(..., true)`, would each have ENABLED submit on an invalid form and re-opened #B246's hole. Both were rejected on that arm, not on reading. Covered by `test/e2e/validator-async-query-revalidation.spec.js` (4 arms, red-first: 01+02 RED on the pre-fix dist, 03 "another field invalid ⇒ still blocked" and 04 "no query rule ⇒ sends" GREEN throughout); arm 01 deliberately waits on the QUERY settling rather than polling the trigger's not-ready marker, because polling the guard is exactly what let the pre-existing FACE e2e stay green through #B293. Browser-bundled ⇒ consumer pickup is restart AND re-bake. **#B294 (2026-08-06) — a submit trigger whose DOM NODE is REPLACED after binding (the AJAX / popin re-render shape) was PERMANENTLY and SILENTLY dead.** Submit binding is **two-stage and only the first stage is delegated**: the native click proxy is registered on the FORM (`:8090`, "Form-level proxies: capture bubbled events from in-tree controls") and survives any re-render, but the listener that does the work is attached to the trigger NODE by `bindSubmitEl` (`:8231`). `gina.events` is a **name → id-STRING** registry (`utils/events.js:42`), so the `submit.<id>` key outlives the node: after a `cloneNode(true)`+`replaceChild` the `:8037` dispatch gate still passes, `cancelEvent` suppresses the native submit, and `triggerEvent` fires at a node with no listener — so the form does not even fall back to a normal submit. Nothing self-heals (`bindForm` latches `binded` at `:8580` behind `:585`, cleared only by `unbindForm`; there is no `MutationObserver` and no `isConnected` check in the bind/dispatch path). **Pre-existing, NOT a 0.6.4 regression** — measured identically on published 0.6.3. Fixed by marking the bound node with a **JS expando** (`__ginaSubmitBoundFor`) and re-binding the live node at the dispatch gate when the marker is absent — measured: `cloneNode(true)` copies `data-*` attributes but NOT expandos, which is exactly why the inherited `dataset.ginaFormSubmitTriggerFor` is useless as a fresh-node test and was a red herring in the original filing. **The rejected alternative is worth recording:** delegating the custom `submit.<id>` event to the form also fixes it (measured), but was measured to RETAIN the `gina.events` key past `unbind` (`registryKeyDeleted:false` vs stock `true`), which would force an edit inside `unbindForm`'s removal loop — six guard variants all keyed on `gina.events[name] == element.id`. Covered by `test/e2e/validator-submit-trigger-rebind.spec.js` (4 arms, red-first: 01 single replacement and 03 double replacement RED pre-fix; 02 untouched-sends and 04 replaced-but-invalid-still-refuses GREEN throughout, so re-binding provably does not bypass validation). **Diagnostic reflex for any "it stopped working after we re-rendered" report: the question is not whether the click listener survived — it did, it is on the form — but whether the node carrying the stage-2 listener is still the node in the document.** Browser-bundled ⇒ restart AND re-bake. **The submit proxy's gesture gate reads the LIVE trigger (#B308, 0.6.5).** The per-form `submit` proxy enforced its disabled gate on a DOMParser copy of the form's innerHTML, testing only native `.disabled` on the copy — blind to the gate's own marker (then `aria-disabled`, `data-gina-form-submit-gated` since #B312, written by `updateSubmitTriggerState`) while SIGHTED on a native `disabled` written mid-dispatch by a double-submit guard (the exact attribute #B293 ruled must not count on a real control — so wrapped-label + double-submit guard was a dead submit, the #B293 signature alive on the submit path). A gated trigger's trusted gesture therefore ran the full collect → validate cycle and SENT whenever the values validated. Now an `e.isTrusted` submit on a form whose REGISTERED trigger is `isTriggerDisabled()` is cancelled and answered with the same display-only `revealValidationState()` as the #B246 click path (errors rendered, first invalid field focused, stale marker re-synced); programmatic `$forms[id].submit()` arrives untrusted (triggerEvent CustomEvent) and deliberately keeps the fresh-validate path — a programmatic submit is not operating the control, the fresh pass is the honest gate there, and cancelling on a cached marker would break fill-then-submit flows. Two gesture shapes reach this gate (probe-measured, Chromium): a wrapped-label click (`<button type="submit"><span>` — the span has no `.type`, so the click proxy never fires and the gesture surfaces as a native submit event), and Enter in a form with NO native submit button (e.g. an `<a data-gina-form-submit="true">` trigger — implicit submission fires a DIRECT trusted submit; with a native button present, Enter instead synthesizes a click on the default button that the #B246 CLICK guard already refuses while the marker stands, so that shape never needed this gate). Covered by `test/e2e/validator-submit-proxy-disabled-gate.spec.js` (5 arms, red-first BOTH directions via the committed `B308_PREFIX_BUNDLE` route-swap hook: label-click and A-tag-Enter RED pre-fix on an EVENT discriminator — the pre-fix cycle fires `validate.<id>` from the no-`on('submit')` branch, while the post-fix reveal is display-only and fires nothing; POSTs cannot discriminate, since an invalid form never sends on either side). Scene lesson for e2e authors: stale-marker scenes are racy BY CONSTRUCTION — a page-level `.value =` write on a bound field is heard SYNCHRONOUSLY (the value property is instrumented and the silent live validation repairs the marker inside the write), and a delayed global silent pass can heal a protocol-level fill's stale marker mid-arm — so pin gate behaviour on a genuinely-invalid form and discriminate on event emission. Browser-bundled ⇒ restart AND re-bake. **The anchor in-flight lock owns `aria-disabled` alone; the settle releases remove it unconditionally (#B309 → #B312, 0.6.5).** On an `<a data-gina-form-submit="true">` trigger send()'s in-flight lock and the not-ready gate historically SHARED `aria-disabled` with opposite lifecycles — the settle releases (the `loadend` fail-safe + the readyState-4 twin) removed it unconditionally, so a mid-flight gate re-mark was silently erased at settle (#B309), and the reveal's valid-form heal erased the LOCK mid-flight the same way (#B313). #B312's single-writer split dissolves the class structurally: the gate writes only `data-gina-form-submit-gated` + the class, the lock is the sole framework `aria-disabled` writer on anchors (buttons lock via native `disabled`), both settle releases are back to the plain unconditional `removeAttribute` (the interim #B309 verdict-stamp replay retired unreleased, its 18-test file with it), and the reveal's show branch never touches aria — so authored `aria-disabled` survives every framework pass (enforced by the gates, never auto-cleared: consumers relying on the old auto-clear-on-valid must migrate) and #B313 is closed by construction. Scene fact that shaped the design: live-check is DELIBERATELY quiet during a real in-flight submit (the #B192 valid branch holds the submit latch until settle), so keystrokes cannot re-mark mid-flight — the reachable mid-flight writer was the #B246/#B308 reveal itself. Covered by `test/core/validator-submit-gated-marker.test.js` (17 — block-scoped single-writer negatives immune to right-extension, authored-aria + lock survival behaviour, the shipped `isTriggerDisabled` EXTRACTED and EXECUTED, terminator-anchored release-shape pins, dist-fidelity pins validated red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. **isRequired's emptiness is anchored at both ends, and trim strips both sides (#B245, 0.6.5).** The emptiness conjunct was `!/^\s+/.test(value)` — leading-anchor only — so ANY leading-whitespace-padded value read as empty (" x" rejected, "x " accepted), and under the documented isRequired-first ordering a paired `trim` healed `local.data` AFTER the error was recorded: a rejected field carried the trimmed non-empty value in the same result. Now `/^\s+$/`: whitespace-ONLY is empty, padded values pass, all-whitespace and the empty string still fail. Same fix: `trim`'s replace gained its missing `g` flag (first-match-only rewriting left the trailing run whenever a leading run matched — " x " → "x "). The validation-rules reference is corrected (it had documented the leading-space rejection as intended while prescribing the isRequired-then-trim pairing that could not work under it). Covered by `test/core/validator-required-whitespace-trim.test.js` (block-scoped comment-stripped source pins + the heal matrix on the REAL plugin over the server auto path + dist-fidelity counts red-first across the rebuild). Browser-bundled ⇒ restart AND re-bake. #B345 (issue #59, shipped in 0.6.7): the #SCS1e paren/`return` strip used to run BEFORE the regex-vs-comparison branch split, so a parenthesized `is` regex literal compiled from MANGLED text — groups destroyed and anchors rebound (`/^(a|b)$/` behaved as `/^a|b$/`: substring-permissive for middle alternatives), quantified groups requantified (`(#TAG)?` → literal `#TA` + optional `G`), and a literal `return` inside a pattern deleted — all silent, since the stripped text still compiled as a valid regex (the `Invalid regex literal` throw structurally cannot catch it). The strip now lives inside the binary-comparison branch only (grammar lock + authored-paren tolerance unchanged — `("a") === ("a")` still strips to a valid comparison); a regex literal compiles exactly as authored, that branch never evaluating the condition as JS. Behavior change (changelog ACTION REQUIRED): paren-carrying `is` patterns now match as authored, i.e. stricter; and the paren-wrapped-regex edge `(/foo/)` now fails closed in the comparison branch instead of being unwrapped into the regex branch. Locked by `test/lib/validator-is-regex-parens.test.js` (14 — red-first source ORDER pins, the reported reproduction, anchors/quantifier/`return` arms, comparison-branch controls, gina.js + wrap-immune gina.min.js order pins). #B389 (gh issue #63, shipped in 0.6.11): the Safari-autocomplete keydown interception (`handleAutoComplete` — REAL-Safari-only per #B135; preventDefault + programmatic value rebuild behind a transient readonly) restored the caret only two setTimeout(0) hops after each rebuild, while a `.value` assignment parks the selection at the END of the field (measured on WebKit AND Chromium) — so a fast second keystroke read a stale `selectionStart` and composed scrambled text ("AXB" where the user typed "ABX"; deterministic whenever two keydowns share one task, which live-check work between keystrokes makes routine). Fixed with a synchronous caret commit after every rebuild (`commitCaret` — measured to stick, and to survive the readonly toggle, on both engines) plus an element-recorded desired-caret tracker (`_ginaAcCaret`/`_ginaAcPending`) the handler trusts while a restore is in flight; the deferred restore (`queueCaretRestore` — the autofill-suppression readonly dance, mechanism-identical) re-asserts the LATEST committed position instead of its own stale capture, and arrows commit through the same tracker so a pending restore cannot undo them. By-catches in the same switch, all fixed to native behavior: #B390 Backspace at position 0 deleted the FIRST character (native no-op), #B391 Delete with a selection starting at 0 ate one char MORE than the selection, #B392 ArrowLeft at position 0 wrapped `setSelectionRange(-1)` to the unsigned maximum and teleported the caret to the END (now floored at 0). Browser-bundled ⇒ pickup is bundle restart AND re-bake. Tests: `test/core/validator-autocomplete-caret.test.js` (red-first: extraction controls, source pins, extracted-real-bytes behavioral arms on a caret-to-end element model, dist pins) + the webkit-only e2e `test/e2e/validator-autocomplete-caret.spec.js` (full-stack real-WebKit reproduction, red-first "AXB" → green "ABX"; chromium/firefox skip — #B135 gates them out; runs in the on-demand cross-engine job). **#B200 (shipped in 0.6.12): a NON-STRING field value no longer aborts the whole validation pass.** `isEmail` and `isJsonWebToken` opened with a TRUTHY-only coercion (`(this.value) ? this.value.toLowerCase() : this.value`), so a truthy non-string — `123` / `true` / `[]` from a JSON body, or a checkbox boolean on the client — reached `.toLowerCase()` and threw; `trim` was WIDER still, its type guard present but COMMENTED OUT and no truthy guard either, so EVERY non-string threw, a falsy `0` included. In all three the rule driver's catch RE-THROWS (`[ ginaFormValidator ] could not evaluate …`), so one bad field kills every remaining one: server-side the request goes unvalidated, client-side the boot-time binding loop dies and later forms silently lose validation AND CSRF injection. Fixed by type-guarding each site against a precedent already in the file — the #B87 `query` coercion for the two rules, isFloat's identical guarded `.replace()` for `trim`. **Measured, and the load-bearing check: the guard does NOT open a #B199-style silent bypass** — a non-string passes through untouched and the rule's own regex then rejects it, so `isEmail`/`isJsonWebToken` record a normal rule-keyed error (`isValid === false`), while `trim`, being a transform, leaves it untransformed (the #B245 both-ends strip on real strings is unchanged). **Reusable rule: type-guard BEFORE a string coercion, never truthy-guard** — a truthy check admits every non-string except the falsy ones (the authn.md §4 lesson, and the same split that sent falsy values to #B199 and truthy ones here). Deliberately NOT fixed in that pass: `isDate` (its throw is a purpose-built catch — an error-contract design call, #B397). `toFloat`/`format` — plus the same-family `set` — were later fixed by #B398 (context-safe rules; `test/core/validator-context-safe-rules.test.js`): `toFloat` reads the live DOM value only when one is reachable and otherwise uses the submitted `this.value` (server-side that IS the raw value, so the rule is fully functional in both contexts; the missing comma that leaked `isFloatingWithCommas` as an implicit global is fixed with it); `set` assigns the value everywhere and guards its DOM write (`isGFFCtx && target`); `format` always worked in BOTH contexts after `isDate` — the filed "dateFormat prototype extension absent server-side" mechanism was REFUTED by measurement (helpers/index installs `Date.prototype.format` unconditionally, server included) — and a non-Date value now throws a NAMED authoring error (`apply isDate(mask) before format(mask)`) instead of the opaque `val.format is not a function` (`toFloat`/`format` were first misread as this same class by a probe whose STRING CONTROL also threw, which is what exposed them). Browser-bundled ⇒ pickup is bundle restart AND re-bake. Tests: `test/core/validator-nonstring-value-guards.test.js` (comment-stripped source pins red-first vs the pre-fix blob, behavioural arms asserting the VERDICT not merely the absence of a throw, wrap-agnostic dist pins red-first vs the pre-fix artifact, plus untouched-rule controls incl. one labelled INVARIANT because it cannot go red). **The form-level keydown proxy defers the native-cancel decision to the namespaced handler (#B444, gh issue #67):** `addListener(gina, $el, 'keydown.<id>', fn)` registers a CUSTOM event type a native keydown can never fire; the form-level `keydownProxyHandler` bridges the two by re-dispatching - and it used to `cancelEvent()` the NATIVE keydown unconditionally BEFORE that dispatch, so on real-Safari UAs (where the autocomplete interception registers exactly such a handler) EVERY modifier chord on a live-checked autocomplete-suppressed field was dead - paste, select-all, copy, cut, undo - with NO paste/beforeinput event observable anywhere (preventDefault on a native keydown suppresses the browser editing command itself), while the interception handler's own chord bail sat one layer too low to help. The proxy now dispatches FIRST and cancels the native keydown only when the handler prevented the synthetic event (which the interception already does on the paths it re-implements and already does not on chords); `triggerEvent` returns the dispatched event to make that decision readable, undefined on the element-less path - treated fail-open to native. The keyup proxy is untouched (no `keyup.<id>` registrar exists). Measured live on WebKit: chords restored, typing interception + caret integrity + autofill suppression unchanged. Tests: `test/core/validator-keydown-proxy.test.js` (extract-and-execute on both changed functions) + `test/e2e/validator-autocomplete-paste.spec.js` (webkit project, cross-engine job). **#FIN4 (`isIban` / `isBic`, 0.6.x):** IBAN and BIC join the built-in rule set. `isIban` validates ISO 13616 in three ordered checks - shape (`^[A-Z]{2}[0-9]{2}[A-Z0-9]{1,30}$`), official per-country length via the constructor-scope `_ibanLengths` registry (87 countries; unknown country codes deliberately pass on shape + checksum so a registry gap never hard-fails a well-formed IBAN), and the ISO 7064 MOD 97-10 checksum folded modulo 97 in <=7-digit chunks of plain Number math (no BigInt in bundled source - build-chain constraint). Tolerant read: validation runs on an uppercased copy with spaces/hyphens stripped; the stored value and the DOM are NEVER mutated (unlike `isEmail`'s lowercase write-back). The type guard folds into `isValid` (`typeof(candidate) == 'string' && rgx.test(...)`) so a toString-spoofing object records an invalid instead of throwing into the rule driver (#B200 posture, tightened). `isBic` validates the ISO 9362 8/11-char shape case-insensitively by regex - no case mutation. Both follow Shape A (`this.valid = isValid && !errors['isRequired']`), the strict #B199 empty bypass, and register English defaults in `_defaultErrorLabels` ('A valid IBAN is required' / 'A valid BIC is required'), culture-localisable like every built-in label. Behavioral + pin coverage: `test/lib/validator-isiban-isbic.test.js` (real-engine; fixtures verified against an independent BigInt oracle). **Autofill signal (#B478, 0.6.27):** the live check re-runs only on the proxied native `change`/`keyup`/`focusin`/`focusout`, and a browser or password-manager fill produces NONE of them - on Chrome not even at fill time, and the filled VALUE stays withheld from script (`.value` reads `''` while `:-webkit-autofill` matches) until the first trusted gesture, which then releases it and fires a keydown/input/change/keyup burst per field (measured, Chrome 152 - which is also why a "gated" trigger always submitted on the first click). So a form filled without keystrokes kept its bind-time gated look on a visibly complete form, and an autofilled INVALID value showed no error. Fix: the default stylesheet (`src/vendor/gina/autofill/sass/autofill.scss`, FUNCTIONAL so deliberately outside `@layer gina`; its keyframe carries a declaration because csso strips an EMPTY `@keyframes` block) gives every `:autofill` / `:-webkit-autofill` control a 1ms keyframe named `gina-autofill-start`; the validator's form-level `animationstart` proxy (`autofillProxyHandler`, also on reassociated controls) dispatches the field's own `change.<id>` live check when the value is readable, and calls `revalidateSilently()` when the browser withholds it. `excludeWithheldAutofill()` narrows the five LIVE-CHECK collectors (bind-time, live global, select global, reveal, silent) so a withheld field neither gates nor renders a false "required" error; the SUBMIT collectors stay strict, so `isValid()` still refuses to post a withheld (empty) credential. A consumer rule setting `animation-name` on those pseudo-classes replaces the hook - keep `gina-autofill-start`. Pins: `test/core/validator-autofill-livecheck.test.js` (extract-and-execute, dist + stylesheet contract) and `test/e2e/validator-autofill.spec.js` (the served keyframe applied through a harness class - real autofill is invisible to automation and to fresh CI profiles). **Honest label for a withheld autofill at submit (#B341, 0.6.27):** the submit collectors are strict by design, so a control the browser autofilled but still withholds fails `isRequired` at click time; that verdict is right, but the plain required label named a problem the user could not see. `isRequired` now consults the plugin's single withheld predicate (exported as `gina.validator.isAutofillValueWithheld`) and, when true, composes the message from the new built-in key `isRequiredAutofill` (default `Filled in by your browser: click the field to confirm it`) - the errors key stays `isRequired`, the verdict and the gate are untouched - and marks the control `data-gina-form-autofill-withheld="true"` until its next adjudication (a CSS/JS hook and the locale-agnostic test signal). `_labelAliasFill` copies an app-supplied `isRequired` onto `isRequiredAutofill`, so a localized catalog keeps its own wording until it translates the new key: never key a test on the English text. Pins: `test/core/validator-autofill-honest-label.test.js` (the rule, `replace()` and the alias fill executed from the shipped engine bytes; red-first through `GINA_FORM_VALIDATOR`) + `test/e2e/validator-autofill-honest-label.spec.js` (chromium + webkit; the plain label read off the page first, the honest render asserted to differ and to carry the marker). **Same-name pairs with a hidden twin (#B510, 0.6.29):** the painter walks `$form.elements` in document order and, on the per-field path, exits on the first control whose name matches — and a BARE `type="hidden"` control (no `form-item-wrapper`) can never be painted (every paint gate skips it). It used to consume the error anyway (box class set, no message, no `aria-invalid`, walk stopped), so the recommended disabled-control shape — a visible control plus a hidden twin carrying `"exclude": false` — lost its server-side field error whenever the hidden twin came first; on a shared box the pre-marked class blocked the visible twin's first-error branch even without the exit. A bare hidden control is now skipped when another control of the same name can be painted (`hasPaintableTwin`); a lone bare hidden keeps the former behaviour, a wrapped hidden is still painted after its wrapper. Browser-bundled: pickup is a bundle restart AND a re-bake. Tests: `test/e2e/validator-hidden-first-paint.spec.js` (real bytes, seven shapes) + `test/core/validator-hidden-first-paint.test.js`. **Binding scope (#B549, 2026-09-17):** the boot scan binds ONLY opted-in forms — a `data-gina-form-*` attribute, an existing id naming a registered rule (`-` read as `.`), or a virtual `gina-upload-*` id; any other `<form>` is left untouched (no minted id, no `$forms` entry, no submit proxy) even on a rules-bearing page, matching the forms guide. Explicit `validateFormById()` / `getFormById()` stay ungated opt-ins. Before the fix the scan's `else` arm bound every form after minting it a generated id, so declaring ONE rule file turned every plain login/signup form on the site into an always-XHR submit — and on isaac HTTP/2 that XHR-answered `req.login()` then lost its session cookie (#B550). **Staged-upload failure paths (gh#83, #B724/#B725/#B727/#B728/#B731, 0.7.2):** (a) #B727 — `onUpload` writes a staging error into the `-error` element as `textContent` inside a created `<p>`, never `innerHTML`: a non-2xx non-JSON body is kept verbatim as `result.message`, so a proxy/WAF HTML page or a reflected filename used to be parsed as live markup (the sink dated from 0.1.1). (b) #B724 — a status-0 settle reports `{status:408, transportError:true, reason, error: a11yLabel('transportError')}`; the text is 'Transport failure: the request did not complete' (a proxy 413 mid-send over HTTP/2 and a navigation abort both DID reach the server), overridable through `gina.config.a11y.transportError`; `reason` is `'unload'` while a `pagehide` is in effect, else `'transport'` — #B728 resets that flag on `pageshow`, because a back-forward-cache restore keeps the page's JS state. (c) #B725 — both payload collectors (`getFormValidationInfos` and the submit proxy's inline loop) skip a `type=file` control carrying `data-gina-form-upload-action`: it stays in `$fields` for rule evaluation, but its `.value` (`C:\fakepath\<name>`) never rides the payload. ⚠️ The default error render runs ALONGSIDE a declared `data-gina-form-upload-on-error` callback (element first, then `error.<vid>` and the callback), so a callback cannot suppress it. (d) #B731 — the error element is OPTIONAL: without it the message is announced through the form's live region (`announceA11yError`, when non-empty), a dev-mode `[FormValidator] upload … no error element` warning names the missing id, and `error.<vid>` and the callback still run; `onUpload` used to `throw new Error(errMsg)` there, with `errMsg` undefined outside the rendering branch, BEFORE either dispatch, so the callback never ran, for an HTTP error and a status-0 failure alike. The slot id is read ONCE, from the file input (`data-gina-form-upload-error`, default `<input id>-error`): it was recomputed for every `<input>` of the real form and kept the LAST one's, so a custom slot was honoured only when the file input was the form's last input. Residual: the bind-time `checkUploadUrlActions` still looks its errors up only at `<id>-error`. Pin: e2e `validator-upload-error-slot-b731.spec.js` (5 arms, red-first on the pre-fix dist).
938
939
 
939
940
  210. **Multipart upload config is ENFORCED — groups, destination, limits, text-field capture + caps, terminal states (consolidates former #187/#188/#228/#230's server half; #B49/#B50/#B51/#B92-adjacent/#B93/#B97).** Every uploaded file must map to a CONFIGURED upload group: the resolved group (no/empty → the default `untagged`) must exist in `settings.json upload.groups` or the request is rejected 400 BEFORE the temp file is created, and `untagged` obeys its own config like any named group (pre-#B50 the checks ran only for defined non-`untagged` groups — an allow-list bypass; the shipped `untagged` default is `allowedExtensions: '*'` + `isMultipleAllowed: true`, so configure `untagged` restrictively or a client can route around a named group's allow-list). Destination + limits honoured: files stream to `uploadDir || tmpPath || os.tmpdir()` with a per-group `path` override and mkdir-if-missing before `createWriteStream` (the mkdir itself crash-guarded → 500, #B145); `maxFields` caps the per-request file COUNT (400 past the cap; 0/unset disables); `maxFieldsSize` parses its unit suffix (B/KB/MB/GB; bare number = MB) as the whole-body cap (431). **The staged on-disk FILENAME is server-generated and opaque, never the client's (#B419, 0.6.16):** `crypto.randomBytes(16).toString('hex') + '.part'`, minted once per part and used at BOTH path-construction sites — the write stream AND `req.files[].path`, which are built independently and must never diverge. Pre-fix the destination was `<fileUploadDir>/<client basename>` with NO per-part component, so two parts sharing a name — within ONE request or across concurrent requests — opened two write streams on ONE path with independent file offsets and INTERLEAVED their bytes into a single hybrid matching neither source, while the framework answered `storeErr:false` + 200 (measured live pre-fix: 12 concurrent same-named uploads → 0/12 clean, one hybrid file; one request with two same-named parts → one 500000-byte file of {B:417353,A:82647}; post-fix 12/12 clean, each byte-exact). The parse precedes routing and all middleware, so an UNAUTHENTICATED POST to ANY url reaches it — an unrouted one answers 404 to the client and still writes the file. A CSPRNG rather than the `Math.random()` used by `movefiles` (V8's is xorshift128+, so staging paths would be predictable, and a group `path` is operator-configurable into a served root); a RANDOM name rather than `filename + <suffix>` (keeps attacker-controlled bytes off the filesystem — RTL-override display spoofing, control chars, reserved device names — and cannot overflow NAME_MAX=255 into an ENAMETOOLONG 500 for a long-but-legal client filename); no interpretable extension, so a misconfigured static-serve of the staging dir cannot hand back an executable type. This is the multer ("a random name that doesn't include any file extension") and formidable (`newFilename` hexoid) convention. `req.files[].originalFilename` still carries the client's name and is what `store()` publishes the file under (`controller.js:4061`) and what a storage driver receives as `originalName`, so the documented "keeping each file's original name" contract is UNCHANGED — only `req.files[].path`'s BASENAME differs, and that field is documented as the temporary file's path. Server-side ⇒ pickup is a bundle restart, no re-bake. **Staged-part lifecycle + orphan reclaim (#B469):** a staged part has exactly TWO reclaim paths — the `movefiles()` unlink when `self.store()` publishes it, and the per-upload `autoTmpCleanupTimeout` deletion timer (`false`/0/empty disables it — the shipped default, and removing staged files is then the operator's own job). The timer is IN-PROCESS: a restart strands every part it was holding, and a part that never reaches `store()` (a request failing AFTER the multipart parse — the parse precedes routing, so auth/validation/quota failures have already staged; or an app consuming the staged file without `store()`) then has NO reclaim path at all. So an ARMED timer also arms a boot-time sweep at server init: each configured landing dir (the resolved global + every group `path`, deduped) is swept NON-recursively for `<32 lowercase hex>.part` REGULAR files (lstat, symlinks never followed; a client-named orphan predating the opaque staged naming is indistinguishable from an application file and is left alone — ⛔ that name gate is LOAD-BEARING, never widen it: `_sweepDirs` includes every group `path`, and a group `path` is ALSO the PERMANENT destination, so on a deployment whose group path is a permanent publicly-served storage root the pattern is the only discriminator protecting real user files; the mtime gate cannot help there, since permanent files are old by definition) older than max(timeout, 1h) — the floor keeps a SIBLING live process's in-flight part on a shared staging dir safe, since its mtime stays fresh while chunks land; ENOENT at any step is not an error (a sibling bundle sweeping the same dir, or the app unlinking its own part). The sweep REPORTS at `info` only when it removed something or hit errors (`upload orphan sweep: removed N staged part(s) across M staging dir(s)`) and at `debug` when it found nothing — so at the default `info` level a silent boot means no staged part was older than the gate, NOT that the sweep did not run; an operator expecting the line after a restart that had nothing left to reclaim (the boot that did the reclaiming having gone with its logs) will otherwise read a healthy no-op as a missing line. Disabled timer = NO sweep — the documented operator-owns-cleanup contract is unchanged. An application MAY unlink `req.files[].path` itself: `movefiles()` tolerates a vanished source (ENOENT) by design, and the armed timer already requires that tolerance. Rule: a group `path` is the PARSE-TIME LANDING (staging) directory, never where published files live — `store(targetDir)` or the group's storage `driver` owns final placement, and one key must never carry both meanings. **Multipart TEXT fields are captured** — a real busboy `'field'` listener (busboy silently SKIPS all non-file parts when none is registered) exposes them on `req.body` + `req[method]` (POST/PUT/PATCH only), values VERBATIM (no url-decode, no `"true"/"false"/"on"/"null"` coercion — the JSON body contract, deliberately: otherwise the same client `send(fd)` call would change value TYPES with file presence), bracket-notation names nested through the urlencoded path's own layer, duplicate plain names last-wins; caps `upload.maxTextFields` (default 1000) + `upload.maxTextFieldSize` (default 1MB, unit-aware, explicit 0 = no limit) answer 400 on breach instead of busboy's silent skip/truncation. **A multipart request with no successfully-parsed file part reaches a TERMINAL state:** fields-only → the request resumes; malformed/empty → 400 via `busboy.on('error')` with a double-response guard (busboy terminates in exactly `finish` XOR `error`; pre-#B93/#B97 both were unauthenticated pre-routing DoS holes — an eternal hang, and an uncaughtException → SIGTERM bundle kill). Rules: a per-group restriction is only a control if the UNCONFIGURED/default case is denied or constrained, never waved through; a documented config key is only real if a code path reads it — grep the consumer before assuming a setting works; any stream/parser whose SUCCESS path drives a request's continuation must ALSO handle its error/empty terminals. Since #STO1 slice 1 a group may also carry driver: '<name>', routing its self.store() step through settings.storage (entry #300; the parse/staging path above is unchanged). Server-side only. Tests: `test/core/upload-groups.test.js` + `upload-config.test.js` + `multipart-nonfile-terminal.test.js` + `multipart-field-capture.test.js` + `upload-concurrent-staging.test.js` + `upload-orphan-sweep.test.js` (+ the client `send(FormData)` half: `validator-send-formdata-multipart-fields.test.js`). **Server-path integrity + terminal-state hardening (former #263/#267/#268/#270 — #B103/#B142/#B143/#B144/#B145/#B223, 0.5.22-0.5.24):** a multipart body stays RAW end-to-end — `request.isMultipart` is computed ONCE at the request prologue and gates the request-stream `setEncoding` (a multipart stream reaches busboy as raw Buffers, its documented input; the decode remains for the text-body branches), write-pipeline chunks pass through VERBATIM, and `req.files[].size` counts BYTES, never a decoded string's length (#B103 — pure-ASCII surviving both old decode layers is why text uploads always worked while every real binary corrupted on every native multipart client: a PNG's 0x89 magic landed as 0xFD). The per-file `size` is finalized in the liner Transform's `_flush` (#B142 — the source-'end' record ran one pipe hop UPSTREAM of the byte counter with 16-object high-water marks still queued, under-counting ~25% on a 1.5MB file while md5 stayed perfect: the bytes were intact, only the number lied; `_flush` runs after the last `_transform` and strictly before the write stream can emit 'finish', both interleavings measured exact), and BOTH write-stream terminal listeners arm AT STREAM CREATION in the 'file' handler (#B143 — arming inside `busboy.on('finish')` lost the race for any early-finishing small file: Node never replays 'finish' for a late listener, so a throttled two-file upload hung deterministically, ~1 in 13 unthrottled; unauthenticated, both engines). Resume fires on `busboyDone && pending === 0`, whichever event lands last (the zero-pending branch still covers the fields-only #B93 terminal), and a mid-stream write error (missing dir, disk full) gets a guarded 500 instead of the historical unhandled-'error' uncaughtException SIGTERM. Downstream, `Controller.store()`'s mover streams each file to a temp sibling and publishes with an ATOMIC rename (a reader never observes a partial file; a pre-existing destination is replaced only on success), propagates the REAL filesystem Error (previously masked as `No file to upload`), and never settles the callback twice (#B223). Since #B227 the SAME semantics govern the general-purpose byte-writer in `helpers/path.js` behind `_().cp()` / `PathObject.mv()` (which CLI copy/build/rename paths ride): temp sibling in the destination's own directory + atomic rename (a pre-existing destination is no longer unlinked BEFORE the copy — its content survives a failed copy), a source-stream error listener (was an unhandled `error` event → process kill), a settled latch (a destination-side failure previously settled twice, the second time as a success, forking `browseCopy`'s directory recursion), and a real `Error` instead of the former plain string (`Error on Path.cp(...): Not found ...`) — so caller `err.stack` prints stop logging `undefined` (on Bun too, which reports such an error without a stack: the helper gives it one, #B654), and a failed copy's temp sibling is removed only once its stream has closed, before the callback — its creation can finish after the failure is reported (#B649); `Controller.store()`'s mover does not have this reap yet — its failure path still checks for the temp sibling and settles at once (#B657, open, code-read). The destination mkdir is crash-guarded (#B145): a group whose custom `path` has a read-only/EACCES/EROFS parent answers a guarded 500 naming group + path — a server CONFIG problem is 500 (the #B50 unconfigured-group 400 stays upstream and unchanged), and pre-fix `fs.mkdirSync` threw SYNCHRONOUSLY inside the parser callback → uncaughtException → SIGTERM, an unauthenticated single-request bundle kill (the multipart parse precedes routing and all middleware, both engines). A per-group `simulateWriteError: true` flag (#B144) lets a consumer deterministically fire the guarded-500 write-error path OUTSIDE production scope only (`!NODE_SCOPE_IS_PRODUCTION`): the 'file' handler creates the REAL write stream, arms the REAL terminal listeners, then synthetically `destroy()`s it — the exact terminal semantics of a real ENOSPC/EIO (an errored stream never emits 'finish', so the request stays terminal at the 500), N destroyed parts collapsing to exactly ONE 500 via throwError's `!res.headersSent` guard; a boot warn scans `upload.groups` for the flag in both scopes (production: "IGNORED — remove before shipping"; else "PROBE active"); nothing ships active. The `group="…"` tag rides a Content-Disposition PARAMETER that `curl -F` / browser FormData cannot emit (the `@rhinostone/busboy` fork parses it into `info.dispositionParams.group`), so a faithful probe HAND-BUILDS the multipart body. **The staged upload client layer (`data-gina-form-upload-*`, lives in the validator plugin — browser-bundled ⇒ consumers RE-BAKE at pickup; former #269/#271/#272/#274 — #R8 slices 1-2, #B146/#B147/#B148/#B149):** the staging POST body is assembled as a `Blob` with each File object embedded RAW (#B148 — the historical `FileReader`/`ab2str` DOMString concatenation UTF-8-inflated every byte >= 0x80 on the wire, ×1.49 measured, a PNG's 0x89 arriving as 0xC2 0x89; the corruption only STARTED at 0.5.22 because the pre-#B103 server's two since-removed decode layers EXACTLY reversed the inflation — two wrongs cancelling — so the #B103 server fix is what exposed the client defect). Multipart FRAMING is byte-identical (same boundary delimiters, same `name=`/`group=`/`filename=` disposition-parameter set, values percent-escaped for CR/LF/double-quote per RFC 7578 §5.1.1); a fixed client must pair with a server >= 0.5.22 (an older server's decode layers would corrupt the now-raw bytes — the #B103 corruption in reverse); files corrupted by the defect are LOSSLESSLY recoverable (the stored bytes are exactly the UTF-8 encoding of the originals: decode utf8 → re-encode latin1). **Upload progress (#R8 slice 1):** `send()` assigns `xhr.upload.onprogress` FRESH on every send (the module-scoped XHR was reused across sends — a stale handler replays the previous send's closure with the wrong id), dispatching the REGISTERED event `uploadProgress.<uploadFormId>` (the events-array registration is required — `on()` validates names against the plugin registry) with `{ status: 100, progress: <int 0-100 | null when lengthComputable is false>, loaded, total, lengthComputable, files: [names] }` — a per-REQUEST aggregate (ONE staging XHR carries every file of a selection; per-file wire progress is not separable). Consumer surfaces: `data-gina-form-upload-on-progress` (bare window identifier, the -on-success convention) + a declarative indicator `data-gina-form-upload-progress="<elId>"` defaulting to `<fieldId>-progress`, opt-in by element presence. The updater feature-detects the target: a native `<progress>` tracks value=loaded/max=total (indeterminate = the value attribute REMOVED for the native animation; error = value 0, NEVER indeterminate — that animation would read as still working); any other element gets percent textContent + `data-gina-upload-progress`/`data-gina-upload-progress-state` styling hooks (preparing|uploading|indeterminate|processing|complete|error) with NO hardcoded wording (i18n-neutral — label via CSS on the state attribute). Lifecycle: `preparing` at selection (covers the FileReader/assembly phase), `uploading` per frame, `processing` at `xhr.upload.onloadend` (the browser finished SENDING — advances the state attribute ONLY, leaving value/percent as the last frame left them so a determinate bar stays visually full), `complete`/`error` finalized in onUpload (one chokepoint covers success and every error/timeout path), reset/delete strips the indicator. The response-side download channel `progress.<id>` is byte-untouched (it also serves attachment downloads). **Drag-and-drop (#R8 slice 2):** a file input carrying `data-gina-form-upload-dropzone="<elementId>"` gets drag listeners bound on the named element at form-bind time; dropped files are assigned to the input (`input.files = dataTransfer.files`) and the input's `change` is re-fired synthetically, so the ENTIRE staging pipeline — group tagging, virtual form, staging POST, previews, hidden metadata fields, reset/delete, upload progress — runs with zero duplicated logic (the change handler reads only `currentTarget`, so a synthetic dispatch is indistinguishable from a trusted one on this path). Contract: EXPLICIT-id-only (deliberately NO `<fieldId>-dropzone` default — auto-binding a coincidentally-named element would attach drag semantics to markup that may carry its own drop handling; absent attribute = inert, missing element = console.warn + inert); the zone is stamped `data-gina-upload-dropzone` (value = owner input id — the first-wins guard: one zone serves one input) and `data-gina-upload-dropzone-state` (`idle` → `over` on a file-drag hover, DEPTH-COUNTED so child-boundary crossings never flicker → `dropped` → back to `idle` at the same onUpload chokepoint that finalizes progress, and at reset/delete) — pure CSS hooks, no hardcoded wording. Only FILE drags react (`dataTransfer.types` must carry `Files`; text/link drags fall through untouched, never preventDefault'd); a multi-file drop on a non-`multiple` input keeps the FIRST file only (console.warn) — configured groups still enforce `isMultipleAllowed` server-side; a bare file input already accepts native browser drops through the same change handler — the attribute exists to delegate a larger/styled element. **Action/preview/bind fixes (#B146/#B147/#B149):** `checkUploadUrlActions`'s default-route fallback writes the attribute ACTUALLY being checked (`setAttribute(action, …)` — it used to hardcode the staging attribute and silently repoint a staging POST at the resolved reset/delete default route, compounding to a SILENT failure when `toUrl()`'s absolute origin tripped a CORS preflight → XHR status-0 → the commented-out status-0 branch); the preview-container guard uses the typeof-null pattern (`&& previewContainer` — `getElementById` returns element|null and `typeof null === 'object'`, so a preview-element MISS used to TypeError the success handler at `.id`); an upload-only input binds QUIETLY — `-delete-action` deliberately has no framework default (it removes an already-saved file, an app-specific endpoint), so a no-default absent action is a single `console.debug` + early return (a WITH-default action that genuinely fails to resolve still errors; the delete requirement stays enforced lazily at `onUploadResetOrDelete`). **Generated hidden fields — the `preview` slot (#B459, 2026-09-08):** the client writes one hidden input per `mandatoryFields` entry (`name`, `group`, `originalFilename`, `ext`, `encoding`, `size`, `height`, `width`, `location`, `mime`, `preview`) into the real form, auto-creating any the form did not declare — and `preview` was auto-created like the others although its value is an OBJECT (`{location, uri, tmpUri, width, height}`), so a form declaring no `[preview][...]` sub-fields got a flat `<prefix>[i][preview]` input, the fill loop's shared assignment string-coerced the staging response's preview object into it, and the real form's submit posted the literal `[object Object]` — a GARBAGE-VALUED field, not an absent one, so a data audit keyed on the field's absence reads the opposite of the truth (key on the value). The trigger is a CONJUNCTION — an undeclared form AND a response carrying a `preview` key; with no key the skip clause fires and the flat input was removed, which is why the documented response shape (no `preview`) never showed it. Since #B459 the auto-create loop seeds that slot with an EMPTY sub-field map instead of an input — so the fill loop still visits the key (the nested thumbnail is rendered from inside it) and posts nothing for it, the documented field set — and the shared assignment never targets the preview slot. Declaring `<prefix>[i][preview][location|uri|width|height]` hidden inputs is the opt-in for persisting the preview: the name parser is last-bracket-only, nesting is decided by a separate `/\[preview\]/i` test on the full name, so declared sub-fields fill from the response's nested object and post as a real nested structure — unchanged by the fix. A deliberately declared FLAT `[preview]` input is filed under the map (`preview.preview`), never as the slot itself, so the coercion path had exactly one entrance. Server code that keyed on the PRESENCE of a `preview` field for an undeclared form must key on its value: garbage before, absent now. Browser-bundled ⇒ pickup is restart AND re-bake. Tests: `validator-upload-preview-slot.test.js` (11: source pins, the auto-create loop extracted from the shipped bytes and executed on jsdom forms, dist fidelity — 9/11 red-first through the `GINA_VALIDATOR_MAIN`/`GINA_PLUGIN_DIST` seams) + e2e `validator-upload-preview-fields.spec.js` (arm 02 red on the pre-fix bundle through its `B459_PREFIX_BUNDLE` route lever, both thumbnails pinned as the constraint). Tests: `upload-binary-integrity.test.js` / `upload-size-accuracy.test.js` (11) / `multipart-multifile-resume.test.js` (15) / `upload-write-error-probe.test.js` / `upload-config.test.js §07` / `validator-upload-progress.test.js` (46) / `validator-upload-binary-wire.test.js` / `validator-upload-action-guard.test.js` — all red-first-validated with control-gated extractions of the shipped bytes. **The store() fluent form is restored (#B420, 0.6.16):** `self.store(target).onComplete(cb)` returns its `{onComplete}` handle synchronously as documented -- an accidental `async` on the declaration had wrapped the handle in a Promise on every stable from v0.6.0 to v0.6.15, so the fluent form (the upload guide entry-point example) threw `TypeError` while the 3-arg callback form worked throughout; the body was await-free, nothing anywhere awaits `store()`, and dropping the keyword is the whole fix (types now declare the fluent return shape). **Per-call since #B475 (2026-09-05):** the fluent handle used to register `self.on('uploaded', cb)` and never remove it, so a second `store()` on the same controller — sequential or overlapping — re-invoked every earlier `onComplete` callback with the later result; it now delivers through the callback path (`start(target, files, cb)`), one delivery per call, with the same arguments and the same synchronous timing on the empty-upload and released-response paths. The `'uploaded'` event no longer fires from `store()` at all: since #B475 the fluent form delivers through the callback, and since #B480 the fluent guard mints the handle for ANY non-function `cb` — `null` included, mirroring `query()`'s `typeof(callback) != 'function'` guard — so `store(target, files, null)` returns the handle instead of starting an upload whose outcome was emitted to nobody; `onComplete()` throws a `TypeError` synchronously when given a non-function (fail-fast at the caller rather than an uncaughtException inside an fs callback), and the seven unreachable `else emit('uploaded')` arms are removed together with the undocumented event (nothing in-tree ever listened to it, and it was never a `@fires`). **`query()`'s fluent `onComplete()` carries the same registration guard since #B485 (0.6.28)** — it minted a deliverer for any argument, so `.onComplete(null)` / `.onComplete('oops')` registered silently and the `TypeError` fired at settle INSIDE the delivery wrapper's own try/catch, surfacing as a misleading « Controller Query Exception while catching back » 500 that blamed the application callback (measured on a real instance — not the uncaughtException first assumed); now a `TypeError` naming `Controller::query` at the caller's line, the call-argument minting guard (`typeof(callback) != 'function'`) unchanged, so `query(options, data, null)` still returns the handle. The other 22 unguarded `onComplete` registration sites (connector `.then` shims, emitter-`once` helpers) are censused under #B491 — query()'s was the only one whose throw was swallowed. Test: `test/core/controller-store-null-callback.test.js` (9: source pins censused on a comment-stripped copy with raw-text controls, plus behavioural arms on a real instance, all red-first validated).
940
941
 
@@ -942,7 +943,7 @@ Dev-mode query instrumentation captures every database query tied to the current
942
943
 
943
944
  212. **Structured (JSON) logging — `GINA_LOG_FORMAT=json` + per-request `requestId`/`durationMs` (consolidates former #152/#155; #M12a/#M12b).** The logger resolves its render format ONCE at init into `opt.format` (precedence `GINA_LOG_FORMAT=json|text` > `GINA_LOG_STDOUT` truthy ⇒ json > `text` default) BEFORE containers are cloned; a JSON line is `{ts, level, bundle, message, group, msg}` — `bundle`/`message` canonical, `group`/`msg` retained as additive back-compat aliases — and the raw `console.log` path honours the format too (otherwise JSON mode would interleave plain lines and break a collector); the `text` default keeps container logs byte-identical. Per-request `requestId` + `durationMs` ride JSON logs via a NARROW `AsyncLocalStorage` (`process.gina._reqALS`, parked on `process.gina` so it survives dev require-cache busting), gated on JSON logging ONLY (text renders no id field, so the ALS would be pure overhead — the text path stays byte-identical/zero-cost): the id resolver honours a SANITISED inbound `X-Request-Id` (`/^[\w.\-]{1,128}$/`, regenerate-on-violation kills log forging) else `crypto.randomUUID()`; the `.run({requestId, startMs})` wrap sits at `handle()` — NOT the request-entry handler — because the `request.on('end')` boundary between them loses async context while `handle()`'s awaits preserve it (the original body became `_handleDispatch`; `handle` is a thin wrapper); HTTP/2 gets per-stream scoping free via Node's per-stream compat `'request'` event, NOT `session.on('stream')`; the JSON-assembly sites read `getStore()` and add `requestId` + per-line `durationMs`, gracefully absent for CLI/boot/off-request logs. `.run()`, never `enterWith()` (enterWith bleeds sideways across siblings). **MS1 (2026-07-24, 0.5.25):** the same id is now PROPAGATED beyond the process — every `self.query()` outbound path (the file-download proxy + both the HTTP/1 and HTTP/2 inter-bundle clients) forwards it as `x-request-id` (sourced from the resolved `req._ginaReqId`, never a raw inbound header; a caller-set value always wins), and `server.js onRequest` ECHOES `X-Request-Id` back on every response — ungated / independent of `GINA_LOG_FORMAT` (the id is always-on even when the JSON-log field isn't) and guarded against an already-sent response — so one logical request stays correlatable as it fans out across bundles and a caller/LB/APM reads the id off the wire. `controller.js`/`server.js` are server-side → no dist rebuild; restart to apply. Tests: `test/core/request-id-propagation.test.js`. **The contextless `require('gina')` boundary + MQ-speaker transport resilience (moved here from the DTO entry that surfaced it; #B276/#B277 0.6.4, #B318 0.6.5, #B323 fixed post-0.6.5 — shipped 0.6.6 with the reconnect).** Importing gina outside a spawned bundle child has ALWAYS been an intended boundary (`index.mjs`'s own docblock states it; both published entries throw alike), but two defects sat on top of it. **(1) The throw was uncatchable and could HANG the process.** The logger is a load-time singleton whose default flows are `['default','mq']`, so `speaker.js` opens a TCP socket BEFORE the boot reaches its throw — and that socket kept the event loop alive (listener-CONTINGENT, measured both ways: nothing bound to the MQ port ⇒ ECONNREFUSED ⇒ exit 0, so the bug is invisible on a quiet machine and hangs with no output on one running bundles or in CI). Fixed by `client.unref()`: a logging transport must never be why a process stays alive. ⚠️ #B318: a THIRD state exists beyond connected/REFUSED — an UNREACHABLE host, where `unref()` covers the socket HANDLE but not the pending `TCPConnectWrap` REQUEST, which holds the loop by itself for the OS connect timeout (~75s on macOS); fixed with an unref'd connect deadline (2s default, `opt.mqConnectTimeout`) that `destroy(err)`s only while `client.connecting`. ⚠️ **That safety claim — "only while connecting" — is MEASURED FALSE and shipped a HIGH regression for one release (#B323):** `client.connecting` does NOT flip when the kernel completes the connect; it flips when the POLL phase runs `afterConnect` — and a timer fires in the TIMERS phase, which precedes poll. On any boot that blocks the event loop past the deadline (a bundle mount off a network filesystem — every Kubernetes-class start) the loop resumes, runs the OVERDUE deadline first, reads `connecting === true` on a connection the kernel established seconds ago, and destroys it; the bundle then logged NOWHERE for the rest of its life (the speaker dialled exactly once, ever). Measured both directions: a kernel-completed dial reads `connecting` TRUE at the timers phase and FALSE at the check phase; a black-holed one reads TRUE at both. The fix defers the verdict one phase — `setImmediate` INSIDE the deadline, so the read happens in the CHECK phase, after poll — a completed connect survives while a genuinely pending one still dies on schedule (#B318's no-hang contract intact). **Generalisable: a state flag written by an event-loop callback cannot be read from an EARLIER phase and treated as current** — `connecting`, `destroyed`, `readyState` and every `pending*` counter share the property, and a blocked loop is precisely what makes the stale read reachable; a timers-phase guard over I/O state wants a `setImmediate` (or the event itself), never a direct read. **The speaker also RECONNECTS (0.6.6):** `startMQSpeaker` returns a stable `{write}` transport FACADE (`mq/index.js` captures the return ONCE for the life of the process — without the indirection a replaced socket is unreachable); each dial builds its OWN `clientOptions` (the listener mints a fresh `sessionId` per connection and the handshake only fires while `clientOptions.sessionId` is unset — a shared object would make every reconnect skip its acknowledgement); `close` on the CURRENT socket (a `current !== client` guard keeps a superseded one from stacking a second connection) arms a capped unref'd backoff (`min(500 * 2^n, 30000)`, reset on connect). Unref'd is load-bearing BOTH ways: a short-lived CLI still exits while a long-lived bundle keeps retrying and HEALS if a listener appears later. The caller callback settles ONCE, the warn fires once per OUTAGE (not per retry), and frames are DROPPED while down — an unbounded queue behind an outage of unknown length is a memory leak, and the `default`/stdout flow carries the same lines regardless. ⚠️ The socket still CONNECTS — `unref` changes only whether it votes on process lifetime (consumer-verified against a live listener: the connected line still logs; "unref'd" is not "suppressed"). ⚠️ The workaround `GINA_LOG_STDOUT=true` does splice `mq` out of the flows but ALSO flips log format to JSON (measured) — a container logging-mode flag pressed into service as a don't-open-a-socket switch. **(2) The boundary's error now names the boundary** — the old message named an internal call (`setPath("gina.home", path): path cannot be empty`) because `getEnvVar` reads `process.gina` ONLY and is empty outside the CLI; it now routes the reader to `SuperController.createTestInstance()`, the supported way to exercise controller code without booting. ⛔ **Do NOT "fix" the boundary by extending the `getEnvVar → process.env` ladder to `gna.js`, nor by skipping the empty `setPath`** — REFUTED BY MEASUREMENT: satisfying that call merely defers the failure 8 lines to a bare-global `ReferenceError` (strictly less legible), and that `setPath` is the SOLE registration site for `gina.home` repo-wide (the SQLite connector and session store read it). Failing fast at the first detectable point is correct. Diagnostic instrument for the hang class: `process._getActiveRequests()` reports `TCPConnectWrap` while `_getActiveHandles()` shows only the unref'd Socket. Tests: `test/lib/logger-mq-speaker-resilience.test.js` (§01 deferral-ordering + reconnect-shape pins; §02 a child that blocks 3s right after the dial and must still deliver — red-first: pre-fix it lost the frame while the connection was ESTABLISHED, the defect's own signature; §03 a listener destroyed and rebound must be heard again; §04 the no-listener child still exits promptly) + `logger-mq-speaker-unref.test.js` (comment-stripped source pin — subtracting the fix left a bare existence pin GREEN against the commented-out line — + a behavioural arm with its own must-be-killed control). **Log redaction (#B433, 0.6.19):** every message is redacted BEFORE it is rendered and dispatched — inside the logger's `emit()`, the single pre-render point all 46 URL-bearing framework sites pass through (both engines, every render delegate, the CSRF filter; all levelled, none `console.log`), plus the raw `console.log` path — so stdout, the MQ speaker/`gina tail`, the file transport and the Inspector taps all receive the same masked line and a JSON line cannot be corrupted (the marker lands inside the message string). Configured per bundle in `settings.json > log.redact` `{enabled, defaults, secrets, patterns}`, ON by default: `defaults` = JWT · URL userinfo password · `Bearer`/`Basic` · named credential query keys (`token`, `access_token`, `api_key`, `secret`, `password`, `signature`, `otp`, …, key kept / value masked) · api-key headers — measured 0 false positives on 433k real log lines, ~1 µs per line, linear patterns; `secrets` = every value the secrets resolver substituted for a `${secret:KEY}` placeholder (settings, connectors, routing — one resolve point) masked verbatim, values < 8 chars skipped with a boot warn; `patterns` = consumer rules (regex source string → `[REDACTED]`, or `{pattern, flags, replacement, name}` with `$1` kept), `g` always enforced. Deliberately NOT a default: a bare long-hex path segment (a sha256 content-address key from storage and an opaque 64-hex credential are the same regex class — the consumer adds `(?<![0-9a-f])[0-9a-f]{64}(?![0-9a-f])` — anchored on the class, not `\b`, which never fires against a prefixed `key_<hex>` segment). Fail-closed: an unknown key, a non-boolean flag, an invalid or empty-matching pattern refuses the boot (a dropped rule is a leak). Installed by `core/config.js::loadBundleConfig` right after the bundle's `secrets.resolve()` — the earliest resolved point, so connector-init lines are already covered — via `console.setRedaction(block, {group, secrets: lib.secrets.getResolvedValues(conf)})`; state lives on the logger's persisted context (survives `refreshCore()`), keyed by bundle, UNION across bundles (one bundle enabling is enough in a merged process; a per-route `logUrl:false` flag was rejected by measurement — it cannot reach the second URL copy inside the #ERRREF error stack). The 404 ref line carries the URL twice in ONE record (prefix + the error's own message); both copies are masked. Restart to apply, no re-bake (server-side only). A per-route or per-site flag is NOT the extension point; the logger seam is. Error rendering (#B434, 0.6.19): an `Error` passed as a log argument renders via `util.inspect` — message, stack, own props, `[cause]` chain — at BOTH stdout writers (the levelled `write()`/`parse()` walk and the raw `console.log` `JSON.stringify` path each saw only ENUMERABLE own props, so a bare Error rendered `{}`), top-level and nested alike, realm-safe (`util.types.isNativeError` beside `instanceof`), and the rendered message still crosses the redaction seam (content is built in `write()`, redacted in `emit()`). Non-Error arguments render byte-identically to before. **The `file` log sink — an IN-PROCESS transport, and the three defects that made it unusable (#B523/#B526–#B531, 0.6.30).** The opt-in `file` container connected to the MQ, received every line and wrote NOTHING: `setup()` was driven from `processProperties.bundles`, which `lib/logger/src/helper.js:125` fills only when `process.argv` matches `bundle:(start|stop|restart)` — while `core/gna.js:294` splices `process.argv` down to `[node, appPath]` in every bundle process loaded through the CLI, so that list is structurally always empty and no filename was ever assigned. Fixing the filename exposed what the dead sink had been hiding, measured on a live daemon topology: **(a)** the container dialled the MQ port at construction and ran `process.exit(0)` from its socket error handler, and in the daemon that dial happens BEFORE the daemon's own listener binds — so `gina start` died before "Framework ready" 3/3 whenever the flow was enabled, and a daemonless bundle exited with status 0 one second after boot; **(b)** the listener FORWARDS every speaker's line to every `writeToFile` session, so every process carrying the flow wrote EVERY bundle's lines into one shared per-host file (measured twice-over with two bundles) and built a full `Config` for a bundle it is not, which crashed `gina bundle:start` 3/3; **(c)** N processes appending to one file each kept their own rotation state and size counter, so rotation raced and lost lines silently. **The sink is therefore no longer MQ-coupled: it listens on the `logger#file` event `emit()` already raises for every flow, exactly like `containers/default`, and writes only the lines ITS OWN process logged.** No socket, no daemon dependency (it works under `gina-container` and in a container), no foreign `Config`, one writer per file. The file is named after the LOG GROUP — `<logdir>/<bundle>@<project>.log` — which is what makes "one writer" true; a group without `@` (the CLI's and the daemon's own `gina` lines) is not filed, matching the guard the old `write()` already applied. Records are written WITHOUT ANSI escapes, and `GINA_LOG_FORMAT=json` is honoured with the same line shape the stdout container writes. Rotation is configured under `rotate` in `~/.gina/user/extensions/logger/file/config.json` — the machine-level surface that also ENABLES the container (it reaches the container because `loadContainers()` merges `flowsOptions[flow]` over the logger options with override:true). Keys: `enabled` (default true), `when` (`daily`|null), `size` (default `10MB`), `count` (default 5), `maxAge` (e.g. `30d`, off). Mechanism is rename-then-reopen, never copy-then-truncate: the retired vendored rotator (MIT/Capriza, undeclared in either manifest so invisible to Socket and Dependabot, and unreachable — its `require` path resolved to a directory that does not exist) copied then truncated, losing every line appended during the copy window. ⚠️ The descriptor is opened SYNCHRONOUSLY and the stream wrapped over it: `createWriteStream(path)` opens asynchronously, so a burst of lines logged within one tick reaches a size trigger while the file does not exist on disk yet and the cascade's `renameSync` throws — measured with 60 lines in one tick. A size or age without an explicit unit is REFUSED, never guessed, and any invalid value disables rotation with a message naming key, value and consequence — reported THROUGH THE FLOWS (`process.emit('logger#'+flow, …)`, one payload per flow, which is what `emit()` itself does), never through raw `process.stdout.write`: under a daemon `lib/cmd/bundle/start.js` consumes the child's stdout and relays nothing after start, so the old raw-stdout refusal reached NOBODY (measured 0 in the daemon log, 0 at a tail client, 0 in the file, 0 in the CLI output). Logging continues either way. Two bounds stated honestly: bytes still queued when the process exits can be lost (`end()` is asynchronous, and making every write synchronous would let a full disk block the request loop), and a stream that stops draining past a 4 MB buffer drops lines with one warning per outage — the same posture the MQ speaker documents. ⚠️ **Generalisable trap that cost this arc most of its time: the repo-root `utils/prototypes.js` installs `Object.prototype.count` + `functionCount` and `Array.prototype.clone` + `inArray` ON REQUIRE, unguarded — so `typeof(anyObject.count)` is `'function'` for EVERY object including `{}`.** A `typeof(user.<key>) != 'undefined'` guard over a user-config object therefore hands the inherited METHOD back as the configured value — here `~~(function)` is 0 and rotation disabled itself reporting "count is undefined" on a valid default. ⛔ Do NOT cite or patch `helpers/prototypes.js:169`: it declares the same names, but its guard `typeof(Object.count) == 'undefined'` reads the INHERITED method once `utils/` has run (`Object` inherits through `Function.prototype`), so every declaration there is skipped — the trap defeats its own guard and that line is dead code. The properties are `enumerable: false`, so `Object.keys()` reports `[]` and `JSON.stringify` drops them, which is exactly why every probe read the key as ABSENT rather than as a function. ⚠️ An isolated repro is the CHEAPEST way to see this, not an impossible one — `node -e "require('./utils/prototypes.js'); console.log(typeof({}).count)"` prints `function` in a plain process with no framework and no invocation (control: without the require it prints `undefined`). What DOES false-negative is requiring the OTHER file: `helpers/prototypes.js` exports a constructor that must be CALLED, so a bare require of it installs nothing and reports every key absent — inside the framework tree too, which is why that probe reads like a legitimate in-context measurement. Pair any such probe with a known-positive control. ⚠️ **The same collision has a SECOND, REQUEST-FACING direction — #B546 (0.6.31):** the trap above is the framework READING a config key that the helper makes look present; the mirror is the framework CALLING `<container>.count()` on a container whose keys the CLIENT chooses (parsed body, query bag, route params, `req.files`, `self.query()` data, a routing rule's validator data, and `| length` on BOTH template engines). There an own field named `count` shadows the helper and the framework calls a string — see #211 for the impact and the `ownCount()` remedy, applied at 27 sites across `core/server.js`, `core/controller/controller.js` and the validator engine. Counts over FRAMEWORK-built objects (response headers, the routing table, config) deliberately keep the shorthand, and the helper itself stays on `Object.prototype` for application code. Use `Object.prototype.hasOwnProperty.call(obj, key)` for any own-key test over config in this framework, never `typeof` — the guard is name-independent, so it is right for all four names and any added later. **#B524 (0.6.30) — `gina-container` applies the container preset ITSELF.** Unless `GINA_LOG_STDOUT` is already set, the launcher writes `process.env.GINA_LOG_STDOUT = 'true'` ABOVE its `utils/helper` require — that require initialises the launcher's OWN logger, and an unconfigured container was measured running TWO redialling speakers (launcher + bundle), each warning `ECONNREFUSED 127.0.0.1:8125` once and then dialling every ≤30 s for the life of the container — and the bundle inherits it through the spawn; `image:build` images inherit it for free, their entrypoint being `gina-init && exec gina-container`. It MUST be a raw `process.env` write (`setEnvVar()` populates `process.gina`, which the logger never reads). Explicit values win both ways, measured on isolated boots: `GINA_LOG_FORMAT=text` ⇒ coloured text with the dial still skipped; `GINA_LOG_STDOUT=false` ⇒ the pre-0.6.30 behaviour byte-for-byte. ⚠️ LAUNCHER topology only: a bundle started through a framework daemon (`gina start` → `bundle:start`) is untouched — there the MQ transport is what `gina tail` reads, and the daemon DISCARDS the bundle's own stdout after startup (`bundle/start.js` `if (isStarting) return;`), so the relay is the only path a runtime line has to an operator. **#B691 (0.7.1) — the one exception, during the boot itself:** the MQ listener keeps NO backlog (`listener.js` `report()` forwards each frame to the tails attached at that instant) and `gina tail` cannot ask for earlier frames, so a tail attached after the boot — the ordinary `bundle:start`-then-`tail` order, and a container init script that tails after starting — saw none of a bundle's boot-time lines, including the 0.7.0 `autoescape` and unanchored-`requirements` warnings. The startup watchdog now passes the bundle's warn-and-above boot lines on to the `bundle:start` client (so also to `bundle:restart`, which prints its start step): `warn`/`warning`, `error`/`err`, `crit` and `alert` (the logger renders the name the caller used, and recommends `warning`/`err`); `emerg` keeps its `aborted :(` path. The filter is `lib/cmd/bundle/inc/boot-lines.js`: it buffers by line across chunks, reads the level from the ANSI-stripped `[date] [level ][group]` header or from a JSON line's `level`, keeps a multi-line entry whole until its colour block closes (`format()` wraps the whole message in one), and caps a line and an entry at 64 KB. It runs after the `isStarting` guard and before the EADDRINUSE/emerg/started checks, so a warning comes before `started V(-.o)V`, which then sits on its own line; with no such line the output is byte-identical. A tail attached first still gets the lines, so they show twice there (accepted). Runtime lines stay the tail's. Pickup = a DAEMON restart (`framework:init` requires a command module once), not a `bundle:restart`. Tests: `test/lib/bundle-start-boot-lines.test.js`. That topology's JSON answer is the OTHER half of #B524: **`gina tail` renders one JSON object per line when its OWN logger's resolved format is `json`** (`GINA_LOG_FORMAT=json` on the tail process — the pod's environment reaches it; `lib/cmd/framework/tail.js` reads `console.getOptions().format`, the logger's single precedence rule, and routes BOTH render sites — the delayed-message replay and the live stream — through one `renderLine(pl)`; same `{ts, level, bundle, message, group, msg}` shape, NO `requestId`/`durationMs` because the relay payload is `{group, level, content}` only). Measured on a daemon-free relay scene (a real `MQListener` on an isolated port + a `gina-container` bundle booted with `GINA_LOG_STDOUT=false` so it speaks + `bin/cli tail`): format unset ⇒ 5 ANSI lines, 0 JSON; `json` ⇒ 5 JSON objects, 0 escapes, the tail's own connect lines included. ⛔ `GINA_LOG_STDOUT=true` is NOT the daemon topology's switch — it never reaches a daemon-spawned bundle's logger (#B532: `bin/cli:336` `filterArgs()` MOVES every `GINA_*` key out of `process.env` before `bundle/start.js:404` spawns with no `env:`, and the child's logger initialises at `gna.js:183` BEFORE the ctx mirror at `:276` restores them — so the tutorial's `GINA_LOG_FORMAT=json gina bundle:start` cannot reach the bundle either, and a daemon-topology JSON line can never carry `requestId` until that is fixed), and if it did reach the bundle it would silence the relay the tail reads.
944
945
 
945
- 213. **Client-plugin discipline — CSP-safe listeners, native-dialog parity, preload coalescing (consolidates former #149/#159/#200).** (1) Client-bundle plugins must NOT inject inline event-handler attributes (`setAttribute('onclick', …)`, `el.onX = …`) — they trip CSP `script-src-attr` under nonce-based policies; suppress defaults with `addEventListener('click', e => e.preventDefault())`, and keep it `preventDefault`-ONLY (no stopPropagation) when the element participates in event delegation other handlers depend on — a `preventDefault`-only listener still sets `event.defaultPrevented` exactly as the inline handler did. (2) Dialog popins open as native modals (`$el.showModal()`) in EVERY env — the dev-only non-modal downgrade + manual overlay are gone for dialog mode (native `::backdrop`); a consumer that PRE-OPENS the dialog (skeleton loading) must also use `showModal()` or it is born non-modal with nothing positioning it; the opt-in `preOpen`/`loadingShell` skeleton is idempotent via `hasAttribute('open')` — NOT getAttribute truthiness, because `showModal()` sets `open` to the EMPTY string — and since #B574 (2026-09-20) `popinOpen`'s own re-entry guard reads the same `hasAttribute('open')`: it read getAttribute truthiness, never skipped a shell-opened dialog, and on the new trigger's default MODELESS resolution called `show()` on the still-modal shell, which throws `InvalidStateError` and left `isOpen` false on a visibly open popin; a native UA close (Escape, `<form method="dialog">`) fires the element's `close` event WITHOUT the plugin's own close path, so a once-per-element `close` listener (the event is `close`, not `cancel` — `cancel` is Escape-only) routes it through the plugin close, keeping open-state/listeners/toolbar consistent — a UA-closed shared popin otherwise re-opens "one render behind" (#B58); and since gh#76 §8 (2026-09-20) the `preOpen` loading shell is an explicit STATE — `$popin.isLoading` is true from the moment the shell shows until the real open (`isOpen` stays false, so the two `!isOpen ⇒ popinOpen` consumers still run it), backed by a per-popin load sequence and the in-flight transport (`_loadSeq` / `_loadXhr`): `close()` / `destroy()` on a loading popin cancel the load (`closeLoadingShell` — sequence bump, `abort()`, teardown by the shell's OWN state because the class-keyed teardown never matches a dialog shell, trigger release, `close.<id>`; the aborted transport's readyState-4 handler runs its release block and then drops on the sequence check, so a cancel fires no `error`), the native `close` sync is bound at SHELL time through `bindNativeCloseSync` (guard `isOpen || isLoading`) so an Escape during the load routes through the plugin instead of re-opening when the content lands (driven on the pre-fix bundle: Escape closed the dialog, the landing re-opened it), `loadContent()` on a loading popin injects into the DIALOG element (`target` still names the container until popinOpen re-points it) and completes the open through popinOpen, and a non-2xx load fires `error.<id>` FIRST, then closes the shell if nothing loaded content into it; a popin without `preOpen` never enters the state, and `getActivePopin()` stays open-only; and #B579 (2026-09-20): the declarative trigger's `loaded.<id>` listener applies a NON-STRING detail as nothing — `popinLoadContent`'s redirect branch fires `loaded.<id>` with the POPIN OBJECT as detail (the legacy listener only binds on it), so a `load()` landing on an already-OPEN `data-gina-dialog` popin wrote the new body and then wiped it, leaving the dialog empty with no error (driven on the published bundle; every version since 0.4.6). (3) A popin/dialog click fired TWO identical GETs because the hover/`focusin` preload — `focusin` is part of the click gesture — was never reused: an in-flight preload registry lets the click-time consume ADOPT the in-flight fetch (waiter woken with the body, caller's own load on failure) instead of fetching again, and the legacy click path consumes preloads at all now (#B54); only the initial GET is ever preloaded. The consume path bypassing the full load tail (redirect/JSON handling) was ASSUMED acceptable — but a hover/focus-WARMED trigger whose GET returns a redirect/JSON response (`application/json` `{isXhrRedirect,location}`) had that raw JSON blind-injected as the popin body via `applyContent` (`$el.innerHTML`), the `_self` tunnel never firing — IDENTICAL symptom to the #B77 `_self` timer race but a DIFFERENT mechanism (the consume path, regression `2c61c494` v0.5.5), so #B77 fixed only the UNWARMED click-time path and the WARMED path persisted until #B80 (2026-07-06, `37e6829c`): `preloadFetch`'s `onreadystatechange` now caches a 2xx response ONLY when its Content-Type is NOT `application/json` (mirrors `popinLoad`'s own `isJsonContent` detection), firing the in-flight waiters with `null` (→ `consumePreload`'s `onMiss` = the click-time `doLoad`) and leaving the cache empty (→ a ready consume returns false → `doLoad`), so a JSON-returning trigger falls through to the click-time `popinLoad`'s full redirect/JSON tail exactly as a non-preloaded click (one extra GET on click — the hover-warm GET is not reused; measured 2 GETs). Only a genuine HTML fragment is preloaded+injected. Browser-bundled → prod dist rebuilt; VERIFIED in a real browser (a warmed legacy `data-gina-popin-url` redirect trigger now shows the tunnel target, not the raw JSON; the decline path positively exercised via resource-timing) — jsdom is falsely green. That residual — the GET itself still fired on hover for side-effecting triggers, authenticated (same-origin cookies) and CSRF-token-less (GET) — is CLOSED by the #B91 per-trigger opt-out (2026-07-10): `data-gina-dialog-preload="false"` on the trigger (honored on legacy `data-gina-popin-url` triggers too — the gate reads the attribute off the closest-matched intent target; case-INSENSITIVE `/^false$/i` on purpose, so a templated "False" cannot fail open and fire the GET anyway — deliberately safer than data-gina-dialog-modal's case-sensitive parse) suppresses BOTH the hover warm and the focusin-on-click warm at the shared intent handler; the opted-out click loads normally at click time (undefined cache slot → consume returns false → the caller's click-time load; exactly one GET per click). Default stays preload-ON: GET is presumed safe to fire early per HTTP semantics — a side-effecting trigger declares itself (the hover-prefetch ecosystem's convention, live-verified). (4) A proxied/tunnelled redirect JSON response (`isXhrRedirect`+`location`, default `_self`) used to `$popin.load()` then arm a blind `setTimeout(50, () => !$popin.isOpen && $popin.open())`; a follow-up load slower than 50 ms opened the popin against a not-yet-injected (skeleton/empty) target → intermittent unhandled-deref crash. Removed as vestigial (v0.1.0, 2021) — the already-armed `loaded.<id>` listener opens CONTENT-FIRST (injects the body via popinBind/handleLoadedBody, THEN popinOpen), so the load alone suffices; the `_self` branch's `return;` still guards the `window.open` fall-through (non-`_self` targets only) (#B77). Event contract: `open.<id>` = a fresh open, `loaded.<id>` = reload/redirect content injected — a consumer needing "content ready on every load" must listen to BOTH. A structurally identical blind timer lived in the SIBLING validator `Validator::Popin now redirecting` path (`core/plugins/lib/validator/src/main.js`), removed by #B79 (2026-07-10): the validator DISCARDED popinLoad's return, and that return IS the `{open}` handle whose `open()` arms the content-first `loaded.<id>` listener — so the timer was papering over a race the discarded handle created (an XHR faster than 50 ms fired `loaded` into the void and the body was lost, a slower one opened an empty popin first). It now captures the handle and calls `open()` inside the same not-open gate, guarding the `undefined` popinLoad returns when the request cannot start (CORS unsupported) so a failed load never blind-opens an empty popin; and the cross-popin branch resets `isRedirecting` before closing the ORIGINAL popin, because `popinClose` early-bails on a redirecting popin — that close was a silent no-op, leaving the original open behind the new one (browser-verified: pre-fix stays open, post-fix closes). MEASURED CORRECTION: the timer was NOT load-bearing for the different-popin branch as long assumed — that branch threw first, because the published `gina.popin` WAS re-assembled with a target-wins merge on every `new Popin()`, so its `getPopinByName`/`getPopinById` stayed bound to the FIRST instance's registry and resolved ONLY the boot popin, while `$popins` + `getActivePopin` saw every popin and `gina.popin.activePopinId` never updated (so `getActivePopin()` returned null once nothing was open) — and popins registered AFTER an instance's publish (click-time in-page dialog registrations) never reached the published registry at all, with destroyed popins lingering in it. FIXED as #B90 (2026-07-10): ONE module-scoped registry (`_sharedPopins`) aliased by every instance's `$popins`; a publish-ONCE `gina.popin = instance` (the published object IS the first instance, LIVE — re-publishing was both the freeze defect and, with a shared registry, a self-merge recursion hazard: gina's merge deep-recurses plain objects and `$popin.eventData` can hold the $popin itself); and a `setActivePopinId` write-through helper at all 7 write sites keeping the published `activePopinId` truthful no matter which instance opens or closes a popin. Browser-verified both ways on the built bundle (pre-fix: accessors blind + the validator cross-popin redirect 422-throws `not found`; fixed: accessors resolve every instance's popins and a form submit redirecting into a DIFFERENT popin works end-to-end — the original closes, the target loads content-first, `activePopinId` follows). Generalises: an accessor published through a target-wins `merge()` silently keeps the first instance's closure — publish a live instance once and share module-scoped state instead of re-merging per construction; and never infer a code path is reachable from the fact that it exists. (5) EAGER content warm — `data-gina-dialog-preload="eager"` (case-insensitive) opts a trigger into a one-shot idle warm-all pass: after `window` load, on requestIdleCallback (setTimeout fallback), serialized one GET at a time, routed through the SAME shared per-trigger gate as the hover warm (`warmTrigger`: disabled skip → the `"false"` opt-out → URL-cache dedup → in-flight reserve + fetch) so the two warm paths cannot drift and whichever fires second is a no-op; skipped entirely under Save-Data; triggers injected after the pass keep the delegated hover warm; staleness matches the shipped no-TTL hover semantics — the opt-in accepts the wider warm→open window. (6) #B139 (2026-07-20, 0.5.22): the content cache no longer outlives the open it warmed — measured pre-fix it was a ONE-GENERATION-LAGGING store (every open after the first paid ~1 GET yet rendered the PREVIOUS open's fetch: the leftover entry — the #B54 in-flight adoption's repopulated body, or the around-open re-warm — blocked the fresh warm's dedup; NO invalidation path existed, close/unbind/destroy never touched the cache; reproduced back to 0.5.4, so it predates #B54 and the eager pass). Fix: close clears the popin's content-URL slot (stamped at consumePreload both branches + popinLoad) AND re-sweeps the same slot ~120ms later, because the close-time a11y focus-return and pointer re-hover fire TRUSTED synthetic intents that re-warm the slot with close-era content within 1ms of the close (measured; a raced sweep is benign by construction — at most one extra fetch, never staleness, since an adopted in-flight body still reaches its open through the waiter chain). AND `preload="false"` triggers now skip the cache READ at open on both open paths — `false` is the hard always-refetch spelling for volatile popins (its GET always happens at open, never from a same-URL sibling's warm); no new annotation vocabulary. Warmed-never-opened entries keep the page-lifetime eager semantics above; default triggers pay at most one extra idempotent GET per close (the swept synthetic warm). Content preload deliberately does NOT ride 103 Early Hints: popin responses vary on `X-Requested-With` (a non-XHR GET of a fragment layout gets the iframe-wrap variant and misses the scripts append), and a browser `Link rel=preload` fetch never carries that header — wrong-variant bytes or a double GET, with no success branch; `self.setEarlyHints()` remains the right tool for a popin's STATIC subassets (the CSS/JS it injects via getScript/getStyle). (7) #A11Y8 (2026-08-05, 0.6.4): the background-`inert` loop in `applyNonModalShims` skipped `instance.target` — the shared `.gina-popins` container — and since every popin lives INSIDE that container, and `popinOpen` never closes the popin it supersedes (it only overwrites `activePopinId`), opening a second NON-MODAL dialog left the first one fully keyboard-reachable behind it: a user could tab out of the dialog on screen and into a stale one. Non-modal is the FRAMEWORK DEFAULT for `data-gina-dialog` (`resolveModal` falls through to `return false` at step 5; only a legacy `data-gina-popin-name` trigger is unconditionally modal), so this was the ordinary path, not an exotic one — note item (2)'s "native modals in EVERY env" predates the non-modal branch and now describes LEGACY triggers only. Fix: descend into the container instead of skipping it, and inert sibling `dialog[open]` ONLY — a `<dialog>` without `open` is already `display:none` per the UA stylesheet, so it is unreachable without help (measured in Chrome with controls firing both directions; the modal path was never affected, native `showModal()` handles the top layer itself). Teardown needed NO change: `removeNonModalShims` sweeps `[data-gina-popin-inert]` document-wide, so dialogs nested in the container restore without it knowing they exist. The `getAttribute('inert') == null` guard means gina only marks what it set, so a consumer-set `inert` is never stamped and never stripped. All seven are browser-bundled — prod dist rebuild required; verify (2), (3), (4) and (5) with a REAL preemptive-open / preload / redirect-tunnel consumer — a minimal smoke page is falsely green; (7) needs TWO popins open at once, the only scene in which it differs at all. (8) **A trigger gate must not trust the native `disabled` ATTRIBUTE on an element the browser already enforces (#B296, 0.6.5).** The three DISPATCH-time gates (`openFromTrigger`, the `bindOpen` document proxy, the per-`$element` listener) now require `!('disabled' in $el)` before the attribute counts, mirroring the validator's `isTriggerDisabled` (#B293) so the two subsystems agree on what "disabled" means. Rationale, measured: a natively-disabled `<button>` delivers **0 clicks to JS**, so the attribute arm could only ever fire on a `disabled` written DURING the dispatch — the shape of every consumer double-submit guard — which ate the open and then cleared the attribute, leaving nothing marked and a normal-looking control; on an `<a>`/custom element/span the browser enforces nothing, so the arm is KEPT (`'disabled' in $el` → `button:true · input:true · anchor:false · custom-element:false · span:false`). It also cannot weaken gina's OWN re-entry guard: `armPopinTrigger` arms `<a>` with `aria-disabled` (untouched arm) and every other tag with native `disabled` — enforced by the browser for real controls, and still enforced by this gate for `<span>`-likes, which is exactly the branch the `in` test preserves. **Two gates are deliberately NOT changed:** the hover/focus `warmTrigger` preload gate (removing it there would start PRELOADING a `<button disabled>` nobody can click — if it ever changes it wants the IDL property `$el.disabled === true`, not the ignore treatment) and the bind-time `$link` skip (structurally unreachable by a mid-dispatch write, and it carries a different one-arm predicate). **Reachability is unequal and the difference is load-bearing:** at `openFromTrigger` gina's proxy is DELEGATED so a button-bound guard always wins the race (plain #B293 shape reproduces); at the per-`$element` listener gina binds to the NODE, so only a CAPTURE-phase or earlier-registered guard can poison it (measured both ways — a target-phase guard bound after gina's does NOT reproduce); the legacy document-proxy site is traced-not-exercised. Test `test/e2e/popin-trigger-native-disabled.spec.js` — a replica cannot catch this, it is an ordering interaction between a consumer listener and a delegated proxy. ⚠️ **Scene trap for anyone writing a popin close-button test:** the close element must carry NO `id` — gina assigns `popin.close.<n>` only to an id-less one and the dispatch gate matches on that prefix, so a consumer id makes EVERY arm read "did not close", subtract control included (that is its own defect, #B299). (9) **A teardown loop must never splice the array it is walking (#B265, 0.6.5).** `popinUnbind`'s validator-form teardown spliced `$popin['$forms']` inside a `for` bounded by a length captured BEFORE the loop, so each splice shifted the array left under an incrementing cursor and the read index skipped one every time — the originally ODD-indexed forms were never destroyed and were left behind in the very array the loop exists to empty. Because the popin's `innerHTML = ''` runs BEFORE the loop, their surviving validator entries pointed at already-detached nodes, and `validateFormById`'s "return existing when available" early return then handed the stale entry back on the next open, so the form and its submit trigger came up silently inert — the same end symptom as #B294 by a completely different route. Fix: snapshot with `.slice()`, clear once with `.length = 0`, iterate the snapshot. **Two things worth carrying beyond this bug.** First, `destroy()` is not free: it fires `destroy.<formId>` (validator `main.js:4241`) and `'destroy'` IS on the plugin's public registrable-event list, so making a skipped form destroy properly starts firing consumer `on('destroy')` callbacks that never fired before — a teardown fix is a behaviour change, not merely a leak fix. Second, per-form teardown is now wrapped: `destroy()` → `unbindForm` → `getFormById(vFormId)` THROWS for a form holding a `data-gina-form-virtual` file input once its virtual form is unresolvable, which is precisely the detached state every form is in at that point — and before the guard, one such throw silently abandoned every LATER form in the loop. **Reachability is much narrower than it looks and must not be overstated:** the loop is gated on `$validatorInstance`, a PER-INSTANCE closure assigned only from `options.validator`; gina's own boot does `new Popin({name:'gina-dialog-boot'})` with no validator, and the delegated `data-gina-dialog` listener is installed once by that boot instance (module guard `_ginaDialogDelegated`), so **the declarative dialog path can never reach this code** — it needs the legacy popin API driven by an explicitly-constructed validator-carrying instance. That is also why the test is a replica + source pins (`test/core/popin-forms-teardown.test.js`) rather than an e2e arm: an e2e scene on the declarative path would be VOID, every arm reading "nothing destroyed" for a reason unrelated to the defect. (10) **Never re-derive an element's ROLE from its id when the call path already proves it (#B299 + #B301, 0.6.5).** `register()`'s close branch gated on `/^popin\.close\./.test(event.target.id)` — a prefix gina mints at bind time — and that single line carried TWO silent defects: gina assigns the prefixed id **only to an id-less element** (`:1560`), so a consumer-supplied id is taken verbatim and matches nothing (**#B299**); and `event.target` is whatever was actually CLICKED, so an icon nested inside the button matches nothing either (**#B301** — the ordinary `<button class="gina-popin-close"><svg/></button>`, and by far the wider case). Both are invisible: `cancelEvent` runs at the top of the listener, so the button also swallows its own default — no error, no navigation, nothing. Fix: read **`event.currentTarget`** (the element `register()` bound — a `.gina-popin-close` by construction, since that function has ONE call site fed only from `querySelectorAll('.gina-popin-close')`) and treat a `popin.click.*` id as the sole exception, which preserves the dual-role element (a trigger that is ALSO a close button — reachable because `bindOpen` scans `document`, not just the host page). ⚠️ **What must NOT be "fixed" instead: rewriting the element's id.** The `$close` teardown sweep removes this listener via `gina.events[eId] == eId` — the degenerate name===id entry that `events.js:42` produces — so decoupling the event name from the id leaks the listener; and overwriting a consumer id would break their selectors, `aria-labelledby` and test hooks. Contrast `bindOpen:1195-1203`, which takes the OPPOSITE policy and does clobber a consumer id on trigger elements. Measured with controls firing both directions on the real bundle (id-less closes / consumer-id does not / nested click does not / patched all close), test `test/e2e/popin-close-dispatch.spec.js`. (11) **gina's own in-flight arm must be honoured by the path that dispatches it — a legacy trigger re-fired during its own load (#B298, 0.6.5).** `armPopinTrigger` marks an `<a>` trigger `aria-disabled="true"` for the duration of its popin's load (every other tag gets the native `disabled`, which the browser enforces). But the ONLY dispatch route for a pure-legacy `data-gina-popin-name` trigger is the `bindOpen` document click proxy, whose predicate tests the native attribute ALONE — and an `<a>` never carries it here — so a second click during the load reached the open handler and issued a SECOND XHR. `bindDelegatedOpen` returns early for a trigger carrying neither `data-gina-dialog` nor `data-gina-dialog-src`, so `openFromTrigger`'s own `aria-disabled` arm never covered this population. Fix: gate at the top of the legacy per-element open handler, predicate copied VERBATIM from `openFromTrigger` (native arm included, so #B296's `!('disabled' in $el)` rule still holds). **Gating THERE rather than in the proxy is load-bearing, and was MEASURED rather than reasoned:** a trigger's direct children each get their own listener from `proxyClick`, which fires the custom event DIRECTLY and never passes the proxy predicate at all — a proxy-level gate still let the child-click route fire twice, while the handler-level gate stops both (both routes converge there, and `currentTarget` is the element `bindOpen` bound, so child markup cannot dodge it). ⚠️ **Reachability is narrower than it looks, and the scene is easy to get wrong:** a real click HOVERS first, which warms the preload (`warmTrigger` reads `data-gina-popin-url` too), and the click then ADOPTS that in-flight fetch instead of calling `popinLoad` — so nothing is ever armed and the question goes unasked (a probe without the opt-out reads 1 request for a reason unrelated to the gate, its own counter-can-read-2 control failing). The defect needs the documented `data-gina-dialog-preload="false"` opt-out (#B91) — i.e. exactly the triggers whose GET has server-side effects, which is why it is not cosmetic. Whether a no-hover activation (touch, some keyboard paths) is a second reachable population is UNMEASURED. Test `test/e2e/popin-legacy-trigger-aria-disabled.spec.js` (4 arms, red-first; the two fix arms failed 2-vs-1 on the pre-fix dist while their scene/armed/click assertions passed, so the red is attributable to the defect). (12) **A click adopting a still-in-flight hover/focus preload is a genuine wait — it now arms the trigger exactly like the cold click-time XHR (#B285, 0.6.5).** Both consume branches previously armed NOTHING (only a preOpen popin got its skeleton), so with warm-on-intent preloading the COMMON open path showed no busy affordance at all. `consumePreload` gained a guarded 4th param `onSettled`, fired synchronously after the ready (cached) apply and at the TOP of the adopted waiter — success AND failure, release-before-apply, the cold readyState-4 order. The CALLERS own the affordance (arm on adopted-wait only via a settled-flag; release via `releasePopinTrigger`, armPopinTrigger's mirror), keeping `consumePreload` loading-state-agnostic — load-bearing for the extract-and-execute test harness, which injects neither `document` nor `loadingState` and calls with ≤3 args (the guard makes the 4th a no-op), and the right factoring anyway (it reports that it settled; callers decide who cares). The ready branch's SYNCHRONOUS settle is equally load-bearing: without it a caller cannot distinguish ready from in-flight and would arm forever — an instant cached open never flashes a busy state. Double-click protection during the wait comes free from EXISTING mechanisms (the entry gates refuse an armed trigger; the browser suppresses a disabled `<button>`), and the failed-adoption leg releases before the click-time fallback re-arms through popinLoad's own path. Playwright note: an armed `<a>` reads [disabled] in the accessibility tree, so Playwright's own actionability check refuses to click it — a second-click scene needs `force: true` (a real pointer is NOT blocked on aria-disabled). e2e `test/e2e/popin-preload-loading-state.spec.js` (5 arms, red-first: 4 red on the pre-fix dist, ready-branch no-arm control green on both). (13) **A free variable inside a `try` turned every successful JSON load into a fabricated server error (#B315, 0.6.6).** `popinLoad`'s readyState-4 2xx branch dispatched `success.<id>` on `$forms[0]` — but the module's sole `var $forms` is declared in `popinBind`, a SIBLING function under `Popin`, so reading it threw `ReferenceError` straight into that same handler's `catch`, which fabricates `{status: 422, error}` and fires `error.<id>`. `success` therefore never reached a subscriber at all. ⚠️ **The observable is worse than a stack trace, and that is the part worth carrying:** the catch overwrites `result.error` with `JSON.parse(xhr.responseText)` whenever the response is `application/json`, and the throwing line is reachable ONLY for JSON (a non-JSON body returns early at the `loaded.` trigger just above it) — so the consumer received a **422 whose payload was the SUCCESSFUL response body**, reading as a server-side failure that never happened. Fix: dispatch on `$el`, matching the two sibling triggers in the same handler (`loaded.`, `error.`) — `popinLoad` is the CONTENT loader, so a JSON response need not involve a form at all; the `$forms[0]` target was copy-paste from a validator context, whose commented-out `handleXhrResponse` line still sits directly above it. Reproduced BEFORE fixing (a replica of the readyState-4 2xx branch with two firing controls: non-JSON → `loaded`, `location` → early return); tests `test/core/popin.test.js §34` (4 — scope pin, single-declaration-plus-position pin, dispatch-target pin, dist-fidelity pin; both source pins validated RED against the pre-fix source, the dist pin RED before the rebuild). ⚠️ **The registry's other two events still never fire (#B315b):** `progress`'s entire `xhr.onprogress` block including its `triggerEvent` is commented out (dead by construction, with its `ontimeout` sibling), and `click` has ZERO `triggerEvent` sites anywhere — the `popin.click.*` identifiers are per-element DOM event names in a different namespace. Both still register cleanly through `on()`, so the surface advertises three events and delivered none; their disposition is a DESIGN question (implement, or drop them from the registry so the surface stops lying) and was deliberately NOT folded into this fix, which was an unambiguous defect. **The popin `::backdrop` rule is scoped to gina's own dialogs (#B329, fixed 2026-08-24).** The sheet shipped `dialog::backdrop` (dark overlay + blur) UNSCOPED, painting gina's backdrop on EVERY consumer `<dialog>` on the page; now `dialog.gina-popin-container::backdrop`, matching the reduced-motion rules that were already scoped. Both dialog-creation sites set the container class on the `<dialog>` element itself, so gina's own popins keep their backdrop. Consumer-visible: a page relying on the free backdrop for its own dialogs styles them itself now, and a popin dialog element supplied by the page's own markup (the adopt-by-id path) needs `gina-popin-container` to keep gina's backdrop. Pins: `popin-backdrop-scope.test.js` (source + compiled twin + `gina.min.css`, present/absent controls). Browser-bundled ⇒ consumers restart AND re-bake. **A form's or a link's `text/html` XHR answer is routed by the popin the SUBMITTING element is inside, never by "the active popin" (gh#76 + #B571, 2026-09-20).** The validator captures `gina.popin.getPopinContaining($form)` into the per-send closure at submit (the same capture stamps the `X-Gina-Popin-Id`/`-Name` request headers, so `self.isPopinContext()` is true only for a contained form) — and since #B572 (2026-09-22) the STAGED-UPLOAD path makes the same capture once per file selection (`$ownerPopin = getPopinContaining($el.form || $el)`, `$el.form` being the consumer's real form), so the virtual `gina-upload-*` form, its preview lookups, its `uploadProperties.isPopinContext` flag and its staging request belong to the popin the REAL form is inside, or to `document.body`. It was the LAST surviving `isPopinContext()` (« some popin is open ») gate: a PAGE form's file chosen while an unrelated popin was open had its virtual form appended inside that popin — staging 200, all ten hidden metadata fields EMPTY, the save posted without the file, the staging request claiming that popin's id, zero errors — reachable through the file-picker race (the OS picker is application-modal and ignores `inert`, which blocks only the click path) and through script-assigned `input.files`; a form INSIDE the popin was always fine and still is (measured 10/10 both ways). Dev-mode notice `[FormValidator][popin] the staged upload of `#<input>` (form `#<form>`) is placed with its own form …`, from `warnIfOldRulePlacedUpload`, the twin of `warnIfOldRuleRouted`; the popin branch's `$target` DOMParser copy is deliberately untouched (measured working when chosen by containment). Pins: `test/core/validator-upload-popin-containment.test.js` (block-scoped absence pin + the helper driven in a lifted scope with every free identifier supplied), e2e `validator-upload-popin-containment.spec.js` (page form + open popin, the picker race, and the inside-the-popin control, red-first through `B572_PREFIX_BUNDLE`). and honours it at settle only while that popin is still open AND its element still contains the form (a close + reopen in flight detaches the form ⇒ legacy payload); anything else — a page form while a popin is open OR loading — is the legacy handler-only `{contentType, content, status}` payload, with a dev-mode `[FormValidator][popin]` notice naming the popin the old rule would have targeted (it REPLACED an open popin's content, or raised a false `422` `Popin x is not open !` on a loading one — the loading window is reachable through a preload-opted-out trigger, never a hovered default one, whose click adopts the warm). The popin branch now also emits the `.hform` (validator) / `.hlink` (`utils/events.js`, whose `.hform` half is dead — no caller passes `$form`) companions with the parsed xhr-data, so declared callbacks run for forms inside popins (#B571). `getActivePopin()` returns OPEN popins only — the `activePopinId` one when open, else the first open — never a registered-but-not-open one (§8(a)); `popinLoadContent` honours `this` when called as a method (both internal sites use `.call($popin, …)`, the manager-level call keeps the active fallback); the redirect branch resolves `sendCtx.popin || (result.popin ? getActivePopin() : null)` — a plain `location` redirect from a page form falls through to the page redirect (measured pre-fix: it loaded INTO the open popin AND navigated, two fetches), a name-less `{popin:{close}}` with nothing open is a no-op. `getPopinContaining` walks the registry with `getElementById(id).contains(el)` (innermost wins; the element id is the popin id on every flavour; 0.2 µs worst case) — a non-modal popin still inerts the page, so a page form can only be submitted by script while one is open, which is what the request-side arms of `test/e2e/validator-popin-form-target.spec.js` do. Companion `test/e2e/popin-preopen-modeless.spec.js` pins #B574 (`popinOpen` guard `hasAttribute('open')`). Browser-bundled ⇒ consumers restart AND re-bake. **#B575 (0.6.32) — the two hidden transport inputs are DEV-ONLY, and both popin branches now parse tolerantly.** `spliceXhrInputs` runs only under `_isDev` (`NODE_ENV_IS_DEV === 'true'`), so outside dev mode a `renderWithoutLayout` answer carries NO `gina-without-layout-xhr-data`/`-view`; both branches dereferenced `.value` on the absent element, the `TypeError` was caught INSIDE the try and surfaced as a false `422` error callback AFTER a successful server write, and the popin was never loaded. One shared `parseXhrHtmlAnswer()` now returns `{doc, data, view}` with `data`/`view` `null` when absent and the two inputs STRIPPED from the parsed document (they are consumed transport — left in, a swap into a region inside a `<form>` would resubmit them and repeated swaps would duplicate their ids). ⚠️ The parsed data is delivered VERBATIM when present — injecting a `status` key would change the payload every contained form already receives (the slice-1 contract `validator-popin-form-target.spec.js §05` pins) — and the payload is `{status}` only when the inputs are absent; `validator-form-target.test.js §06` pins that absence SCOPED to the popin branch, since `result.status = xhr.status` is legitimate elsewhere (the legacy payload builders in `utils/events.js`). (14) **`data-gina-dialog-target` is ONE selector applied on BOTH sides, with THREE fallbacks — all announced in dev mode since #B580 (2026-09-22).** `applyContent($el, html, $popin, partialTarget)` reads the slot with `$el.querySelector(partialTarget)` and the incoming region with `parsed.querySelector(partialTarget)` over a `new DOMParser()` document, then assigns `$slot.innerHTML = $incoming.innerHTML` — the SLOT ELEMENT survives, which is what preserves chrome and its bindings. The three misses are all fail-soft (a popin open is a retryable read, unlike a submit): a selector the ENGINE REFUSES ⇒ full replace — pre-#B580 this threw an uncaught `SyntaxError` out of the `loaded.<id>` handler / preload-consume tail, so the dialog silently kept its previous content (MEASURED on the real bundle: `Failed to execute 'querySelector' on 'Element': '#slot >' is not a valid selector.`); no match in the OPEN DIALOG ⇒ full replace; no match in the ANSWER ⇒ the whole `parsed.body` into the slot — the SECOND fallback, which the published guide did not mention and nothing pinned. `warnPartialTarget()` emits one `console.warn` per event naming the popin, the selector and which fallback ran, gated on `gina.config.envIsDev`. ⚠️ Testing surface: the e2e harness (`test/e2e/runtime-server.js` serves `envIsDev:'false'`) can assert only the FALLBACK — the NOTICE belongs to `popin.test.js §22`, which drives the REAL brace-walk-extracted function, because the §21 replica omits `$popin` and mirrors the pre-fix body; keep `applyContent`'s signature and first `if ( !partialTarget )` branch byte-shaped or the regex pins at `popin.test.js:1121-1130` and `popin-reload-open.test.js:61` go red. ⚠️ The attribute is the dialog-scoped sibling of `data-gina-form-select`, NOT of `data-gina-form-target`: one selector, both sides, nothing resolved relative to an element, so the `this`/`closest x`/`find x`/`next x` grammar does not apply — all four are valid CSS, so writing one takes the silent fallback instead of reporting a mistake. ⚠️ The "legacy triggers get a full replace" rule covers PURE legacy only: `bindDelegatedOpen` returns early ONLY for a trigger carrying NEITHER `data-gina-dialog` NOR `data-gina-dialog-src`, so a MIXED trigger (legacy attributes + `-src`) routes through `openFromTrigger` and DOES get the partial swap.
946
+ 213. **Client-plugin discipline — CSP-safe listeners, native-dialog parity, preload coalescing (consolidates former #149/#159/#200).** (1) Client-bundle plugins must NOT inject inline event-handler attributes (`setAttribute('onclick', …)`, `el.onX = …`) — they trip CSP `script-src-attr` under nonce-based policies; suppress defaults with `addEventListener('click', e => e.preventDefault())`, and keep it `preventDefault`-ONLY (no stopPropagation) when the element participates in event delegation other handlers depend on — a `preventDefault`-only listener still sets `event.defaultPrevented` exactly as the inline handler did. (2) Dialog popins open as native modals (`$el.showModal()`) in EVERY env — the dev-only non-modal downgrade + manual overlay are gone for dialog mode (native `::backdrop`); a consumer that PRE-OPENS the dialog (skeleton loading) must also use `showModal()` or it is born non-modal with nothing positioning it; the opt-in `preOpen`/`loadingShell` skeleton is idempotent via `hasAttribute('open')` — NOT getAttribute truthiness, because `showModal()` sets `open` to the EMPTY string — and since #B574 (2026-09-20) `popinOpen`'s own re-entry guard reads the same `hasAttribute('open')`: it read getAttribute truthiness, never skipped a shell-opened dialog, and on the new trigger's default MODELESS resolution called `show()` on the still-modal shell, which throws `InvalidStateError` and left `isOpen` false on a visibly open popin; a native UA close (Escape, `<form method="dialog">`) fires the element's `close` event WITHOUT the plugin's own close path, so a once-per-element `close` listener (the event is `close`, not `cancel` — `cancel` is Escape-only) routes it through the plugin close, keeping open-state/listeners/toolbar consistent — a UA-closed shared popin otherwise re-opens "one render behind" (#B58); and since gh#76 §8 (2026-09-20) the `preOpen` loading shell is an explicit STATE — `$popin.isLoading` is true from the moment the shell shows until the real open (`isOpen` stays false, so the two `!isOpen ⇒ popinOpen` consumers still run it), backed by a per-popin load sequence and the in-flight transport (`_loadSeq` / `_loadXhr`): `close()` / `destroy()` on a loading popin cancel the load (`closeLoadingShell` — sequence bump, `abort()`, teardown by the shell's OWN state because the class-keyed teardown never matches a dialog shell, trigger release, `close.<id>`; the aborted transport's readyState-4 handler runs its release block and then drops on the sequence check, so a cancel fires no `error`), the native `close` sync is bound at SHELL time through `bindNativeCloseSync` (guard `isOpen || isLoading`) so an Escape during the load routes through the plugin instead of re-opening when the content lands (driven on the pre-fix bundle: Escape closed the dialog, the landing re-opened it), `loadContent()` on a loading popin injects into the DIALOG element (`target` still names the container until popinOpen re-points it) and completes the open through popinOpen, and a non-2xx load fires `error.<id>` FIRST, then closes the shell if nothing loaded content into it; a popin without `preOpen` never enters the state, and `getActivePopin()` stays open-only; and #B579 (2026-09-20): the declarative trigger's `loaded.<id>` listener applies a NON-STRING detail as nothing — `popinLoadContent`'s redirect branch fires `loaded.<id>` with the POPIN OBJECT as detail (the legacy listener only binds on it), so a `load()` landing on an already-OPEN `data-gina-dialog` popin wrote the new body and then wiped it, leaving the dialog empty with no error (driven on the published bundle; every version since 0.4.6). (3) A popin/dialog click fired TWO identical GETs because the hover/`focusin` preload — `focusin` is part of the click gesture — was never reused: an in-flight preload registry lets the click-time consume ADOPT the in-flight fetch (waiter woken with the body, caller's own load on failure) instead of fetching again, and the legacy click path consumes preloads at all now (#B54); only the initial GET is ever preloaded. The consume path bypassing the full load tail (redirect/JSON handling) was ASSUMED acceptable — but a hover/focus-WARMED trigger whose GET returns a redirect/JSON response (`application/json` `{isXhrRedirect,location}`) had that raw JSON blind-injected as the popin body via `applyContent` (`$el.innerHTML`), the `_self` tunnel never firing — IDENTICAL symptom to the #B77 `_self` timer race but a DIFFERENT mechanism (the consume path, regression `2c61c494` v0.5.5), so #B77 fixed only the UNWARMED click-time path and the WARMED path persisted until #B80 (2026-07-06, `37e6829c`): `preloadFetch`'s `onreadystatechange` now caches a 2xx response ONLY when its Content-Type is NOT `application/json` (mirrors `popinLoad`'s own `isJsonContent` detection), firing the in-flight waiters with `null` (→ `consumePreload`'s `onMiss` = the click-time `doLoad`) and leaving the cache empty (→ a ready consume returns false → `doLoad`), so a JSON-returning trigger falls through to the click-time `popinLoad`'s full redirect/JSON tail exactly as a non-preloaded click (one extra GET on click — the hover-warm GET is not reused; measured 2 GETs). Only a genuine HTML fragment is preloaded+injected. Browser-bundled → prod dist rebuilt; VERIFIED in a real browser (a warmed legacy `data-gina-popin-url` redirect trigger now shows the tunnel target, not the raw JSON; the decline path positively exercised via resource-timing) — jsdom is falsely green. That residual — the GET itself still fired on hover for side-effecting triggers, authenticated (same-origin cookies) and CSRF-token-less (GET) — is CLOSED by the #B91 per-trigger opt-out (2026-07-10): `data-gina-dialog-preload="false"` on the trigger (honored on legacy `data-gina-popin-url` triggers too — the gate reads the attribute off the closest-matched intent target; case-INSENSITIVE `/^false$/i` on purpose, so a templated "False" cannot fail open and fire the GET anyway — deliberately safer than data-gina-dialog-modal's case-sensitive parse) suppresses BOTH the hover warm and the focusin-on-click warm at the shared intent handler; the opted-out click loads normally at click time (undefined cache slot → consume returns false → the caller's click-time load; exactly one GET per click). Default stays preload-ON: GET is presumed safe to fire early per HTTP semantics — a side-effecting trigger declares itself (the hover-prefetch ecosystem's convention, live-verified). (4) A proxied/tunnelled redirect JSON response (`isXhrRedirect`+`location`, default `_self`) used to `$popin.load()` then arm a blind `setTimeout(50, () => !$popin.isOpen && $popin.open())`; a follow-up load slower than 50 ms opened the popin against a not-yet-injected (skeleton/empty) target → intermittent unhandled-deref crash. Removed as vestigial (v0.1.0, 2021) — the already-armed `loaded.<id>` listener opens CONTENT-FIRST (injects the body via popinBind/handleLoadedBody, THEN popinOpen), so the load alone suffices; the `_self` branch's `return;` still guards the `window.open` fall-through (non-`_self` targets only) (#B77). Event contract: `open.<id>` = a fresh open, `loaded.<id>` = reload/redirect content injected — a consumer needing "content ready on every load" must listen to BOTH. A structurally identical blind timer lived in the SIBLING validator `Validator::Popin now redirecting` path (`core/plugins/lib/validator/src/main.js`), removed by #B79 (2026-07-10): the validator DISCARDED popinLoad's return, and that return IS the `{open}` handle whose `open()` arms the content-first `loaded.<id>` listener — so the timer was papering over a race the discarded handle created (an XHR faster than 50 ms fired `loaded` into the void and the body was lost, a slower one opened an empty popin first). It now captures the handle and calls `open()` inside the same not-open gate, guarding the `undefined` popinLoad returns when the request cannot start (CORS unsupported) so a failed load never blind-opens an empty popin; and the cross-popin branch resets `isRedirecting` before closing the ORIGINAL popin, because `popinClose` early-bails on a redirecting popin — that close was a silent no-op, leaving the original open behind the new one (browser-verified: pre-fix stays open, post-fix closes). MEASURED CORRECTION: the timer was NOT load-bearing for the different-popin branch as long assumed — that branch threw first, because the published `gina.popin` WAS re-assembled with a target-wins merge on every `new Popin()`, so its `getPopinByName`/`getPopinById` stayed bound to the FIRST instance's registry and resolved ONLY the boot popin, while `$popins` + `getActivePopin` saw every popin and `gina.popin.activePopinId` never updated (so `getActivePopin()` returned null once nothing was open) — and popins registered AFTER an instance's publish (click-time in-page dialog registrations) never reached the published registry at all, with destroyed popins lingering in it. FIXED as #B90 (2026-07-10): ONE module-scoped registry (`_sharedPopins`) aliased by every instance's `$popins`; a publish-ONCE `gina.popin = instance` (the published object IS the first instance, LIVE — re-publishing was both the freeze defect and, with a shared registry, a self-merge recursion hazard: gina's merge deep-recurses plain objects and `$popin.eventData` can hold the $popin itself); and a `setActivePopinId` write-through helper at all 7 write sites keeping the published `activePopinId` truthful no matter which instance opens or closes a popin. Browser-verified both ways on the built bundle (pre-fix: accessors blind + the validator cross-popin redirect 422-throws `not found`; fixed: accessors resolve every instance's popins and a form submit redirecting into a DIFFERENT popin works end-to-end — the original closes, the target loads content-first, `activePopinId` follows). Generalises: an accessor published through a target-wins `merge()` silently keeps the first instance's closure — publish a live instance once and share module-scoped state instead of re-merging per construction; and never infer a code path is reachable from the fact that it exists. (5) EAGER content warm — `data-gina-dialog-preload="eager"` (case-insensitive) opts a trigger into a one-shot idle warm-all pass: after `window` load, on requestIdleCallback (setTimeout fallback), serialized one GET at a time, routed through the SAME shared per-trigger gate as the hover warm (`warmTrigger`: disabled skip → the `"false"` opt-out → URL-cache dedup → in-flight reserve + fetch) so the two warm paths cannot drift and whichever fires second is a no-op; skipped entirely under Save-Data; triggers injected after the pass keep the delegated hover warm; staleness matches the shipped no-TTL hover semantics — the opt-in accepts the wider warm→open window. (6) #B139 (2026-07-20, 0.5.22): the content cache no longer outlives the open it warmed — measured pre-fix it was a ONE-GENERATION-LAGGING store (every open after the first paid ~1 GET yet rendered the PREVIOUS open's fetch: the leftover entry — the #B54 in-flight adoption's repopulated body, or the around-open re-warm — blocked the fresh warm's dedup; NO invalidation path existed, close/unbind/destroy never touched the cache; reproduced back to 0.5.4, so it predates #B54 and the eager pass). Fix: close clears the popin's content-URL slot (stamped at consumePreload both branches + popinLoad) AND re-sweeps the same slot ~120ms later, because the close-time a11y focus-return and pointer re-hover fire TRUSTED synthetic intents that re-warm the slot with close-era content within 1ms of the close (measured; a raced sweep is benign by construction — at most one extra fetch, never staleness, since an adopted in-flight body still reaches its open through the waiter chain). AND `preload="false"` triggers now skip the cache READ at open on both open paths — `false` is the hard always-refetch spelling for volatile popins (its GET always happens at open, never from a same-URL sibling's warm); no new annotation vocabulary. Warmed-never-opened entries keep the page-lifetime eager semantics above; default triggers pay at most one extra idempotent GET per close (the swept synthetic warm). Content preload deliberately does NOT ride 103 Early Hints: popin responses vary on `X-Requested-With` (a non-XHR GET of a fragment layout gets the iframe-wrap variant and misses the scripts append), and a browser `Link rel=preload` fetch never carries that header — wrong-variant bytes or a double GET, with no success branch; `self.setEarlyHints()` remains the right tool for a popin's STATIC subassets (the CSS/JS it injects via getScript/getStyle). (7) #A11Y8 (2026-08-05, 0.6.4): the background-`inert` loop in `applyNonModalShims` skipped `instance.target` — the shared `.gina-popins` container — and since every popin lives INSIDE that container, and `popinOpen` never closes the popin it supersedes (it only overwrites `activePopinId`), opening a second NON-MODAL dialog left the first one fully keyboard-reachable behind it: a user could tab out of the dialog on screen and into a stale one. Non-modal is the FRAMEWORK DEFAULT for `data-gina-dialog` (`resolveModal` falls through to `return false` at step 5; only a legacy `data-gina-popin-name` trigger is unconditionally modal), so this was the ordinary path, not an exotic one — note item (2)'s "native modals in EVERY env" predates the non-modal branch and now describes LEGACY triggers only. Fix: descend into the container instead of skipping it, and inert sibling `dialog[open]` ONLY — a `<dialog>` without `open` is already `display:none` per the UA stylesheet, so it is unreachable without help (measured in Chrome with controls firing both directions; the modal path was never affected, native `showModal()` handles the top layer itself). Teardown needed NO change: `removeNonModalShims` sweeps `[data-gina-popin-inert]` document-wide, so dialogs nested in the container restore without it knowing they exist. The `getAttribute('inert') == null` guard means gina only marks what it set, so a consumer-set `inert` is never stamped and never stripped. All seven are browser-bundled — prod dist rebuild required; verify (2), (3), (4) and (5) with a REAL preemptive-open / preload / redirect-tunnel consumer — a minimal smoke page is falsely green; (7) needs TWO popins open at once, the only scene in which it differs at all. (8) **A trigger gate must not trust the native `disabled` ATTRIBUTE on an element the browser already enforces (#B296, 0.6.5).** The three DISPATCH-time gates (`openFromTrigger`, the `bindOpen` document proxy, the per-`$element` listener) now require `!('disabled' in $el)` before the attribute counts, mirroring the validator's `isTriggerDisabled` (#B293) so the two subsystems agree on what "disabled" means. Rationale, measured: a natively-disabled `<button>` delivers **0 clicks to JS**, so the attribute arm could only ever fire on a `disabled` written DURING the dispatch — the shape of every consumer double-submit guard — which ate the open and then cleared the attribute, leaving nothing marked and a normal-looking control; on an `<a>`/custom element/span the browser enforces nothing, so the arm is KEPT (`'disabled' in $el` → `button:true · input:true · anchor:false · custom-element:false · span:false`). It also cannot weaken gina's OWN re-entry guard: `armPopinTrigger` arms `<a>` with `aria-disabled` (untouched arm) and every other tag with native `disabled` — enforced by the browser for real controls, and still enforced by this gate for `<span>`-likes, which is exactly the branch the `in` test preserves. **Two gates are deliberately NOT changed:** the hover/focus `warmTrigger` preload gate (removing it there would start PRELOADING a `<button disabled>` nobody can click — if it ever changes it wants the IDL property `$el.disabled === true`, not the ignore treatment) and the bind-time `$link` skip (structurally unreachable by a mid-dispatch write, and it carries a different one-arm predicate). **Reachability is unequal and the difference is load-bearing:** at `openFromTrigger` gina's proxy is DELEGATED so a button-bound guard always wins the race (plain #B293 shape reproduces); at the per-`$element` listener gina binds to the NODE, so only a CAPTURE-phase or earlier-registered guard can poison it (measured both ways — a target-phase guard bound after gina's does NOT reproduce); the legacy document-proxy site is traced-not-exercised. Test `test/e2e/popin-trigger-native-disabled.spec.js` — a replica cannot catch this, it is an ordering interaction between a consumer listener and a delegated proxy. ⚠️ **Scene trap for anyone writing a popin close-button test:** the close element must carry NO `id` — gina assigns `popin.close.<n>` only to an id-less one and the dispatch gate matches on that prefix, so a consumer id makes EVERY arm read "did not close", subtract control included (that is its own defect, #B299). (9) **A teardown loop must never splice the array it is walking (#B265, 0.6.5).** `popinUnbind`'s validator-form teardown spliced `$popin['$forms']` inside a `for` bounded by a length captured BEFORE the loop, so each splice shifted the array left under an incrementing cursor and the read index skipped one every time — the originally ODD-indexed forms were never destroyed and were left behind in the very array the loop exists to empty. Because the popin's `innerHTML = ''` runs BEFORE the loop, their surviving validator entries pointed at already-detached nodes, and `validateFormById`'s "return existing when available" early return then handed the stale entry back on the next open, so the form and its submit trigger came up silently inert — the same end symptom as #B294 by a completely different route. Fix: snapshot with `.slice()`, clear once with `.length = 0`, iterate the snapshot. **Two things worth carrying beyond this bug.** First, `destroy()` is not free: it fires `destroy.<formId>` (validator `main.js:4241`) and `'destroy'` IS on the plugin's public registrable-event list, so making a skipped form destroy properly starts firing consumer `on('destroy')` callbacks that never fired before — a teardown fix is a behaviour change, not merely a leak fix. Second, per-form teardown is now wrapped: `destroy()` → `unbindForm` → `getFormById(vFormId)` THROWS for a form holding a `data-gina-form-virtual` file input once its virtual form is unresolvable, which is precisely the detached state every form is in at that point — and before the guard, one such throw silently abandoned every LATER form in the loop. **Reachability is much narrower than it looks and must not be overstated:** the loop is gated on a validator — since #B756 (0.7.2) the one the POPIN was registered with (`$popin.options.validator`, falling back to the closure's `$validatorInstance`); before, the closure alone, a PER-INSTANCE value assigned only from `options.validator`. Gina's own boot does `new Popin({name:'gina-dialog-boot'})` with no validator, and the delegated `data-gina-dialog` listener is installed once by that boot instance (module guard `_ginaDialogDelegated`), so **the declarative dialog path can never reach this code** — it needs a popin constructed with a validator (`new Popin({name, validator})`, the legacy popin API). #B756 was the closure gate's own defect: `gina.popin` IS that validator-less boot instance, so the documented `gina.popin.close(name)` skipped a validator-registered popin's teardown and the reopened form came up unbound (`validateFormById`'s early return again — a third route to the #B265 symptom), while the popin's own `close()` and its close button run the registering instance and always tore down. Pin: arm 04 of e2e `validator-upload-popin-reopen-b733.spec.js`. That is also why the test is a replica + source pins (`test/core/popin-forms-teardown.test.js`) rather than an e2e arm: an e2e scene on the declarative path would be VOID, every arm reading "nothing destroyed" for a reason unrelated to the defect. (10) **Never re-derive an element's ROLE from its id when the call path already proves it (#B299 + #B301, 0.6.5).** `register()`'s close branch gated on `/^popin\.close\./.test(event.target.id)` — a prefix gina mints at bind time — and that single line carried TWO silent defects: gina assigns the prefixed id **only to an id-less element** (`:1560`), so a consumer-supplied id is taken verbatim and matches nothing (**#B299**); and `event.target` is whatever was actually CLICKED, so an icon nested inside the button matches nothing either (**#B301** — the ordinary `<button class="gina-popin-close"><svg/></button>`, and by far the wider case). Both are invisible: `cancelEvent` runs at the top of the listener, so the button also swallows its own default — no error, no navigation, nothing. Fix: read **`event.currentTarget`** (the element `register()` bound — a `.gina-popin-close` by construction, since that function has ONE call site fed only from `querySelectorAll('.gina-popin-close')`) and treat a `popin.click.*` id as the sole exception, which preserves the dual-role element (a trigger that is ALSO a close button — reachable because `bindOpen` scans `document`, not just the host page). ⚠️ **What must NOT be "fixed" instead: rewriting the element's id.** The `$close` teardown sweep removes this listener via `gina.events[eId] == eId` — the degenerate name===id entry that `events.js:42` produces — so decoupling the event name from the id leaks the listener; and overwriting a consumer id would break their selectors, `aria-labelledby` and test hooks. Contrast `bindOpen:1195-1203`, which takes the OPPOSITE policy and does clobber a consumer id on trigger elements. Measured with controls firing both directions on the real bundle (id-less closes / consumer-id does not / nested click does not / patched all close), test `test/e2e/popin-close-dispatch.spec.js`. (11) **gina's own in-flight arm must be honoured by the path that dispatches it — a legacy trigger re-fired during its own load (#B298, 0.6.5).** `armPopinTrigger` marks an `<a>` trigger `aria-disabled="true"` for the duration of its popin's load (every other tag gets the native `disabled`, which the browser enforces). But the ONLY dispatch route for a pure-legacy `data-gina-popin-name` trigger is the `bindOpen` document click proxy, whose predicate tests the native attribute ALONE — and an `<a>` never carries it here — so a second click during the load reached the open handler and issued a SECOND XHR. `bindDelegatedOpen` returns early for a trigger carrying neither `data-gina-dialog` nor `data-gina-dialog-src`, so `openFromTrigger`'s own `aria-disabled` arm never covered this population. Fix: gate at the top of the legacy per-element open handler, predicate copied VERBATIM from `openFromTrigger` (native arm included, so #B296's `!('disabled' in $el)` rule still holds). **Gating THERE rather than in the proxy is load-bearing, and was MEASURED rather than reasoned:** a trigger's direct children each get their own listener from `proxyClick`, which fires the custom event DIRECTLY and never passes the proxy predicate at all — a proxy-level gate still let the child-click route fire twice, while the handler-level gate stops both (both routes converge there, and `currentTarget` is the element `bindOpen` bound, so child markup cannot dodge it). ⚠️ **Reachability is narrower than it looks, and the scene is easy to get wrong:** a real click HOVERS first, which warms the preload (`warmTrigger` reads `data-gina-popin-url` too), and the click then ADOPTS that in-flight fetch instead of calling `popinLoad` — so nothing is ever armed and the question goes unasked (a probe without the opt-out reads 1 request for a reason unrelated to the gate, its own counter-can-read-2 control failing). The defect needs the documented `data-gina-dialog-preload="false"` opt-out (#B91) — i.e. exactly the triggers whose GET has server-side effects, which is why it is not cosmetic. Whether a no-hover activation (touch, some keyboard paths) is a second reachable population is UNMEASURED. Test `test/e2e/popin-legacy-trigger-aria-disabled.spec.js` (4 arms, red-first; the two fix arms failed 2-vs-1 on the pre-fix dist while their scene/armed/click assertions passed, so the red is attributable to the defect). (12) **A click adopting a still-in-flight hover/focus preload is a genuine wait — it now arms the trigger exactly like the cold click-time XHR (#B285, 0.6.5).** Both consume branches previously armed NOTHING (only a preOpen popin got its skeleton), so with warm-on-intent preloading the COMMON open path showed no busy affordance at all. `consumePreload` gained a guarded 4th param `onSettled`, fired synchronously after the ready (cached) apply and at the TOP of the adopted waiter — success AND failure, release-before-apply, the cold readyState-4 order. The CALLERS own the affordance (arm on adopted-wait only via a settled-flag; release via `releasePopinTrigger`, armPopinTrigger's mirror), keeping `consumePreload` loading-state-agnostic — load-bearing for the extract-and-execute test harness, which injects neither `document` nor `loadingState` and calls with ≤3 args (the guard makes the 4th a no-op), and the right factoring anyway (it reports that it settled; callers decide who cares). The ready branch's SYNCHRONOUS settle is equally load-bearing: without it a caller cannot distinguish ready from in-flight and would arm forever — an instant cached open never flashes a busy state. Double-click protection during the wait comes free from EXISTING mechanisms (the entry gates refuse an armed trigger; the browser suppresses a disabled `<button>`), and the failed-adoption leg releases before the click-time fallback re-arms through popinLoad's own path. Playwright note: an armed `<a>` reads [disabled] in the accessibility tree, so Playwright's own actionability check refuses to click it — a second-click scene needs `force: true` (a real pointer is NOT blocked on aria-disabled). e2e `test/e2e/popin-preload-loading-state.spec.js` (5 arms, red-first: 4 red on the pre-fix dist, ready-branch no-arm control green on both). (13) **A free variable inside a `try` turned every successful JSON load into a fabricated server error (#B315, 0.6.6).** `popinLoad`'s readyState-4 2xx branch dispatched `success.<id>` on `$forms[0]` — but the module's sole `var $forms` is declared in `popinBind`, a SIBLING function under `Popin`, so reading it threw `ReferenceError` straight into that same handler's `catch`, which fabricates `{status: 422, error}` and fires `error.<id>`. `success` therefore never reached a subscriber at all. ⚠️ **The observable is worse than a stack trace, and that is the part worth carrying:** the catch overwrites `result.error` with `JSON.parse(xhr.responseText)` whenever the response is `application/json`, and the throwing line is reachable ONLY for JSON (a non-JSON body returns early at the `loaded.` trigger just above it) — so the consumer received a **422 whose payload was the SUCCESSFUL response body**, reading as a server-side failure that never happened. Fix: dispatch on `$el`, matching the two sibling triggers in the same handler (`loaded.`, `error.`) — `popinLoad` is the CONTENT loader, so a JSON response need not involve a form at all; the `$forms[0]` target was copy-paste from a validator context, whose commented-out `handleXhrResponse` line still sits directly above it. Reproduced BEFORE fixing (a replica of the readyState-4 2xx branch with two firing controls: non-JSON → `loaded`, `location` → early return); tests `test/core/popin.test.js §34` (4 — scope pin, single-declaration-plus-position pin, dispatch-target pin, dist-fidelity pin; both source pins validated RED against the pre-fix source, the dist pin RED before the rebuild). ⚠️ **The registry's other two events still never fire (#B315b):** `progress`'s entire `xhr.onprogress` block including its `triggerEvent` is commented out (dead by construction, with its `ontimeout` sibling), and `click` has ZERO `triggerEvent` sites anywhere — the `popin.click.*` identifiers are per-element DOM event names in a different namespace. Both still register cleanly through `on()`, so the surface advertises three events and delivered none; their disposition is a DESIGN question (implement, or drop them from the registry so the surface stops lying) and was deliberately NOT folded into this fix, which was an unambiguous defect. **The popin `::backdrop` rule is scoped to gina's own dialogs (#B329, fixed 2026-08-24).** The sheet shipped `dialog::backdrop` (dark overlay + blur) UNSCOPED, painting gina's backdrop on EVERY consumer `<dialog>` on the page; now `dialog.gina-popin-container::backdrop`, matching the reduced-motion rules that were already scoped. Both dialog-creation sites set the container class on the `<dialog>` element itself, so gina's own popins keep their backdrop. Consumer-visible: a page relying on the free backdrop for its own dialogs styles them itself now, and a popin dialog element supplied by the page's own markup (the adopt-by-id path) needs `gina-popin-container` to keep gina's backdrop. Pins: `popin-backdrop-scope.test.js` (source + compiled twin + `gina.min.css`, present/absent controls). Browser-bundled ⇒ consumers restart AND re-bake. **A form's or a link's `text/html` XHR answer is routed by the popin the SUBMITTING element is inside, never by "the active popin" (gh#76 + #B571, 2026-09-20).** The validator captures `gina.popin.getPopinContaining($form)` into the per-send closure at submit (the same capture stamps the `X-Gina-Popin-Id`/`-Name` request headers, so `self.isPopinContext()` is true only for a contained form) — and since #B572 (2026-09-22) the STAGED-UPLOAD path makes the same capture once per file selection (`$ownerPopin = getPopinContaining($el.form || $el)`, `$el.form` being the consumer's real form), so the virtual `gina-upload-*` form, its preview lookups, its `uploadProperties.isPopinContext` flag and its staging request belong to the popin the REAL form is inside, or to `document.body`. It was the LAST surviving `isPopinContext()` (« some popin is open ») gate: a PAGE form's file chosen while an unrelated popin was open had its virtual form appended inside that popin — staging 200, all ten hidden metadata fields EMPTY, the save posted without the file, the staging request claiming that popin's id, zero errors — reachable through the file-picker race (the OS picker is application-modal and ignores `inert`, which blocks only the click path) and through script-assigned `input.files`; a form INSIDE the popin was always fine and still is (measured 10/10 both ways). Dev-mode notice `[FormValidator][popin] the staged upload of `#<input>` (form `#<form>`) is placed with its own form …`, from `warnIfOldRulePlacedUpload`, the twin of `warnIfOldRuleRouted`; the popin branch's `$target` DOMParser copy is deliberately untouched (measured working when chosen by containment). Pins: `test/core/validator-upload-popin-containment.test.js` (block-scoped absence pin + the helper driven in a lifted scope with every free identifier supplied), e2e `validator-upload-popin-containment.spec.js` (page form + open popin, the picker race, and the inside-the-popin control, red-first through `B572_PREFIX_BUNDLE`). **A popin close + reopen re-stages, and same-named staged inputs in two forms stay apart (#B733/#B732, 0.7.2):** the file-input change handler reuses a virtual `gina-upload-*` record's element only while it `isConnected`; a detached record is `destroy()`ed (its listeners and their registry entries go with it), deleted from `$forms`, and a new virtual form is built — it used to fall back to `getFormById()`, which returns the validator RECORD, and use that record as the element, so after a popin close (the content is wiped, nothing destroys the virtual form) every selection threw `setAttribute is not a function` and staged nothing. The virtual id of a bracket-less name ends with `-<form id>` (`gina-upload-doc-<formId>`; a bracketed name already carried the form id, once per `]`, and is unchanged) — `gina-upload-doc` was shared by every form, so the second form's upload filled the FIRST form's hidden fields and left its own empty; a listener or selector matching a bracket-less virtual id literally must read the input's `data-gina-form-virtual` instead. Same-named staged inputs inside ONE form still share a virtual form (their hidden-field names collide too). Pins: e2e `validator-upload-popin-reopen-b733.spec.js` (4 arms, incl. #B756's `gina.popin.close(name)`) and `validator-upload-same-name-b732.spec.js` (2 arms), red-first on the pre-fix dist. For a submit, the validator honours its capture at settle only while that popin is still open AND its element still contains the form (a close + reopen in flight detaches the form ⇒ legacy payload); anything else — a page form while a popin is open OR loading — is the legacy handler-only `{contentType, content, status}` payload, with a dev-mode `[FormValidator][popin]` notice naming the popin the old rule would have targeted (it REPLACED an open popin's content, or raised a false `422` `Popin x is not open !` on a loading one — the loading window is reachable through a preload-opted-out trigger, never a hovered default one, whose click adopts the warm). The popin branch now also emits the `.hform` (validator) / `.hlink` (`utils/events.js`, whose `.hform` half is dead — no caller passes `$form`) companions with the parsed xhr-data, so declared callbacks run for forms inside popins (#B571). `getActivePopin()` returns OPEN popins only — the `activePopinId` one when open, else the first open — never a registered-but-not-open one (§8(a)); `popinLoadContent` honours `this` when called as a method (both internal sites use `.call($popin, …)`, the manager-level call keeps the active fallback); the redirect branch resolves `sendCtx.popin || (result.popin ? getActivePopin() : null)` — a plain `location` redirect from a page form falls through to the page redirect (measured pre-fix: it loaded INTO the open popin AND navigated, two fetches), a name-less `{popin:{close}}` with nothing open is a no-op. `getPopinContaining` walks the registry with `getElementById(id).contains(el)` (innermost wins; the element id is the popin id on every flavour; 0.2 µs worst case) — a non-modal popin still inerts the page, so a page form can only be submitted by script while one is open, which is what the request-side arms of `test/e2e/validator-popin-form-target.spec.js` do. Companion `test/e2e/popin-preopen-modeless.spec.js` pins #B574 (`popinOpen` guard `hasAttribute('open')`). Browser-bundled ⇒ consumers restart AND re-bake. **#B575 (0.6.32) — the two hidden transport inputs are DEV-ONLY, and both popin branches now parse tolerantly.** `spliceXhrInputs` runs only under `_isDev` (`NODE_ENV_IS_DEV === 'true'`), so outside dev mode a `renderWithoutLayout` answer carries NO `gina-without-layout-xhr-data`/`-view`; both branches dereferenced `.value` on the absent element, the `TypeError` was caught INSIDE the try and surfaced as a false `422` error callback AFTER a successful server write, and the popin was never loaded. One shared `parseXhrHtmlAnswer()` now returns `{doc, data, view}` with `data`/`view` `null` when absent and the two inputs STRIPPED from the parsed document (they are consumed transport — left in, a swap into a region inside a `<form>` would resubmit them and repeated swaps would duplicate their ids). ⚠️ The parsed data is delivered VERBATIM when present — injecting a `status` key would change the payload every contained form already receives (the slice-1 contract `validator-popin-form-target.spec.js §05` pins) — and the payload is `{status}` only when the inputs are absent; `validator-form-target.test.js §06` pins that absence SCOPED to the popin branch, since `result.status = xhr.status` is legitimate elsewhere (the legacy payload builders in `utils/events.js`). (14) **`data-gina-dialog-target` is ONE selector applied on BOTH sides, with THREE fallbacks — all announced in dev mode since #B580 (2026-09-22).** `applyContent($el, html, $popin, partialTarget)` reads the slot with `$el.querySelector(partialTarget)` and the incoming region with `parsed.querySelector(partialTarget)` over a `new DOMParser()` document, then assigns `$slot.innerHTML = $incoming.innerHTML` — the SLOT ELEMENT survives, which is what preserves chrome and its bindings. The three misses are all fail-soft (a popin open is a retryable read, unlike a submit): a selector the ENGINE REFUSES ⇒ full replace — pre-#B580 this threw an uncaught `SyntaxError` out of the `loaded.<id>` handler / preload-consume tail, so the dialog silently kept its previous content (MEASURED on the real bundle: `Failed to execute 'querySelector' on 'Element': '#slot >' is not a valid selector.`); no match in the OPEN DIALOG ⇒ full replace; no match in the ANSWER ⇒ the whole `parsed.body` into the slot — the SECOND fallback, which the published guide did not mention and nothing pinned. `warnPartialTarget()` emits one `console.warn` per event naming the popin, the selector and which fallback ran, gated on `gina.config.envIsDev`. ⚠️ Testing surface: the e2e harness (`test/e2e/runtime-server.js` serves `envIsDev:'false'`) can assert only the FALLBACK — the NOTICE belongs to `popin.test.js §22`, which drives the REAL brace-walk-extracted function, because the §21 replica omits `$popin` and mirrors the pre-fix body; keep `applyContent`'s signature and first `if ( !partialTarget )` branch byte-shaped or the regex pins at `popin.test.js:1121-1130` and `popin-reload-open.test.js:61` go red. ⚠️ The attribute is the dialog-scoped sibling of `data-gina-form-select`, NOT of `data-gina-form-target`: one selector, both sides, nothing resolved relative to an element, so the `this`/`closest x`/`find x`/`next x` grammar does not apply — all four are valid CSS, so writing one takes the silent fallback instead of reporting a mistake. ⚠️ The "legacy triggers get a full replace" rule covers PURE legacy only: `bindDelegatedOpen` returns early ONLY for a trigger carrying NEITHER `data-gina-dialog` NOR `data-gina-dialog-src`, so a MIXED trigger (legacy attributes + `-src`) routes through `openFromTrigger` and DOES get the partial swap.
946
947
 
947
948
  214. **Per-request routing-clone correctness — narrow the defensive clone, and propagate mutations onto returned clones (consolidates former #194/#195; #B52-residual, 2026-06-18).** The router's per-request `options.conf = JSON.clone(conf)` — a multi-MB deep clone of the ENTIRE bundle config per matched request, held for the request's whole query+render window — was narrowed to: shallow-copy the top level + `conf.content`, deep-clone ONLY `conf.content.routing`, the one subtree actually MUTATED per request (audited: every other per-request write is a whole-subtree REASSIGNMENT a shallow copy isolates, and plugins/connectors/models read config via their own clones, never through the request's) — removing a dev/high-load heap high-water-mark proportional to config size (measured 163→62 MB peak under 150 concurrent slow requests, drains on idle). **Narrowed again — the routing deep clone is retired too (#P40 S3b, 2026-09-09):** the only per-request write on that subtree is a whole-property REPLACEMENT on the MATCHED rule (`options.conf.content.routing[options.rule].param = params.param`), and `params.middleware` — the array `processMiddlewares` splices — is already the request's own copy from the matcher; so `this.route` now shallow-copies the routing map and shallow-copies the matched rule only, every other rule staying shared by reference (the deep clone cost ~5 µs at 8 routes and ~290 µs at 380 on the dev host, on every matched request, for a map nothing else mutates). Pinned by `test/core/router-per-rule-copy.test.js` (source pins with a comment-strip control + a behavioural compiled from the shipped source whose shared-rule arm discriminates against the deep clone) and the realigned `test/core/router.test.js §11`. Rule: copy only what a per-request write actually touches, at the granularity of the write — a whole-property replacement needs a shallow copy of its parent, never a deep clone of the tree — and share the immutable remainder by reference; but audit EVERY per-request writer first, or a missed mutate-into-a-shared-subtree reintroduces cross-request contamination. Sibling: `getRouteByUrl` (server-side redirect/relative-route resolution AND browser-bundled) aliased the shared route config's `param` by REFERENCE while the matcher rewrites `param.{path,namespace,file,title}` in place for `:placeholder` routes — request A's substitution contaminated request B (server) and repeat navigations (client). The fix is TWO coordinated edits, not just "clone the alias": clone `param` per request AND propagate the substituted param onto the returned route object — the function returns a fresh clone of the SINGLETON, so cloning alone returns the un-substituted `:placeholder` and regresses even the first request. Rule: when a matcher both aliases a shared config object and returns a re-clone of that object, isolating the alias is insufficient — propagate the per-request mutations onto the returned clone. The sibling is browser-bundled → prod dist rebuild. **Third member of the same family — do not COPY a key the source does not have (#B423, 2026-08-26).** The `setOptions` per-request loop that refreshes each cloned rule's `host`/`hostname` from `envConf.routing[r]` copied both unconditionally, for EVERY rule. But `config.js`'s host/hostname defaulting deliberately EXCLUDES `param.control === "redirect"` rules (`… && !/^redirect$/.test(routing[rule].param.control)`), so those keys are **ABSENT** there — and `target.k = source.k` on an absent `source.k` CREATES an own property valued `undefined`. The write lands on `envConf._routingCloned`, which is cached for the PROCESS LIFETIME, so the poisoning is permanent: every later no-arg `getConfig()` → `JSON.clone(local.options.conf)` then hit `source[key] === undefined` and emitted the clone's `"should not be left \`undefined\`. Assigning to \`null\`"` warn — one `host` + one `hostname` per redirect rule per clone, forever (a consumer measured it as ~76% of that log line's volume, drowning the genuine warns it exists to surface). Fixed by existence-guarding both copies (`typeof(source.k) != 'undefined'`), so absent stays absent. **Two gates bound the surface, both measured:** the whole block sits inside `if ( typeof(local.options.template) != 'undefined' && … )` — TEMPLATE/view routes ONLY, so an API-only bundle never reproduced it — and the copy loop is `_isRoutingUpdateNeeded`-gated. **Rule: in a refresh-the-clone loop, an assignment is not free — copying an absent key MANUFACTURES an `undefined` the clone utility is right to complain about. Guard the copy, never the warn** (silencing `JSON.clone`'s warn would hide a signal the framework deliberately emits), and never "fix" it upstream by giving redirect rules a host — that exclusion is intentional. Server-side only (controller.js is not browser-bundled — verified by dist token grep with a firing control) ⇒ restart, no re-bake. Tests: `test/core/controller-routing-host-copy.test.js` (source pins on the guard + the upstream exclusion, plus a real-`JSON.clone` behavioral whose SUBTRACT arm runs the pre-fix unguarded shape and asserts it both creates the keys and fires exactly one warn per key per rule).
948
949
 
@@ -1016,7 +1017,7 @@ Dev-mode query instrumentation captures every database query tied to the current
1016
1017
  275. **The `controller:` CLI group — `add` scaffolds, `remove`/`rm` reference-awarely deletes, `rename` reference-awarely renames a namespace controller (#R9, 2026-07-23).** `gina controller:add <name> <bundle> @<project> [--controls=a,b,c] [--api|--views]` — a new OFFLINE CLI group (`lib/cmd/controller/`, registered in `bin/cli` allowedOffline; a develop install serves the verb immediately, npm consumers from 0.5.25). Generates `controllers/controller.<name>.js` with one JSDoc'd action stub per `--controls` entry (omitted → a single `default` action — the `core/template/boilerplate/bundle_namespace` seed's own action name). Bundle FLAVOR auto-detected via `config/templates.json` presence (config.js's `hasViews` signal, offline-readable without a boot): a VIEW bundle → `self.render()` stubs + one template per action at `templates/html/<name>/<action>.html`; an API-only bundle → `self.renderJSON()` stubs, no templates. `--api`/`--views` force it (both → error). Route wiring is PRINT-ONLY: prints paste-ready rules keyed `<name>-<action>` carrying `namespace` + `param.control`, and for a VIEW rule an explicit `param.file: "<action>"` — render-swig.js:364-383 strips a `<namespace>-` prefix from a DEFAULTED `param.file` (which equals the rule name) and emits a per-request "does not respect gina naming convention" WARN, so the explicit file resolves `templates/html/<ns>/<action>.html` warn-free (the boilerplate `homepage` rule sidesteps it with a non-prefixed name); the `default` action's URL is the namespace root `/<name>`, else `/<name>/<action>`. Refuses an existing `controller.<name>.js` (no `--force` overwrite — remove it first), and validates the namespace charset `/^[a-z][a-z0-9_]*$/i` BEFORE any path build (`controller` reserved case-insensitively; the value is interpolated into file paths, a RegExp and a JS class name, so a loose value is an injection vector). Positionals come from the group's own pure `inc/args.js` parser (`opt.argv.slice(3)` minus `@project`/`--flags`) — CmdHelper's clean positional cleanup is `bundle:`-ONLY (helper.js:482), so `self.bundles` for any other topic is contaminated with the project's OTHER bundles and cannot be read for `<name> <bundle>`. Pure `inc/` helpers (`namespace` charset+className, `scaffold` parseControls/buildRules/formatRulesBlock/renderController/renderTemplate, `args` positionals) are require-by-path unit-tested behaviourally; the paste-ready report prints via flush-safe `fs.writeSync(1, …)` (the connector:infer precedent — a `console.log`/`process.stdout.write` before `process.exit` truncates on a pipe). Boot-verified end-to-end in an isolated home: the scaffolded view controller serves HTTP 200 with the rendered body and NO naming-convention warn. Tests: `test/lib/controller-add.test.js` (38). **`controller:remove <name> <bundle> @<project> [--dry-run] [--force] [--format=json]`** (alias `controller:rm` = `module.exports = require('./remove')`) is REFERENCE-AWARE: a namespace with no matching `controller.<name>.js` doesn't error — `router.js:521-525` WARNs and silently falls back to the default `controller.js` (a misdispatch), so a bare delete is unsafe. The pure `inc/reference-scan.js` (node `fs`/`path` only, no framework globals) scans the four measured reference sites — the controller file, routing.json rule-level `namespace` AND `param.namespace` (both load a controller; the body is comment-stripped with a STRING-AWARE regex so a `$schema` URL survives, and non-object entries are skipped like `config.js:2029`), and `requireController('<name>')` literals (both quote styles) across the bundle's `.js` tree — then REFUSES with a per-file blocker list unless clean (routing rules + external `requireController` calls block; a self-reference inside the file being deleted is moot). Clean → interactive readline yes/no (the `view:add` idiom; a non-TTY stdin aborts, naming `--force`/`--dry-run`) → deletes the controller file + its `templates/html/<name>/` tree. It NEVER edits routing.json. `--force` deletes the file+templates even with blockers (listing what remains to clean by hand), `--dry-run` previews, `--format=json` emits an envelope (`{controllerFile,templateDir,routingRefs,requireRefs,dynamicRefs,blocking,removable,removed}`) and deletes only with `--force`. Dynamic references a static scan cannot resolve (`param.namespace` `:variable`, non-literal `requireController(expr)`) are surfaced as an ADVISORY note, never silently cleared. The default `controller.js` can never be removed (`controller` is reserved). Deletion-plan file counts are taken BEFORE the delete. Tests: `test/lib/controller-remove.test.js` (34). **`controller:rename <old> <new> <bundle> @<project> [--dry-run] [--force] [--format=json]`** reuses the scanner + adds the pure `inc/reference-rewrite.js` (also fs/global-free): it moves `controller.<old>.js` → `controller.<new>.js`, moves `templates/html/<old>/`, and rewrites the old namespace at the two ANCHORED sites — routing.json `"namespace": "<old>"` VALUES (rule-level AND param.namespace; a QUOTED-STRING-anchored `replace`, NOT a JSON parse→stringify, so comments/ordering/whitespace survive byte-for-byte, and a substring like `checkout2` or a `:variable` value is never matched) and every `requireController('<old>')` literal (both quote styles, spacing preserved). Unlike `remove`, rename DOES edit routing.json (only those values). A full plan is shown, then an interactive readline confirm (non-TTY aborts naming `--force`); apply is ALL-OR-NOTHING — snapshot every write+move, roll back on any failure. `--force` applies without the prompt, `--dry-run` previews, `--format=json` emits an envelope and applies only with `--force`. Residuals it REPORTS-not-rewrites: `:variable`/non-literal dynamic refs and the cosmetic `<Bundle><Namespace>Controller` class name (gina loads by file path, so it is a label, not a reference). Target collision (`controller.<new>.js` or `templates/html/<new>/` exists) hard-refuses — no overwrite. Tests: `test/lib/controller-rename.test.js` (18).
1017
1018
 
1018
1019
  276. **Worker-global proxy-context poisoning by port-less internal calls — the `getUrl`/`url` template filters, the cross-bundle redirect-by-URL target and the error-fallback redirect now prefer the emitting request's own classification; opt-in `server.proxy.requireForwardedHeaders` makes classification deterministic (#B152).** A request whose inbound Host carries no `:port` is classified reverse-proxied (the first arm of the per-request classification, both engines) and REFRESHES `process.gina.PROXY_HOST`/`PROXY_HOSTNAME` — but a port-less Host is also what an internal call addressed by service/DNS name carries (a container health probe on an app route — only `/_gina/health/check` itself, `$`-anchored, bypasses the classification block — a mesh hop, a sibling-bundle request), so ONE such call repointed every later worker-global read at the internal host: both `getUrl` filter branches (the path/bundle-override `hostname` composition AND the post-`getRoute` `proxy_hostname` override feeding `toUrl()`), the redirect-by-URL `getRoute(path).toUrl()` branch, and `throwError`'s route-object fallback. On a proxy-less fresh worker the same call CREATES proxy mode permanently (the composite latch arms once the global exists). Fixed server-side, NO dist rebuild (`getUrl` is not browser-bundled — the dist's `getUrl` hits are `getUrlProps`, a substring artifact): all four readers prefer THIS request's per-request slots (`req._ginaIsProxyHost` strict-`=== true` + `req._ginaProxyHostname`) over the worker-global, which stays the fallback for renders with no request of their own — a raw victim resolves the config host despite the latch, a proxied victim emits its own host, a slot-less caller is byte-identical; `core/router.js` fills the slots when ABSENT so the Express engine (which never set them) gets per-request truth engine-agnostically, isaac's earlier identical classification always winning. The opt-in key (settings.json + published schema; strict boolean, fail-safe false, boot-resolved once, `console.info` when armed) disables the port-less heuristic so ONLY X-Forwarded-Host classifies as proxied — the one mechanism that also protects req-less renders; enable it only behind a front proxy that always sends X-Forwarded-Host. Two mechanism corrections vs the field report that surfaced this: `PROXY_PORT` is boot-static (never request-refreshed; the boot scheme-switch forces it to 80/443, clobbering any proxy.json-hostname port), so a nonstandard port in an emitted URL comes from the victim request's OWN normalized `headers.port`/`[':port']`, never the poisoner; and moving container probes off `/_gina/health/check` onto app routes is what acquires the bug — nothing signals it. Pickup: restart (server-side only). Tests: `test/core/proxy-request-scope.test.js` (44, incl. a real `getRoute`/`toUrl` subtract reproducing the internal-host emission pre-fix). **Falsy proxy-host state degrades the URL builder, never crashes it (former #291, #B168, 0.6.0):** server-side `getRoute()` resolves `route.proxy_hostname` from the worker global with an envConf fallback, and BOTH are framework-produced falsy states (the global is boot-set only from a proxy config carrying a hostname and otherwise first written by a proxied request; the envConf fallback is deliberately written null for a direct-classified render) — with the worker-wide proxied latch true and both unset, the unguarded rewrite threw `TypeError: Cannot read properties of null (reading 'replace')` on EVERY server-side `getRoute()` while the state lasted: URL building for mail/workers, redirects and health checks alike (observed live: 72 identical failures through a readiness probe, healing in-process once a later render rewrote the envConf fallback). The truthiness guard falls back to the route's direct hostname AND flips `route.isProxyHost` false — `toUrl()` keys on that flag, so a bare skip would stringify the unset value into the emitted URL, converting a loud crash into silently wrong links; a once-per-process warning names the degraded state. The `url` template filters hold `getRoute()`'s own resolution and degrade only when nothing resolved anywhere (their per-request override used to re-force the flag with an unresolved value in slot-less renders, undoing the fix — and replacing a usable envConf-derived value with an unset one); the non-proxied branch's gate requires TRUTHY (the old `typeof` admitted a defined-but-falsy global — typeof null is 'object'). Browser bundle changed (`lib/routing` ships in it) with zero client behavior change — the client arm resolves from `window.location`, which cannot be unset. Rule: a framework-produced falsy state must DEGRADE the builder, and the degrade must flip the flag downstream code keys on. **#B367 (0.6.8) — those same headers are now VALIDATED WHERE THEY ARE READ, because until 0.6.8 they reached the browser unescaped and were a reflected-XSS vector.** `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Forwarded-Prefix` and the caller's own `Host`/`:authority` feed `page.environment.hostname` / `.webroot` / `.proxyHost` / `.proxyHostname`, which the client bootstrap (`gina.onload.min.js`) carries in SINGLE-QUOTED JS string literals (`hostname:'{{ … }}'`, `window.__ginaWebroot='{{ … }}'`). The substitution is `whisper(dic, layout, /\{{ ([a-zA-Z.]+) \}}/g)` — whisper's rule branch is `replaceable.replace(rule, (s,key) => dictionary[key] || s)`, a RAW splice that escapes nothing — and the loader is injected AFTER template compilation precisely so the engine never sees those tokens. So a single quote in one header closed the literal and executed attacker script on every rendered page, unauthenticated (reproduced live over HTTP on both the host and prefix vectors). The guard is at INGEST, not emission, and that choice is load-bearing: whisper runs over the WHOLE layout, so the same dictionary also feeds HTML contexts where a JS-string escape would emit literal backslashes; and the sibling values already safe (`forms`, `routing`, `validatorLabels`) use `encodeRFC5987ValueChars` + a client `decodeURIComponent`, so extending that pattern would have changed the browser bundle and billed every consumer a re-bake for a server-side flaw. A host must be `name[:port]` or a bracketed IPv6 literal (`/^[A-Za-z0-9._:\[\]-]+$/`, ≤255), a forwarded scheme must be exactly `http`/`https`, a prefix must be URL-path characters; anything else is REFUSED and the request falls back to the bundle's configured host/webroot as if the header were absent — including the classification itself, so a malformed `X-Forwarded-Host` no longer marks the request proxied. The guard is TWINNED in `core/server.isaac.js` and `core/router.js` (the same deliberate twinning as the classification above) and a test pins the two character classes EQUAL, because a drift makes one engine exploitable again. Known consequence: a comma-separated `X-Forwarded-Host` (chained proxies) now fails the guard and falls back — pre-fix it produced `scheme://a.example, b.example`, i.e. already garbage; splitting the list is deliberately NOT done, since element 0 is the original client's own value and taking it would reopen the vector, and any other element needs a trusted-hop count the framework does not have. Server-side only ⇒ pickup is a bundle RESTART, no re-bake. ⚠️ **Field reachability (consumer-reported 2026-08-15, and it INVALIDATED our own published check): `X-Forwarded-Prefix` is the header that actually exposes people, and the obvious audit question misses it.** We had told operators to ask whether their proxy SETS the `X-Forwarded-*` headers from its own knowledge or FORWARDS what the client sent. That question is a FALSE-NEGATIVE GENERATOR: a conscientious edge config sets `Host`/`X-Forwarded-Host`/`X-Forwarded-Proto`/`X-Forwarded-For` and never MENTIONS `X-Forwarded-Prefix` (a mount path is a concern most deployments never set deliberately), and nginx forwards any header it does not explicitly override — so the prefix travels verbatim while the other three are correctly replaced, and the operator answers "we overwrite them, we're fine" and is WRONG. Reported on a two-layer nginx topology (both layers setting Host + proto, outer setting X-Forwarded-Host, NEITHER naming the prefix — measured 0 with firing controls). **Verified first-hand here at the `v0.6.7` tag: the prefix block's only enclosing scope is `server.on('request', …)` — there is NO proxy-classification gate on it at all**, so the prefix is accepted whether or not the request counts as proxied, and correctly overriding `X-Forwarded-Host` cannot close it; the gap is sufficient alone. Ask the question PER HEADER ("is `X-Forwarded-Prefix` named in this config?") at EVERY layer, never as a blanket `X-Forwarded-*` question. Rule: a request header that will be interpolated into a client-side script is untrusted input at the READ site, and the cheapest correct place to reject it is where it enters — not where it is emitted. Second rule, from the reachability miss: when you hand operators a check for whether a flaw can reach them, make it name EACH input separately — a check phrased over a family of headers silently assumes the proxy treats the family uniformly, and proxies do not. **#B502 (0.6.28) — the LAST req-less reader now resolves per request, and the router twin stops rewriting the worker-global from direct requests.** Everything above re-pointed the framework's OWN callers; an APPLICATION-level `getRoute(…).toUrl()` — the form this file documents — still resolved from `getContext('isProxyHost')` (a latch that arms the moment any request writes `process.gina.PROXY_HOSTNAME` and never resets) plus that global. Measured on isolated two-bundle scenes: after ONE port-less-Host request, every later DIRECT request's `toUrl()` lost its port on an http/1.1 bundle (`http://127.0.0.1/web/echo/3` for a bundle on `localhost:9940` — isaac strips the port from `headers.host` BEFORE the router twin re-classifies, so every direct isaac request re-wrote the globals from the stripped host) and, on an http/2 + https bundle, emitted the LAST port-less client's host verbatim (`http://evil.example/…` after one `Host: evil.example` — attacker-choosable, and the shape a field report described). Fix in three parts: `server.js handle()` enters `process.gina._reqALS` on EVERY request in every log mode (#M12b had it JSON-only) with a `proxy` slot; `core/router.js` fills the slot from THIS request's stamps right after the engine-agnostic classification; `lib/routing getRoute()` reads the store FIRST and keeps the latch + worker-global only for req-less callers (boot, CLI, cron — no store). The router twin's `proxyReqIsProxied` now defers to an existing isaac stamp (the pre-strip truth), so direct requests no longer rewrite the worker-global; isaac's port-less scheme chain matches the twin's (X-Forwarded-Proto, then PROXY_SCHEME, then the bundle scheme). Both logger readers stay JSON-gated, so text mode gains only the ALS enter. `requireForwardedHeaders` closes the port-less-Host path only on earlier versions — the classification's `X-Forwarded-Host` term is ungated on both engines (`server.isaac.js:2188-2192`, router twin), so a client with direct reach could still supply the host; the complete interim was a proxy overriding both headers. `lib/routing` is browser-bundled ⇒ pickup is a bundle RESTART **and a re-bake** — the only dist hunks are the server branch's three lines and the client branch is byte-identical, so the re-bake keeps bundle-freshness honest and changes no client behaviour. Residual, by design: `PROXY_SCHEME` is the PROJECT's `def_scheme` (gna.js), so with no `X-Forwarded-Proto` an https bundle in an http-default project still stamps `http://` on both twins (#B506, LOW). Tests: `test/core/proxy-context-b502.test.js` (source pins on all three parts + a real-ALS replica of the resolution rule; red-first 6/4 on the pre-change source). **#B511 (0.6.29) — a path-form `getUrl` route containing `@` no longer 500s the render (both engines).** The swig and nunjucks `getUrl` filters split any `@`-bearing route as `rule@bundle` BEFORE testing whether it was a path, so `'/assets/img/common/header@2x.png' | getUrl` — the retina idiom the filter's own comment cites — became rule `/assets/img/common/header` @ bundle `2x.png`, failed the bundle lookup and called `throwError(500)` mid-template; `throwError` RENDERS (it never throws), the site is outside the filter's routing try, and nothing returns after it, so the page answered 500 while the action's own render was discarded by the released-response guard (measured live on an isolated prod boot: 500 → 200 with the extension intact after the fix, on swig and on a bare-template nunjucks scene). The guard adds one term to the split's condition — a route starting with `/` is a path, never a rule reference — at both sites. Unchanged: `'rule@bundle'` still splits, a passed base still wins over the in-string form, and a path that needs another bundle's host passes it as the base argument (`| getUrl(null, 'web')`) — the only form one measured consumer corpus used (zero bare calls). Disclosed consequence: a path-form route cannot name a bundle in-string. Neither filter lib is browser-bundled (`build.json` 0/0) ⇒ pickup = bundle RESTART, no re-bake. Tests: `test/lib/geturl-b511.test.js` (per-engine source pins incl. the guard's position BEFORE the path branch — the ordering IS the defect — and a predicate lifted from each engine's shipped condition, with the pre-fix decision as the subtract control). Generalises: a soft-failing filter can still hard-fail through a render-side `throwError` reached before its own try — pin the ORDER of the guards, not just their presence.
1019
- 277. **Render/output cache — pluggable backends, release-namespaced keys, event invalidation, flush, Cache-Status (consolidates former #234/#235/#237/#238/#242/#244; 0.5.18–0.5.22).** The framework caches rendered responses under two key namespaces — `static:` HTML + `data:` JSON — in the multi-purpose server Map (which ALSO holds `swig:` compiled templates + `http2session:` sessions: every bulk operation scopes to the output namespaces ONLY). CONFIG: bundle-wide defaults on `server.cache` (`enable` is the hard gate for writer AND reader; plus `path`/`ttl`/`sliding`/`maxAge`); `type`/`store` come from the bundle `config/settings.json` top-level `cache` block, folded into the runtime `server.cache` at config-load (env.json keys win; without the fold the documented default was inert); per-route via a routing.json rule's top-level `cache: {…}` with FILL-only inheritance — a per-route value always wins, and a route that sets `type` at all keeps it (only an OMITTED `type` inherits the bundle default). STRATEGIES: `memory` (inline content); `fs` (body file + a `.meta` sidecar carrying created/ttl/sliding/maxAge/headers/events; restart READ-BACK on an index miss with ABSOLUTE expiry preserved — a restart never extends a TTL; pure-sliding read-back counts as a fresh access, a documented imprecision); `redis` two-tier (L1 = the in-process Map written synchronously; L2 = shared redis via fire-and-forget `PSETEX` — the response never waits; `warm()` repopulates L1 with the AUTHORITATIVE remaining PTTL and re-registers the route's events so a warmed entry stays evictable; fail-open with one once-per-process warn + a 1s `commandTimeout` so a blackholed socket cannot hang the render hot path; a graceful shutdown leaves L2 INTACT — peer replicas and the restarting replica warm from it; every redis route needs a ttl or `invalidateOnEvents` — boot-enforced fail-fast, with proportionality: an `enable:false` bundle never opens a connection and would-be-fatals downgrade to loud warns). KEYS: one `buildKey` owns `[<token>:]<kind>:<bundle>:<url>` at every writer AND reader (the format was previously duplicated at 4 sites and drifted); the token (`GINA_CACHE_NAMESPACE || GINA_VERSION`, sanitized) release-namespaces every cache so a framework upgrade auto-invalidates, and the token is ALSO an fs PATH segment — a measured requirement, not cosmetics: with the token only in the key, a namespace change reused the same file path and read-back served stale cross-namespace bytes. EVENTS: routes register `cache.invalidateOnEvents`; fire from a controller via `self.cache.invalidateByEvent(event)` (a deliberately NARROW facade — it never exposes `from()`/`set()`, and reaches the OWN process only) or cross-bundle via `gina cache:clear @<project> --event=<name>` / `POST /_gina/cache/clear?event=<name>`; TTL/sliding expiry runs the SAME eviction as an explicit delete (cleanup fns fire + registrations reclaim), and fs/redis persistence re-registers events on read-back/warm. FLUSH: the CLI runs the offline fs reclaim FIRST (works with the bundle down; reclaims prior-release orphan dirs; never touches the reserved `config`/`swig` infra-cache dirs) then the in-heap endpoint (admin-gated, POST-only — a flush is a mutation a prefetch must not fire; `event` WINS over `bundle`); `--dry-run` previews the fs set + probes reachability read-only and deliberately claims NO count. READ PATH: engine-agnostic in the shared request handler (express included — previously isaac-only), so a fresh replica's FIRST GET can serve a peer replica's write; every hit carries RFC 9211 `Cache-Status: <name>; hit; ttl=N; detail=memory|redis|fs` (naming the physical tier that served the bytes) and every genuine miss `<name>; fwd=uri-miss` — `<name>` = `server.cache.name` when set to a valid token (#B238: a letter then up to 63 of `[A-Za-z0-9._-]`, a conservative RFC 8941 subset; an invalid value boot-warns and falls back), else the default `gina-cache`, resolved ONCE at boot (server.js stamps `instance._cacheName`; all mint sites on both engines read the stamp, so the identifier cannot disagree across engines or hit/miss) — and when `server.hidePoweredBy` is true + the cache enabled + no name set, boot warns that the wire still names the framework (warn-not-flip: the identifier is a documented-stable wire value — set any token e.g. `cache` to close the disclosure, or explicit `gina-cache` to silence); cached routes re-mint CSP nonces on hits (#B130 — header + body, stored entries never mutated). **A gated route is never cached** — pairing `cache` with `param.requireAuth`/`roles`/`policy` (or leaving it mode-gated) REFUSES the boot, and the delegates skip the write regardless: the key carries no principal and both serve points precede the authorization gate (#B158). Server-side only — none of this ever needs a dist rebuild. Tests: `test/lib/render-cache.test.js`, `test/core/server-render-cache-read.test.js`, `test/core/server-render-cache-boot.test.js`, `test/lib/cache-clear.test.js`; consumer guide: the docs-site caching guide. Since 0.7.1 the isaac PRE-ROUTING read runs only while `server.cache.enable` is on (the phase-2 per-request trims, slice H) — parity with the writers and the express-side read; an entry left on disk by a run that had caching enabled is no longer served while it is disabled.
1020
+ 277. **Render/output cache — pluggable backends, release-namespaced keys, event invalidation, flush, Cache-Status (consolidates former #234/#235/#237/#238/#242/#244; 0.5.18–0.5.22).** The framework caches rendered responses under two key namespaces — `static:` HTML + `data:` JSON — in the multi-purpose server Map (which ALSO holds `swig:` compiled templates + `http2session:` sessions: every bulk operation scopes to the output namespaces ONLY). CONFIG: bundle-wide defaults on `server.cache` (`enable` is the hard gate for writer AND reader; plus `path`/`ttl`/`sliding`/`maxAge`); `type`/`store` come from the bundle `config/settings.json` top-level `cache` block, folded into the runtime `server.cache` at config-load (env.json keys win; without the fold the documented default was inert); per-route via a routing.json rule's top-level `cache: {…}` with FILL-only inheritance — a per-route value always wins, and a route that sets `type` at all keeps it (only an OMITTED `type` inherits the bundle default). STRATEGIES: `memory` (inline content); `fs` (body file + a `.meta` sidecar carrying created/ttl/sliding/maxAge/headers/events; restart READ-BACK on an index miss with ABSOLUTE expiry preserved — a restart never extends a TTL; pure-sliding read-back counts as a fresh access, a documented imprecision); `redis` two-tier (L1 = the in-process Map written synchronously; L2 = shared redis via fire-and-forget `PSETEX` — the response never waits; `warm()` repopulates L1 with the AUTHORITATIVE remaining PTTL and re-registers the route's events so a warmed entry stays evictable; fail-open with one once-per-process warn + a 1s `commandTimeout` so a blackholed socket cannot hang the render hot path; a graceful shutdown leaves L2 INTACT — peer replicas and the restarting replica warm from it; every redis route needs a ttl or `invalidateOnEvents` — boot-enforced fail-fast, with proportionality: an `enable:false` bundle never opens a connection and would-be-fatals downgrade to loud warns). KEYS: one `buildKey` owns `[<token>:]<kind>:<bundle>:<url>` at every writer AND reader (the format was previously duplicated at 4 sites and drifted); the token (`GINA_CACHE_NAMESPACE || GINA_VERSION`, sanitized) release-namespaces every cache so a framework upgrade auto-invalidates, and the token is ALSO an fs PATH segment — a measured requirement, not cosmetics: with the token only in the key, a namespace change reused the same file path and read-back served stale cross-namespace bytes. EVENTS: routes register `cache.invalidateOnEvents`; fire from a controller via `self.cache.invalidateByEvent(event)` (a deliberately NARROW facade — it never exposes `from()`/`set()`, and reaches the OWN process only) or cross-bundle via `gina cache:clear @<project> --event=<name>` / `POST /_gina/cache/clear?event=<name>`; TTL/sliding expiry runs the SAME eviction as an explicit delete (cleanup fns fire + registrations reclaim), and fs/redis persistence re-registers events on read-back/warm. FLUSH: the CLI runs the offline fs reclaim FIRST (works with the bundle down; reclaims prior-release orphan dirs; never touches the reserved `config`/`swig` infra-cache dirs) then the in-heap endpoint (admin-gated, POST-only — a flush is a mutation a prefetch must not fire; `event` WINS over `bundle`); `--dry-run` previews the fs set + probes reachability read-only and deliberately claims NO count. READ PATH: engine-agnostic in the shared request handler (express included — previously isaac-only), so a fresh replica's FIRST GET can serve a peer replica's write; every hit carries RFC 9211 `Cache-Status: <name>; hit; ttl=N; detail=memory|redis|fs` (naming the physical tier that served the bytes) and every genuine miss `<name>; fwd=uri-miss` — `<name>` = `server.cache.name` when set to a valid token (#B238: a letter then up to 63 of `[A-Za-z0-9._-]`, a conservative RFC 8941 subset; an invalid value boot-warns and falls back), else the default `gina-cache`, resolved ONCE at boot (server.js stamps `instance._cacheName`; all mint sites on both engines read the stamp, so the identifier cannot disagree across engines or hit/miss) — and when `server.hidePoweredBy` is true + the cache enabled + no name set, boot warns that the wire still names the framework (warn-not-flip: the identifier is a documented-stable wire value — set any token e.g. `cache` to close the disclosure, or explicit `gina-cache` to silence); cached routes re-mint CSP nonces on hits (#B130 — header + body, stored entries never mutated). **A gated route is never cached** — pairing `cache` with `param.requireAuth`/`roles`/`policy` (or leaving it mode-gated) REFUSES the boot, and the delegates skip the write regardless: the key carries no principal and both serve points precede the authorization gate (#B158). Server-side only — none of this ever needs a dist rebuild. Tests: `test/lib/render-cache.test.js`, `test/core/server-render-cache-read.test.js`, `test/core/server-render-cache-boot.test.js`, `test/lib/cache-clear.test.js`; consumer guide: the docs-site caching guide. Since 0.7.1 the isaac PRE-ROUTING read runs only while `server.cache.enable` is on (the phase-2 per-request trims, slice H) — parity with the writers and the express-side read; an entry left on disk by a run that had caching enabled is no longer served while it is disabled. **#B750 (fixed for 0.7.2):** `serveRenderCacheHit`'s HTTP/2 branch assigned `res.headersSent = true` after `stream.end()` — `headersSent` is a getter-only accessor on BOTH Node response classes (it already reports a raw send), so in strict-mode server.js the assignment threw a TypeError after the body went out: every redis L2 warm over HTTP/2 (`detail=redis`) logged an unhandled rejection and lost its access line. Never assign `headersSent`; the same fix removed four strict-mode siblings (render-nunjucks HEAD and body over HTTP/2, render-xml over HTTP/2 and HTTP/1.1). A fake response with a plain writable `headersSent` cannot reproduce the throw, which is why the suites missed it: test on a real `Http2ServerResponse`. Tests: `test/core/h2-raw-send-b749-b750.test.js`.
1020
1021
 
1021
1022
  278. **A cache key is opaque DATA — never route it through an expression/condition evaluator.** An output-cache key embeds the request URL, so a querystring's `?`/`=` parse as OPERATOR tokens in any condition-DSL lookup — the render cache's event registry originally lived in a collection whose dedup ran keys through the condition evaluator, and the SECOND cache-miss render of any cached GET route with `invalidateOnEvents` + a querystring THREW (in the swig path that rejection unwound to the function-level try, whose catch answered 500 — discarding an already-rendered page). The registry became a plain array matched with `===`. Generalizes beyond caching: never let a value carrying user-controlled characters reach a query/condition DSL as an expression — match registry rows with `===` on plain structures, and treat every composite key as an opaque string.
1022
1023
 
@@ -1047,7 +1048,7 @@ Dev-mode query instrumentation captures every database query tied to the current
1047
1048
  298. **Render/request-path deep-clone & id-mint reduction (#P39 slice 1, 0.6.2; slice 3 = #P40, the `getConfig()` copy-on-write view, below) — the filter-factory options travel by REFERENCE, locale lookups are memoized, `conf.locales` is a lazy per-request accessor, and `lib/uuid` right-sizes its entropy batch.** The CPU-profiling baseline convicted per-request copy/id overhead as ~60% of render-arm CPU, 98.7% of the clone time in three caller-attributed paths; all three are gone. (1) `controller.render-swig.js` / `controller.render-nunjucks.js` hand `local.options` to the filter factory UN-cloned, and `lib/swig-filters` / `lib/nunjucks-filters` `getInstance()` stashes that wrapper by reference — the singleton's `_options` has no writer beyond the stash itself (every filter only reads it; the `getUrl`/`getWebroot` merge fills a fresh `{}` target), the request path already shares `conf.routing`/`conf.reverseRouting`/`conf.forms` by reference, and `req`/`res` always passed by reference anyway (the cloner bails on non-plain constructors) — so reference-passing adds no new mutation-visibility class and the stash-then-await interleave window is byte-identical to the cloned era. Anything that MUTATES what it reads from the filter context must clone its own copy first (the pre-existing contract; `getRoute()` already does). (2) `setOptions` no longer builds two Collections per request over the boot-static region sets (each was a deep copy of ~500 nested records plus one 16-char id minted per record ≈ 750 webcrypto calls, paid by EVERY request in a views-bearing bundle, render or not): a module-level per-culture memo answers the language → region-set and country → row lookups; `conf.locale` is a per-request deep copy of the ONE resolved row (isolating the `.date` write and template mutations); `conf.locales` is a LAZY self-replacing accessor — the request's own deep copy materializes on first read (sole framework reader: `self.getLocales()`), so the common request pays nothing while whole-conf clones and serializers materialize it transparently, and assignment writes through. (3) `lib/uuid` sizes its `getRandomValues` batch to the REQUESTED id length (the fixed 7-byte batch was sized for the 4-char default, so `uuid(16)` — the form `lib/collection` mints per item — needed ~3 webcrypto calls; now 1; each call pays a full randomFillSync regardless of byte count, so call count is the cost). (4) The cloner's dead per-recursion `Object.keys` allocation is removed. Measured A/B on the profiling harness (fresh runs both sides, same knobs): render wall 32,186 → 7,268 ms for 3000 requests = 4.4× throughput (10.7 → 2.4 ms/render), deep-clone share 50.31% → 1.81%, GC 2,522 → 137 ms, randomFillSync out of the top-15 frames on both arms, upload-arm clone floor 253 → 11 ms — the pre-stated ≈2× ceiling was beaten because allocation-pressure knock-on (GC, locality) fell with the clones. `lib/uuid` is AMD-bundled, so the browser bundle changed: consumer pickup = restart AND per-bundle re-bake. `Collection`'s own eager per-item `_uuid` minting is UNCHANGED (its only consumers are collection-internal; `toRaw()` strips). **Slice 3 — `getConfig()` no longer deep-clones (#P40, 0.6.x): both forms return a per-call COPY-ON-WRITE VIEW (`lib/conf-view`, registered as `lib.confView`).** Reads pass through to the shared conf at no copy cost, a write lands in the view's private overlay (never in the live conf, never visible to another call), and the first enumeration of a node (`Object.keys` / `JSON.stringify` / `for…in` / spread / `JSON.clone`) materialises that subtree once into a plain private copy; identity is stable, and two paths to one shared object give one view node, so `conf.settings === conf.content.settings` is PRESERVED where the clone silently split it; non-plain values pass through by reference exactly as the cloner did; a frozen child is copied once into the overlay, a frozen root yields a plain deep copy; an accessor runs against its own object (the lazy `locales` getter keeps working). Measured on the profiling harness — a route behind a middleware pair whose constructors each read `this.getConfig()`, the shape a consumer production profile convicted — 14,719 → 1,884 ms for 3,000 requests, deep-clone share 72.9 % → 3.1 % (the bare form cloned a 445 KB resolved conf on a minimal scaffold, megabytes on a large bundle; middleware construction itself was ACQUITTED by a `json-mw-noconf` split arm). Three things a clone allowed do not work on an UN-enumerated node: `structuredClone(view)` throws `DataCloneError`; `Object.freeze` / `seal` throw `TypeError` (enumerate the parent first — the node is then a plain copy); `console.log` / `util.inspect` render the shared values rather than the overlay (property reads and `JSON.stringify` are truthful). One divergence, bounded by the proxy invariants: a non-configurable own property of the shared tree refuses delete / redefine, and a write when it is also non-writable (an array's `length` takes writes; no conf node carries such a property). Opt-out per bundle: `settings.json > controller.getConfig.mode: "clone"` (read per call) restores the historical deep clone — the two `JSON.clone(...)` literals stay in the clone branch. Rule for a caller: a view is yours to read, mutate and serialise; enumerate before you freeze; opt into `clone` only for `structuredClone`. Server-side only ⇒ restart, no re-bake. Tests: `test/lib/conf-view.test.js` (30 arms on the shipped module incl. a subtract control) + `test/core/controller-getconfig-view.test.js` (comment-stripped pins incl. the extraction-boundary control; arms compiled from the source with the real lib; a real instance through the registry; red-first 12 red / 12 green against the pre-#P40 bytes). Remaining clone sites (hot route-matching-loop clones, `getRoute`-per-`getUrl` filter call, `getParams` per-call clone, cssColl/jsColl construction, async-delegate store clones) stay measure-gated for a future profile. **Phase-2 per-request trims (0.7.1; measured on the json profile scene at 281–556 µs busy per request depending on host load — read the paired deltas, never absolutes):** (F) `helpers/context.js` binds `lib/merge` once at module scope — `setContext()` and the global `getConfig()` resolved it with a relative `require` on EVERY call (3 µs and one `Module._resolveFilename` each, two calls per routed request; Node's relative-resolve cache never short-circuits the shape, because a production boot loads the module through `lib/logger` first and a cache hit never primes the helpers-directory pair). (G) `core/router.js` probes a bundle's `controllers/setup.js` once per bundle in production — `resolveSetupFile()`, memoized by bundle name; dev keeps the per-request probe its eviction depends on — where the per-request probe cost an `accessSync` throw plus an `lstatSync` throw whenever the file was absent. (H) `core/server.isaac.js` looks the output cache up only while `server.cache.enable` is on (every writer already refused to store while it is off, and `server.js`'s engine-agnostic read gated on the same flag): the production-unconditional lookup cost two `existsSync()` per GET and could serve an entry left on disk by an earlier run that had the cache enabled. (#B703) `helpers/path.js` keeps no path registry: `helpers/index.js` calls `PathHelper()` WITHOUT `new`, so in that sloppy-mode constructor `this.paths = []` was `global.paths`, which every `_()` extended with each distinct normalized path for the process lifetime (two per distinct URL through the output cache's file paths, query string included) and which every `_()` and every PathObject `toString()` scanned with `indexOf` (103 µs per unseen path at ~52k entries, measured); nothing read it. `toString()` and `toUnixStyle()` return the value exactly as before; `toWin32Style()` now converts on every call, where the registry returned a value it had not recorded — one whose trailing separator `mkdir()`/`rm()`/`isValidPath()` stripped — with forward slashes. Trap: a helper invoked without `new` writes its `this.<field>` onto the global object, so read the call site before treating a `this.` field as per-instance state. (E) The three per-request Config resolutions — `resolveRouteConfig` (`core/router.js`), `hasViews` and `loadBundleConfiguration` (`core/server.js`) — read `Config.instance` once `Config.initialized` is set: each `new Config().getInstance()` built a throwaway instance only to hand back the singleton's envConf (or the singleton) and re-set Env/Scope/Host on it to the values they already held (getInstance() runs once at boot). The old calls stay as the fallback — a worker's context-merged instance leaves `Config.initialized` unset, and #B542's refusal after an aborted init still surfaces — and `hasViews` consults its per-bundle memo before resolving anything. (B) `setOptions`' page.environment block — which runs on EVERY routed request of a bundle with views, JSON routes included — computes three per-process values once: the encoded `page.environment.forms` export is memoized in a module-scope WeakMap keyed by the forms catalog object (the catalog is loaded once per bundle and the framework never writes it — the validator and `getFormsRules()` work on clones, `getConfig()` hands out copy-on-write views; re-encoding it cost ~5 µs per KB, measured 587 µs per request on a 118 KB catalog vs 0.008 µs for the hit), the `memory allocated` heap-limit label is computed on the first render (it cost a `require('v8')` + getHeapStatistics() per request), and one `isoDateTime` stamp feeds both `page.environment.date.now` and the locale's `date.now`. The forms lookup sits behind a `typeof` guard so the block still runs when a harness compiles it on its own; `page.forms` holds the catalog by reference, so render data that deep-fills it no longer reaches later requests' export. (D1) `set()` — the dotted-path render-data setter, ~60 calls per routed request of a bundle with views — tests for a dot with `name.indexOf('.') !== -1` and splits with `name.split('.')` instead of `/\./.test()` and `split(/\./g)`: the same answers for every string (`split` ignores the `g` flag), −4.8 µs per request measured in the profile scene; `test/core/controller-set-path.test.js`'s differential against the frozen pre-#P39 `set()` keeps certifying the behaviour. (A) `lib/inherits`' composed constructor copies nothing onto the instance: no own `prototype` property (it was enumerable — visible to `Object.keys()` / `JSON.stringify()`), no `this.prototype.name = this.name` stamp onto a SHARED prototype per `new` (in a nested chain it wrote the child's name onto the PARENT's prototype), and no `for…in` own-copy of the parent prototype's falsy members (EventEmitter re-sets its three as own anyway); `if (this)`, the `name` default, `b.apply` then `cache.apply` are kept, so a call without `new` (the validator's is-alias) still works. Measured censuses: 0 readers of an instance's own `prototype` and 0 falsy prototype data props in the framework or a large consumer; entity names still resolve because every connector stamps the COMPOSED class's prototype after `inherits()`. Browser-bundled: prod dist rebuild, consumer pickup = restart AND re-bake. −13 µs per request at the design scale. (C2) A bundle's resolved env configuration stays in V8 fast mode: `%HasFastProperties` probes along `loadBundleConfig` found TWO dictionary triggers — `merge(files, conf[bundle][env])` (the env keys poured into the non-empty `files` target by keyed stores) and, masked until that one is undone, `delete conf[bundle][env].tmpSettingFileContent` (a delete of a non-last property). Only the final shape matters at request time, so the delete is a rebuild without the key (object rest) that re-points `files` and `conf[bundle][env]` together — they are one object from the merge until `files = whisper(…)`, and only named stores follow — BEFORE `secrets.resolve()`, which keys its resolved-paths WeakMap by this very object. The router's per-request `Object.assign({}, conf)` fell from ~20 to ~0.2 µs in the profile scene. Rule: a keyed store past ~17 keys into a non-empty object, or a `delete` of a non-last key, puts an object in dictionary mode for good — rebuild it once (spread / rest) after the last structural change, never per request.
1048
1049
  299. **lib/math checkSum helpers - dispatch & serialization contract**: `checkSumSync(filenameOrData, algorithm, encoding)` (defaults md5/hex) accepts a string, object, or array; objects/arrays are serialized first - a plain object becomes sorted `key:value` pairs joined with commas (function properties skipped), an array becomes the JSON of a sorted copy (order-insensitive; the input array is never mutated). A string ending in an extension shape (a dot + 1-10 alphanumerics) is PROBED as a filename, and the file branch is taken only when the path resolves to an existing regular file (stat-gated): a stat miss (no such entry, name too long, not-a-directory path, NUL-carrying string) or an exists-but-directory entry falls through to hashing the input as DATA, so serialized records ending in `.com`/`.net`/`.pdf` return checksums instead of throwing ENOENT/ENAMETOOLONG (#B209; until 0.6.2 they threw, with the caller's data named as a path in the error), while an EXISTING unreadable file (EACCES) still throws. Until 0.6.2 every ARRAY input also collapsed to the checksum of the empty string - all arrays collided at one constant hash - and sort() reordered the caller's array in place (#B208, fixed alongside; previously stored array checksums were the degenerate constant and change by construction). The wide probe shape is #B210 (fixed alongside): before it only dot+3-lowercase tails fired, so paths like `file.js`, `file.json`, or `FILE.TXT` hashed as PATH STRINGS - stored sums for such paths change on upgrade (they never tracked content); an extension-LESS path (`Makefile`, `LICENSE`) still hashes as a data string, never read from disk - pass file content (or an extension-bearing path) when you mean the file. The async sibling `checkSum(filename, algorithm, encoding, isCheckingFromData, cb)` routes to the file branch on ANY dot in the input unless `isCheckingFromData` is true. `operate(expression)` evaluates arithmetic strings via a shunting-yard parser (`+ - * / %`, parentheses, decimals, unary sign) and throws on any non-arithmetic character - no eval / new Function (#SCS1).
1049
1050
  300. **Pluggable object storage (`lib/storage`, #STO1)** — an **adapter** (where bytes live) crossed with a **strategy** (how keys are laid out), behind one callback-shaped contract: `put(stream, meta, cb)` -> `{key, size, contentType}` / `get(key, cb)` -> stream / `stat(key, cb)` -> meta|null / `release(key, cb)` / `resolve(key, cb)` -> `{kind:'path'|'inline'}` / `capabilities`. Slice 0 ships the `local` adapter + `sharded` strategy (`YYYY/MM/DD/<ulid><ext>`); the cas slice adds the `cas` strategy (its own block below) and the stream slice the `stream` strategy (**its own entry #302** — large sequential media + resumable uploads); `getRange(key, start, end, cb)` is present on ALL THREE local strategies with `capabilities.ranges` true — `end` is INCLUSIVE (matching the HTTP header and `createReadStream`), an over-long `end` CLAMPS, and only a `start` at/beyond the object size errors (the caller's 416); it answers from either tier, and under `cas` a released blob stays invisible to it. The serving half now ships too — `self.serveFromStorage(driverName, key, opts)` (its own entry) answers 206/416/304 with `Accept-Ranges`/`Content-Range` on both engines, gated on `capabilities.ranges`, so the flag is finally consumed by the framework itself. The `s3` adapter SHIPPED (its own entry #304): `capabilities.offload` has its first `true` and its first framework consumer (`serveFromStorage`'s presigned-307 path) in the same release, so consumers must BRANCH on `capabilities` rather than assume — every capability flag has now flipped at least once. **Keys are OPAQUE**: store what `put()` returns, never parse or compose one, which is what lets a later strategy change the layout without breaking stored references. Config is `settings.json > storage` = `{default, drivers:{<name>:{adapter, strategy, root, maxObjectSize, store}}}` — drivers NEST under their own key (the `upload.groups` shape) so a driver may legitimately be called `default`. Reached as `gina.storage()` (the `default` driver) or `gina.storage('<name>')`; the accessor is assigned UNCONDITIONALLY so an unconfigured bundle gets a named `[storage] not configured` error instead of `gina.storage is not a function`. **Metadata rides a connector seam** — `lib/storage-store.js` is the 5th member of the `job-store`/`audit-store`/`render-cache-store`/`session-store` dispatcher family (connectors.json entry name -> `.connector` -> `core/connectors/<c>/lib/storage-store.js`, throw-on-unresolvable = boot abort, and the dispatcher passes the DRIVER NAME through so two drivers may share one connectors entry); the default is an embedded SQLite file at `<root>/.meta.db`, documented **single-process-per-driver-root** (SQLite locking on a shared network FS is the known-broken case the seam exists for). **`couchbase` is the first connector store** (`core/connectors/couchbase/lib/storage-store.js`, SDK major 3|4 from the PROJECT's node_modules) and is the supported answer for a shared root — several bundles or replicas on one driver root: one JSON doc per row keyed `<prefix><driver>:<key>`, with `d`+`k` as the sargable discriminators every N1QL query filters on. Per-key atomicity is Couchbase's own CAS — every refcount verb is a `get` -> `mutateIn(...,{cas})` loop (bounded retries, then a coded `GINA_STORAGE_CAS_CONTENTION` error), and `removeIfZero` is a CAS-guarded `remove` whose mismatch IS the resurrection signal. **That atomic claim IS the multi-process sweep coordination** — concurrent sweepers are safe because exactly one claim per blob wins, so NO election layer ships (one would also make `storage:gc` report `collected:0` on a non-holder); the seam's residual is unchanged and is put-vs-sweep, never sweeper-vs-sweeper. The inline payload rides as **base64 inside the JSON doc, never a binary document body** — a binary value cannot be parsed, indexed or queried by Couchbase, which would make `listZeroRefs`/`stats` impossible, and refcount+payload must sit under ONE CAS (cost: +33%, so an `inlineThreshold` above ~14MB overflows the 20MB doc ceiling). `acquireRef` REFUSES to adopt an existing non-refcounted row (a `sharded` row or the `.driver` stamp) instead of silently stamping a count onto it. Two secondary indexes (`gina_storage_refs (d,refs,zeroAt)` / `gina_storage_keys (d,k)`) are probed via `system:indexes` and created when missing — a refusal is non-fatal but LOGS THE EXACT DDL, because an unindexed query ERRORS rather than merely running slow and the sweep's bare call would swallow it forever. ⚠️ **A statement alias that is a N1QL RESERVED WORD is refused outright by the server, so the whole verb fails to PARSE** — `stats` shipped aliasing `AS inline` and `INLINE` is reserved (measured on BOTH the 7.6 and current reserved-word references, so this was never version-specific), fixed by backticking that one alias (#B356); the other four aliases are not reserved and stay bare. **The class is INVISIBLE to this store's own suite by construction** — the tests drive a fake SDK that pattern-matches statement SHAPES and never parses N1QL, so no amount of coverage there can see a reserved word — which is why the guard is a LEXICAL pin over the captured statements carrying its own can-fail control (it must flag the pre-fix statement and clear the backticked one). Real-cluster verification against a four-node **8.0.2** deployment (the store was designed against 7.x, so this is the first 8.0 datapoint): the `system:indexes` probe and both index shapes work, the durability map matches the SDK's members, a stale-CAS `mutateIn` raises `CasMismatchError`, 32 concurrent `acquireRef` calls across 4 processes on one key yield ONE blob at refs 32, and concurrent sweepers claim exactly once over 5/5 trials with the winner varying — confirming the no-election-layer decision. ⚠️ One operational consequence of `listZeroRefs` being a GSI query: a just-zeroed blob can be invisible to the sweep briefly, so a test that zeroes and immediately sweeps may legitimately see nothing (the grace window covers it; the next pass collects). Optional `durability` (majority|majorityAndPersistToActive|persistToMajority) rides every mutation; default is the SDK's own level, the same honesty class as the embedded store's WAL `synchronous=NORMAL`. **Security, all boot-enforced or per-call**: a driver `root` inside ANY bundle's web-served tree is a boot FATAL — and the check reads `publicPath` **plus every `content.statics` target**, because a statics mapping can point anywhere on disk, so checking `publicPath` alone leaves a hole; the metadata DB living inside the root is safe only BECAUSE of that fatal. Per-call key handling runs three guards **in a load-bearing ORDER**: confinement FIRST (a lib-local `confineToBase` copy — the engine copies are closure-private), THEN canonical-form, THEN reserved dot-segments. Reversing the first two silently shadows the security boundary: `..` starts with a dot, so a reserved-check-first ordering answers every traversal attempt with "reserved", `confineToBase` is never reached, and a traversal test passes for the wrong reason — measured and fixed during the build. Non-canonical keys (`a/../b`) are refused because they would address one file under two metadata keys; `.tmp` / `.meta.db` are refused because they are reachable without leaving the root. **Write path** = the #B223 discipline generalised: stream to `<root>/.tmp/` then publish by `rename(2)` (atomic — a reader never sees a partial; same root ⇒ same FS ⇒ no EXDEV), error listeners armed AT STREAM CREATION on BOTH streams (#B143 — `.pipe()` returns the destination, so one chained listener covers only one, and an unlistened `'error'` takes the bundle down), a settled latch (`close` also follows `error`), the REAL error propagated (never a fabricated one), size measured from the PUBLISHED file (not an in-flight counter), and a metadata-write failure rolls the object back so a caller that got no key is never left with unreferenceable bytes. Durability is BY STRATEGY since the cas slice: `sharded` writes are NOT fsynced (disclosed in the module JSDoc the way `lib/audit` discloses its own tail), while `cas` publishes fsync by DEFAULT — the temp file HARD before the rename (a failure fails the put), the parent directory BEST-EFFORT after it (platforms that cannot fsync a directory — Windows, some network mounts — skip that half silently; macOS honours fsync as the platform defines it, F_FULLFSYNC being native-addon territory gina does not ship). The first fsync anywhere in gina — dir-fd fsync measured working on darwin under both runtimes before shipping. **Size tiering (the tiering slice)**: objects strictly UNDER a driver's `inlineThreshold` (unit-suffixed string, default `"64KB"`, `"0B"` = off per driver) are stored INLINE — one metadata-store transaction carrying the payload as a binary-safe `data` Buffer on the row, no temp file, no directories, no filesystem at all — while at-or-above objects take the file path; the crossing is decided mid-stream by buffering the head (memory bound: threshold + one chunk per in-flight put) and spilling byte-order-exact into the temp+rename path the moment the running total reaches the threshold. The key shape is IDENTICAL in both tiers (keys stay opaque); `resolve()` answers `{kind:'inline'}` (bytes via `get()`, which serves the row as a stream), `stat()` strips the payload, `release()` needs no special casing, and `capabilities.inline` is true iff the threshold is active. Reads are payload-presence-driven, NOT threshold-driven, so changing the threshold is retroactively safe — existing objects stay readable on either side, and the embedded store migrates a pre-tiering database in place (additive `data BLOB` column, idempotent). 64KB is the MEASURED knee, not folklore (2026-08-13 bench on the shipped stack, sequential puts: inline 10.8-12.9x faster at 1KB, 8-8.5x at 4KB, 5.7-6.6x at 16KB, 2.7-3.3x at 64KB, destabilizing above — on APFS/NVMe, the case MOST favourable to the file path, so slower/network filesystems widen the win). Inline durability is the same class as the un-fsynced rename path (WAL + synchronous=NORMAL: a crash can lose the last committed transaction, never a torn row). Two operational facts stated rather than implied: the metadata store becomes PAYLOAD-BEARING (losing it loses sub-threshold objects, not just their metadata — file-backed bytes survive an index loss; any root-level backup still carries `<root>/.meta.db`), and sub-threshold objects are not individually visible on disk (the `sharded` SSH-browsability rationale holds only at-or-above the threshold). A connector-backed store inherits payload rows, so binary-safe round-trip is part of the SEAM contract, not implementation detail. Direct factory callers get tiering OFF when the key is absent — defaults resolve in `start()`, the `maxObjectSize` pattern, which is what keeps the adapter's low-level tests pinned to the file path. **The `cas` strategy (the cas slice): the key IS the content address** — `blobs/<algo>/<aa>/<bb>/<hex>`, extension-less BY CONSTRUCTION (an extension would derive from the untrusted `originalName`, so identical bytes under different names would mint different keys and silently break dedup; `contentType` lives in the metadata row) — so two puts of identical bytes return the SAME key with refs=2 and `deduplicated:true` on the second result, metadata first-write-wins. `release()` DECREMENTS and never deletes bytes; a blob reaching 0 refs is stamped and reads as ABSENT through get/stat/resolve/findByDigest (the grace window is GC detail, not a caller-visible afterlife), while a re-upload inside the window resurrects it for free. The GC sweep (per-driver unref'd setInterval — the lib/job precedent, `lib/cron` stays dormant; `sweepInterval` default '15m', '0s' = off; `sweepGrace` default '1h'; both REASONED defaults, not benchmarks — they gate reclaim latency only) collects blobs at 0 past grace in a load-bearing ORDER: claim the row FIRST (`removeIfZero` — a guarded delete a concurrent `acquireRef` resurrection always beats), unlink the file SECOND. The inverse order can LOSE BYTES under a racing dedup-hit put (it would discard its temp against a row whose file just vanished); this order's crash window merely orphans an UNREFERENCED file — invisible to every verb, harmless, `storage:verify` territory (the CLI/maintenance slice below) — and a dedup hit whose blob file is missing (exactly that residue) HEALS by renaming its own temp in. In-process the sweep-vs-put race cannot interleave AT ALL (the embedded store is synchronous, so acquire→check→publish and claim→unlink each run in one uninterruptible JS turn); the multi-process residual belongs to future connector stores and is documented on the seam contract. `findByDigest(algo, hex, cb)` -> key|null is the ONLY sanctioned digest→key door (keys stay opaque even though cas keys LOOK parseable) and is a DEDUP ORACLE — "someone already uploaded this exact file" is an information leak across users — so it ships as a driver verb ONLY, no HTTP endpoint: a consumer exposing one owns auth and per-driver scoping. Algorithm agility: `hash` (default 'sha256') is per-driver config validated at boot against THIS runtime's `crypto.getHashes()` — an unavailable name is FATAL, and the capability is runtime-scoped (measured: sha256/sha512 on both supported runtimes, blake2b512 Node-only) — and since the algo is a path segment, a hash CHANGE is additive (the old namespace stays addressable and findByDigest-able under its own algo, dedup does not span namespaces, the boot notes the change once and restamps) while a STRATEGY change on populated storage is a re-key migration, which is why `start()` now stamps every driver root (a reserved `.driver` row whose stamp rides the store's binary-safe `data` slot) and warns EVERY boot on mismatch without restamping — surfaced through the new `opt.warn` hook (a callback, not a logger import; `start()`'s boolean return is unchanged). The refcount machinery is four OPTIONAL seam verbs (`acquireRef`/`releaseRef`/`listZeroRefs`/`removeIfZero`) the embedded store implements atomically-by-synchrony from plain UPDATE/SELECT/INSERT — deliberately NO `RETURNING`, which sits outside the Bun sqlite adapter's measured surface — plus additive idempotent `refs`/`zero_at` migrations; a cas driver REFUSES to build over a store lacking the verbs (boot fatal), and `refs` appears on get()/stat() output only for refcounted rows (the `data` pattern, so non-cas rows keep their exact shape — `set()` must never touch a refcounted row, REPLACE would reset the count). Config keys are PER-STRATEGY (`STRATEGY_KEYS`: `hash`/`fsync`/`sweepInterval`/`sweepGrace` are consumed by cas and ignored-with-a-warning under sharded; durations parse via `parseDuration`, unit-required exactly like the size keys — '15m' means minutes here and megabytes there, which is why neither parser guesses). The sharded write path, key shape and durability are byte-for-byte unchanged, and size tiering applies under cas exactly as under sharded — an inline cas blob is a refcounted row carrying `data`, dedup included, collected by row-deletion alone. `maxObjectSize` requires an EXPLICIT unit (`"50MB"`); a bare number warns and falls back to the default, deliberately stricter than `settings.upload` where a bare number means MB for back-compat — `upload` already reads a bare number as a count (`maxFields`) in one key and as MB (`maxFieldsSize`) in another, so there is no house meaning to inherit and guessing would be the bug. **Framework independence is the structural contract**: `lib/storage` imports no gina core, no `lib` registry, and no injected globals (`getContext`/`getConfig`/`_()`), so the web-served roots are INJECTED into `validateConfig` rather than read; `lib/sqlite-driver` is the ONE sanctioned import and an exact-count test pins it so a second exception cannot arrive as drift. `validateConfig(block, {servedRoots})` is pure and enable-agnostic (the `lib/render-cache` shape): it lints unconditionally and returns `{fatal, warnings, driverCount}` — the CALLER owns aborting, and the three semantics often misattributed to the validator (lint-always / build-only-when-enabled / fatal-downgrades-to-warn) all live in the boot block, not in it. Boot wiring sits in `gna.js` inside `server.on('started')`, i.e. **POST-listen**, so a storage fatal exits on an already-bound port exactly as the #RC4 and #AI6 blocks do; a pre-listen refusal would mean moving the lint into `core/server.js` `init()` where the audit trail does it. **Slice 1 binds the upload path (the #B224 resolution vehicle): a settings.json upload.groups.<name>.driver key routes that group's self.store() step through the named driver.** store() partitions per file on the RESOLVED group (req.files[].group — which since #B140, same arc, carries untagged instead of undefined for untagged parts; caller-synthesized lists normalize the same way): files in driver-less groups keep the historical move path byte-for-byte (same entry shapes — no new fields, same false success sentinel, same abort-on-first-error), while routed files stream from their parse-time staging path into driver.put() sequentially (abort on first failure, NO rollback of already-published objects, ENOENT-tolerant post-publish source unlink — the mover's exact semantics) and their result entries carry {file, group, driver, key, size, type, encoding} with NO filename: key is the opaque reference, size is the layer's own measurement of the published bytes, reads go through gina.storage(driver). target may be null ONLY when every file routes (a move-path file with no target errors naming the file + group; the target dir is only created when a move-path file exists). Boot lint: gna.js collects every bundle's group→driver bindings as NEUTRAL {owner, driver, path} tuples into validateConfig's context.groupBindings — a binding naming an undefined driver (or ANY binding when no storage/drivers block exists) is FATAL; path BESIDE driver is LEGAL (for a routed group path is only the parse-time staging dir — the multipart landing behavior is unchanged) but a path INSIDE the driver's root warns (staging into the store tree strands files no key references). Parse-time enforcement (#B50 gate, allowedExtensions, isMultipleAllowed, maxFields) is untouched; a routed file additionally meets the driver's maxObjectSize at put() time. Riding the same arc, #B224 resolved as retire+declare: the scaffold template's inert advertised keys (filePrefix/subFolder/per-group maxFieldsSize — and the inert block-level encoding, which busboy ignores in favor of its hardcoded utf8 param decoder) are retired, the three false per-group-redefinability comments corrected, and schema/settings.json now declares the REAL upload key set at both levels (additionalProperties stays true at BOTH — apps legitimately carry their own group keys, e.g. app-side composition). **The CLI/maintenance slice: `storage:stats` / `storage:gc` / `storage:verify` + the always-on admin-gated `/_gina/storage/{stats,gc,verify}` endpoint family.** The spine: who touches the store depends on who OWNS it (the embedded store is single-process-per-root), so each command sends its own request to the bundle's assigned ports (the cache:stats candidates walk — advance on ECONNREFUSED): a RESPONSE means RUNNING and the owning process did the work via the endpoint (engine-agnostic `server.js onRequest()` ONLY — the isaac listener falls through via its cb, the `/_gina/agent` precedent; `app.json admin.allowFrom` gate, ^-anchored patterns per the release-watch lesson, NO dev gate; stats/verify GET, gc POST — a gc pass is a mutation; the handlers read `?driver=`/`?dryRun=` from `request.originalUrl`, because isaac strips the query from `request.url` before its cb hands the request to `server.js` — until 0.7.1 a RUNNING isaac bundle ignored both, so `storage:gc --dry-run` ran a REAL collection and `--driver=` scoped nothing (#B710); the URL tests stay on `request.url`); every-port-ECONNREFUSED means DOWN and the CLI resolves the bundle's raw `settings.storage` (requireJSON — scaffolded configs carry comments) and builds the REAL driver through the boot's own exported seams (`_resolveDriverConf`/`_FACTORIES`/`_createEmbeddedMetaStore`, `sweepInterval:0`; a missing root is reported, never mkdir'd — a stats read must not mutate); a TIMEOUT or any other socket error opens NOTHING (the bundle may be alive and owning the store). Connector-backed stores have no offline path — named-and-skipped ("nothing local to open; start the bundle and re-run"). New module/driver surface: `lib.storage.list()` (built driver names, `[]` before start — the enumeration door, so callers need no isStarted() pre-check); `stats(cb)` on BOTH strategies → `{name, strategy, root, capabilities, store}` with `store` = the metadata store's OPTIONAL `stats()` aggregate `{objects, refcounted, zeroRefPending, inline, bytes}` (reserved dot-key rows excluded — the `.driver` stamp must not read as an object) or `null` = "store reports no stats"; cas `sweepNow([{dryRun}], cb)` — `_sweepOnce` PROMOTED to a documented door, the underscore alias kept for the test seam — → `{collected|collectable, drained}` where `drained` answers "anything older-than-grace left?" so gc DRAINS by looping the batch-capped pass (hard-bounded at 1000 passes) instead of guessing the cap, and dryRun lists without claiming a row; cas `verify([{fix}], cb)` — the files↔rows scan, BOTH directions age-gated past `sweepGrace` (a young file may be in-flight work; a young row sits inside put()'s acquire→rename window, where a row legitimately precedes its file): `file-without-row` (the sweep's crash residue — FIXABLE, `fix` unlinks; OFFLINE-ONLY: the CLI refuses `--fix` while the bundle runs and the endpoint never parses a fix flag, making the refusal structural) vs `row-without-file` at refs>=1 (LOSS EVIDENCE — reported, NEVER auto-fixed: deleting the row would destroy the signal; zero-ref rows are skipped — the sweep's claim + ENOENT-tolerant unlink self-heals them, so reporting would flap); the findings LIST is capped at 1000 with exact counts + a truncation flag (a silent cap would read as "that was all of them"); the rows direction rides the second OPTIONAL seam verb `listKeys(afterKey, limit, cb)` (key-ordered cursor pages, dot-keys excluded) — a store lacking it degrades verify to files-only (`rowsChecked:false`). `sharded` has no sweep and no verify in v1 — named-and-skipped, never an error; gc/verify against a root shared by several bundles are idempotent (same result from whichever bundle). CLI grammar is the cache family's: `<bundle> @<project>` or all-bundles, `--driver=`, `--format=json` (fs.writeSync flush-before-exit), `--dry-run` (gc), `--fix` (verify); `storage:` joined bin/cli's `allowedOffline` (the hard gate — a topic dir alone is unreachable, the dispatch itself is pure `lib/cmd/<topic>/<action>.js` convention). ⚠️ **The metadata store NEVER invokes a caller's callback from inside its own `try` (#B565).** Seven methods (`set`, `remove`, `acquireRef`, `releaseRef`, `listZeroRefs`, `removeIfZero`, `listKeys`) used to, and the consequence was twofold: the store's `catch` SWALLOWED an error the application threw, then re-invoked the SAME callback with that error dressed as a store error. Any caller that latches on first settle — `local-cas` `verify()` does — read the re-entry as a no-op and never completed, so the operation HUNG rather than reporting; the caller saw neither a result nor an exception. `get()` always had the correct shape and is the template: the `try` wraps ONLY the SQLite statement, the callback runs after it on every path, and a genuine store error still arrives as `fn(err)`. Pinned by `test/lib/storage-meta-store-callback-isolation.test.js` (17 arms: every method must let an application throw escape AND must invoke the callback exactly once). Same family as the #B473/#B475/#B429 settle-discipline rule — a completion handle settles on a per-call channel, and a library must never convert a caller's exception into its own error.
1050
- 301. **FormValidator — submit pipeline, async `query` rule, loading state & error rendering (consolidates former #257/#261/#264/#296/#297; the engine/binding/a11y parent is #209).** **Rules & binding (former #257 — #B127/#B128/#B129 0.5.20, #B138 0.5.22):** numbered `is` aliases (`is0`, `is1`, … — multiple `is` conditions per field) INSTALL and enforce on client AND server (anchored `/^is\d+$/`) with a DISTINCT error key per alias on both sides (previously the per-rule typecheck `continue`d on exactly the undefined state the lazy installer required — silently skipping every `is<N>` key — and the shared server key let a later PASSING alias erase an earlier FAILING alias's error, a silent bypass). ⚠️ Enforcement-tightening: `is<N>` rules that silently skipped START ENFORCING at pickup — sweep rule JSONs for `is<N>` keys before upgrading. The `is` grammar remains ONE binary comparison (`=== !== == != < > <= >=`), no `&&` compounds. #B138: the shared `_currentValidatorAlias` handshake slot re-arms immediately before EVERY delegation (it was armed once at install while `is()` consumes-with-delete — any re-application against the same instance collapsed every numbered rule onto the bare key `is`, last-declared-wins, its message rendered twice). Injected forms (`validateFormById`) resolve their declared rule ATTRIBUTE-FIRST (`data-gina-form-rule`; the id-derived name stays the fallback) — pre-fix a form injected after boot bound `{}` rules. Live-check gate precedence: an AUTHOR-set `data-gina-form-live-check-enabled` always wins; the gate decides only when the attribute is absent (rules bound → true). The cross-field `_case_` scan coerces the CASE VALUE (declare boolean cases as JSON booleans; the strings "true"/"false" coerce consistently), and a MATCHING case application replaces that field's rule set in the bound form's rules store PERSISTENTLY for the page. There are NO standalone `maxLength`/`minLength` engine rules — length constraints are `isString`/`isNumber`/`isInteger` parameters; unknown rule keys are silently skipped. **Autocomplete interception + stale-error clear (former #261 — #B134/#B135/#B136):** the Safari-autocomplete keydown interception (autocomplete="off" fields on a live-check form) (a) bails on modifier chords — `e.metaKey`/`e.ctrlKey` returns BEFORE preventDefault so native select-all/copy/paste/cut/undo run (pre-fix Cmd+A typed the chord letter and keyboard paste was dead — `execCommand("paste")` is inert in unprivileged content); (b) gates on REAL Safari only (`/safari/i && !/chrom(e|ium)/i` — every Chromium UA carries the Safari token; WebKit-on-iOS browsers stay matched by design); and (c) the live-check whole-form pass, when it turns VALID, clears every previously-errored field's rendered error in BOTH copies instead of leaving a stale blocking paragraph beside the re-enabled trigger (the pass still never renders NEW errors on untouched fields). **The async `query` rule (former #264 — #B87 0.5.22; the #B332-#B346 cluster, 0.6.7):** the result path is BLANKET-guarded — a throw while processing the response (a detached target's null `.form` after a popin closed mid-flight, a malformed JSON body, a boolean value hitting `.toLowerCase()`) routes to `releaseQueryWaiter` (warn + release with field state as-is — FAIL-OPEN by design: the verdict is unknown and the server re-validates on submit); `xhr.onload`'s catch and the wrapped `onerror` route to the same release; the release helper is itself try-guarded and names the field. A submit can never complete while the verdict is on the wire, and ONE production POST per click at most: `getOwnedElements` dedups by node identity (an id-less submit trigger used to enter twice and stack TWO listeners — one click ran two full validate() passes, #B333), the async-arm guard is PASS-LOCAL (`armedAsyncFields` — waiters stack per pass and self-detach on first fire; the second pass used to find the first pass's waiter, zero ITS OWN counter and complete on the sync-only verdict ~200ms before the query answered, #B332), the wire dedups via a `ginaFormValidatorQueryPending` dataset marker, and every `validated.<formId>` listener consumes only its own pass's dispatch and detaches (stale listeners used to replay the submit once per leftover, #B334). #B337: the completion payload is the no-arg `d.getErrors()` — the WHOLE pass's verdict (field-scoped payloads meant other invalid fields were adjudicated but never rendered on the submit path, and the inverse scene dispatched an EMPTY error set — refusing the submit with no rendered reason). Engine contract worth knowing: `queryFromFrontend` SKIPS the wire when `self.isValid()` is already false, so whether a query fires on an invalid form depends on FIELD ORDER — the settle/skip completion payload must carry the full verdict either way. #B338: both formerly-synchronous releases (cached verdict, wire skip) defer via `queueMicrotask` so every release takes the post-pass shape a real settle has (they used to fire mid-field-loop, composing a completion missing every field declared after the query field and CLEARING their rendered errors on a re-click). #B342: a latched form's async completion dispatches UNCONDITIONALLY — the waiter's clean path used to dispatch only when the query field happened to be LAST in the rule set, so a clean submit with the query declared earlier starved the callback, no POST ever left, and the `isSubmitting` latch stranded the form until reload (masked pre-arc by the very #B332 double-pass the arc removed). #B346 closed the LAST silent door, one step UPSTREAM — the trigger gate: with live-check on, `updateSubmitTriggerState` gates the trigger whenever the form is not-yet-valid, but while a query is ON THE WIRE the form has NO verdict, and the gate misclassified verdict-pending as invalid — a click inside the round-trip window died silently (consumer-measured 0/6 inside vs 6/6 after; the window scales with query LATENCY, not typing speed). The refusal is narrowed by `isAwaitingQueryVerdictOnly`: the framework's gated mark must be the ONLY refusal arm (an authored `aria-disabled` or non-IDL `disabled` still refuses), an owned field carries the in-flight marker (checked in-form AND on `form="<id>"` reassociated controls, CSS.escape-guarded), and no SETTLED field holds a committed error (the pending field's own provisional `query` entry — staged by the same-value fast path — does not count; counting it refused every window the narrowing exists to recognize, measured). Consulted at the click proxy, the trusted-submit proxy (wrapped-label `<button type="submit"><span>` shapes; Enter with a default button is spec-synthesized into a click on it) and the loading arm; the shared fresh-validate path LATCHES `isSubmitting` when proceeding under a pending verdict (an un-latched async pass STARVES — this was also silently starving programmatic `submit()`) and releases the latch before any consumer `submit.<id>` dispatch, so a stopping custom handler cannot strand the form. **Send lifecycle (former #296 — #B175/#B176 0.6.1, #B192 0.6.3):** every `send()` builds its own LOCAL XHR (the module-scope reuse re-`open()`ed a completed instance, synchronously replaying the PREVIOUS submit's `onreadystatechange` at readyState 1 — submitting form B after form A stranded A's trigger natively-disabled with no release path) and registers a `loadend` release — success, error, timeout and abort alike — removing `disabled`/`aria-disabled` + `data-gina-form-loading` and clearing `$form.isSending`/`$form.sent`; `$form.isSending` genuinely spans send→settled; the timeout path REMOVES `data-gina-form-loading` (never writes the truthy string "false"). #B192: a REJECTED submit releases the `isSubmitting` latch (the only clear was the XHR settle, which a rejected submit never reaches — one invalid attempt latched the flag for the page's life, swallowing every later keystroke; the flag lives on the `$forms[id]` OBJECT so it even survived a full unbind/rebind). Diagnostic: read `gina.validator.$forms['<id>'].isSubmitting` — truthy IS the latched state (exactly what the live-check gate tests); the other discriminator is the live-check request count after a keystroke (0 latched, 1 healthy). ⚠️ **Do NOT use `isValidating` for this** — consumer-A/B-measured NON-discriminating (2026-08-01): it inits null and is set by EVERY validation cycle, so on any form that live-checked before the submit it reads `false` in BOTH the latched and healthy states. #B176: the live-check opt-out is honored consistently — the two gates that evaluated the rules-count boolean INSIDE `/^(true)$/i.test(…)` (an explicit `"false"` short-circuited to the count and matched) now test the attribute alone; the three `registerForLiveChecking` sites always did. Standing caution: `setOptions()` is a MODULE-wide default setter, not per-call. **Loading state (`data-gina-loading` + `lib/loading-state`, 0.6.4-0.6.5):** the FORM-scoped `data-gina-form-loading` says "a request is in flight"; the TRIGGER-scoped `data-gina-loading` marks the submit-like element the user actually operated — `"true"` while its action runs, `"false"` at ANY terminal outcome. Five release paths, two reaching no XHR at all: a submit REJECTED by validation, `send()`'s rate-limit early return, plus `loadend`, readyState 4, and never-arming when the disabled-trigger gate turns the click away. ⚠️ **The `"false"` value CONSTRAINS READERS: the attribute stays PRESENT when released — match on the VALUE, `[data-gina-loading="true"]`, never a bare `[data-gina-loading]`** (which matches a released trigger and pins the loading style on permanently); framework code uses the primitive's `isArmed()` (`=== "true"`). Arming happens at TWO sites because one is not enough: the click proxy (after the disabled-trigger gate, climbing from `event.target` to the owning button — a click on `<button type="submit"><span>Save</span></button>` targets the inner node) and the native submit proxy (where Enter, `form.submit()`, `$validator.submit()` and that same wrapped-label click actually surface). The attribute NAME is configurable — `gina.setOptions({ loadingAttribute: 'data-loading' })` (#B305: `setOptions()` used to write an orphan object nothing read — inert for every key since it shipped; it now merges into the exposed `gina.config` in place; on builds predating the fix use direct assignment `gina.config.loadingAttribute = …`), read LAZILY on every call; deliberately NO auto-detection (a DOM probe reads nothing at rest, a stylesheet probe races late sheets and throws cross-origin, and writing both names stacks spinners). The logic lives in the stateless `lib/loading-state` primitive (aliased into the browser bundle in BOTH build configs, required from `core.js`, intentionally NOT in the `lib/index.js` registry). `resolveTrigger` takes an optional third argument — a PREDICATE saying what counts as a trigger (default: submit-like controls only): the WALK belongs to the primitive, the notion of a trigger to each caller; a predicate that throws is deliberately not caught. ⚠️ Neither popin nor link passes one — both resolve by id/`closest()`, never from a raw click target. **The LINK plugin carries the same one-XHR-per-request repair (0.6.4):** each link request builds its own transport carrying a sequence taken BEFORE the supersede-abort (an `open()` on a busy object implicitly ABORTS it, an aborted request reaches readyState 4 with status 0, and `handleXhr` has no status-0 branch — the first click's completion fired NOTHING); link arms `data-gina-loading` on the anchor inside `linkRequest` (every entry funnels through it) and releases from a `loadend` listener — which fires for success, error, abort and status-0 alike; only an indefinite hang escapes it. The arm is GATED on `xhr.addEventListener` existing (the CORS branch can swap in a legacy `XDomainRequest` — never arm what cannot be released), and the release is deliberately NOT sequence-guarded (the sequence drops a stale RESPONSE; a superseded request must still RELEASE its trigger). Measured, not assumed: `abort()` fires `abort` + `loadend` SYNCHRONOUSLY, so the superseded request releases before the newer click arms. ⚠️ Still open and deliberately so: a genuine network failure remains silent and a hanging link request has no deadline (`xhr.timeout` never set) — both held back as consumer-visible changes for their own release note. **POPIN arms its trigger too (0.6.4):** `armPopinTrigger()` at both arm sites; releases at readyState 4, in `popinClose`, and in `popinUnbind` — the LAST is load-bearing because routing transitions reach `popinUnbind` WITHOUT `popinClose`. ⚠️ `data-gina-popin-loading` (the CONTAINER — "this popin is filling") and `data-gina-loading` (the TRIGGER — "this control is busy") are NOT the same signal; a rename between them is a regression. The formerly-open preload gap is CLOSED (#B285, 0.6.5): a popin opened from an in-flight preload arms via the adopted-preload arm. Maintainer cautions: `popin.test.js:778` pins `showLoadingShell($popin, $el);` at >= 2 LITERAL occurrences (absorbing the arm block into a helper reds it — grep it with `-F`, `$` and `(` are PCRE metacharacters), and `popin.test.js`'s `driveConsume` extract-and-executes the consumePreload bytes with only 7 injected symbols. **Default CSS + the `@layer gina` override contract (0.6.4-0.6.7):** `[data-gina-loading="true"]` gets a `progress` cursor + an opacity pulse gated on `prefers-reduced-motion: no-preference` (a static dim under `reduce` — a traded signal, not none). Two omissions are deliberate: NO pseudo-element spinner (stacks with a consumer's, forces `position: relative`) and NO `pointer-events: none` (suppresses the second click link deliberately supersedes). Default LOOKS (loading + the `data-gina-form-submit-gated` not-ready look) ship inside `@layer gina`, so ANY un-layered project rule overrides them regardless of specificity or load order — load-bearing because gina's stylesheet-injection order relative to project CSS is author-dependent; projects organising their own CSS in layers order themselves with `@layer gina, app;`; NEVER add `!important` inside the layer (important INVERTS layer priority). Functional rules (popin structure, scroll-lock) deliberately stay UN-layered so a generic project reset cannot break the mechanism. Browsers without `@layer` drop the block (attributes still written, project CSS still applies); `animation: none` drops just the motion; renaming via `loadingAttribute` opts out of the stylesheet entirely (keyed on the default name — the gated attribute is NOT renameable). ⚠️ Build gotcha, general to any new gina stylesheet: the CSS phase concatenates ONLY the compiled file whose basename matches its own directory (`foo/sass/bar.scss` compiles but never reaches `gina.min.css`); `inspector` is skipped entirely (served separately). **Labels & rendering (former #297 — #B178 family, 0.6.1):** the key an app can OBSERVE in a field's `errors` object is not always the key the label catalog is consulted under — four rule families write a generic key while consulting a specific label key (the float NaN branch renders `errorLabels['toFloatNAN']` into `errors['toFloat']`; the Length families render `Min`/`Max` label variants into the generic `is<X>Length` keys) — so an ALIAS FILL applies to every app-supplied label layer (server catalog node, client whisper, per-culture registrations, `setErrorLabels()`): a supplied generic fills the specific keys the app did not supply; a supplied specific still wins; English defaults untouched. Numbered `is` aliases with no rule-supplied text fall back to the shared `is` label (translate catalog key `is` once to cover every alias); the user-validator setup loop fills only when the app supplied none (it used to reset every user-defined validator's label to English, clobbering catalog translations). Render-side: the aria-live announcement joins per-message texts with a sentence separator (raw `textContent` concatenated with NO separator — a screen reader received one run-on string), and two rules resolving to byte-identical text render ONCE (the dev inspector still records every error key). All browser-bundled — consumers RE-BAKE at pickup; apps that translated only observable keys will see those messages switch from English to their language. **Transport-layer settle — a submit that never reaches the server now reports (#B447).** An XHR settling at `readyState` 4 with `status` 0 — network down, connection refused, DNS gone, the server process restarting — used to emit NOTHING: the success arm tests `/^2/.test(xhr.status)`, the error arm was `else if (xhr.status != 0)` which EXCLUDES 0, the arm that would have caught 0 sat fully commented out, and there was no trailing `else` — so both `error.<id>` and `error.<id>.hform` were unreachable for exactly the failure `data-gina-form-event-on-submit-error` most needs to cover. The `loadend` fail-safe still released the submit trigger, so the form spun, re-enabled, and said nothing. Both events now fire with `{ status: 408, transportError: true, error: <text> }`: 408 keeps `status >= 400` handlers working, and `transportError` is what distinguishes this from a genuine timeout (which emits a bare 408). The result stashes to `$form.eventData.error`, matching the three sibling arms in the same readyState chain and the key the readers actually consult — NOT the `on*` keys `ontimeout`/`onprogress` write, which are measured write-only (0 readers each) and are never cleared by the pre-validation `delete`. ⚠️ `xhr.timeout` is still never assigned, so the two `ontimeout` handlers remain dead code (#B448, LOW, on-demand — closing it means inventing a client-side timeout config surface that does not exist today); a real network timeout lands on THIS arm instead, which is why #B448 is NOT a coverage hole. Browser-bundled ⇒ consumers RE-BAKE at pickup. Tests: `validator-transport-error.spec.js` (arm 01 is a POSITIVE CONTROL — a 500 MUST fire the error event, without which arm 02's silence proves nothing; the `error.<id>.hform` channel is deliberately NOT asserted, because declaring the attribute flips `listenToXhrEvents` on and would change the shared fixture's scene) + `validator-autocomplete-interception.test.js` / `validator-livecheck-stale-error-clear.test.js` (extracted-real-bytes + red-first dist pins — Closure De-Morgans the chord bail, so minified pins target property-name shapes, never var names) + the #B332-cluster e2e matrix (incl. the field-order arm: a clean form whose query field is NOT last must send exactly once, after the settle). **Server-side twin — the `query` rule must not mutate the shared `app.proxy` conf (#B522, 0.6.30):** `queryFromBackend` bound its request options straight to `getConfig(<bundle>, 'app').proxy[<target>]` — the GLOBAL two-argument `getConfig` returns the bundle conf BY REFERENCE (`resolveBundlesConf` hands back `conf.bundlesConfiguration.conf` / `conf.envConf` with no clone) — then wrote `opt.method` and `opt.path = route.url` into it, while `controller.query()` added `requestTimeout` from the calling route's `queryTimeout` BEFORE its own `merge(JSON.clone(options), …)`. All three persisted on `content.app.proxy[<target>]` for the process lifetime. ⚠️ `path` and `requestTimeout` are DOCUMENTED `proxyTarget` properties (`schema/app.json`), so the shared node carried a clobbered `path` and a stale `requestTimeout`, not merely stray keys. ⚠️ Be precise about WHICH key does harm: the clobbered `path` is OBSERVABLE to every reader of that conf but has NO framework CONSUMER — `checkBundleStatus` overwrites `opt.path` with its own `route.url` one line after reading the object, `downloadFromURL`'s protocol-upgrade loop never reads `path`, and nothing prepends a configured `proxyTarget.path` (`controller.query()` uses `options.path` AS the request path). The key that IS consumed is `requestTimeout`: once a stale value sits on the shared target the guard `typeof options.requestTimeout === 'undefined'` cannot fire, so a later route's declared `queryTimeout` is silently ignored — MEASURED, a 9s route governed by a polluted 1s (Δ8994 ms against an unpolluted control) — and on HTTP/2 the timeout EVICTS AND DESTROYS the pooled session for that authority (measured shared: two requests reuse one client object) before re-issuing up to 3×, so one route's live-check degrades traffic that never touched the bug. Fixed by cloning at the single mutating bind (`JSON.clone(...)`, a real recursive deep clone, not `JSON.parse(JSON.stringify(...))`); an `app.proxy` census found THREE write sites but only ONE onto the SHARED object. The other two — `controller.js:3757-3758` (`opt.method`/`opt.path` in `checkBundleStatus`) and `:3871-3873` (`bundleObj.host`/`.hostname`/`.port`, inside `downloadFromURL`'s protocol-upgrade loop) — cites re-measured on HEAD 2026-09-10, the former `:3694-3695`/`:3808-3810` having drifted — write just as freely and are safe for ONE reason: they go through the CONTROLLER's `getConfig`, whose copy-on-write view (or the `controller.getConfig.mode: 'clone'` opt-out) captures the write in a per-call overlay. ⚠️ **That is the rule to carry away: whether a write to a conf object is safe depends on WHICH `getConfig` produced it — the global two-argument one returns the live node by reference, the controller's returns a view or a clone — never on how the write looks.** ⚠️ And the view is ONE-DIRECTIONAL: it stops writes reaching the shared tree but does NOT sanitise reads, so before this fix the `checkBundleStatus` reader observed the clobbered `path` even through its view (`lib/conf-view/src/main.js:228-230` returns a non-plain value straight off the shared target; `materialize` at `:176-188` copies FROM it, so `Object.keys` / `JSON.stringify` / `JSON.clone` of a view all fold stray keys in). The read-only sibling one line above the fix was itself dead code and is REMOVED (#B536, 0.6.30) — a second by-reference grab of the shared bundle `app` conf that fed nothing; verified by the identifier reading 0 in the source with firing controls, and the verbatim r.js bundle no longer carries it. The FRONTEND twin never touches proxy conf and is unaffected. ⚠️ Pickup = restart AND RE-BAKE even though the changed branch is server-only: the file is browser-bundled (`build.json` aliases `lib/form-validator`), so `gina.min.js` moves — measured by identity, with `gina.min.css` byte-identical as the firing control. Test: `validator-proxy-conf-clone-b522.test.js` drives the REAL shipped module exactly as the router does, through the sanctioned `setContext('__mock__', { config })` hook, and its §02 arm is a CAN-FAIL instrument check — the stub server must actually receive the route url, without which the pollution assertions could pass vacuously by never reaching the write site at all. A pre-existing false-positive warning on that path is FIXED at its producer (#B537, 0.6.30): `getRoute()` resolves a req-less caller's proxied classification from the `isProxyHost` context latch, whose only boot-time writer runs when the proxy configuration resolved a record for the running scope and env — a bundle whose `proxy.json` carries no such record boots with it unset, the fallback read returned `undefined`, and the server-side `JSON.clone` flagged the non-boolean key on every clone. The fallback now coerces with `|| false`, the idiom five sibling readers of the same latch already use. No consumer distinguishes `undefined` from `false` — every read is truthiness-only, and the `getUrl` filters overwrite the flag before reading it — so the change is invisible except for the warning; on the request path the value was already a strict boolean from the request store, so the `undefined` reaches only a req-less caller, and the framework has no req-less `getRoute()` caller of its own (application code only). Pinned by `routing-isproxyhost-boolean-b537.test.js`: real-module drives, a frozen pre-fix copy compiled in-memory as the subtract control, and a can-fail warn-capture arm. Pickup: restart; `gina.min.js` changes (identity-measured, `gina.min.css` byte-identical) but the client branch never produced the value and the client clone never warned, so the re-bake is byte parity. **#gh76 slice 2 — the submit pipeline owns the `text/html` answer contract (0.6.32).** A form places its own answer with three attributes resolved AT SUBMIT from the form (never from the page state at settle): `data-gina-form-target` (hx-target grammar — `this` | `closest <sel>` (ancestor-OR-self) | `find <sel>` | a document-scoped CSS selector; `next`/`previous` RESERVED and refused so adding them later stays additive), `data-gina-form-swap` (nine htmx strategies, `innerHTML` default) and `data-gina-form-select` (ALL matches, document order). A declared target WINS over the popin the form is in (#213); absent, the popin path is unchanged. Refusal is FAIL-LOUD and happens BEFORE `xhr.open` — unresolvable target, invalid selector, reserved/unknown form, unknown strategy ⇒ `error.<id>` (+ `.hform`) with `{status:422, reason:'targetError', attribute, value, transportError:false}`, `isSending`/`sent` cleared, the trigger released, NO request sent — deliberately the inverse of `data-gina-dialog-target`'s fallback to a full replace (silent in production, announced in dev mode since #B580), because a submit has already changed server state. ⚠️ That refusal is a FOURTH loading-state terminal path and is OWNERSHIP-GATED (`ownedByEarlierSend`, read BEFORE `send()` claims `isSending`): with `withRateLimit:false` a second attempt can be refused while the first still runs, and both the release and `isSending` belong to that first request — `armSubmitLoading` is first-wins, so the refused attempt armed nothing of its own (the #B247 rule; `loading-state.test.js §06` counts the sites AND pins the guard — it caught this releasing unconditionally). Success payload = the legacy `{contentType, content, status}` PLUS `target`/`swap`/`swapped`/`data`/`view`; `swapped:false` is not an error (`swap:'none'`, a `select` matching nothing, or a target detached in flight). Events `beforeswap` (cancelable via `preventDefault()`, `detail.content` re-read after dispatch so a listener may rewrite it — needs the `on()` wrapper's name-scoped `cancelEvent` exception for `beforeswap.` only) and `afterswap` (after the region is bound; declarative hook `data-gina-form-event-on-swap`). The swapped region is bound through the SHARED `bindRegion()` policy (utils/dom, #213). ⚠️ The validator declared its OWN module-local `bindRegion` for the forms half: `var` HOISTS over the whole file, so it shadowed the shared global and `applySwap` silently bound forms while never re-creating a swapped region's `<script src>` — the local is `bindFormsInRegion`, the PUBLISHED `gina.validator.bindRegion` name unchanged, and `region-binding.test.js §02` now pins the ABSENCE of a local `bindRegion`. A swap replacing the submitting form (`outerHTML`/`delete`) keeps its listeners through the success events, then binds the same-id replacement (`deferFormId`). Pinned by `validator-form-target.test.js` (27) + `validator-form-target.spec.js` (18 e2e arms). Browser-bundled ⇒ restart AND re-bake. **#gh76 slice 3 — out-of-band swaps (0.6.32).** Any element of the answer carrying `data-gina-swap-oob` is swapped into the PAGE element with the same `id`, independently of where the answer itself goes (htmx's `hx-swap-oob`). `true`/empty ⇒ `outerHTML` (the element ITSELF, attribute stripped on landing); a strategy name ⇒ the element's CONTENT with the wrapper dropped (`innerHTML` would otherwise nest a duplicate id); `<strategy>:<selector>` RESERVED. Every candidate is REMOVED from the answer whether or not it swapped — the main fragment is always clean — and the whole pass is STRING-GATED on `result.content.indexOf('data-gina-swap-oob') > -1`, so an answer without the attribute takes byte-identical paths (the gate is pinned at exactly 3 occurrences). Reached from all THREE html paths: `applySwap` (BEFORE `select` narrows, so an element outside the selection still lands; where both address the same element the main swap wins), the popin branch, and the legacy target-less tail — so an answer carrying nothing else still updates the page, htmx's `hx-swap="none"` rule. The payload gains `oob` (one entry per element, `{id, strategy, swapped}` plus `reason` for `noId`/`noTarget`/`reserved`/`unknownStrategy`/`cancelled`) and `remainder` (the answer minus them — what a handler should insert, since `content` stays RAW and would land them twice); both are ABSENT below the gate, so `'oob' in payload` means "the answer carried the attribute". Events fire PER ELEMENT: `oobbeforeswap.<id>` (cancelable, `detail.content` rewritable) and `oobafterswap.<id>` (+ its `.hform` companion when armed). ⚠️ `oobbeforeswap` has NO `.hform` twin, mirroring `beforeswap`: a cancel is a DECISION while the declared-callback channel carries notifications — and the `on()` wrapper's cancelEvent exception had to widen from `/^beforeswap\./` to `/^(oob)?beforeswap\./` or the cancel never reaches a `.on()` listener at all. Three traps worth carrying. (1) An author-supplied `<template>` is honoured (htmx parity), and one EMPTIED of its oob elements is REMOVED with them — otherwise an oob-only answer leaves `<template></template>`, whose non-empty remainder makes the popin path call `loadContent` and BLANK the open dialog; measured, `loadContent('')` throws nothing, keeps `isOpen`, fires `open`+`loaded`, and leaves a blank dialog whose form is UNBOUND, which is exactly why the empty-remainder skip exists. (2) `textContent` takes the REMAINDER, not the raw answer, or the consumed transport renders as visible text. (3) A nested oob inside ANOTHER oob is not processed on its own — it travels with its ancestor and its attribute is stripped there (htmx's `allowNestedOobSwaps:false` parity), while one merely nested in a NON-oob wrapper is processed and removed from it. An oob swap that replaces or removes the SUBMITTING form defers its binding (`deferFormId`) and reports `rebindSelf`, finalized by the shared tail — the popin branch RETURNS before that tail, so it finalizes itself, and the main swap's flag became `sendCtx.rebindSelf || detachesForm` so it can no longer clear a flag an oob swap already set. The same two test files now carry 50 unit arms + 26 e2e arms. Browser-bundled ⇒ restart AND re-bake. **#gh76 §6 — the server's last word (0.6.32):** three RESPONSE headers, read at settle inside the `text/html` branch (a JSON answer never reaches the read), after the answer is known to be HTML and BEFORE the target fork and `beforeswap` (htmx's order — a listener keeps the final say): `X-Gina-Retarget` (the `data-gina-form-target` grammar through the same `resolveSwapTarget`; it CREATES a target where none was declared, replaces a declared one, and `sendCtx.targetAttr` names the header — a declaration made by the server, so it wins over the popin exactly as an attribute does), `X-Gina-Reswap` (one of `SWAP_STRATEGIES`) and `X-Gina-Reselect` (a selector). The invalid-value rule is ASYMMETRIC, each half a rule that already existed: a Retarget that cannot be resolved DROPS the target — no swap, not into the declared target and not into the popin (the server plainly meant somewhere else) — and the success payload is the swap shape with `swapped: false`, `reason: 'retargetError'` and `target: null`, never an error callback (the false-422 shape slice 1 removed); a Reswap/Reselect that fails validation is IGNORED and the declared value kept (htmx degrades an invalid `HX-Reswap` the same way), as is either one without any target (`reason: 'noTarget'`). The report — `sendCtx.overrides`, one `{ value, applied, reason }` entry per header — rides the payload (`overrides`, present only when a header was sent), the `beforeswap` detail, and the legacy target-less payload; the popin path's parsed data stays VERBATIM (the slice-1 contract), so an ignored override there is a dev-mode console notice only. SAME-ORIGIN ONLY: the three headers are honoured only from a response whose origin is the page's own (`xhr.responseURL` — the URL after redirects — against `location.origin`); a cross-origin Retarget is refused like an unresolvable one (no swap, `reason: 'crossOrigin'`), a cross-origin Reswap/Reselect is ignored and the declared value kept, and a missing or unparseable `responseURL` or an opaque origin on either side reads as not-same-origin (fail-closed) — htmx needs no such read because `selfRequestsOnly` refuses the cross-origin REQUEST, while a form here posts to its raw `action`, so the gate lives at the read, and `Access-Control-Expose-Headers` buys nothing for these three; no controller helper — `self.getResponseObject().setHeader()` is the documented way. `applyResponseOverrides(xhr, sendCtx, $target, id)` is extractable (unit seam), pinned to exactly ONE call site inside the html branch. **Request coordination (#gh76 §7, UNRELEASED):** the DEFAULT is DERIVED and needs NO attribute — `deriveSync($form)` reads `data-gina-form-target` + `data-gina-form-swap` and returns `{strategy:'replace', derived:true, key, swap}` ONLY when the swap is in `REPLACING_SWAPS` (`innerHTML`/`outerHTML`/`textContent`/`delete`) AND the target RESOLVES; an insertion, `none`, no target, an unresolvable target, or a swap the pre-flight would refuse, all return `null` (no coordination at all, today's behaviour). The swap is read with `.trim()` only — CASE-SENSITIVE, matching the pre-flight's `SWAP_STRATEGIES.indexOf` — so a value heading for a refusal can never abort a running request on the way. `data-gina-form-sync` is the OVERRIDE (`drop`|`replace`|`queue`, no modifiers); `abort`, `queue <mod>` and `<selector>:<strategy>` are REFUSED with a message naming the reason AND the alternative (deliberate non-adoptions, not gaps: `abort` is htmx's disposable-GET idiom and a form submit is a validated POST with side effects; the `queue` modifiers exist in htmx because its trigger spec has `queue:` modifiers of its own; `<selector>:<strategy>` names a key that is already known). ⚠️ A SUPERSEDE IS NOT A ROLLBACK: the aborted request was already in flight, so it may have REACHED the server and its writes stand — a consumer must never read `abort`/`superseded` as 'not saved' and RESUBMIT (a duplicate write, on a feature whose premise is that a submit is a POST with side effects); the outcome of a superseded submit is the server's to report, never the event's. ⚠️ ORDERING IS LOAD-BEARING TWICE OVER: the derivation runs AFTER the module-wide `withRateLimit` gate (so one form's own re-submits keep the one-at-a-time rule and the derived default only ever coordinates DIFFERENT forms — zero blast radius on a single form), and the whole decision runs BEFORE `instance.$forms[id].isSending = true`, because `xhr.abort()` settles the aborted handler SYNCHRONOUSLY — claiming first lets that settle clear the flag this send just took (#B332 class). Same reason the handler-top `$form.isSubmitting = false` is guarded on `!sendCtx.superseded`. Keyed on the RESOLVED SWAP TARGET (`resolveSyncKey` for the explicit path; the derived path carries its own `syncParsed.key`) — a form key would not see the case the issue is about, two DIFFERENT forms answering into one region. Registry = a module `WeakMap<Element,{xhr,ctx,queue}>` (a swapped-away target is a new element, so its entry is collected — correct); NO seq counter, the per-send XHR object (#B175) IS the identity a late settle is checked against; NO `abortable` flag — the ARRIVING submit's strategy decides and nothing is carried on the entry. A SUPERSEDED request runs its RELEASE arms (trigger, loading state, a11y) then RETURNS before the status dispatch, emitting `abort.<id>` `{status:0, reason:'superseded', sync, derived}` — it must never reach the #B447 transport arm, which reads readyState-4/status-0 as a 408 the server never sent; there is deliberately NO `.hform` twin (nothing went wrong). `derived` is ON THE PAYLOAD because the default needs no attribute, so a page may find nothing in its own markup that asked for the abort. `replace` re-arms `armSubmitLoading` after the abort released it (first-arm-wins, stash just cleared). Exactly ONE turn-away exists (`drop`), so `disarmSubmitLoading` counts 6 in the file (1 JSDoc + 5 sites, pinned in `test/lib/loading-state.test.js`) — `queue` WAITS and keeps its loading state (pending, not abandoned; the replay's first-arm-wins carries it to the real settle), `replace` proceeds. An UNREADABLE sync value decides nothing and its refusal is DEFERRED to the pre-flight: from the top of send() the declared callbacks are not bound yet (`listenToXhrEvents` runs later), so a refusal there reaches nobody — measured, it silently delivered no error; a DERIVED result never carries an error by construction. `data-gina-form-disabled-elt` = a comma list in the target grammar, resolved after every refusal and before `xhr.open`, refcounted in a second WeakMap, marked `data-gina-disabled-by`, released at the `loadend` fail-safe (the one chokepoint covering error/abort/timeout) PLUS the binary branch's error exit, which never reaches the wire; an element the PAGE disabled is skipped and never counted. Both refusals reuse `refuseSend`, so `reason` stays `'targetError'` and `attribute` discriminates. The slot is claimed on the line AFTER each of the three `xhr.send()` sites — claiming earlier wedges a key forever on the one path that opens an XHR and never sends it. `parseSync`/`deriveSync`/`decideSync`/`queueSyncSend`/`shiftSyncQueue`/`resolveDisabledElts` are extractable (unit seam). ⚠️ Instrument: `input.disabled` reflects only the element's OWN attribute — a control disabled by a `<fieldset>` ancestor is observable through `:disabled`, not `.disabled`. ⚠️ Instrument: Closure renames `deriveSync`/`REPLACING_SWAPS` out of the MINIFIED bundle, so the discriminating content probe between a pre-reframe and a reframed `gina.min.js` is `abortable` (9 → 0); the readable names survive only in the unminified `dist/.../gina.js`. ⚠️ #B247's ownership rule had to be RE-DERIVED for a target key and the gap was found by a cold read, then DRIVEN: the module-wide gate may return without releasing because it only ever fires on the form that already owns the request, whose settle releases it — but a TARGET key turns away a DIFFERENT form, whose armed `data-gina-loading` nothing else would ever clear (measured: stranded `true` on the real bundle). ⚠️ **A template writing `data-gina-form-sync` must write the WHOLE attribute conditionally, never an empty one.** The gate handing a form its own overlap decision is `syncAttr === null` (`core/plugins/lib/validator/src/main.js:3174`), so a present-but-empty `data-gina-form-sync=""` is NOT absent: it passes the gate, reaches `parseSync('')` → `{error:'empty value'}` (`:913`) and REFUSES the submit before anything is sent — the same fail-loud path an unknown strategy takes. Swig-safe shape: `{% if page.view.params and page.view.params.sync %} data-gina-form-sync="{{ page.view.params.sync }}"{% endif %}`, and the `page.view.params and` half is LOAD-BEARING because `page.view.params` is set only `if (ownCount(parameters) > 0)` (`core/controller/controller.js:1432`) — i.e. it is ABSENT on a route reached with no parameters, so testing `page.view.params.sync` alone throws there.
1051
+ 301. **FormValidator — submit pipeline, async `query` rule, loading state & error rendering (consolidates former #257/#261/#264/#296/#297; the engine/binding/a11y parent is #209).** **Rules & binding (former #257 — #B127/#B128/#B129 0.5.20, #B138 0.5.22):** numbered `is` aliases (`is0`, `is1`, … — multiple `is` conditions per field) INSTALL and enforce on client AND server (anchored `/^is\d+$/`) with a DISTINCT error key per alias on both sides (previously the per-rule typecheck `continue`d on exactly the undefined state the lazy installer required — silently skipping every `is<N>` key — and the shared server key let a later PASSING alias erase an earlier FAILING alias's error, a silent bypass). ⚠️ Enforcement-tightening: `is<N>` rules that silently skipped START ENFORCING at pickup — sweep rule JSONs for `is<N>` keys before upgrading. The `is` grammar remains ONE binary comparison (`=== !== == != < > <= >=`), no `&&` compounds. #B138: the shared `_currentValidatorAlias` handshake slot re-arms immediately before EVERY delegation (it was armed once at install while `is()` consumes-with-delete — any re-application against the same instance collapsed every numbered rule onto the bare key `is`, last-declared-wins, its message rendered twice). Injected forms (`validateFormById`) resolve their declared rule ATTRIBUTE-FIRST (`data-gina-form-rule`; the id-derived name stays the fallback) — pre-fix a form injected after boot bound `{}` rules. Live-check gate precedence: an AUTHOR-set `data-gina-form-live-check-enabled` always wins; the gate decides only when the attribute is absent (rules bound → true). The cross-field `_case_` scan coerces the CASE VALUE (declare boolean cases as JSON booleans; the strings "true"/"false" coerce consistently), and a MATCHING case application replaces that field's rule set in the bound form's rules store PERSISTENTLY for the page. There are NO standalone `maxLength`/`minLength` engine rules — length constraints are `isString`/`isNumber`/`isInteger` parameters; unknown rule keys are silently skipped. **Autocomplete interception + stale-error clear (former #261 — #B134/#B135/#B136):** the Safari-autocomplete keydown interception (autocomplete="off" fields on a live-check form) (a) bails on modifier chords — `e.metaKey`/`e.ctrlKey` returns BEFORE preventDefault so native select-all/copy/paste/cut/undo run (pre-fix Cmd+A typed the chord letter and keyboard paste was dead — `execCommand("paste")` is inert in unprivileged content); (b) gates on REAL Safari only (`/safari/i && !/chrom(e|ium)/i` — every Chromium UA carries the Safari token; WebKit-on-iOS browsers stay matched by design); and (c) the live-check whole-form pass, when it turns VALID, clears every previously-errored field's rendered error in BOTH copies instead of leaving a stale blocking paragraph beside the re-enabled trigger (the pass still never renders NEW errors on untouched fields). **The async `query` rule (former #264 — #B87 0.5.22; the #B332-#B346 cluster, 0.6.7):** the result path is BLANKET-guarded — a throw while processing the response (a detached target's null `.form` after a popin closed mid-flight, a malformed JSON body, a boolean value hitting `.toLowerCase()`) routes to `releaseQueryWaiter` (warn + release with field state as-is — FAIL-OPEN by design: the verdict is unknown and the server re-validates on submit); `xhr.onload`'s catch and the wrapped `onerror` route to the same release; the release helper is itself try-guarded and names the field. A submit can never complete while the verdict is on the wire, and ONE production POST per click at most: `getOwnedElements` dedups by node identity (an id-less submit trigger used to enter twice and stack TWO listeners — one click ran two full validate() passes, #B333), the async-arm guard is PASS-LOCAL (`armedAsyncFields` — waiters stack per pass and self-detach on first fire; the second pass used to find the first pass's waiter, zero ITS OWN counter and complete on the sync-only verdict ~200ms before the query answered, #B332), the wire dedups via a `ginaFormValidatorQueryPending` dataset marker, and every `validated.<formId>` listener consumes only its own pass's dispatch and detaches (stale listeners used to replay the submit once per leftover, #B334). #B337: the completion payload is the no-arg `d.getErrors()` — the WHOLE pass's verdict (field-scoped payloads meant other invalid fields were adjudicated but never rendered on the submit path, and the inverse scene dispatched an EMPTY error set — refusing the submit with no rendered reason). Engine contract worth knowing: `queryFromFrontend` SKIPS the wire when `self.isValid()` is already false, so whether a query fires on an invalid form depends on FIELD ORDER — the settle/skip completion payload must carry the full verdict either way. #B338: both formerly-synchronous releases (cached verdict, wire skip) defer via `queueMicrotask` so every release takes the post-pass shape a real settle has (they used to fire mid-field-loop, composing a completion missing every field declared after the query field and CLEARING their rendered errors on a re-click). #B342: a latched form's async completion dispatches UNCONDITIONALLY — the waiter's clean path used to dispatch only when the query field happened to be LAST in the rule set, so a clean submit with the query declared earlier starved the callback, no POST ever left, and the `isSubmitting` latch stranded the form until reload (masked pre-arc by the very #B332 double-pass the arc removed). #B346 closed the LAST silent door, one step UPSTREAM — the trigger gate: with live-check on, `updateSubmitTriggerState` gates the trigger whenever the form is not-yet-valid, but while a query is ON THE WIRE the form has NO verdict, and the gate misclassified verdict-pending as invalid — a click inside the round-trip window died silently (consumer-measured 0/6 inside vs 6/6 after; the window scales with query LATENCY, not typing speed). The refusal is narrowed by `isAwaitingQueryVerdictOnly`: the framework's gated mark must be the ONLY refusal arm (an authored `aria-disabled` or non-IDL `disabled` still refuses), an owned field carries the in-flight marker (checked in-form AND on `form="<id>"` reassociated controls, CSS.escape-guarded), and no SETTLED field holds a committed error (the pending field's own provisional `query` entry — staged by the same-value fast path — does not count; counting it refused every window the narrowing exists to recognize, measured). Consulted at the click proxy, the trusted-submit proxy (wrapped-label `<button type="submit"><span>` shapes; Enter with a default button is spec-synthesized into a click on it) and the loading arm; the shared fresh-validate path LATCHES `isSubmitting` when proceeding under a pending verdict (an un-latched async pass STARVES — this was also silently starving programmatic `submit()`) and releases the latch before any consumer `submit.<id>` dispatch, so a stopping custom handler cannot strand the form. **Send lifecycle (former #296 — #B175/#B176 0.6.1, #B192 0.6.3):** every `send()` builds its own LOCAL XHR (the module-scope reuse re-`open()`ed a completed instance, synchronously replaying the PREVIOUS submit's `onreadystatechange` at readyState 1 — submitting form B after form A stranded A's trigger natively-disabled with no release path) and registers a `loadend` release — success, error, timeout and abort alike — removing `disabled`/`aria-disabled` + `data-gina-form-loading` and clearing `$form.isSending`/`$form.sent`; `$form.isSending` genuinely spans send→settled; the timeout path REMOVES `data-gina-form-loading` (never writes the truthy string "false"). #B192: a REJECTED submit releases the `isSubmitting` latch (the only clear was the XHR settle, which a rejected submit never reaches — one invalid attempt latched the flag for the page's life, swallowing every later keystroke; the flag lives on the `$forms[id]` OBJECT so it even survived a full unbind/rebind). Diagnostic: read `gina.validator.$forms['<id>'].isSubmitting` — truthy IS the latched state (exactly what the live-check gate tests); the other discriminator is the live-check request count after a keystroke (0 latched, 1 healthy). ⚠️ **Do NOT use `isValidating` for this** — consumer-A/B-measured NON-discriminating (2026-08-01): it inits null and is set by EVERY validation cycle, so on any form that live-checked before the submit it reads `false` in BOTH the latched and healthy states. #B176: the live-check opt-out is honored consistently — the two gates that evaluated the rules-count boolean INSIDE `/^(true)$/i.test(…)` (an explicit `"false"` short-circuited to the count and matched) now test the attribute alone; the three `registerForLiveChecking` sites always did. Standing caution: `setOptions()` is a MODULE-wide default setter, not per-call. **Loading state (`data-gina-loading` + `lib/loading-state`, 0.6.4-0.6.5):** the FORM-scoped `data-gina-form-loading` says "a request is in flight"; the TRIGGER-scoped `data-gina-loading` marks the submit-like element the user actually operated — `"true"` while its action runs, `"false"` at ANY terminal outcome. Five release paths, two reaching no XHR at all: a submit REJECTED by validation, `send()`'s rate-limit early return, plus `loadend`, readyState 4, and never-arming when the disabled-trigger gate turns the click away. ⚠️ **The `"false"` value CONSTRAINS READERS: the attribute stays PRESENT when released — match on the VALUE, `[data-gina-loading="true"]`, never a bare `[data-gina-loading]`** (which matches a released trigger and pins the loading style on permanently); framework code uses the primitive's `isArmed()` (`=== "true"`). Arming happens at TWO sites because one is not enough: the click proxy (after the disabled-trigger gate, climbing from `event.target` to the owning button — a click on `<button type="submit"><span>Save</span></button>` targets the inner node) and the native submit proxy (where Enter, `form.submit()`, `$validator.submit()` and that same wrapped-label click actually surface). The attribute NAME is configurable — `gina.setOptions({ loadingAttribute: 'data-loading' })` (#B305: `setOptions()` used to write an orphan object nothing read — inert for every key since it shipped; it now merges into the exposed `gina.config` in place; on builds predating the fix use direct assignment `gina.config.loadingAttribute = …`), read LAZILY on every call; deliberately NO auto-detection (a DOM probe reads nothing at rest, a stylesheet probe races late sheets and throws cross-origin, and writing both names stacks spinners). The logic lives in the stateless `lib/loading-state` primitive (aliased into the browser bundle in BOTH build configs, required from `core.js`, intentionally NOT in the `lib/index.js` registry). `resolveTrigger` takes an optional third argument — a PREDICATE saying what counts as a trigger (default: submit-like controls only): the WALK belongs to the primitive, the notion of a trigger to each caller; a predicate that throws is deliberately not caught. ⚠️ Neither popin nor link passes one — both resolve by id/`closest()`, never from a raw click target. **The LINK plugin carries the same one-XHR-per-request repair (0.6.4):** each link request builds its own transport carrying a sequence taken BEFORE the supersede-abort (an `open()` on a busy object implicitly ABORTS it, an aborted request reaches readyState 4 with status 0, and `handleXhr` has no status-0 branch — the first click's completion fired NOTHING); link arms `data-gina-loading` on the anchor inside `linkRequest` (every entry funnels through it) and releases from a `loadend` listener — which fires for success, error, abort and status-0 alike; only an indefinite hang escapes it. The arm is GATED on `xhr.addEventListener` existing (the CORS branch can swap in a legacy `XDomainRequest` — never arm what cannot be released), and the release is deliberately NOT sequence-guarded (the sequence drops a stale RESPONSE; a superseded request must still RELEASE its trigger). Measured, not assumed: `abort()` fires `abort` + `loadend` SYNCHRONOUSLY, so the superseded request releases before the newer click arms. ⚠️ Still open and deliberately so: a genuine network failure remains silent and a hanging link request has no deadline (`xhr.timeout` never set) — both held back as consumer-visible changes for their own release note. **POPIN arms its trigger too (0.6.4):** `armPopinTrigger()` at both arm sites; releases at readyState 4, in `popinClose`, and in `popinUnbind` — the LAST is load-bearing because routing transitions reach `popinUnbind` WITHOUT `popinClose`. ⚠️ `data-gina-popin-loading` (the CONTAINER — "this popin is filling") and `data-gina-loading` (the TRIGGER — "this control is busy") are NOT the same signal; a rename between them is a regression. The formerly-open preload gap is CLOSED (#B285, 0.6.5): a popin opened from an in-flight preload arms via the adopted-preload arm. Maintainer cautions: `popin.test.js:778` pins `showLoadingShell($popin, $el);` at >= 2 LITERAL occurrences (absorbing the arm block into a helper reds it — grep it with `-F`, `$` and `(` are PCRE metacharacters), and `popin.test.js`'s `driveConsume` extract-and-executes the consumePreload bytes with only 7 injected symbols. **Default CSS + the `@layer gina` override contract (0.6.4-0.6.7):** `[data-gina-loading="true"]` gets a `progress` cursor + an opacity pulse gated on `prefers-reduced-motion: no-preference` (a static dim under `reduce` — a traded signal, not none). Two omissions are deliberate: NO pseudo-element spinner (stacks with a consumer's, forces `position: relative`) and NO `pointer-events: none` (suppresses the second click link deliberately supersedes). Default LOOKS (loading + the `data-gina-form-submit-gated` not-ready look) ship inside `@layer gina`, so ANY un-layered project rule overrides them regardless of specificity or load order — load-bearing because gina's stylesheet-injection order relative to project CSS is author-dependent; projects organising their own CSS in layers order themselves with `@layer gina, app;`; NEVER add `!important` inside the layer (important INVERTS layer priority). Functional rules (popin structure, scroll-lock) deliberately stay UN-layered so a generic project reset cannot break the mechanism. Browsers without `@layer` drop the block (attributes still written, project CSS still applies); `animation: none` drops just the motion; renaming via `loadingAttribute` opts out of the stylesheet entirely (keyed on the default name — the gated attribute is NOT renameable). ⚠️ Build gotcha, general to any new gina stylesheet: the CSS phase concatenates ONLY the compiled file whose basename matches its own directory (`foo/sass/bar.scss` compiles but never reaches `gina.min.css`); `inspector` is skipped entirely (served separately). **Labels & rendering (former #297 — #B178 family, 0.6.1):** the key an app can OBSERVE in a field's `errors` object is not always the key the label catalog is consulted under — four rule families write a generic key while consulting a specific label key (the float NaN branch renders `errorLabels['toFloatNAN']` into `errors['toFloat']`; the Length families render `Min`/`Max` label variants into the generic `is<X>Length` keys) — so an ALIAS FILL applies to every app-supplied label layer (server catalog node, client whisper, per-culture registrations, `setErrorLabels()`): a supplied generic fills the specific keys the app did not supply; a supplied specific still wins; English defaults untouched. Numbered `is` aliases with no rule-supplied text fall back to the shared `is` label (translate catalog key `is` once to cover every alias); the user-validator setup loop fills only when the app supplied none (it used to reset every user-defined validator's label to English, clobbering catalog translations). Render-side: the aria-live announcement joins per-message texts with a sentence separator (raw `textContent` concatenated with NO separator — a screen reader received one run-on string), and two rules resolving to byte-identical text render ONCE (the dev inspector still records every error key). All browser-bundled — consumers RE-BAKE at pickup; apps that translated only observable keys will see those messages switch from English to their language. **Transport-layer settle — a submit that never reaches the server now reports (#B447).** An XHR settling at `readyState` 4 with `status` 0 — network down, connection refused, DNS gone, the server process restarting — used to emit NOTHING: the success arm tests `/^2/.test(xhr.status)`, the error arm was `else if (xhr.status != 0)` which EXCLUDES 0, the arm that would have caught 0 sat fully commented out, and there was no trailing `else` — so both `error.<id>` and `error.<id>.hform` were unreachable for exactly the failure `data-gina-form-event-on-submit-error` most needs to cover. The `loadend` fail-safe still released the submit trigger, so the form spun, re-enabled, and said nothing. Both events now fire with `{ status: 408, transportError: true, error: <text> }`: 408 keeps `status >= 400` handlers working, and `transportError` is what distinguishes this from a genuine timeout (which emits a bare 408). The result stashes to `$form.eventData.error`, matching the three sibling arms in the same readyState chain and the key the readers actually consult — NOT the `on*` keys `ontimeout`/`onprogress` write, which are measured write-only (0 readers each) and are never cleared by the pre-validation `delete`. ⚠️ `xhr.timeout` is still never assigned, so the two `ontimeout` handlers remain dead code (#B448, LOW, on-demand — closing it means inventing a client-side timeout config surface that does not exist today); a real network timeout lands on THIS arm instead, which is why #B448 is NOT a coverage hole. Browser-bundled ⇒ consumers RE-BAKE at pickup. Tests: `validator-transport-error.spec.js` (arm 01 is a POSITIVE CONTROL — a 500 MUST fire the error event, without which arm 02's silence proves nothing; the `error.<id>.hform` channel is deliberately NOT asserted, because declaring the attribute flips `listenToXhrEvents` on and would change the shared fixture's scene) + `validator-autocomplete-interception.test.js` / `validator-livecheck-stale-error-clear.test.js` (extracted-real-bytes + red-first dist pins — Closure De-Morgans the chord bail, so minified pins target property-name shapes, never var names) + the #B332-cluster e2e matrix (incl. the field-order arm: a clean form whose query field is NOT last must send exactly once, after the settle). **Server-side twin — the `query` rule must not mutate the shared `app.proxy` conf (#B522, 0.6.30):** `queryFromBackend` bound its request options straight to `getConfig(<bundle>, 'app').proxy[<target>]` — the GLOBAL two-argument `getConfig` returns the bundle conf BY REFERENCE (`resolveBundlesConf` hands back `conf.bundlesConfiguration.conf` / `conf.envConf` with no clone) — then wrote `opt.method` and `opt.path = route.url` into it, while `controller.query()` added `requestTimeout` from the calling route's `queryTimeout` BEFORE its own `merge(JSON.clone(options), …)`. All three persisted on `content.app.proxy[<target>]` for the process lifetime. ⚠️ `path` and `requestTimeout` are DOCUMENTED `proxyTarget` properties (`schema/app.json`), so the shared node carried a clobbered `path` and a stale `requestTimeout`, not merely stray keys. ⚠️ Be precise about WHICH key does harm: the clobbered `path` is OBSERVABLE to every reader of that conf but has NO framework CONSUMER — `checkBundleStatus` overwrites `opt.path` with its own `route.url` one line after reading the object, `downloadFromURL`'s protocol-upgrade loop never reads `path`, and nothing prepends a configured `proxyTarget.path` (`controller.query()` uses `options.path` AS the request path). The key that IS consumed is `requestTimeout`: once a stale value sits on the shared target the guard `typeof options.requestTimeout === 'undefined'` cannot fire, so a later route's declared `queryTimeout` is silently ignored — MEASURED, a 9s route governed by a polluted 1s (Δ8994 ms against an unpolluted control) — and on HTTP/2 the timeout EVICTS AND DESTROYS the pooled session for that authority (measured shared: two requests reuse one client object) before re-issuing up to 3×, so one route's live-check degrades traffic that never touched the bug. Fixed by cloning at the single mutating bind (`JSON.clone(...)`, a real recursive deep clone, not `JSON.parse(JSON.stringify(...))`); an `app.proxy` census found THREE write sites but only ONE onto the SHARED object. The other two — `controller.js:3757-3758` (`opt.method`/`opt.path` in `checkBundleStatus`) and `:3871-3873` (`bundleObj.host`/`.hostname`/`.port`, inside `downloadFromURL`'s protocol-upgrade loop) — cites re-measured on HEAD 2026-09-10, the former `:3694-3695`/`:3808-3810` having drifted — write just as freely and are safe for ONE reason: they go through the CONTROLLER's `getConfig`, whose copy-on-write view (or the `controller.getConfig.mode: 'clone'` opt-out) captures the write in a per-call overlay. ⚠️ **That is the rule to carry away: whether a write to a conf object is safe depends on WHICH `getConfig` produced it — the global two-argument one returns the live node by reference, the controller's returns a view or a clone — never on how the write looks.** ⚠️ And the view is ONE-DIRECTIONAL: it stops writes reaching the shared tree but does NOT sanitise reads, so before this fix the `checkBundleStatus` reader observed the clobbered `path` even through its view (`lib/conf-view/src/main.js:228-230` returns a non-plain value straight off the shared target; `materialize` at `:176-188` copies FROM it, so `Object.keys` / `JSON.stringify` / `JSON.clone` of a view all fold stray keys in). The read-only sibling one line above the fix was itself dead code and is REMOVED (#B536, 0.6.30) — a second by-reference grab of the shared bundle `app` conf that fed nothing; verified by the identifier reading 0 in the source with firing controls, and the verbatim r.js bundle no longer carries it. The FRONTEND twin never touches proxy conf and is unaffected. ⚠️ Pickup = restart AND RE-BAKE even though the changed branch is server-only: the file is browser-bundled (`build.json` aliases `lib/form-validator`), so `gina.min.js` moves — measured by identity, with `gina.min.css` byte-identical as the firing control. Test: `validator-proxy-conf-clone-b522.test.js` drives the REAL shipped module exactly as the router does, through the sanctioned `setContext('__mock__', { config })` hook, and its §02 arm is a CAN-FAIL instrument check — the stub server must actually receive the route url, without which the pollution assertions could pass vacuously by never reaching the write site at all. A pre-existing false-positive warning on that path is FIXED at its producer (#B537, 0.6.30): `getRoute()` resolves a req-less caller's proxied classification from the `isProxyHost` context latch, whose only boot-time writer runs when the proxy configuration resolved a record for the running scope and env — a bundle whose `proxy.json` carries no such record boots with it unset, the fallback read returned `undefined`, and the server-side `JSON.clone` flagged the non-boolean key on every clone. The fallback now coerces with `|| false`, the idiom five sibling readers of the same latch already use. No consumer distinguishes `undefined` from `false` — every read is truthiness-only, and the `getUrl` filters overwrite the flag before reading it — so the change is invisible except for the warning; on the request path the value was already a strict boolean from the request store, so the `undefined` reaches only a req-less caller, and the framework has no req-less `getRoute()` caller of its own (application code only). Pinned by `routing-isproxyhost-boolean-b537.test.js`: real-module drives, a frozen pre-fix copy compiled in-memory as the subtract control, and a can-fail warn-capture arm. Pickup: restart; `gina.min.js` changes (identity-measured, `gina.min.css` byte-identical) but the client branch never produced the value and the client clone never warned, so the re-bake is byte parity. **#gh76 slice 2 — the submit pipeline owns the `text/html` answer contract (0.6.32).** A form places its own answer with three attributes resolved AT SUBMIT from the form (never from the page state at settle): `data-gina-form-target` (hx-target grammar — `this` | `closest <sel>` (ancestor-OR-self) | `find <sel>` | a document-scoped CSS selector; `next`/`previous` RESERVED and refused so adding them later stays additive), `data-gina-form-swap` (nine htmx strategies, `innerHTML` default) and `data-gina-form-select` (ALL matches, document order). A declared target WINS over the popin the form is in (#213); absent, the popin path is unchanged. Refusal is FAIL-LOUD and happens BEFORE `xhr.open` — unresolvable target, invalid selector, reserved/unknown form, unknown strategy ⇒ `error.<id>` (+ `.hform`) with `{status:422, reason:'targetError', attribute, value, transportError:false}`, `isSending`/`sent` cleared, the trigger released, NO request sent — deliberately the inverse of `data-gina-dialog-target`'s fallback to a full replace (silent in production, announced in dev mode since #B580), because a submit has already changed server state. ⚠️ That refusal is a FOURTH loading-state terminal path and is OWNERSHIP-GATED (`ownedByEarlierSend`, read BEFORE `send()` claims `isSending`): with `withRateLimit:false` a second attempt can be refused while the first still runs, and both the release and `isSending` belong to that first request — `armSubmitLoading` is first-wins, so the refused attempt armed nothing of its own (the #B247 rule; `loading-state.test.js §06` counts the sites AND pins the guard — it caught this releasing unconditionally). Success payload = the legacy `{contentType, content, status}` PLUS `target`/`swap`/`swapped`/`data`/`view`; `swapped:false` is not an error (`swap:'none'`, a `select` matching nothing, or a target detached in flight). Events `beforeswap` (cancelable via `preventDefault()`, `detail.content` re-read after dispatch so a listener may rewrite it — needs the `on()` wrapper's name-scoped `cancelEvent` exception for `beforeswap.` only) and `afterswap` (after the region is bound; declarative hook `data-gina-form-event-on-swap`). The swapped region is bound through the SHARED `bindRegion()` policy (utils/dom, #213). ⚠️ The validator declared its OWN module-local `bindRegion` for the forms half: `var` HOISTS over the whole file, so it shadowed the shared global and `applySwap` silently bound forms while never re-creating a swapped region's `<script src>` — the local is `bindFormsInRegion`, the PUBLISHED `gina.validator.bindRegion` name unchanged, and `region-binding.test.js §02` now pins the ABSENCE of a local `bindRegion`. A swap replacing the submitting form (`outerHTML`/`delete`) keeps its listeners through the success events, then binds the same-id replacement (`deferFormId`). Pinned by `validator-form-target.test.js` (27) + `validator-form-target.spec.js` (18 e2e arms). Browser-bundled ⇒ restart AND re-bake. **#gh76 slice 3 — out-of-band swaps (0.6.32).** Any element of the answer carrying `data-gina-swap-oob` is swapped into the PAGE element with the same `id`, independently of where the answer itself goes (htmx's `hx-swap-oob`). `true`/empty ⇒ `outerHTML` (the element ITSELF, attribute stripped on landing); a strategy name ⇒ the element's CONTENT with the wrapper dropped (`innerHTML` would otherwise nest a duplicate id); `<strategy>:<selector>` RESERVED. Every candidate is REMOVED from the answer whether or not it swapped — the main fragment is always clean — and the whole pass is STRING-GATED on `result.content.indexOf('data-gina-swap-oob') > -1`, so an answer without the attribute takes byte-identical paths (the gate is pinned at exactly 3 occurrences). Reached from all THREE html paths: `applySwap` (BEFORE `select` narrows, so an element outside the selection still lands; where both address the same element the main swap wins), the popin branch, and the legacy target-less tail — so an answer carrying nothing else still updates the page, htmx's `hx-swap="none"` rule. The payload gains `oob` (one entry per element, `{id, strategy, swapped}` plus `reason` for `noId`/`noTarget`/`reserved`/`unknownStrategy`/`cancelled`) and `remainder` (the answer minus them — what a handler should insert, since `content` stays RAW and would land them twice); both are ABSENT below the gate, so `'oob' in payload` means "the answer carried the attribute". Events fire PER ELEMENT: `oobbeforeswap.<id>` (cancelable, `detail.content` rewritable) and `oobafterswap.<id>` (+ its `.hform` companion when armed). ⚠️ `oobbeforeswap` has NO `.hform` twin, mirroring `beforeswap`: a cancel is a DECISION while the declared-callback channel carries notifications — and the `on()` wrapper's cancelEvent exception had to widen from `/^beforeswap\./` to `/^(oob)?beforeswap\./` or the cancel never reaches a `.on()` listener at all. Three traps worth carrying. (1) An author-supplied `<template>` is honoured (htmx parity), and one EMPTIED of its oob elements is REMOVED with them — otherwise an oob-only answer leaves `<template></template>`, whose non-empty remainder makes the popin path call `loadContent` and BLANK the open dialog; measured, `loadContent('')` throws nothing, keeps `isOpen`, fires `open`+`loaded`, and leaves a blank dialog whose form is UNBOUND, which is exactly why the empty-remainder skip exists. (2) `textContent` takes the REMAINDER, not the raw answer, or the consumed transport renders as visible text. (3) A nested oob inside ANOTHER oob is not processed on its own — it travels with its ancestor and its attribute is stripped there (htmx's `allowNestedOobSwaps:false` parity), while one merely nested in a NON-oob wrapper is processed and removed from it. An oob swap that replaces or removes the SUBMITTING form defers its binding (`deferFormId`) and reports `rebindSelf`, finalized by the shared tail — the popin branch RETURNS before that tail, so it finalizes itself, and the main swap's flag became `sendCtx.rebindSelf || detachesForm` so it can no longer clear a flag an oob swap already set. The same two test files now carry 50 unit arms + 26 e2e arms. Browser-bundled ⇒ restart AND re-bake. **#gh76 §6 — the server's last word (0.6.32):** three RESPONSE headers, read at settle inside the `text/html` branch (a JSON answer never reaches the read), after the answer is known to be HTML and BEFORE the target fork and `beforeswap` (htmx's order — a listener keeps the final say): `X-Gina-Retarget` (the `data-gina-form-target` grammar through the same `resolveSwapTarget`; it CREATES a target where none was declared, replaces a declared one, and `sendCtx.targetAttr` names the header — a declaration made by the server, so it wins over the popin exactly as an attribute does), `X-Gina-Reswap` (one of `SWAP_STRATEGIES`) and `X-Gina-Reselect` (a selector). The invalid-value rule is ASYMMETRIC, each half a rule that already existed: a Retarget that cannot be resolved DROPS the target — no swap, not into the declared target and not into the popin (the server plainly meant somewhere else) — and the success payload is the swap shape with `swapped: false`, `reason: 'retargetError'` and `target: null`, never an error callback (the false-422 shape slice 1 removed); a Reswap/Reselect that fails validation is IGNORED and the declared value kept (htmx degrades an invalid `HX-Reswap` the same way), as is either one without any target (`reason: 'noTarget'`). The report — `sendCtx.overrides`, one `{ value, applied, reason }` entry per header — rides the payload (`overrides`, present only when a header was sent), the `beforeswap` detail, and the legacy target-less payload; the popin path's parsed data stays VERBATIM (the slice-1 contract), so an ignored override there is a dev-mode console notice only. SAME-ORIGIN ONLY: the three headers are honoured only from a response whose origin is the page's own (`xhr.responseURL` — the URL after redirects — against `location.origin`); a cross-origin Retarget is refused like an unresolvable one (no swap, `reason: 'crossOrigin'`), a cross-origin Reswap/Reselect is ignored and the declared value kept, and a missing or unparseable `responseURL` or an opaque origin on either side reads as not-same-origin (fail-closed) — htmx needs no such read because `selfRequestsOnly` refuses the cross-origin REQUEST, while a form here posts to its raw `action`, so the gate lives at the read, and `Access-Control-Expose-Headers` buys nothing for these three; no controller helper — `self.getResponseObject().setHeader()` is the documented way. `applyResponseOverrides(xhr, sendCtx, $target, id)` is extractable (unit seam), pinned to exactly ONE call site inside the html branch. **Request coordination (#gh76 §7, UNRELEASED):** the DEFAULT is DERIVED and needs NO attribute — `deriveSync($form)` reads `data-gina-form-target` + `data-gina-form-swap` and returns `{strategy:'replace', derived:true, key, swap}` ONLY when the swap is in `REPLACING_SWAPS` (`innerHTML`/`outerHTML`/`textContent`/`delete`) AND the target RESOLVES; an insertion, `none`, no target, an unresolvable target, or a swap the pre-flight would refuse, all return `null` (no coordination at all, today's behaviour). The swap is read with `.trim()` only — CASE-SENSITIVE, matching the pre-flight's `SWAP_STRATEGIES.indexOf` — so a value heading for a refusal can never abort a running request on the way. `data-gina-form-sync` is the OVERRIDE (`drop`|`replace`|`queue`, no modifiers); `abort`, `queue <mod>` and `<selector>:<strategy>` are REFUSED with a message naming the reason AND the alternative (deliberate non-adoptions, not gaps: `abort` is htmx's disposable-GET idiom and a form submit is a validated POST with side effects; the `queue` modifiers exist in htmx because its trigger spec has `queue:` modifiers of its own; `<selector>:<strategy>` names a key that is already known). ⚠️ A SUPERSEDE IS NOT A ROLLBACK: the aborted request was already in flight, so it may have REACHED the server and its writes stand — a consumer must never read `abort`/`superseded` as 'not saved' and RESUBMIT (a duplicate write, on a feature whose premise is that a submit is a POST with side effects); the outcome of a superseded submit is the server's to report, never the event's. ⚠️ ORDERING IS LOAD-BEARING TWICE OVER: the derivation runs AFTER the module-wide `withRateLimit` gate (so one form's own re-submits keep the one-at-a-time rule and the derived default only ever coordinates DIFFERENT forms — zero blast radius on a single form), and the whole decision runs BEFORE `instance.$forms[id].isSending = true`, because `xhr.abort()` settles the aborted handler SYNCHRONOUSLY — claiming first lets that settle clear the flag this send just took (#B332 class). Same reason the handler-top `$form.isSubmitting = false` is guarded on `!sendCtx.superseded`. Keyed on the RESOLVED SWAP TARGET (`resolveSyncKey` for the explicit path; the derived path carries its own `syncParsed.key`) — a form key would not see the case the issue is about, two DIFFERENT forms answering into one region. Registry = a module `WeakMap<Element,{xhr,ctx,queue}>` (a swapped-away target is a new element, so its entry is collected — correct); NO seq counter, the per-send XHR object (#B175) IS the identity a late settle is checked against; NO `abortable` flag — the ARRIVING submit's strategy decides and nothing is carried on the entry. A SUPERSEDED request runs its RELEASE arms (trigger, loading state, a11y) then RETURNS before the status dispatch, emitting `abort.<id>` `{status:0, reason:'superseded', sync, derived}` — it must never reach the #B447 transport arm, which reads readyState-4/status-0 as a 408 the server never sent; there is deliberately NO `.hform` twin (nothing went wrong). `derived` is ON THE PAYLOAD because the default needs no attribute, so a page may find nothing in its own markup that asked for the abort. `replace` re-arms `armSubmitLoading` after the abort released it (first-arm-wins, stash just cleared). Exactly ONE turn-away exists (`drop`), so `disarmSubmitLoading` counts 6 in the file (1 JSDoc + 5 sites, pinned in `test/lib/loading-state.test.js`) — `queue` WAITS and keeps its loading state (pending, not abandoned; the replay's first-arm-wins carries it to the real settle), `replace` proceeds. An UNREADABLE sync value decides nothing and its refusal is DEFERRED to the pre-flight: from the top of send() the declared callbacks are not bound yet (`listenToXhrEvents` runs later), so a refusal there reaches nobody — measured, it silently delivered no error; a DERIVED result never carries an error by construction. `data-gina-form-disabled-elt` = a comma list in the target grammar, resolved after every refusal and before `xhr.open`, refcounted in a second WeakMap, marked `data-gina-disabled-by`, released at the `loadend` fail-safe (the one chokepoint covering error/abort/timeout) PLUS the binary branch's error exit, which never reaches the wire; an element the PAGE disabled is skipped and never counted. Both refusals reuse `refuseSend`, so `reason` stays `'targetError'` and `attribute` discriminates. The slot is claimed on the line AFTER each of the three `xhr.send()` sites — claiming earlier wedges a key forever on the one path that opens an XHR and never sends it. `parseSync`/`deriveSync`/`decideSync`/`queueSyncSend`/`shiftSyncQueue`/`resolveDisabledElts` are extractable (unit seam). ⚠️ Instrument: `input.disabled` reflects only the element's OWN attribute — a control disabled by a `<fieldset>` ancestor is observable through `:disabled`, not `.disabled`. ⚠️ Instrument: Closure renames `deriveSync`/`REPLACING_SWAPS` out of the MINIFIED bundle, so the discriminating content probe between a pre-reframe and a reframed `gina.min.js` is `abortable` (9 → 0); the readable names survive only in the unminified `dist/.../gina.js`. ⚠️ #B247's ownership rule had to be RE-DERIVED for a target key and the gap was found by a cold read, then DRIVEN: the module-wide gate may return without releasing because it only ever fires on the form that already owns the request, whose settle releases it — but a TARGET key turns away a DIFFERENT form, whose armed `data-gina-loading` nothing else would ever clear (measured: stranded `true` on the real bundle). ⚠️ **A template writing `data-gina-form-sync` must write the WHOLE attribute conditionally, never an empty one.** The gate handing a form its own overlap decision is `syncAttr === null` (`core/plugins/lib/validator/src/main.js:3174`), so a present-but-empty `data-gina-form-sync=""` is NOT absent: it passes the gate, reaches `parseSync('')` → `{error:'empty value'}` (`:913`) and REFUSES the submit before anything is sent — the same fail-loud path an unknown strategy takes. Swig-safe shape: `{% if page.view.params and page.view.params.sync %} data-gina-form-sync="{{ page.view.params.sync }}"{% endif %}`, and the `page.view.params and` half is LOAD-BEARING because `page.view.params` is set only `if (ownCount(parameters) > 0)` (`core/controller/controller.js:1432`) — i.e. it is ABSENT on a route reached with no parameters, so testing `page.view.params.sync` alone throws there. **Staged-upload wait (#B726, 0.7.2):** a submit made while one of its form's staged uploads is on the wire WAITS, then sends once. Both doors call `holdSubmitForStagedUploads` before collecting — door A (a click on the bound trigger) just before `getFormValidationInfos` and before the latch; door B (the native submit proxy: a click inside the button, Enter in a form with no submit button, `<input type=submit>`, `requestSubmit()`, `$forms[id].submit()`) only AFTER `cancelEvent(e)`, or the browser posts the form itself. The in-flight signal is the virtual `gina-upload-*` record's `sent` (set after `xhr.send`, cleared at readyState 4 and `loadend`), reached through the input's `data-gina-form-virtual` — NOT `isSending`, which is claimed before `xhr.open` and stays true for good when a staging send throws before leaving (measured: an unparsable upload action makes `xhr.open` throw). One wait per form (`stagedUploadWait`): the busy state the gesture armed is kept and `uploadPending` ('Waiting for the upload to finish…', overridable) is announced once; a second gesture while waiting is a no-op, and one made when nothing is in flight any more drops a stale wait. `onUpload` calls `noteStagedUploadSettled` before anything in it can throw, and the decision runs in a microtask after the whole settle (`resolveStagedUploadWait`): an upload still in flight keeps it waiting; any failure, or the form leaving the page, cancels it and releases the busy state only when the form has no request of its own in flight (the sixth `disarmSubmitLoading` site, pinned by `test/lib/loading-state.test.js` §06 (f)); otherwise the submit is replayed THROUGH ITS OWN DOOR (door A re-dispatches `submit.<triggerId>`, door B `triggerEvent(form, 'submit')`), which collects the now-filled fields again — never route a click through door B: the two collectors differ deliberately and a consumer `on('submit')` is consulted only there. **A newer selection supersedes the staging request (#B729, 0.7.2):** the virtual form carries `data-gina-form-sync="replace"`, so the module rate-limit gate (`isSending || sent`) yields — a declared sync attribute owns its overlap decision — and a file chosen while the input's previous staging request is still on the wire aborts that request through the gh#76 coordination path and is sent; the superseded request settles through `abort.<vid>` only (`reason: 'superseded'`, no error, no on-error callback), and a submit already waiting keeps waiting, for the NEW request, and sends its metadata. Before, the gate returned silently — no event, no log — after the change handler had already reset the indicator and announced the upload, and the first request then filled the hidden fields with the REPLACED file's metadata while the input showed the new one. Pin: e2e `validator-upload-reselect-b729.spec.js` (3 arms). Not held: a direct `send(data)` (the caller's payload) and an upload started after a submit already passed the door check (#B740). `unbindForm` drops a pending wait. No timeout is set on a staging request, so an upload that never answers keeps the submit waiting.
1051
1052
  302. **The `stream` storage strategy (`lib/storage/src/local-stream`, #STO1) — large sequential media with RESUMABLE uploads.** Keys name an ASSET, not a file: `assets/<ulid>/original<ext>` — `original` is the reserved base-rendition name and the per-asset directory reserves the namespace future renditions land in (no rendition API in this release). Keys stay OPAQUE; never parse the directory back out. What per-asset colocation buys is OPERATIONAL grouping (one `rm -rf` per asset, one `rsync` of a subtree), NOT physical locality — a directory does not place extents, the allocator does — and gina neither prevents nor CAN prevent fragmentation: Node exposes no `fallocate` binding, native addons are not shipped, and `ftruncate` yields a fully SPARSE file (measured: a 256MB truncate reserves 0 blocks, and truncating past the free space succeeds, so it delivers neither anti-fragmentation nor an early ENOSPC). Contiguity is a filesystem/volume concern (XFS extent hints and the like); `chunkSize` (default `'8MB'`, `parseSize`) is the write stream's `highWaterMark` and nothing more. **Five capability-gated verbs beyond the shared contract** (`capabilities.resumable` true — branch on the flag, never probe): `createUpload(meta, cb)` -> `{uploadId, chunkSize, expectedSize}` / `writeSegment(uploadId, offset, stream, cb)` -> `{offset, length, received}` / `statUpload(uploadId, cb)` -> `{expectedSize, contentType, originalName, createdAt, received[], missing[], complete}` / `finalize(uploadId, cb)` -> `{key, size, contentType}` (put()'s shape) / `abortUpload(uploadId, cb)` -> `{aborted}`, idempotent. `statUpload` is NOT optional — resume is impossible unless a client can learn what landed; it is the resumable twin of `stat()`, and being computed from the session's own files it stays accurate across a process restart with no in-memory session table. **`expectedSize` is REQUIRED at create**: without a declared total finalise cannot verify coverage and `statUpload` cannot say what is MISSING (only what arrived), so a caller who does not know the size uses `put()` — the same exclusion tus takes without its defer-length extension, and strictly more useful than S3 multipart's `ListParts`, which can never name a missing part because it never learns the total. The declared size also permits an early `maxObjectSize` refusal before any byte moves, **advisory only**: it is client-supplied, so the running byte counter in `writeSegment` (which refuses a segment running past the declared end) is the real enforcement. **The KEY is minted at `createUpload` and persisted in the manifest**, never invented at finalise — minting it late means a row write that fails after the bytes are published leaves them at an address nobody holds, an unreachable orphan PLUS a session that can never finalise again. **The filesystem IS the manifest**, and the seam gains ZERO new verbs: a session is `<root>/.uploads/<uploadId>/` holding `data` (assembled IN PLACE via positional writes, `flags:'r+'` + `start`, so segments may arrive out of order and no assembly pass runs at finalise — measured, a segment-per-file layout costs ~1.9 s/GB of extra concat I/O where this costs ~4ms), `meta.json` (written ONCE through temp+rename) and one zero-byte `<offset>-<length>.ok` marker per DURABLE range. `.uploads` is a dot segment, structurally uncollidable with any caller key (the key guards reject dot segments), like `.tmp` and the `.driver` stamp. The argument for that home is FATE-SHARING, not seam economy: `fsync(data)` happens-before the marker is created, which is only enforceable while both live on the same medium — a store-backed manifest (SQLite WAL, redis, couchbase) can survive a crash the bytes did not, and a manifest claiming a range the bytes lost passes coverage and publishes ZEROS. **Coverage is an INTERVAL UNION, and that is the whole safety argument.** A hole in a sparse file reads back as ZEROS rather than erroring (measured), so an unverified finalise publishes a plausible, silently corrupt object; and SUMMING marker lengths is not enough — markers `0-800` and `400-1200` sum to exactly a declared 1600 while `1200-1600` is uncovered. So finalise sorts the markers, merges overlapping AND adjacent spans, and requires exactly one span equal to `[0, expectedSize)`. A gap is a REAL error and the session is PRESERVED so the client can complete it, never a silent publish; the message names the first gap. A segment that ends early is not an error — its marker records the RECEIVED length only, so the union stays exact and re-sends may overlap freely (re-sending a covered range is idempotent). Two further finalise guards: a data file SHORTER than the declared size under a full marker set refuses the publish (marker/bytes desync — the one shape that would still publish zeros), and one LONGER is truncated back (unreachable through the API, so it means tampering). **`finalize` is IDEMPOTENT with a heal path**: if the final path already exists it skips assembly, (re)writes the row and cleans the session — so a finalise that published the bytes and then failed on the row is completed by simply retrying it. Bytes are published BEFORE the row deliberately (the inverse manufactures rows-without-files, which cas's verify classifies as loss evidence and never auto-fixes); the residue here is the benign kind — `get()` falls through to the file path when no row exists. **Durability: `fsync` defaults TRUE, and only the DATA fsync is load-bearing** — per segment the order is positional write -> `fsync(data)` -> create marker, so a surviving marker implies durable bytes; the marker's own durability is deliberately NOT guaranteed, since a lost marker beside present bytes costs a re-send, which is the safe direction. Measured: a process SIGKILLed after an un-fsynced write still yields the full bytes to a later reader (the page cache carries them), so markers stay honest across PROCESS death without fsync — only power loss or a kernel panic opens the window. The cost is a RANGE, not one flattering figure: ~12ms per 8MB segment is ~2% of network-paced ingest at 100 Mbps, ~19% at 1 Gbps, and fsync-DOMINANT at 10 Gbps, so a LAN-ingest consumer sets `fsync:false` and accepts the documented window. The parent-directory flush is best-effort (Windows and some network mounts cannot), and macOS honours fsync as the platform defines it — the `F_FULLFSYNC` caveat cas already documents. **Abandoned sessions get their OWN age-gated sweep** on two triggers — at driver BUILD (what reclaims whatever a crashed process left behind) and on an unref'd `setInterval` — because sessions legitimately live for DAYS where a crashed put()'s temp is dead immediately. A REFUSED put() (over `maxObjectSize`, or a source that errors) does not rely on that sweep at all: its cleanup waits for the write stream's `'close'` and the CALLBACK WAITS WITH IT, so a caller told the put failed can never observe residue (#B358). Do not "simplify" that back to a synchronous `existsSync`-gated unlink — `fs.createWriteStream()` opens ASYNCHRONOUSLY, so a refusal raised from the `'data'` handler runs while the `open(2)` is still in the threadpool: `existsSync` reads false, the unlink is skipped, and the worker thread creates the file a tick later, with NO event-loop turn in between for a synchronous check to catch it (measured ~30% orphan rate before the fix). **ALL THREE local strategies carry this fix** — `sharded` and `cas` build their write stream lazily inside `spill()`, so their window opens on the OTHER arm (a source error arriving just after the spill, rather than a `maxObjectSize` refusal), which is exactly how a first pass that measured only the refusal arm wrongly concluded they were immune. Fault-injecting the single false `existsSync` answer orphans a real temp on both. Two arms reach this handler — the `'data'` refusal and `stream.on('error')` — and a measurement covering one licenses no claim about the other. `sessionTtl` default `'24h'`, `sessionSweepInterval` default `'1h'`, both `parseDuration` (unit required); `'0s'` disables the interval only, and a ZERO ttl snaps back to the default rather than meaning "off", since it would reclaim in-flight uploads. Liveness is `mtime(data)`, NOT the session directory's — `data` advances on every segment write while a directory mtime only moves when entries are added or removed — with a dir-mtime fallback so a session whose data was renamed away by a half-finished finalise stays reclaimable. The same pass reclaims `<root>/.tmp` orphans from crashed `put()`s at the same cutoff (sharded has that gap and no such sweep). **No tiering, no hashing, by design**: `inlineThreshold` and `hash` are NOT in `STRATEGY_KEYS.stream`, so setting either warns as an ignored key rather than silently doing nothing, and `capabilities` reads `{offload:false, ranges:true, dedup:false, resumable:true, inline:false}`. `release()` unlinks the object and then rmdirs the asset directory, which succeeds only while it is empty — a future rendition beside `original` correctly keeps it. **Not in this release, each still deferred or capability-gated**: a rendition API; async post-hoc checksums; an `s3` adapter; and any `storage:gc`/`storage:verify`/stats extension for stream — those stay cas-only, so a stream driver has NO `sweepNow`/`verify` and is named-and-skipped by the maintenance surface rather than erroring. Multi-process is DOCUMENTED, not enforced: parallel segment writes are safe when ranges are DISJOINT (measured — two uncoordinated processes interleaving 1MB stripes into one shared file produced byte-exact output on a POSIX-coherent local filesystem), but relaxed-coherence network mounts are UNMEASURED, so route one session's segments to one process there. Shipping `stream` also EMPTIED `DEFERRED_STRATEGIES` — the list and its boot branch stay (cheap, and the next designed-before-implemented name inherits a working message) but are now exercised by nothing, which is recorded rather than hidden.
1052
1053
  303. HTTP Range serving for stored objects — `self.serveFromStorage(driverName, key[, opts])` is the read-side companion of `self.store()`'s driver routing and the consumer `capabilities.ranges` was waiting for: a controller action serves a stored object with the whole HTTP protocol dance owned by the framework, identically on both engines (headers and status ride the response object, bytes ride `renderStream`'s two arms — no `/_gina` endpoint involved). The flow is `stat()`-gated: an unknown or released key answers 404 through `throwError` (cas hides zero-ref rows from `stat`, so a released blob 404s by construction; on `stream` a finalize-heal residue whose metadata row write failed is readable at the driver but 404s here until the idempotent finalize heals the row), and a missing/unconfigured DRIVER is an app config error — 500, never 404. Validators are minted from the layer's own invariants: `ETag: "<key>"` is deliberately STRONG because storage keys are immutable (every strategy publishes via temp+rename, cas is content-addressed, and no in-place mutation API exists), plus `Last-Modified` from the publish time; `If-None-Match` is weak-compared against that ETag (a `W/`-prefixed echo matches) and answers 304 with no driver read. Range evaluation is GET-only and capability-gated (`driver.capabilities.ranges` false ⇒ the header is ignored and no `Accept-Ranges` is advertised — the forward seam for offloading adapters): a single `bytes=a-b`/`a-`/`-n` that is satisfiable answers 206 with `Content-Range: bytes a-b/<size>` and an exact `Content-Length`; an unsatisfiable one (start at or past the size, a `-0` suffix, any range against an empty object) answers 416 with `Content-Range: bytes */<size>` and an empty body; multi-range lists, foreign units, syntactic garbage and `a-b` with a>b are IGNORED into the full 200, which RFC 9110 sanctions; `If-Range` honours the Range only on an exact validator match, so anything unevaluable degrades fail-safe to the full body rather than risking a corrupted client-side assembly. Trust model, fail-closed: the stored contentType is UPLOADER-supplied verbatim, so without `opts.contentType` the active-content types (html/xml/svg/javascript/ecmascript) downgrade to `application/octet-stream` — nosniff cannot stop a DECLARED `text/html` from rendering — and every facade response carries `X-Content-Type-Options: nosniff`; an explicit `opts.contentType` is the app's informed choice and serves verbatim. Caching defaults to `Cache-Control: private, max-age=31536000, immutable` (correct because a key's bytes can never change; `private` keeps shared caches out of the application's authorization), with `opts.cacheControl` winning verbatim; `opts.download`/`opts.filename` emit an RFC 6266 attachment disposition with control characters stripped from the uploader-supplied name (header-injection guard — Node's setHeader would otherwise THROW on a CR/LF-bearing stored originalName, inside an async callback). HEAD answers headers-only with full-size accounting and no driver read. Headers are applied only in the driver-success callback, so a read error routes through `throwError` with no Range headers leaked; a post-stat race (cas grace expiry mid-download, a vanished file) maps the read verbs' `STORAGE_NO_OBJECT` code to 404 and anything else to 500 — the machine codes (`STORAGE_NO_OBJECT`/`STORAGE_RANGE_UNSATISFIABLE`/`STORAGE_INVALID_RANGE`, minted by `util.codedError` with message wording byte-unchanged) shipped for exactly this discrimination. The enablers landed in the same release on `renderStream`: non-SSE Buffer chunks pass through byte-exact (the historical unconditional utf8 decode substituted U+FFFD into binary payloads, which made the delegate unusable for byte serving; valid-UTF-8 re-encodes identically and SSE keeps its decode), HEAD parity (headers-only, the iterable never consumed, a destroyable source destroy()ed), caller-set headers surviving the delegate defaults (the h2 frame literal used to beat the pending merge and the h1 setHeader clobbered — a serving caller's Cache-Control/Content-Range now ride), and the post-end `headersSent` bookkeeping guarded (getter-only on both real response classes under the file's strict mode — the swallowed throw had killed the Inspector Flow stream-write/total timeline entries on every streamed request). Deliberately NOT here: multipart/byteranges (multi-range ⇒ full 200), Range on statics (the pure `_parseRangeHeader(header, size)` helper is reusable when that arc comes), and Range on non-GET methods. Offload serving SHIPPED with the s3 adapter (#304): `capabilities.offload` ⇒ GET/HEAD answer 307 to a presigned URL after the local 304 check, and the provider serves Range natively. Tests: `test/core/serve-from-storage.test.js` (parser matrix + fail-closed contentType matrix as extract-and-execute of the shipped bytes; the protocol arms driven end-to-end on REAL drivers across sharded/cas/stream with byte-exact 200/206 assertions) + `test/core/render-stream.test.js` §13-§16 + `test/lib/storage-error-codes.test.js`.
1053
1054
  304. **s3 storage adapter (#STO1 — the layer's last step)** — the second ADAPTER: `adapter:'s3'` + `bucket` (+ optional `region`/`endpoint` (https:// prepended when scheme-less; region defaults `us-east-1` ONLY beside a custom endpoint — real AWS keeps the SDK's own chain, whose missing-region message beats a mis-signed default)/`prefix` (trailing `/` normalised; the OPAQUE key EXCLUDES it, so re-pointing `prefix` orphans stored objects exactly as re-pointing a local `root` does)/`forcePathStyle` (MinIO)/`presignExpiry` ('15m')/`sweepGrace` ('1h')/`maxObjectSize`/static credentials). Dispatch became adapter × strategy by COMPOSITION — `ADAPTER_FACTORIES = {local: FACTORIES, s3: {sharded}}` — so the flat `_FACTORIES` stays the local row (the `storage:*` CLI dispatches through it offline and source-pins it; all three CLI verbs name s3 drivers remote and SKIP them BEFORE any build, ahead of the store skip so a warn-ignored `store` key cannot mislead the reason). s3 runs `sharded` ONLY — key naming; the provider owns placement — and refuses `cas`/`stream` at boot naming why (S3 ETags are not content digests; multipart is the provider's own resumable); `strategy` may be omitted (defaults sharded). STORELESS: `store=null`, no embedded `.meta.db`, no `.driver` stamp — one PutObject atomically carries bytes + ContentType (verbatim) + `x-amz-meta-gina-name` = encodeURIComponent(originalName) capped ~1KB (URI-encoded OURSELVES — AWS's RFC 2047 handling of non-ASCII metadata values is AWS-specific, plain ASCII round-trips on every compatible; AWS-doc-measured: user metadata 2KB total, keys lowercased, IMMUTABLE after upload, matching the layer's immutable keys). `stat()` = HeadObject (strongly consistent per AWS docs; ⚠️ a missing key answers 404 only WITH `s3:ListBucket`, else 403 — the guide ships the minimal policy: GetObject/PutObject/DeleteObject/ListBucket/ListBucketMultipartUploads/AbortMultipartUpload, bucket/prefix-scoped). `release()` = DeleteObject (idempotent acknowledgement — S3 reports no prior existence, so `existed:true` is DOCUMENTED as ack-only). `put()` streams through lib-storage's `Upload` (unknown length ⇒ multipart under the hood) behind a counting transform enforcing `maxObjectSize` MID-STREAM (breach ⇒ best-effort `Upload.abort()` + the REAL cap error; reported size = the meter's count — the bytes the provider acknowledged). The crashed-put analog is an INCOMPLETE MULTIPART UPLOAD (bills storage until aborted, no expiry — AWS-doc-measured): a build-time age-gated sweep (ListMultipartUploads → AbortMultipartUpload past `sweepGrace`, one bounded page per boot, best-effort never-throw — the sharded temp-orphan shape) reclaims them; a bucket `AbortIncompleteMultipartUpload` lifecycle rule stays recommended defense-in-depth. `resolve(key[, opts], fn)` answers the reserved `{kind:'url', url, expiresAt}` — a presigned GetObject; presigning is LOCAL SigV4 computation, so existence is deliberately NOT verified (the serving facade is stat-gated; a direct caller's URL to a vanished object answers the provider's own 404 — documented, not hidden). The NEW optional middle `opts` ({contentType, download, filename, cacheControl}) rides the SIGNED `response-content-type`/`-content-disposition`/`-cache-control` overrides (signed-request-only per AWS — a presigned URL qualifies) and landed on the CONTRACT for all strategies: locals accept and IGNORE it (zero prior resolve() callers, measured), so one facade call site serves every driver. `capabilities` = `{offload:true` (the layer's FIRST true — and its first reader ships beside it), `ranges:true` (get/getRange are REAL GetObject(+Range) proxies: provider InvalidRange/416 → STORAGE_RANGE_UNSATISFIABLE, NoSuchKey/NotFound → STORAGE_NO_OBJECT, anything else RAW-forwarded — the typed class is the signal), `dedup:false, resumable:false, inline:false}`. `serveFromStorage()` offload path: after the stat gate AND the local If-None-Match 304 (key ETag — no presign spent), GET/HEAD → **307** (method-preserving — HEAD stays HEAD) + Location + `Cache-Control: private, no-store` on the REDIRECT (it must never outlive its signature) while the PAYLOAD's policy (default `private, max-age=31536000, immutable`; `opts.cacheControl` wins) rides `response-cache-control`; the facade runs its fail-closed contentType DOWNGRADE BEFORE presigning and hands resolve() the SAFE type — the stored-XSS guard holds when the provider serves the bytes; `opts.offload:false` forces the in-process proxy path; an offload-claiming driver whose resolve answers no url is a LOUD 500 (driver contract violation), never a silent degrade; non-GET/HEAD methods always proxy. SDK = 3 PROJECT-side packages (`@aws-sdk/client-s3` + `@aws-sdk/lib-storage` + `@aws-sdk/s3-request-presigner`; v3 required — a module without S3Client is refused by name) resolved LAZILY through `start()`'s injected `requireProjectModule` (built in gna.js from `getPath('project')` — lib/storage is framework-independent, test-enforced, and cannot reach the globals; tests inject a fake SDK via the factory's 4th `deps` arg); a missing SDK on a CONFIGURED driver is boot-FATAL with the npm hint (the no-safe-degraded-mode rationale). `${secret:KEY}` works for the static credential pair TODAY (the whole-tree config walk); omit both credentials and the SDK default provider chain runs (env / shared config / IMDS / IRSA — the container-native path, first-class). S3-compat honesty: strong read-after-write consistency (HEAD + LIST included) and the metadata semantics are AWS-DOCUMENTED behaviour; a compatible provider owns its own — stated in the guide, and the adapter uses only the core compat surface (Put/Get/Head/Delete/ListObjectsV2/ListMultipartUploads/Abort + SigV4 presigning). Tests: `test/lib/storage-s3.test.js` (REAL adapter + fake SDK that THROWS on unimplemented commands, 60) + `test/core/serve-from-storage-offload.test.js` (17 — incl. the signed-downgrade security pin and 304-before-presign).
@@ -1080,9 +1081,10 @@ Dev-mode query instrumentation captures every database query tied to the current
1080
1081
  323. **Exact-money primitive — `lib.money` / `gina.money` (#FIN5).** Monetary amounts must never ride IEEE-754 floats (`0.10 + 0.20` is wrong out of the box); the money module makes the correct pattern cheap: amounts travel as STRINGS on the wire (the JSON parser preserves them byte-exact), live as INTEGER minor units (BigInt) internally, and only become locale display via `Intl.NumberFormat` (the i18n layer's job — deliberately NOT this module's). API, identical server-side (`require('lib/money')` bare-module or `lib.money`) and client-side (`gina.money`, browser-bundled): `parse(str, code)` — strict wire-string parse that THROWS on a number input, a malformed string, or MORE fractional digits than the currency's exponent (rounding is the application's decision, never the library's); `fromMinor(int, code)` / `toMinor(a)` (JSON-safe string out); `add` / `subtract` / `compare` — same-currency ONLY, a mismatch always throws (mixed-currency arithmetic is the corruption class the module exists to prevent); `multiply(a, factor)` — INTEGER factors only (a fractional rate needs the application's own rounding, on minor units); `format(a)` — the canonical wire string with exactly the currency's fraction digits ('20.00', '-0.05', '150' for JPY); `exponent(code)` — ISO 4217 minor-unit exponent, default 2 with the exceptions in-lib (JPY/CLP/KRW/XOF… 0; BHD/KWD/OMR/TND… 3; CLF/UYW 4), malformed codes throw. Amount shape: `{ currency, exponent, minor: BigInt }` — exact beyond `Number.MAX_SAFE_INTEGER` minor units. The module is dependency-free, dual-published (node + AMD), and uses the `BigInt()` constructor form exclusively — no BigInt literals — so every tool in the browser-bundle build chain parses it. Pickup for changes: bundle restart AND re-bake (browser-bundled via build.json).
1081
1082
 
1082
1083
  324. **Idempotency keys (#FIN6) — `Idempotency-Key` request deduplication at the router band, per the IETF draft (draft-ietf-httpapi-idempotency-key-header).** Opt-in TWICE: settings.json `server.idempotency` (`enabled` strictly true + `namespace` naming a DECLARED kv namespace + `keyField` naming the session.user identity property; optional `ttl` default 24h, `inflightTtl` default 2m, `maxBodySize` default 256KB, `retryAfter` default 5s — folded from settings into the runtime server block with env.json winning, resolved once at engine start onto `instance._idempotency`, boot-REFUSED on a structurally invalid enabled block) AND per route: routing.json top-level `idempotency: true` or `{"required": true}` (400 when the header is missing) — top-level like `rateLimit`, NOT `param.*`; unflagged routes are byte-identical. Band order: 401 -> 429 -> idempotency (the gate runs strictly AFTER the rate-limit verdict at BOTH router dispatch sites, owned promise terminals) -> message validation (#FIN3, routes that declare it) -> DTO 422. Mechanics over the #KV1 primitive: the first request bearing a key RESERVES with `setnx` + the in-flight TTL (crash-safe by expiry: a dead process's reservation self-releases and a retry re-executes), executes with `request._idemCapture` stamped; `renderJSON` records the envelope at its single stringify choke point — status, ALLOWLISTED headers only (`content-type`, `location`; `set-cookie` is NEVER stored), body <= maxBodySize, sha256 payload fingerprint (verbatim `req.rawBody` when present, else the JSON-serialized parsed body) — under the retention TTL. A retry with the same key + payload is answered with the RECORDED response + `Idempotency-Replayed: true`; an in-flight duplicate gets 409 + Retry-After; the same key with a DIFFERENT payload gets 422; responses >= 500, oversize bodies, and anything not passing through renderJSON (a template render, a redirect, an error egress) RELEASE the reservation via the response-finish belt so retries re-execute — only what was recorded is ever replayed. Principal scoping is structural: the reservation key is `idem:<u:|m: principal>:<METHOD>:<rule>:<sha256 key>` (the rate-limit `deriveKey` precedence), so a stored response can never cross principals, and anonymous (principal-less) callers are SKIPPED entirely — the same class of lesson as the authz+cache replay hazard. Dedup SCOPE follows the namespace's backend: in-memory dedups PER PROCESS (a retry on another replica RE-EXECUTES — the resolver warns), redis/sqlite share across replicas. Outage policy is the namespace's `failMode`: `open` proceeds without dedup, `closed` answers 503 + Retry-After. Audit auto-events fire on the 400/409/422/503 denials. `lib/routing`'s per-route flag propagation rides the browser bundle (inert client-side — dist moves; the feature's pickup is a bundle restart). Tests: `test/core/idempotency.test.js` (48 — resolver matrix, gate verdicts, record/release, real kv memory-namespace round-trips).
1083
- 325. **`renderXML` delegate (#FIN2) — first-class XML responses, the outbound half of #FIN1's verbatim inbound handling.** `self.renderXML(xmlContent[, contentType])` delegates to `core/controller/controller.render-xml.js` through the house delegate-to-file pattern (3-key deps `{self, local, headersSent}`, dev-mode `require.cache` bust). gina does NOT build XML any more than it parses it: the caller serialises with its own library and hands over a string; the delegate owns the wire concerns only. **Content-type defaults to `application/xml`** — RFC 7303 §4.1 recommends it over `text/xml`, and the mime table CANNOT supply it: `core/mime.types` declares the `xml` key TWICE (`:672` `application/xml`, `:673` `text/xml`), so JSON.parse last-wins resolves `mime['xml']` to `text/xml` (MEASURED). The duplicate is deliberately left alone — that key also types `.xml` STATICS, a separate blast radius. An explicit 2nd argument covers the suffix family (`application/soap+xml`, `application/atom+xml`, a vendor tree), mirroring `renderStream(iterable, contentType)`; a blank/whitespace value falls back to the default; charset comes from `conf.encoding`. **Status comes from `response.statusCode`, NOT from the payload** — a string body has no envelope for render-json's `jsonObj.status`/`errno` convention, so `self.throwError()` stays the error path. **Carried from render-json:** HEAD suppression (byte-length `content-length`, never string length), the HTTP/2 `stream.respond()` frame carrying `:status` as a pseudo-header (a literal 200 served every error as 200 — #B172) plus the pending-`getHeaders()` fold (headers set upstream do NOT travel with a raw respond()), #H10 `wantTrailers`/`waitForTrailers`, the #B36 released-response guard placed BEFORE the first `local.res.stream` read, #B63 function-scoped deps, terminal-exit `local.req/res/next` nulling on every path, and `catch → self.throwError(500)`. **The #FIN6 hook is carried too** — `lib.idempotency.record()` at this delegate's single body-resolution point: without it an idempotency-reserved route answering XML would RESERVE and never RECORD, so the finish belt would release the reservation and a retry would re-execute. **Deliberately NOT carried:** #DTO2 response DTOs and the `__ginaQueries`/`__ginaFlow` sidecars (JSON-object transforms with nowhere to live in an XML document), and the render cache — DEFERRED, not rejected. ⚠️ Whoever picks that up: the cache kind is a CLOSED enum read at two hardcoded sites (`server.js:7430` and `server.isaac.js:2437` both iterate `['data','static']`), so a third kind would be written and never looked up — reuse `'data'` or extend BOTH readers. No XML declaration is prepended (a framework-emitted `encoding=` could contradict the caller's charset, and the inbound side is verbatim too). Server-side only — `core/controller/**` is NOT browser-bundled (measured, firing control) — so no dist rebuild; pickup = bundle restart. Tests: `render-xml.test.js` (31 — real-delegate drives for every branch, plus source pins).
1084
+ 325. **`renderXML` delegate (#FIN2) — first-class XML responses, the outbound half of #FIN1's verbatim inbound handling.** `self.renderXML(xmlContent[, contentType])` delegates to `core/controller/controller.render-xml.js` through the house delegate-to-file pattern (3-key deps `{self, local, headersSent}`, dev-mode `require.cache` bust). gina does NOT build XML any more than it parses it: the caller serialises with its own library and hands over a string; the delegate owns the wire concerns only. **Content-type defaults to `application/xml`** — RFC 7303 §4.1 recommends it over `text/xml`, and the mime table CANNOT supply it: `core/mime.types` declares the `xml` key TWICE (`:672` `application/xml`, `:673` `text/xml`), so JSON.parse last-wins resolves `mime['xml']` to `text/xml` (MEASURED). The duplicate is deliberately left alone — that key also types `.xml` STATICS, a separate blast radius. An explicit 2nd argument covers the suffix family (`application/soap+xml`, `application/atom+xml`, a vendor tree), mirroring `renderStream(iterable, contentType)`; a blank/whitespace value falls back to the default; charset comes from `conf.encoding`. **Status comes from `response.statusCode`, NOT from the payload** — a string body has no envelope for render-json's `jsonObj.status`/`errno` convention, so `self.throwError()` stays the error path. **Carried from render-json:** HEAD suppression (byte-length `content-length`, never string length), the HTTP/2 `stream.respond()` frame carrying `:status` as a pseudo-header (a literal 200 served every error as 200 — #B172) plus the pending-`getHeaders()` fold (headers set upstream do NOT travel with a raw respond()), #H10 `wantTrailers`/`waitForTrailers`, the #B36 released-response guard placed BEFORE the first `local.res.stream` read, #B63 function-scoped deps, terminal-exit `local.req/res/next` nulling on every path, and `catch → self.throwError(500)`. **Not carried since 0.7.2 (#B750):** render-json's `response.headersSent = true` after the send — a silent no-op in that sloppy-mode file, it THREW here (getter-only accessor, strict mode): over HTTP/2 the TypeError reached that `catch → throwError(500)` on an already-sent response and skipped the cleanup; over HTTP/1.1 the inner catch swallowed it, skipped the branch's `return`, and the fall-through called `next()` on an ended response. **The #FIN6 hook is carried too** — `lib.idempotency.record()` at this delegate's single body-resolution point: without it an idempotency-reserved route answering XML would RESERVE and never RECORD, so the finish belt would release the reservation and a retry would re-execute. **Deliberately NOT carried:** #DTO2 response DTOs and the `__ginaQueries`/`__ginaFlow` sidecars (JSON-object transforms with nowhere to live in an XML document), and the render cache — DEFERRED, not rejected. ⚠️ Whoever picks that up: the cache kind is a CLOSED enum read at two hardcoded sites (`server.js:7430` and `server.isaac.js:2437` both iterate `['data','static']`), so a third kind would be written and never looked up — reuse `'data'` or extend BOTH readers. No XML declaration is prepended (a framework-emitted `encoding=` could contradict the caller's charset, and the inbound side is verbatim too). Server-side only — `core/controller/**` is NOT browser-bundled (measured, firing control) — so no dist rebuild; pickup = bundle restart. Tests: `render-xml.test.js` (31 — real-delegate drives for every branch, plus source pins).
1084
1085
  326. **Message-schema validation seam (#FIN3) — application-supplied validators at the router band, `param.messageValidator`.** The framework ships NO schema engine (no XSD, no Schematron, no JSON-Schema runtime — zero new parser surface, the same discipline as #FIN1's parse-free XML intake); the application supplies the validator, gina supplies the boot registry, the async-capable gate and the fail-closed refusal shape. Opt-in is the route declaration alone — `"param": { "messageValidator": "<name>" }` (nested beside `param.dto`, its exact species: a name string binding the route to a bundle file; `param.*` rides BOTH route builders wholesale so no builder edits, no dist rebuild — unlike the top-level boolean flags) — resolving to a FACTORY module at `<bundle>/message-validators/<name>.js`: `module.exports = function (ctx) { return function validate(document, req) {...}; };` with `ctx = { bundle, env }`. The factory runs ONCE at bundle boot, synchronously (compile schemas / open a sidecar pool there; this IS the dry-run — a missing file, non-factory export, non-function return or factory throw REFUSES the boot naming the route, never a silent skip in production; a validator file edit needs a bundle restart, like DTOs and routing.json). The registry lives on `process.gina._messageValidators` (the `_dtos`/`_policies`/`_authenticators` pattern — survives dev hot-reload, dies on restart). At request time the gate runs at BOTH router dispatch sites strictly AFTER authorization (401), rate limiting (429) and idempotency — the disclosure ordering: an unauthenticated or throttled caller never learns whether its document validates — and BEFORE the DTO pipe (document-level before field-level; for an XML body `req[method]` is `{}` so a DTO would be vacuous there anyway). Dormant routes mint ZERO promises (the band stays synchronous); armed, the gate owns every terminal and the router carries a last-resort catch. `validate(document, req)` receives the VERBATIM body string (`request.rawBody` — the #FIN1 XML document byte-exact; for JSON the raw string with the parsed object still on `req[method]`; `''` for multipart/body-less) plus the live request (read headers/routing; never write the response) and may return a verdict or a promise of one: `{ valid: true }` proceeds; `{ valid: false, status?, errors?, retryAfter? }` refuses — status 422 default (schema-invalid), 400 (not parseable as the expected format), or 503 + optional `retryAfter` seconds → `Retry-After` (the checker is unavailable — the idempotency-outage shape; a validator that catches its own sidecar failure answers this instead of 500); any other status or a non-verdict shape is a LOUD 500 contract violation, and a throw/rejection is a fail-closed 500 with the stack logged server-side (`error` on the wire never carries it) — a request NEVER proceeds unvalidated. The `errors` array (`{ message, line?, column?, path? }`) is forwarded verbatim on the 400/422 JSON body as a top-level `errors` key — the `fields`-map sibling in the error envelope (the ApiError merge recognises `errors` alongside `fields`/`flash`), riding `status`/`error`/`ref` (#ERRREF) with the scope-gated stack semantics unchanged; the gate never truncates it, so a validator expecting pathological documents caps its own report. A refusal egresses via `controller.throwError` (engine-agnostic — no isaac mirroring) and never passes through renderJSON, so an idempotency reservation on the same route RELEASES via the finish belt and a corrected retry re-executes. Deliberate slice-1 scope: INBOUND only (an emit-time hook is demand-gated; the registered module is a plain factory an application can already import directly in its own tests/CI), no settings.json surface (the route declaration is the whole opt-in — the `param.dto` precedent), string names only (per-route behaviour reads `req.routing.rule` off the handed request). Implementation: `lib/message-validator` (plain-required, router-bound), boot walk in `core/server.js` between the DTO registrar and the authz lint, gate wired in `core/router.js` at both dispatch sites. Tests: `test/lib/message-validator.test.js` (33 — factory-contract behaviorals on real fixture files, dormancy, the full verdict matrix on the real gate, wiring pins red-first validated).
1085
1086
  327. **`self.forward()` — declarative relay of a route to another route, on this bundle or a sibling bundle of the project (#B488, 0.6.28).** A route names `forward` as its `control` and the target in `param.url` — `<rule>` for the current bundle, `<rule>@<bundle>[/<env>]` for a sibling (the reference form `redirect()` and `getRoute()` accept). Every other non-reserved `param` key is a placeholder value for the target route, read from the captured URL parameters when the incoming URL provided it and taken as a static value otherwise (reserved: `url`, `urlIndex`, `control`, `file`, `title`, `bundle`, `project`, `hostname`, `port`, `path`, `method`). The target route binds its own placeholder (`"id": ":id"` in its `param`) like any parameterised route — unbound, `:id` is a literal path segment and the forwarded value travels as request data instead of substituting into the path. The upstream call rides `query()`: a `<bundle>@<project>` hostname is resolved from the environment configuration (host, port, protocol, scheme), the resolved route url — webroot included — is the forwarded path, and the incoming `req[method]` data travels as body or query string; `param.method` overrides the forwarded method, and `hostname`/`port`/`path` address a raw host instead of a bundle. An object answer is relayed with `renderJSON()`, a string answer verbatim with `renderTEXT()` — `query()` delivers the parsed body only, never the upstream content-type, so a non-JSON body is not re-encoded but its content-type is not mirrored either; a non-2xx status, a transport failure or an unknown target route is answered through `throwError()`. **`multipart/form-data` IS relayed since 0.6.28 (#B489)** — the parsed text fields are re-flattened to bracket notation (the exact inverse of the parser's nester) and the staged `req.files` are read back at their staged paths into ONE RFC 7578 body by `lib/multipart`, under a CSPRNG boundary re-drawn until it occurs in no part. It travels through `query()`'s new `options.body` raw-body pass-through — a Buffer or string sent verbatim under the caller's own `headers['content-type']` (defaulting to `application/octet-stream`), refusing a non-empty `data` beside it with `BODY_AND_DATA` and any other body type with `BODY_TYPE`, both synchronously and before any upstream contact — which is exempt from the unconditional json labelling AND from the HTTP/2 request prep's incoming-content-type forward, which would otherwise stamp the BROWSER's boundary onto a body framed with ours (the flag carrying that exemption is in `_NON_HTTP_OPTS`, so it can never become a header). Buffered ⇒ bounded: the cap is the source's `upload.maxFieldsSize` when the parser stamped a usable one — it is DISABLED when unset/0/invalid and compares a `content-length` a chunked request never sends, so it bounds nothing by itself, and `server.js` therefore publishes the resolved value as `req.uploadMaxSize` — else a 16 MB default; the check sums field bytes + on-disk `statSync` sizes BEFORE a byte is read (`record.size` is provisional until the liner flush), and a breach answers 413 naming both numbers. ⚠️ **Decoded-vs-encoded asymmetry:** that check is on DECODED bytes, while the TARGET applies its own `maxFieldsSize` to the ENCODED `content-length` (decoded + framing — measured 591 B for 1 file + 3 fields, 1327 B for 5 + 3, ~184 B per extra part), so with source and target at the SAME cap a body within a few hundred bytes of it clears the relay and is refused 431 at the target; that 431 is the framing delta, NOT a corrupted body — keep the target's cap slightly above the source's. Files forwarded under a method that carries no body answer 400; an unreadable staged part answers 500 naming the path. Staged files are READ, never deleted — the source's cleanup timer, `store()` and the boot orphan sweep still own them. Live-proven cross-process on BOTH transports (the target echoed `httpVersion` 1.1 and 2.0, each with the other as its control), the relayed parts md5-identical to the same upload sent directly, nested + flat text fields and a UTF-8 filename surviving the round trip. Until 0.6.28 the method resolved the target route and then discarded it — forwarding to the target's webroot alone, or to `route.param.port` as the path for a raw host — substituted the `":id"` declaration instead of the captured value, and inverted the `project` override, so it never reached its target; its source carried a work-in-progress note. Server-side ⇒ pickup = bundle restart.
1086
1087
 
1087
1088
  328. **`bundle:build` / `project:build` `--skip-unchanged` — skip the wipe-and-copy of a release whose source is byte-identical to what it was built from (0.6.29-alpha.2).** Both verbs are a `rm -rf` of `releases/<bundle>/<scope>/<env>/<version>/` followed by a recursive byte-copy of the bundle source; they compile nothing (asset pipelines belong to the `prepare`/`postbuild` hooks declared in `manifest.json#buildScripts`). With the opt-in flag the build signs the source — `lib.releaseWatch.buildSignature(root, {prior})`: sha1 over the sorted `relpath|size|sha1(bytes)` lines of every regular file plus `relpath|symlink|target` for every symlink, `DEFAULT_IGNORE` segments pruned, spec `BUILD_SIG_SPEC` 1 — and records it at the release ROOT as `.gina-build.json` (`writeBuildMarker`, atomic temp+rename, written in the copy callback AFTER the `node_modules` link, so a copy killed mid-way leaves no marker and always rebuilds). Timestamps do not count: a fresh checkout or a plain `cp` does not invalidate — the property the #RWATCH mtime fingerprint (`fingerprintTree`, `FP_SPEC` 1, untouched) deliberately lacks, because its fail-safe direction is « stale ⇒ rebuild » while a skip needs the opposite. Stat fast path: given the prior marker, a file whose recorded `size` and `floor(mtimeMs)` both match reuses the recorded sha1 with no read — except entries whose mtime falls within `BUILD_SIG_RACY_MS` (2 s) before the marker's `signedAt` (git's racily-clean rule, for coarse-mtime filesystems), which are re-read. A stable-mtime source therefore costs one stat walk (measured 6.5 ms vs 5.5 ms for the #RW1 walk over 1,811 entries, 0 reads); a source whose mtimes were rewritten is read once, then stat-only. The decision is the pure `decideBuildAction({force, marker, releaseExists, signature})`: the ONLY skip is flag on + a marker of the current spec + release present + equal signatures; every other input rebuilds with a reason (`signature unavailable` · `--force` · `no marker` · `marker spec <s> ≠ <current>` · `release missing` · `<k> file(s) changed` with the sorted `changed` list from `diffBuildFiles` · `signature mismatch`), and any exception while evaluating is a warn + rebuild. Nothing changes without the flag (no signature, no marker), so the first flagged build always copies. On a skip the manifest update, the #RW1 stamp, both hooks and a guarded `node_modules` link (lstat first — a blind `symlinkSync` throws EEXIST) still run. `--force` rebuilds and still records the marker; `--dry-run` resolves every release, prints `[ dry-run ] would skip <target> (unchanged since <builtAt>, <n> files)` / `would rebuild <target>: <reason>` (the first 10 changed files listed) and writes nothing, runs no hook; `--format=json` writes ONE `{ project, scope, dryRun, skipUnchanged, releases:[{bundle, env, target, action, reason, changed, fileCount, builtAt}] }` envelope via `fs.writeSync(1, …)` — both modes read the same per-release records the build acted on. The `postbuild` hook's env gains `GINA_BUILD_SKIPPED_BUNDLES` (comma list of the bundles whose every resolved env was skipped) and `GINA_BUILD_SKIPPED_ALL` (`1` when that is every bundle resolved this run), set only with the flag, so a hook that bakes its own outputs can skip its own work. Not detected by design: out-of-band edits inside a release (omit the flag or pass `--force`), file mode changes (the copy never preserved them), the framework version (recorded in the marker for diagnostics only). Flags must be declared in BOTH `lib/cmd/bundle/arguments.json` and `lib/cmd/project/arguments.json` (an undeclared `--` token becomes a `NODE_OPTIONS` entry for the prepare hook); `project:build` skips creating a dev-env release record but resolves one an earlier `bundle:build` created. Server/CLI-side only ⇒ pickup = bundle restart, no re-bake.
1088
1089
  329. **RFC 9218 Extensible Priorities — the `Priority` header on the request, the outbound call and the response, and urgency-ordered async jobs (#H12, 2026-09-14).** `req.priority` is parsed ONCE per request on BOTH engines — at the top of isaac's request listener AND of the engine-agnostic catch-all, fill-when-absent guarded — so on isaac the statics, `/_gina/*` and render-cache hits carry it exactly as they do on Express (they answer inside the listener and never reach the catch-all; a parse only in the catch-all would have left the two engines asymmetric on precisely the traffic RFC 9218 targets). The parser is `lib/priority` (`require('lib/priority')`, `gina.lib.priority`): `parse(headerValue)` → `{ urgency 0-7, incremental, present }` per RFC 9218 §4 over an RFC 8941 dictionary — `u` Integer 0–7 default 3, `i` Boolean default false (a bare `i` is true; `i=1` is an Integer, i.e. the wrong type), unknown members / out-of-range or wrong-type values / member parameters ignored INDIVIDUALLY, a grammar failure (`U=1`, a trailing comma, `u = 1`…) ignored WHOLE as if absent (`present:false`), multiple field lines joined with `, `, never throws; `serialize({urgency, incremental})` → `u=N, i`; `normalizeUrgency(v)` → an integer 0–7 or 3. `self.query()` resolves the outbound header ONCE before dispatch — one site for both transports, every retry and `self.forward()` — through `resolveOutbound()`: (1) a caller-set `headers.priority` (any casing) is left alone; (2) `options.priority: false` sends nothing; (3) an explicit `options.priority` (`{urgency, incremental}` or a wire string) is sent normalized and NEVER falls through; (4) a PRESENT inbound header propagates in normalized form — RFC 9218 is end-to-end, a sub-request made for a `u=0` page is itself `u=0`. The `priority` OPTION is stripped before the merge like `critical`, because on HTTP/2 every stray `options` key ships as a wire header. `self.setPriority({urgency, incremental})` emits the RESPONSE header — an explicit `u=3` IS emitted, since on a response only an explicit member overrides the client's value (§8); headersSent-guarded (a released response is "sent", #B31), chainable. ⚠️ Advisory and CLIENT-SUPPLIED: nothing in gina grants more on its strength; a consumer may read `req.priority` only to yield. What Node cannot do stays out, deliberately: no DATA-frame send scheduling, no `PRIORITY_UPDATE` frame API (`http2stream.priority()` is a no-op), so the framework CARRIES the signal and never reorders its own writes; the #MS5 circuit breaker and the #MS6 quota do not consult it (the breaker has no queue; a client-supplied urgency may only ever YIELD, which needs an overload-shedding gate that does not exist). **`lib/job` selection is urgency-ordered:** `self.startJob(fn, { urgency: 0-7 })` / `lib.job.create(fn, { urgency })` stamps the urgency on the in-process QUEUE ENTRY (default 3; anything that is not an integer 0–7 ⇒ 3 via `normalizeUrgency`; never on the record — the fn is per-process, so there is nothing durable to order); the worker's `_dequeue()` starts the FIRST entry of the LOWEST urgency class — FIFO within a class, so equal urgencies keep the order they always had — and a retried attempt re-enters with the urgency it was created with; `stats()` is unchanged. The value is EXPLICIT ONLY, never inherited from `req.priority` — `self.startJob(fn, { urgency: req.priority.urgency })` is the application's one-liner, because a client-supplied ordering hint is the application's decision. Pickup: bundle RESTART (both engine files are load-once and `lib/job` is a plain-require singleton), NO re-bake.
1090
+ 330. **Fast lane (#P49) — opt-in controller-free dispatch for JSON routes, `param.lane` (0.7.2, slice 1).** A route opts in with `"param": { "lane": "<module>", "control": "<export>" }` — nested in `param` like `param.dto`, so it rides both route builders wholesale: no `lib/routing` edit, no dist rebuild; server-side only, pickup = bundle restart, no re-bake. `param.lane` names `<bundle>/lanes/<module>.js` without `.js` (`"admin/users"` → `lanes/admin/users.js`; each `/`-separated segment matches `^[A-Za-z0-9_][A-Za-z0-9_.-]*$`, so no `..`, no leading `.` or `/`, and no leading `:`, which routing would read as a URL binding). `param.control` keeps its schema-required role and names the module's OWN function export (an inherited `constructor` or `hasOwnProperty` is refused), so `bundle:openapi`'s operationId and `bundle:mcp`'s `io.gina.control` read it unchanged. A URL parameter still needs its binding in `param` (`"id": ":id"`, read as `ctx.params.id`); without it the route does not match. Handler: `module.exports.list = function (ctx) { ctx.json({...}); };` — sync or `async`, called as `fn.call(moduleExports, ctx)`; the module is required ONCE at boot, so setup goes at module scope. Registry: `process.gina._lanes['<bundle>::<lane>#<control>']`, built by `lib/lane` `registerRoutes()` from `core/server.js` `init()` before `listen`; the request path is an O(1) `lookup(req.routing)` — no fs, no `require`. Branch point: `_handleDispatch`, after the match, the `validator::` requirements, the 405, the HEAD alias and the render-cache read. On isaac the lane is the TERMINAL of the bundle's `app.use` chain (session + CSRF still run, `csrfExempt` works; `ctx.session` is `req.session`); on express the app layers already ran and the lane dispatches directly. The controller path is unchanged (no `router.js` or controller edit) and pays one property read. Skipped per lane request: the namespace probe, the options clone, the controller `require`, `inherits`, `new Controller` + `setOptions` (incl. the views block), route middleware, the gates. `ctx` (prototype methods; detached use needs `bind`): `req`/`res` (nulled once answered — a late `json()`/`error()` logs `[ Lane ] … called after the response was released` and returns `false`), `params`, `get` (`req.get`, set on GET/HEAD only), `body` (`req.body`, the POST/PUT/PATCH alias), `routing`, `requestId`, `culture`, `session` (getter), `json(data)`, `error(...)` (`throwError` is an alias), `getConfig(name?)` (the copy-on-write view; `controller.getConfig.mode: "clone"` honoured; the proxy hostname re-point), `hasRole(role)` (the session user; its machine-caller fallback reads `req.machineCaller`, which only the authorization gate sets, so it is inert on a lane route in 0.7.2), `isXMLRequest()`, `pauseRequest(data, storage?)` (the controller's halted-request snapshot). Not on `ctx`: `query`, the `render*` methods, `redirect`, `store`, `startJob`, `t`, `audit`, early hints, trailers, `setPriority` — a route needing one stays a controller route. `ctx.json()` follows `renderJSON`: a `status` key naming a listed non-200 code sets the status (an `errno` alone never does); `param.responseDto` shapes a 2xx; one `JSON.stringify`; the idempotency record at the same point; the access line `METHOD [status] url`; HEAD = headers + `content-length`, no body; written through raw HTTP/2 `stream.respond()` (folding `res.getHeaders()`), the HTTP/2 send shim isaac installs whenever its middleware chain runs (so a wrapping session middleware's `writeHead`/`end` fire), or HTTP/1.x with an explicit `content-length`. `ctx.error()` resolves the controller `throwError` call shapes (`(errorObj)`, `(err)`, `(status, msg|err)`, `(status, obj)`, `(res, status, msg)`; `(status)` and `()` answer the status-text envelope where the controller throws or no-ops) into `{ status, error: <status text>, message, fields | errors, stack (local scope only — outside it a stack-bearing title/message/error keeps its first line), ref }` — `ref` 6 uppercase hex, or a relay-safe supplied one — plus ONE pairing line `[ BUNDLE ][ <bundle> ][ Lane ][ ref X ][ req <id> ] METHOD [ status ] url` followed by the full detail (stack, `cause`); it writes `writeHead(code, { content-type, content-length })` + `end`, so headers set earlier (CORS, `X-Request-Id`, `Retry-After`) are kept. Always JSON: no HTML error page, no `fallback`. A handler's sync throw or rejection answers `ctx.error(500, err)` (a non-Error value is wrapped) — a DIFFERENT body from a controller route's thrown action, which the router answers through the server-level `throwError(res, 500, err.stack)` as `{ status, error: <the error text>, ref }` with no `message`. A handler that never answers leaves the response open; the dispatcher never returns the handler's promise and never calls `next`. BOOT REFUSALS (``[ SERVER ] Route `<rule>`: …``, exit before listen): a malformed lane name; `param.control` empty, non-string, `onReady`, `setup` or `redirect`; a rule kept for another bundle; route `middleware`, incl. one inherited from `routing.global.json`; `cache`; `negotiate`; `namespace`; `method: "ws"`; in 0.7.2 every gate key — `param.requireAuth` (unless `false`), `param.roles`, `param.policy`, `rateLimit` or `idempotency` (unless `false`/`null`), `param.messageValidator`, `param.dto` — and the implicit gates: `auth.requireAuthByDefault: true` without `param.public: true`, `server.rateLimit.enabled: true` without `"rateLimit": false`; a missing module (with a hint when the name ends in `.js`), a module that fails to load (the refusal carries the runtime's own error: Node's SyntaxError stack, Bun's `BuildMessage` text), or no own function export of that name. Allowed: `param.responseDto`, `csrfExempt`, `requirements`, `scopes`, `param.public`, `param.requireAuth: false`, `rateLimit: false`. A lane route the bundle did not register at boot, or whose module failed to reload, answers 500 naming it — never a fall-through to a controller. DEV RELOAD: `core/gna.js` watches the `lanes/` root and every directory holding a registered module, for `change` and `rename` (an atomic save), and sets `__hotReload.lane`; the next lane dispatch re-requires every registered module, re-resolves the exports and prunes `lib/lane`'s `module.children`. The watcher block runs inside `gna.onStarted`: a bundle whose `index.js` never calls `onStarted()` (the scaffold leaves it commented out) has no watcher, and the lane then reloads its modules on EVERY lane request in dev — like the controllers. Production loads them once. Observability is inherited: request id, `X-Request-Id` echo, JSON-log `requestId`/`durationMs`, the metrics `route` label, the access line; Inspector Flow bars `lane-dispatch` + `response-write`; the Inspector Query tab does NOT capture a lane handler's queries (only the controller's `setOptions` enters the query-capture context). Engines: isaac HTTP/1.1 and HTTP/2; the Express adapter over HTTP/1.1 (it does not serve HTTP/2). Measured (one process serving a controller route, a lane route and the router-entry floor, mirrored rounds, HTTP/1.1 loopback, ten short records, access line on): lane/controller a median 0.39× CPU (0.25–0.54), about 150 µs saved per request; without the access line the lane costs no more than the router-entry floor. Migration trap: a route that already carried a `param.lane` key for its own data now opts in — rename the key. Running the gates on lane routes (slice 2: the five gate libraries call only `throwError` and `pauseRequest` on the object they are given, so `ctx` can be their egress unchanged) is NOT scheduled.