ecoportal-api-graphql 1.3.13 → 1.3.16

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 (275) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +87 -0
  3. data/lib/ecoportal/api/common/graphql/client.rb +0 -2
  4. data/lib/ecoportal/api/common/graphql/http_client.rb +6 -0
  5. data/lib/ecoportal/api/graphql/base/ai_summary_version.rb +17 -17
  6. data/lib/ecoportal/api/graphql/base/file_container.rb +14 -14
  7. data/lib/ecoportal/api/graphql/base/location_classification_type.rb +15 -15
  8. data/lib/ecoportal/api/graphql/base/location_structure.rb +27 -27
  9. data/lib/ecoportal/api/graphql/base/organization.rb +15 -15
  10. data/lib/ecoportal/api/graphql/base/page/task.rb +24 -0
  11. data/lib/ecoportal/api/graphql/base/page.rb +17 -16
  12. data/lib/ecoportal/api/graphql/base/template.rb +35 -0
  13. data/lib/ecoportal/api/graphql/base.rb +37 -35
  14. data/lib/ecoportal/api/graphql/builder/contractor_entity.rb +41 -41
  15. data/lib/ecoportal/api/graphql/compat/response.rb +35 -35
  16. data/lib/ecoportal/api/graphql/connection/action.rb +11 -11
  17. data/lib/ecoportal/api/graphql/connection/action_category.rb +11 -11
  18. data/lib/ecoportal/api/graphql/connection/contractor_entity.rb +11 -11
  19. data/lib/ecoportal/api/graphql/connection/page.rb +11 -11
  20. data/lib/ecoportal/api/graphql/connection/person_member.rb +11 -11
  21. data/lib/ecoportal/api/graphql/connection/preview_page.rb +11 -11
  22. data/lib/ecoportal/api/graphql/connection.rb +17 -17
  23. data/lib/ecoportal/api/graphql/file_upload/client.rb +21 -10
  24. data/lib/ecoportal/api/graphql/fragment/field_configuration.rb +38 -0
  25. data/lib/ecoportal/api/graphql/fragment/force.rb +14 -10
  26. data/lib/ecoportal/api/graphql/fragment/location_node.rb +26 -26
  27. data/lib/ecoportal/api/graphql/fragment/page_task.rb +39 -0
  28. data/lib/ecoportal/api/graphql/fragment/template.rb +51 -0
  29. data/lib/ecoportal/api/graphql/fragment.rb +3 -0
  30. data/lib/ecoportal/api/graphql/input/contractor_entity.rb +14 -14
  31. data/lib/ecoportal/api/graphql/input/preset_view/update.rb +16 -16
  32. data/lib/ecoportal/api/graphql/input/workflow_command/add_stage.rb +18 -18
  33. data/lib/ecoportal/api/graphql/input/workflow_command/edit_field_configuration.rb +21 -21
  34. data/lib/ecoportal/api/graphql/input/workflow_command/edit_page.rb +28 -28
  35. data/lib/ecoportal/api/graphql/input/workflow_command/edit_page_creator_permissions.rb +18 -18
  36. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/cross_reference.rb +23 -23
  37. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/date.rb +20 -20
  38. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/image_gallery.rb +20 -20
  39. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/people.rb +24 -24
  40. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/plain_text.rb +20 -20
  41. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/rich_text.rb +20 -20
  42. data/lib/ecoportal/api/graphql/input/workflow_command/field_config/select.rb +20 -20
  43. data/lib/ecoportal/api/graphql/input/workflow_command/move_field.rb +18 -18
  44. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_forces.rb +18 -18
  45. data/lib/ecoportal/api/graphql/input/workflow_command/reorder_section.rb +18 -18
  46. data/lib/ecoportal/api/graphql/input/workflow_command.rb +251 -251
  47. data/lib/ecoportal/api/graphql/input.rb +24 -24
  48. data/lib/ecoportal/api/graphql/logic/connection.rb +34 -34
  49. data/lib/ecoportal/api/graphql/model/page/task.rb +16 -0
  50. data/lib/ecoportal/api/graphql/model/page.rb +16 -15
  51. data/lib/ecoportal/api/graphql/model/person_member.rb +15 -15
  52. data/lib/ecoportal/api/graphql/model/template.rb +15 -0
  53. data/lib/ecoportal/api/graphql/model.rb +32 -30
  54. data/lib/ecoportal/api/graphql/mutation/ai_summary/generate.rb +45 -45
  55. data/lib/ecoportal/api/graphql/mutation/ai_summary/submit_feedback.rb +40 -40
  56. data/lib/ecoportal/api/graphql/mutation/contractor_entity.rb +14 -14
  57. data/lib/ecoportal/api/graphql/mutation/page/approve_review_task.rb +40 -40
  58. data/lib/ecoportal/api/graphql/mutation/page/batch_update_review_task.rb +38 -40
  59. data/lib/ecoportal/api/graphql/mutation/page/create_draft.rb +38 -40
  60. data/lib/ecoportal/api/graphql/mutation/page/delete_draft.rb +38 -40
  61. data/lib/ecoportal/api/graphql/mutation/page/execute_force_commands.rb +7 -7
  62. data/lib/ecoportal/api/graphql/mutation/page/execute_workflow_commands.rb +8 -7
  63. data/lib/ecoportal/api/graphql/mutation/page/publish_draft.rb +38 -40
  64. data/lib/ecoportal/api/graphql/mutation/page/reject_review_task.rb +40 -40
  65. data/lib/ecoportal/api/graphql/mutation/page/restart_review_task.rb +40 -40
  66. data/lib/ecoportal/api/graphql/mutation/page/undo_review_task.rb +40 -40
  67. data/lib/ecoportal/api/graphql/mutation/preset_view/destroy.rb +34 -35
  68. data/lib/ecoportal/api/graphql/mutation/register/destroy.rb +35 -35
  69. data/lib/ecoportal/api/graphql/mutation/smart_fill/generate.rb +36 -36
  70. data/lib/ecoportal/api/graphql/mutation/smart_fill/submit_feedback.rb +40 -40
  71. data/lib/ecoportal/api/graphql/mutation/smart_fill.rb +13 -13
  72. data/lib/ecoportal/api/graphql/mutation/template/create_related_page.rb +46 -46
  73. data/lib/ecoportal/api/graphql/mutation/template/destroy_related_page.rb +1 -1
  74. data/lib/ecoportal/api/graphql/mutation/template/update_information.rb +1 -1
  75. data/lib/ecoportal/api/graphql/mutation.rb +20 -20
  76. data/lib/ecoportal/api/graphql/payload/contractor_entity.rb +14 -14
  77. data/lib/ecoportal/api/graphql/payload/ok_payload.rb +36 -21
  78. data/lib/ecoportal/api/graphql/payload/page/draft.rb +26 -13
  79. data/lib/ecoportal/api/graphql/payload/page/review_task.rb +13 -13
  80. data/lib/ecoportal/api/graphql/payload/page/review_task_batch.rb +23 -0
  81. data/lib/ecoportal/api/graphql/payload/page.rb +20 -19
  82. data/lib/ecoportal/api/graphql/payload/preset_view.rb +15 -11
  83. data/lib/ecoportal/api/graphql/payload/register.rb +15 -11
  84. data/lib/ecoportal/api/graphql/payload/template/create_related_page.rb +1 -1
  85. data/lib/ecoportal/api/graphql/payload/template/destroy_related_page.rb +1 -1
  86. data/lib/ecoportal/api/graphql/payload/template/update_information.rb +1 -1
  87. data/lib/ecoportal/api/graphql/payload.rb +23 -23
  88. data/lib/ecoportal/api/graphql/query/location_structures.rb +57 -57
  89. data/lib/ecoportal/api/graphql/query/page.rb +45 -45
  90. data/lib/ecoportal/api/graphql/query/page_with_forces.rb +9 -3
  91. data/lib/ecoportal/api/graphql/query/pages_workflow_commands.rb +9 -3
  92. data/lib/ecoportal/api/graphql/query/register_preset_views.rb +78 -78
  93. data/lib/ecoportal/api/graphql/query.rb +27 -27
  94. data/lib/ecoportal/api/graphql.rb +9 -5
  95. data/lib/ecoportal/api/graphql_version.rb +1 -1
  96. metadata +9 -180
  97. data/.ai-assistance/bridge/CLAUDE.md +0 -338
  98. data/.ai-assistance/bridge/archive/.gitkeep +0 -0
  99. data/.ai-assistance/bridge/archive/oscar-a1b2c3d-gitlab-mcp-doc-update.inbox.md +0 -29
  100. data/.ai-assistance/bridge/archive/oscar-a1b2c3d-gitlab-mcp-doc-update.outbox.md +0 -18
  101. data/.ai-assistance/bridge/archive/oscar-c912c25-gemini-design-review.inbox.md +0 -42
  102. data/.ai-assistance/bridge/archive/oscar-c912c25-gemini-design-review.outbox.md +0 -115
  103. data/.ai-assistance/bridge/context/gemini-review-prompt.txt +0 -48
  104. data/.ai-assistance/bridge/context/gemini-review-response.md +0 -104
  105. data/.ai-assistance/bridge/context/project.md +0 -42
  106. data/.ai-assistance/bridge/inbox/.gitkeep +0 -0
  107. data/.ai-assistance/bridge/outbox/.gitkeep +0 -0
  108. data/.ai-assistance/bridge/outbox/request-for-standards-discovery.md +0 -48
  109. data/.ai-assistance/bridge/queue/.gitkeep +0 -1
  110. data/.ai-assistance/capabilities/CLAUDE.md +0 -27
  111. data/.ai-assistance/capabilities/assumptions-log.md +0 -80
  112. data/.ai-assistance/capabilities/code.md +0 -47
  113. data/.ai-assistance/capabilities/connectors.md +0 -37
  114. data/.ai-assistance/capabilities/cowork.md +0 -55
  115. data/.ai-assistance/code/OVERVIEW.md +0 -155
  116. data/.ai-assistance/code/data_fields.md +0 -242
  117. data/.ai-assistance/code/dependencies.md +0 -151
  118. data/.ai-assistance/code/diff_as_input.md +0 -234
  119. data/.ai-assistance/code/diff_pairing_engine.md +0 -243
  120. data/.ai-assistance/code/diff_service_deep_dive.md +0 -192
  121. data/.ai-assistance/code/ecoPortal_architecture/00_overview_and_index.md +0 -55
  122. data/.ai-assistance/code/ecoPortal_architecture/01_terminology_dictionary.md +0 -181
  123. data/.ai-assistance/code/ecoPortal_architecture/02_data_model.md +0 -192
  124. data/.ai-assistance/code/ecoPortal_architecture/03_api_layers.md +0 -147
  125. data/.ai-assistance/code/ecoPortal_architecture/04_graphql_queries_mutations.md +0 -277
  126. data/.ai-assistance/code/ecoPortal_architecture/05_page_workflows.md +0 -200
  127. data/.ai-assistance/code/ecoPortal_architecture/06_search_and_filters.md +0 -228
  128. data/.ai-assistance/code/ecoPortal_architecture/07_data_fields.md +0 -197
  129. data/.ai-assistance/code/ecoPortal_architecture/08_stages_sections.md +0 -243
  130. data/.ai-assistance/code/ecoPortal_architecture/09_people_contractors_locations.md +0 -196
  131. data/.ai-assistance/code/ecoPortal_architecture/10_forces_workflow_builder.md +0 -132
  132. data/.ai-assistance/code/ecoPortal_architecture/11_integration_gems.md +0 -187
  133. data/.ai-assistance/code/ecoPortal_architecture/12_ai_documentation_sources_gaps.md +0 -236
  134. data/.ai-assistance/code/ecoPortal_architecture/13_ai_infrastructure.md +0 -183
  135. data/.ai-assistance/code/ecoportal_schema_reference.md +0 -240
  136. data/.ai-assistance/code/filter_contract_matrix.md +0 -177
  137. data/.ai-assistance/code/graphql_domain_knowledge.md +0 -240
  138. data/.ai-assistance/code/refactoring/datafield-readwrite-shape-asymmetry.md +0 -71
  139. data/.ai-assistance/code/refactoring/opportunities.md +0 -251
  140. data/.ai-assistance/code/schema_analysis.md +0 -321
  141. data/.ai-assistance/code/search_filters.md +0 -868
  142. data/.ai-assistance/code/spec_coverage.md +0 -73
  143. data/.ai-assistance/code/template_diff_pairing_domain.md +0 -175
  144. data/.ai-assistance/code/workflow-command-guide.md +0 -466
  145. data/.ai-assistance/code/workflow-space.md +0 -353
  146. data/.ai-assistance/conventions/CLAUDE.md +0 -30
  147. data/.ai-assistance/conventions/code-working-tree-protocol.md +0 -199
  148. data/.ai-assistance/conventions/gitignore-rules.md +0 -42
  149. data/.ai-assistance/conventions/permission-guidance.md +0 -120
  150. data/.ai-assistance/integrations/README.md +0 -70
  151. data/.ai-assistance/integrations/gitkraken-mcp.md +0 -107
  152. data/.ai-assistance/integrations/gitlab-mcp.md +0 -123
  153. data/.ai-assistance/integrations/local-git.md +0 -60
  154. data/.ai-assistance/local_paths.example.md +0 -17
  155. data/.ai-assistance/projects/TODO.md +0 -118
  156. data/.ai-assistance/projects/api-v2-to-graphql-migration/DECISIONS.md +0 -168
  157. data/.ai-assistance/projects/api-v2-to-graphql-migration/INTENT.md +0 -60
  158. data/.ai-assistance/projects/api-v2-to-graphql-migration/TODO.md +0 -267
  159. data/.ai-assistance/projects/api-v2-to-graphql-migration/UPSTREAM.md +0 -53
  160. data/.ai-assistance/projects/api-v2-to-graphql-migration/notes/csv-template-pipeline-design.md +0 -102
  161. data/.ai-assistance/projects/api-v2-to-graphql-migration/notes/cutover-usecase-gap-audit.md +0 -139
  162. data/.ai-assistance/projects/compat-layer-audit/COMPAT_AUDIT.md +0 -244
  163. data/.ai-assistance/projects/dynamic-model-generation/INTENT.md +0 -93
  164. data/.ai-assistance/projects/eco-helpers-compat/INTENT.md +0 -244
  165. data/.ai-assistance/projects/eco-helpers-compat/MIGRATION_GUIDE.md +0 -266
  166. data/.ai-assistance/projects/eco-helpers-compat/TODO.md +0 -86
  167. data/.ai-assistance/projects/ecoportal-api-v2-doublemodel-review/INTENT.md +0 -101
  168. data/.ai-assistance/projects/graphql-agent/GAP_ANALYSIS.md +0 -177
  169. data/.ai-assistance/projects/ooze-graphql-native-migration/DECISIONS.md +0 -161
  170. data/.ai-assistance/projects/ooze-graphql-native-migration/INTENT.md +0 -125
  171. data/.ai-assistance/projects/ooze-graphql-native-migration/INVENTORY.md +0 -136
  172. data/.ai-assistance/projects/ooze-graphql-native-migration/RISKS.md +0 -126
  173. data/.ai-assistance/projects/ooze-graphql-native-migration/TODO.md +0 -261
  174. data/.ai-assistance/projects/ooze-graphql-native-migration/analysis/2026-06-30-cutover-workflow-deep-review.md +0 -122
  175. data/.ai-assistance/projects/ooze-graphql-native-migration/analysis/2026-07-01-forces-via-workflow-commands-miss-rca.md +0 -148
  176. data/.ai-assistance/projects/page-model/DECISIONS.md +0 -245
  177. data/.ai-assistance/projects/page-model/TODO.md +0 -190
  178. data/.ai-assistance/projects/qa-services-delivery/DECISIONS.md +0 -93
  179. data/.ai-assistance/projects/qa-services-delivery/INTENT.md +0 -76
  180. data/.ai-assistance/projects/qa-services-delivery/PHASE3-SCOPE.md +0 -115
  181. data/.ai-assistance/projects/qa-services-delivery/ROADMAP.md +0 -99
  182. data/.ai-assistance/projects/qa-services-delivery/TODO.md +0 -81
  183. data/.ai-assistance/projects/search-filter-builder/INTENT.md +0 -107
  184. data/.ai-assistance/projects/search-filter-builder/TODO.md +0 -131
  185. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +0 -87
  186. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +0 -108
  187. data/.ai-assistance/projects/template-diff-deploy/INTENT.md +0 -12
  188. data/.ai-assistance/projects/template-diff-deploy/TODO.md +0 -9
  189. data/.ai-assistance/projects/template-maintenance/DESIGN.md +0 -134
  190. data/.ai-assistance/projects/template-maintenance/PHASE0-FINDINGS.md +0 -93
  191. data/.ai-assistance/projects/template-maintenance/README.md +0 -14
  192. data/.ai-assistance/projects/template-model-logic/CATEGORY_CATALOG.md +0 -236
  193. data/.ai-assistance/projects/template-model-logic/CLASSIFICATION_SPIKE.md +0 -243
  194. data/.ai-assistance/projects/template-model-logic/DESIGN_NOTE.md +0 -154
  195. data/.ai-assistance/projects/workflow-space/TODO.md +0 -213
  196. data/.ai-assistance/reinstall-claude-desktop-windows.md +0 -136
  197. data/.ai-assistance/scripts/CLAUDE.md +0 -150
  198. data/.ai-assistance/scripts/bridge-inbox-check.sh +0 -75
  199. data/.ai-assistance/scripts/bridge-init.sh +0 -86
  200. data/.ai-assistance/scripts/bridge-status.sh +0 -44
  201. data/.ai-assistance/scripts/capabilities-check.ts +0 -104
  202. data/.ai-assistance/scripts/check-outbox.sh +0 -43
  203. data/.ai-assistance/scripts/dep_graph.rb +0 -91
  204. data/.ai-assistance/scripts/lock-acquire.sh +0 -103
  205. data/.ai-assistance/scripts/lock-multi.sh +0 -124
  206. data/.ai-assistance/scripts/lock-queue.sh +0 -94
  207. data/.ai-assistance/scripts/setup-mcps.test.ts +0 -188
  208. data/.ai-assistance/scripts/setup-mcps.ts +0 -234
  209. data/.ai-assistance/scripts/task-complete.ts +0 -74
  210. data/.ai-assistance/scripts/task-create.ts +0 -75
  211. data/.ai-assistance/scripts/task-read.ts +0 -125
  212. data/.ai-assistance/scripts/token-logger.js +0 -220
  213. data/.ai-assistance/scripts/token-report.ts +0 -158
  214. data/.ai-assistance/scripts/token-session-start.js +0 -66
  215. data/.ai-assistance/skills/ai-instructions/SKILL.md +0 -48
  216. data/.ai-assistance/skills/code-specs/SKILL.md +0 -69
  217. data/.ai-assistance/skills/corporate-policies/SKILL.md +0 -201
  218. data/.ai-assistance/skills/dep-graph/SKILL.md +0 -139
  219. data/.ai-assistance/skills/ep-ai-manager/SKILL.md +0 -417
  220. data/.ai-assistance/skills/gemini-assist/SKILL.md +0 -63
  221. data/.ai-assistance/skills/gemini-assist/gemini-mcp-server.js +0 -205
  222. data/.ai-assistance/skills/gemini-assist/gemini_ask.py +0 -1
  223. data/.ai-assistance/skills/gemini-assist/gemini_ask.rb +0 -240
  224. data/.ai-assistance/skills/gemini-assist/prompts/cycle_end_review.txt +0 -25
  225. data/.ai-assistance/skills/graphql-schema-analysis/SKILL.md +0 -261
  226. data/.ai-assistance/skills/procedural-memory/SKILL.md +0 -319
  227. data/.ai-assistance/skills/project-cycle/SKILL.md +0 -177
  228. data/.ai-assistance/skills/project-self-docs/SKILL.md +0 -181
  229. data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +0 -378
  230. data/.ai-assistance/skills/refactor/SKILL.md +0 -62
  231. data/.ai-assistance/skills/rubocop/SKILL.md +0 -93
  232. data/.ai-assistance/skills/ruby-scripting/SKILL.md +0 -215
  233. data/.ai-assistance/skills/spec-generation/SKILL.md +0 -72
  234. data/.ai-assistance/standards-version.json +0 -22
  235. data/.ai-assistance/token-budget.json +0 -32
  236. data/.ai-assistance/version.json +0 -39
  237. data/.claude/settings.json +0 -150
  238. data/.env.example +0 -18
  239. data/.gitattributes +0 -15
  240. data/.gitignore +0 -37
  241. data/.gitlab-ci.yml +0 -45
  242. data/.markdownlint.json +0 -4
  243. data/.rspec +0 -3
  244. data/.rubocop.yml +0 -121
  245. data/.ruby-version +0 -1
  246. data/.yardopts +0 -10
  247. data/CLAUDE.md +0 -243
  248. data/Gemfile +0 -30
  249. data/Rakefile +0 -90
  250. data/bin/console +0 -14
  251. data/bin/setup +0 -8
  252. data/bin/setup.ps1 +0 -6
  253. data/docs/self-docs/ARCHITECTURE.md +0 -88
  254. data/docs/self-docs/CHANGES.jsonl +0 -12
  255. data/docs/self-docs/COMPLIANCE.md +0 -79
  256. data/docs/self-docs/CONVENTIONS.md +0 -74
  257. data/docs/self-docs/INTEGRATIONS.md +0 -65
  258. data/docs/self-docs/OPERATIONS.md +0 -76
  259. data/docs/self-docs/OVERVIEW.md +0 -64
  260. data/docs/self-docs/STATUS.md +0 -73
  261. data/docs/self-docs/self-docs-index.json +0 -51
  262. data/docs/worklog.md +0 -977
  263. data/ecoportal-api-graphql.gemspec +0 -40
  264. data/scripts/auto-worker-scheduler.sh +0 -386
  265. data/tests/actions_get.rb +0 -7
  266. data/tests/contractor_entities_get.rb +0 -20
  267. data/tests/contractor_entity_create.rb +0 -19
  268. data/tests/contractor_entity_udpate.rb +0 -20
  269. data/tests/dump_page_model.rb +0 -74
  270. data/tests/dump_template_model.rb +0 -90
  271. data/tests/loc_structure_get.rb +0 -10
  272. data/tests/loc_structure_update.rb +0 -51
  273. data/tests/loc_structures_get.rb +0 -15
  274. data/tests/local_libs.rb +0 -20
  275. data/tests/validate_queries.rb +0 -127
@@ -1,234 +0,0 @@
1
- # Code Spec: `as_update` / `as_input` Pipeline
2
-
3
- **Scope:** `Common::GraphQL::Model::Diffable`, `Common::GraphQL::Model::AsInput`,
4
- `DiffService`, `ClassicDiffService`, `HashDiffNesting`
5
- **Last updated:** 2026-06-05
6
-
7
- ---
8
-
9
- ## What This Pipeline Does
10
-
11
- Models that include `AsInput` (via `Model < DoubleModel`) can:
12
- 1. **Diff themselves** against their original document via `#as_update` → returns a Hash
13
- of only the changed properties.
14
- 2. **Convert that diff** to mutation input via `#as_input` → returns a Hash ready to be
15
- passed as the `input:` variable to a GraphQL mutation.
16
-
17
- ---
18
-
19
- ## Class Hierarchy
20
-
21
- ```
22
- Common::GraphQL::Model < Common::Content::DoubleModel
23
- includes Diffable → provides #as_update, #dirty?
24
- includes AsInput → provides #as_input, .as_input(hash)
25
-
26
- Diffable::DiffService < ClassicDiffService
27
- includes HashDiffNesting → change_diff, patch_update, patch_new, patch_delete, ...
28
- includes HashHelpers
29
-
30
- Diffable::ClassicDiffService
31
- includes HashDiffNesting
32
- provides #diff, #classic_diff, #curr_doc, #prev_doc
33
- ```
34
-
35
- ---
36
-
37
- ## Current Output Format of `DiffService#diff` — KNOWN ISSUE
38
-
39
- **`#as_update` calls `DiffService#diff` which calls `classic_diff(flat: true)`, which calls
40
- `change_diff(curr_doc, prev_doc)`.**
41
-
42
- `change_diff` routes through `patch_update` for any Hash that has an `:id` key, producing:
43
-
44
- ```ruby
45
- {
46
- id: "abc123",
47
- api_operation: :update,
48
- change_data: { name: "New Name", status: "active" }
49
- }
50
- ```
51
-
52
- **This is WRONG for GraphQL mutations.** The GraphQL mutation input should be flat:
53
-
54
- ```ruby
55
- { id: "abc123", name: "New Name", status: "active" }
56
- ```
57
-
58
- **Why it's wrong:** GraphQL mutation inputs (`UpdateActionInput`, `UpdatePageInput`, etc.)
59
- do not have `api_operation` or `change_data` fields — they expect changed properties directly
60
- at the top level alongside `id`.
61
-
62
- **Root cause:** `HashDiffNesting` was ported from `ecoportal-api-v2`'s `HashDiffPatch`.
63
- The v2 gem uses JSON Patch semantics (`api_operation`, `change_data`). GraphQL mutations
64
- do not — they use direct property assignment.
65
-
66
- **Decision (2026-06-04):** GraphQL `as_update` must produce changed-props-only output with
67
- no patch-operation wrapper. See `DECISIONS.md` ("as_update diff: changed props only").
68
-
69
- **Fix required (TODO 4.2/4.3):** Either:
70
- - Override `classic_diff` in `DiffService` to call `change_data(curr, prev)` directly
71
- (returns just the changed fields, no wrapper), and re-add `id` separately; or
72
- - Have `AsInput#as_input` detect and unwrap the patch-op structure before building input.
73
- The preferred approach per the DECISIONS is to fix `DiffService` itself.
74
-
75
- ### Nested objects
76
-
77
- `diff_reduce` cascades through nested model objects (those that respond to `#as_update`)
78
- and replaces the nested path in the result with each nested model's own `as_update` output.
79
- These nested diffs also currently produce `api_operation`/`change_data` wrappers if the
80
- nested object has an `id`.
81
-
82
- For GraphQL, nested arrays of objects with IDs (e.g. `locations`, `associatedPeople`)
83
- are expressed differently per mutation — sometimes as `[ID]` arrays, sometimes as
84
- `IdDiffInput` (add/remove deltas), sometimes as full nested input objects. The `InputClass`
85
- (per the `from_model` pattern, TODO 4.3) is responsible for reshaping nested diffs into
86
- the correct format for each mutation.
87
-
88
- ---
89
-
90
- ## `AsInput` Current Behavior
91
-
92
- ### Instance method `#as_input(clientMutationId: '')`
93
-
94
- 1. Calls `as_update` → gets the diff hash (currently with patch-op wrapper, see above).
95
- 2. Calls `self.class.as_input(diff, clientMutationId: ...)`.
96
-
97
- ### Class method `.as_input(hash, clientMutationId: '')`
98
-
99
- 1. Deep-converts hash keys to symbols.
100
- 2. Merges `clientMutationId:`.
101
- 3. Calls `remove_nil_keys_deep(hash, target: :id)` — removes nil-valued `:id` keys
102
- (keeps `id:` only when it has a value; removes nil ids from nested objects so they
103
- don't confuse the server).
104
-
105
- **Current output (wrong):**
106
- ```ruby
107
- {
108
- id: "abc123",
109
- api_operation: :update,
110
- change_data: { name: "New Name" },
111
- clientMutationId: ""
112
- }
113
- ```
114
-
115
- **Desired output (target):**
116
- ```ruby
117
- { id: "abc123", name: "New Name", clientMutationId: "" }
118
- ```
119
-
120
- ---
121
-
122
- ## `patch_ver` — Concurrency Control
123
-
124
- **Schema:** `patchVer: Int!` (non-null) on `BasePageInterface`. Present on all Page types.
125
- `BasePage` in the gem maps it as `passthrough :patchVer`.
126
-
127
- **How it works:**
128
- - Every Page response includes `patchVer` (an incrementing Integer).
129
- - On `UpdatePage` mutation, the caller may pass `patchVer:` (optional).
130
- - The server checks that the submitted `patchVer` matches the current value; if not,
131
- the update is rejected (optimistic locking / last-write-wins prevention).
132
-
133
- **Current gap (TODO 4.2):** `AsInput#as_input` does not inject `patchVer` into the
134
- output. The subject model (`BasePage` subclass) holds `patchVer` via `passthrough :patchVer`
135
- but it is never extracted and included in the mutation input hash.
136
-
137
- **Fix required:** In `AsInput#as_input` (instance method), read `self.patchVer` if the
138
- model responds to it, and include it in the input hash at the top level:
139
-
140
- ```ruby
141
- def as_input(clientMutationId: '')
142
- diff = as_update # TODO: once fixed, returns flat changed props
143
- diff[:patchVer] = patchVer if respond_to?(:patchVer) && !patchVer.nil?
144
- self.class.as_input(diff, clientMutationId: clientMutationId)
145
- end
146
- ```
147
-
148
- ---
149
-
150
- ## `from_model` Pattern (TODO 4.3)
151
-
152
- **Decision (2026-06-04):** Conversion from model diff to mutation-specific input shape is
153
- owned by the `Input` class, not the model. Each mutation's input class implements
154
- `InputClass.from_model(model)`.
155
-
156
- **Why:** The model (`BasePage`, `Base::Action`, etc.) should not know which mutation it
157
- feeds. The input class knows its own schema shape (field names, nested structure, ID vs
158
- IdDiff arrays, etc.).
159
-
160
- **Pattern:**
161
- ```ruby
162
- class Input::Action::Update < Logic::Input
163
- def self.from_model(action_model)
164
- diff = action_model.as_update # after fixing: flat {name:, status:, ...}
165
- new(
166
- id: action_model.id,
167
- patchVer: action_model.patchVer, # if applicable
168
- **map_fields(diff)
169
- )
170
- end
171
-
172
- private_class_method def self.map_fields(diff)
173
- # rename, reshape, filter fields as needed for this specific mutation input
174
- diff.slice(:name, :description, :status, :dueDate, :assignedPersonMemberIds, ...)
175
- end
176
- end
177
- ```
178
-
179
- **The `as_input` instance method becomes a convenience delegator:**
180
- ```ruby
181
- def as_input(target_class: default_input_class, **kargs)
182
- target_class.from_model(self, **kargs)
183
- end
184
- ```
185
-
186
- ---
187
-
188
- ## `root?` Objects in the Diff
189
-
190
- Models marked `root!` (via `Ecoportal::API::Common::GraphQL::ClassHelpers`) are excluded
191
- from cascaded diffs. These are typically "lookup" objects that are referenced but not
192
- owned — e.g. `Base::Organization` (root!), `Base::Action` (root!). Their nested presence
193
- in a model's JSON will not generate diff entries.
194
-
195
- This is correct behaviour: you don't diff a referenced Organization when updating a Page;
196
- you only diff properties the Page itself owns.
197
-
198
- ---
199
-
200
- ## Key Files
201
-
202
- | File | Role |
203
- |------|------|
204
- | `lib/.../model.rb` | `Model < DoubleModel`, includes `Diffable` and `AsInput` |
205
- | `lib/.../model/diffable.rb` | `#as_update`, `#dirty?`, `DIFF_CLASS` |
206
- | `lib/.../model/diffable/diff_service.rb` | Main diff logic, `#diff`, `#diff_reduce` |
207
- | `lib/.../model/diffable/classic_diff_service.rb` | Base: `#curr_doc`, `#prev_doc`, `change_diff` dispatch |
208
- | `lib/.../model/diffable/hash_diff_nesting.rb` | `change_diff`, `patch_update/new/delete`, array diffing |
209
- | `lib/.../model/as_input.rb` | `#as_input`, `.as_input(hash)` class method |
210
- | `lib/.../interface/base_page.rb` | `passthrough :patchVer` — Page models carry this |
211
-
212
- ---
213
-
214
- ## Backend Reference (ecoPortal_master)
215
-
216
- - `app/graphql/types/pages/interfaces/base_page_interface.rb` — `patchVer: Int!` (non-null)
217
- - `app/graphql/mutations/pages/update.rb` — `argument :patch_ver, Integer, required: false`
218
- - `app/graphql/types/fill_in_page/inputs/page_input.rb` — Page update input fields:
219
- `locations`, `other_tags`, `time_zone`, `state`, `task_priority`, `name`, `external_id`
220
- - `app/graphql/types/fill_in_page/inputs/data_field_input.rb` — DataField inputs (complex)
221
-
222
- **UpdatePage mutation shape:**
223
- ```graphql
224
- mutation UpdatePage($id: ID!, $patch_ver: Int, $page: PageInput, $data_fields: [DataFieldInput], ...) {
225
- updatePage(id: $id, patchVer: $patch_ver, page: $page, dataFields: $data_fields, ...) {
226
- item { ...BasePageInterface }
227
- errors { ... }
228
- }
229
- }
230
- ```
231
-
232
- Note: `id`, `patchVer`, and `page` (the input object) are separate top-level arguments —
233
- not nested under a single `input:` object like Action mutations. This is a structural
234
- difference that the `from_model` pattern must account for per mutation type.
@@ -1,243 +0,0 @@
1
- # Diff — Command Synthesis, Deploy & Pairing Engine
2
-
3
- **Area:** `lib/ecoportal/api/graphql/diff/` · **Status:** built (gem branch
4
- `feature/template-diff-deploy-pairing`) · **Domain reference (read first):**
5
- `template_diff_pairing_domain.md`.
6
-
7
- This doc covers the diff → command → deploy pipeline and the cross-object pairing engine + learning
8
- ledger. `VersionDiff`/`Change` (the self-version structural diff) are covered inline in their files.
9
-
10
- ---
11
-
12
- ## 1. The pipeline
13
-
14
- ```
15
- pair (id | genome | type+label | ledger) Diff::Pairing::Engine (+ Ledger)
16
- └─► diff on paired model Diff::VersionDiff → [Change]
17
- └─► delta as WorkflowCommands Diff::CommandSynthesizer (+ IdResolver)
18
- └─► apply (gated on unsupported) Diff::Deploy#execute!
19
- ```
20
-
21
- Self-version (same object, retained ids) needs NO pairing — `VersionDiff` matches by id and the
22
- synthesiser replays as-is. Cross-object (UAT↔PROD) needs pairing to translate the ids first.
23
-
24
- ---
25
-
26
- ## 2. Classes
27
-
28
- | Class | File | Role |
29
- |---|---|---|
30
- | `Diff::Strategy` | `diff/strategy.rb` | Composable diff config (pairing × scope × move-sensitivity × intent); `#filter(changes)` |
31
- | `Diff::VersionDiff` | `diff/version_diff.rb` | Self-version (`:id` pairing) front-end → `[Change]`; accepts `strategy:` |
32
- | `Diff::CrossObjectDiff` | `diff/cross_object_diff.rb` | Cross-object front-end: pairs fields via `Pairing::Engine`, emits same `[Change]` |
33
- | `Diff::Change` | `diff/change.rb` | One change (op/kind/id/before/after/parent_id) (unchanged) |
34
- | `Diff::CommandSynthesizer` | `diff/command_synthesizer.rb` | `[Change]` → ordered built `WorkflowCommand` batch + `unsupported` |
35
- | `Diff::IdResolver` | `diff/id_resolver.rb` | Maps `resolve(kind, human-key) => id` (stage name / section heading → target id) |
36
- | `Diff::Deploy` | `diff/deploy.rb` | Orchestrates diff → commands → apply; gates on `unsupported` |
37
- | `Diff::Pairing::Signals` | `diff/pairing/signals.rb` | Weak signals (genome/type/label/options), each 0.0..1.0 or nil |
38
- | `Diff::Pairing::Candidate` | `diff/pairing/candidate.rb` | Scored source↔target proposal (`score`, `signals`, `matched_by`) |
39
- | `Diff::Pairing::Engine` | `diff/pairing/engine.rb` | Multi-signal equivalence matcher → accepted / ambiguous / unmatched |
40
- | `Diff::Pairing::Ledger` | `diff/pairing/ledger.rb` | Persisted confirmed equivalences (JSON); consulted first |
41
-
42
- ---
43
-
44
- ## 3. CommandSynthesizer — edit mode
45
-
46
- `CommandSynthesizer.new(changes, resolver: nil)`. Emits in a dependency-safe order (stages → sections
47
- → fields → options; within a kind removes → adds → moves → edits). A `command_for` result may be a
48
- single hash, an **Array** of hashes (a move that emits remove+add), or nil (unsupported); `build_all`
49
- normalises via `wrap` (NOT `Kernel#Array`, which would splat a Hash).
50
-
51
- | Change | Command(s) | Notes |
52
- |---|---|---|
53
- | stage added/removed/moved | `addStage`/`removeStage`/`moveStage` | |
54
- | stage `name` changed | `editStage(stageId, name)` | |
55
- | section added/removed | `addSection`/`removeSection` | |
56
- | section `heading` changed | `editSectionHeader(sectionId, header)` | |
57
- | **section `stage` moved** | `removeStageSection` + `addStageSection` | **needs resolver** for stage ids; else UNSUPPORTED |
58
- | field added/removed | `addField`/`removeField` | |
59
- | field `label` changed | `editFieldConfiguration(dataFieldId, label)` | |
60
- | **field `section` moved** | `moveField(id, sectionId)` | **needs resolver** for section id; else UNSUPPORTED |
61
- | field `type` changed | — | **UNSUPPORTED** (no `editFieldType`; a rebuild would drop data) |
62
- | option added/removed/changed(label,weight) | `add/remove/editSelectFieldOption` | uses `change.parent_id` (field id); nil → UNSUPPORTED |
63
- | **field_config changed** (typed byType) | `editFieldConfiguration(byType: { <key> => { <attr> => after } })` | `change.by_type` names the sub-hash (`:gauge`/`:select`/`:date`); nil by_type → UNSUPPORTED. Emitted only for confirmed read↔write-key matches (see VersionDiff) |
64
- | **gauge_stop added** | `addGaugeFieldStop(dataFieldId, threshold, color)` | threshold+color carried on `change.after`; `parent_id` = gauge field id |
65
- | **gauge_stop removed** | `removeGaugeFieldStop(dataFieldId, stopId)` | `parent_id` = gauge field id |
66
- | **gauge_stop changed** (threshold\|color) | `editGaugeFieldStop(dataFieldId, stopId, <attr>)` | one command per changed attr |
67
-
68
- **Resolver contract:** any object answering `resolve(kind, key) => id | nil`. Without one, moves stay
69
- UNSUPPORTED — the synthesiser NEVER guesses a target id. `IdResolver.from_doc(target_doc)` builds one
70
- by indexing stage names + section headings; **ambiguous keys (duplicates) resolve to nil** (safe).
71
-
72
- ---
73
-
74
- ## 4. Deploy
75
-
76
- ```ruby
77
- plan = Diff::Deploy.from_versions(before_doc, after_doc) # self-version replay
78
- plan = Diff::Deploy.from_versions(uat_v1, uat_v2, target_doc: prod_doc) # cross-object: moves resolve vs PROD
79
- plan.commands # ordered built command hashes, ready for executeWorkflowCommands
80
- plan.unsupported # [Change] needing human handling (never guessed)
81
- plan.changelog # human one-liners
82
- plan.execute!(page) # sends via page.execute_workflow_commands; RAISES if unsupported unless allow_partial: true
83
- ```
84
-
85
- Inert until `execute!` with an explicit executor. `from_versions` derives an `IdResolver` from
86
- `target_doc:` (override with `resolver:`).
87
-
88
- ---
89
-
90
- ## 5. Pairing engine (equivalence matching)
91
-
92
- Cross-object pairing is entity-resolution, not lookup (no shared Mongo ids). `Engine#pair(sources,
93
- targets)` returns `Result(accepted, ambiguous, unmatched)`:
94
-
95
- 1. **Ledger first** — a previously-confirmed pair auto-resolves (`matched_by: :ledger`, score 1.0),
96
- consuming the target so it can't be re-assigned. Pairing improves over time.
97
- 2. **Score the rest** — every remaining source×target via `Signals`, weighted mean over the signals
98
- that APPLY (nil signals excluded so a select field isn't penalised for absent genome).
99
- 3. **Greedy one-to-one assignment**, strongest links first.
100
- 4. **Classify:** `accepted` (score ≥ `accept_threshold` 0.85 AND not within `tie_margin` 0.1 of the
101
- runner-up) · `ambiguous` (0.5–0.85, or a near-tie) · `unmatched` (< 0.5 review_threshold).
102
-
103
- **Signals + weights:** genome 0.5, type 0.2, label 0.2, options 0.1.
104
- - **genome** — match 1.0, mismatch 0.0 (does NOT veto — a field may be re-purposed keeping its
105
- genome, or genome may be absent on newer types), nil when neither side carries one. Strong but
106
- **fallible** (6 documented failure modes — see domain ref §5).
107
- - **type** — `__typename` equality (a type change disqualifies a data pairing).
108
- - **label** — case/space-insensitive exact = 1.0, else token Jaccard (partial credit for a re-purpose
109
- candidate like `Date logged` → `Date of sign-off`).
110
- - **options** — select options overlap by **value** (primary) / label — the correct option identity,
111
- never genome; nil for non-select fields.
112
-
113
- **Never guesses:** only `accepted` is safe to auto-apply/record; `ambiguous`+`unmatched` are surfaced
114
- for a human. A same-label/type field whose genome CONTRADICTS is pulled below accept (→ ambiguous or
115
- unmatched), never auto-paired.
116
-
117
- ---
118
-
119
- ## 6. Learning ledger (first-class artifact)
120
-
121
- `Ledger` records only CONFIRMED equivalences (auto-accepted high-confidence or human-adjudicated),
122
- keyed by `(kind, source_id)`, storing `target_id` + `matched_by` + `confidence` + `signals` +
123
- `recorded_at`. `Engine#confirm!(candidate, matched_by: :human)` writes back. JSON round-trips via
124
- `Ledger.load(path)` / `#save`; a missing file yields an empty writable ledger; a later record
125
- supersedes an earlier one (human corrects an auto-accept). The how-resolved log is the interim bridge
126
- data Product's Field-ID / template-entity-id effort needs.
127
-
128
- > Note the struct member is `matched_by`, NOT `method` — `method` would shadow `Object#method`
129
- > (`Lint/StructNewOverride`). The `Ledger#record` / `Engine#confirm!` keyword is likewise `matched_by:`.
130
-
131
- ---
132
-
133
- ## 7. Gauge stops + typed byType config emission (round #3 — DONE)
134
-
135
- `VersionDiff` now emits two new `Change` kinds (id-paired, self-version):
136
-
137
- - **`:gauge_stop`** — diffs each Gauge field's `stops` list (`{ id, threshold, color }`, matched by
138
- retained stop id): added (carries `after: { threshold:, color: }`), removed, changed
139
- (`threshold`/`color`). `parent_id` = the gauge field id.
140
- - **`:field_config`** — typed per-field configuration changes, `parent_id` = field id and `by_type`
141
- = the `editFieldConfiguration.byType` sub-hash key. Emission is **data-driven + conservative**
142
- (`VersionDiff::BYTYPE_CONFIG`): only where the READ doc property name matches the WRITE byType
143
- input key EXACTLY. Currently confirmed against the schema + input classes:
144
- - `Gauge` → `{ gauge: [max] }`
145
- - `Select` → `{ select: [dataType, multiple, flat, other, otherDesc] }`
146
- - `Date` → `{ date: [showTime, pastOnly, todayButton] }`
147
- Anything else (e.g. PlainText `multiline` — not a `plainText` byType key; `required` — no
148
- editFieldConfiguration key at all) is **not emitted** (unmappable → never fabricated).
149
-
150
- `Stop` shape and the gauge byType `max` verified against
151
- `.ai-assistance/tmp/20260605T101224_live_ep_graphql_schema.graphql.json` (`Stop { id, threshold: Float,
152
- color: String }`; `WorkflowEditFieldConfigurationGaugeInput { max: Float }`).
153
-
154
- ## 7b. placeholderId threading (dependent intra-batch creates — round #3 — DONE)
155
-
156
- `CommandSynthesizer.new(changes, thread_placeholders:)` (Deploy passes `true` by default). When on,
157
- every ADDED structural node (stage/section/field) is assigned a deterministic client-chosen
158
- `placeholderId` keyed by its source id; `addStage`/`addSection`/`addField` emit that placeholder, and
159
- any later command in the SAME batch that references such a node (`addSelectFieldOption` /
160
- `add/edit/removeGaugeFieldStop` / `editFieldConfiguration` dataFieldId) is rewritten to the
161
- placeholder instead of the target-invalid source id (`ref(id)` helper). References to pre-existing
162
- nodes keep their real id. Threading OFF → empty map → the source ids pass through unchanged (the
163
- prior self-version behaviour). This makes add-then-reference sequences self-consistent within one
164
- `executeWorkflowCommands` call. The placeholderId inputs are native to the command modules
165
- (`addStage`/`addSection`/`addField` all list `placeholderId` in VALID_KEYS; `addGaugeFieldStop`
166
- accepts an optional one too).
167
-
168
- **Note on self-version scope:** a self-version `VersionDiff` subsumes a NEW field's options/config
169
- into its single `added`-field change (children are not separately emitted when the parent is brand
170
- new), so the natural self-version batch has no add-then-reference pair. Threading is therefore
171
- latent for pure self-version replay and load-bearing for the CROSS-OBJECT / assembled change-set
172
- replay (a field created in the batch referenced by later commands). Deploy is proven end-to-end
173
- against such an assembled change-set.
174
-
175
- ## 7c. What remains UNSUPPORTED / deferred
176
-
177
- - **Field type change** — no schema command; would need remove+add (drops data) → always UNSUPPORTED.
178
- - **Moves without a resolver** — field/section moves need a target id; nil resolver → UNSUPPORTED.
179
- - **Non-confirmed byType config props** — anything outside `BYTYPE_CONFIG` (PlainText `multiline`,
180
- `required`, RichText `markdown`/`content`, People/Table/CrossReference/etc. bodies): the read
181
- fragment and/or the byType input keys were not confirmed to line up 1:1, so they are NOT emitted
182
- (never fabricated). Extend `BYTYPE_CONFIG` per type once the read shape is confirmed.
183
- - **Gauge stop reordering** — stops are matched by id (add/remove/attr change) but a pure reorder is
184
- not modelled (no ordering field surfaced on `Stop`); left UNSUPPORTED.
185
- - **Structural cross-references in emitted adds** — `addField`/`addSection` still emit only
186
- label/placeholder (not `stageId`/`sectionId` back-refs to their added parent). Once those refs are
187
- emitted, placeholder threading will thread them too (the primitive is already in place).
188
- - **Interactive assisted-resolution UX + `TypedFieldsPairing` as an extra signal** — eco-helpers layer.
189
-
190
- ## 8. Diff modalities — `Strategy` + `CrossObjectDiff` (Phase 4 — DONE)
191
-
192
- Diffing is a COMPOSABLE FAMILY (domain ref §8), not one diff. `Diff::Strategy` is the value object
193
- over four axes:
194
-
195
- - **pairing** `:id` (self-version, default) · `:genome` · `:type_label` · `:assisted` (full engine).
196
- `:id` needs no pairing map; the other three are `cross_object?` (need one).
197
- - **scope** `:structural` (all) · `:config_only` (field_config/option/gauge_stop) · `:data_migration`
198
- (field changes only).
199
- - **move_sensitive** — false suppresses `:moved` changes; config_only/data_migration never emit moves.
200
- - **intent** `:changelog` · `:deploy` · `:sync_readiness` (documentation; `:deploy` keeps threading on).
201
-
202
- `Strategy#filter(changes)` applies scope + move-sensitivity to a computed change-set. `Strategy.default`
203
- is `{id, structural, move-aware, changelog}` — EXACTLY the pre-Phase-4 `VersionDiff` behaviour (BC).
204
-
205
- `VersionDiff.new(before, after, strategy:)` is the `:id` front-end: it computes the full id-paired
206
- change-set once (`all_changes`) and returns `strategy.filter(all_changes)`. Default = unchanged.
207
-
208
- `CrossObjectDiff.new(source, target, engine:, strategy:)` is the cross-object front-end for two docs
209
- with NO shared ids:
210
- 1. flatten each doc's data-fields; `engine.pair(source_fields, target_fields)` (genome+type+label+
211
- options, ledger-first);
212
- 2. for each ACCEPTED pair emit `:changed(label|type)` keyed by the **source** id; target-only field →
213
- `:added`; a source with no accepted target AND not in the review set → `:removed`;
214
- 3. **never guesses** — ambiguous + unmatched sources/targets are held in `#unresolved` (surfaced for a
215
- human), NOT auto-paired / auto-added / auto-removed. A same-genome RELABEL is ambiguous by design
216
- (could be a re-purpose) → not auto-emitted unless the ledger/human confirms the pair.
217
-
218
- The emitted `[Change]` feeds the EXISTING `CommandSynthesizer` / `Deploy` unchanged (one emission
219
- layer). `Deploy.from_cross_object(source, target, engine:, strategy:)` + `Deploy#pairing` expose the
220
- accepted/ambiguous/unmatched for adjudication before apply.
221
-
222
- ## 9. Unified BUILD emitter — `Builder::TemplateBuilder` (shares the emission layer)
223
-
224
- `lib/ecoportal/api/graphql/builder/template_builder.rb`. Turns a declarative template spec
225
- (stages→sections→fields→options→config→gauge-stops) into an ordered `WorkflowCommand` batch for
226
- `Builder::Template#create/update(commands:)`. Order: addStage → addSection → addStageSection →
227
- editSectionHeader → addField → addSelectFieldOption / addGaugeFieldStop / editFieldConfiguration(byType:).
228
-
229
- It uses the SAME `placeholderId` id-threading primitive as `CommandSynthesizer`: every created node
230
- gets a deterministic client-chosen `placeholderId` and later same-batch commands reference it (stage
231
- placeholder ← addStageSection.stageId + addField.stageId; section placeholder ← addStageSection.sectionId
232
- + addField.sectionId; field placeholder ← option/stop/config dataFieldId). So BUILD (add everything)
233
- and DIFF-DEPLOY (add/remove/edit the delta) emit through ONE layer. Only VALID command-input keys are
234
- emitted (input classes slice + compact); config is passed through under its byType sub-hash exactly as
235
- supplied — nothing is fabricated. UNSUPPORTED in a build spec (deferred): forces/strategies/callbacks/
236
- tasks/recipients (shape unconfirmed); `field_type` enum not schema-validated (passed through as given).
237
-
238
- ## 10. Related
239
-
240
- - `template_diff_pairing_domain.md` — the full problem space (READ FIRST).
241
- - `lib/.../input/workflow_command.rb` — the command vocabulary both emitters target.
242
- - `.ai-assistance/projects/template-automatic-build-maintenance/{INTENT,TODO}.md` (the merged plan;
243
- `template-diff-deploy/` + `template-maintenance/` are now pointers).
@@ -1,192 +0,0 @@
1
- # Code Spec: DiffService Deep Dive
2
-
3
- **Scope:** `DiffService`, `ClassicDiffService`, `HashDiffNesting`, `AsInput` — bugs discovered
4
- during 4.4 spec work, current state, and what still needs fixing.
5
- **Last updated:** 2026-06-05
6
-
7
- ---
8
-
9
- ## Current State (post-4.2 / pre-4.4 completion)
10
-
11
- ### What works
12
- - **Flat diffs on simple models** (no cascade): `change_data` produces correct output
13
- after key normalisation fix (see below).
14
- - **`patchVer` injection** in `AsInput#as_input`.
15
- - **`from_model` delegation** via `Logic::Input.from_model`.
16
- - **`ContractorEntity::Update.from_model`** IdDiff reshaping.
17
-
18
- ### What is still broken / incomplete
19
- See TODO 4.4 for the spec gaps. Key issues:
20
-
21
- ---
22
-
23
- ## Bug 1 — String vs Symbol Keys in `change_data` (ROOT CAUSE OF MOST FAILURES)
24
-
25
- `ClassicDiffService#prev_doc` returns `model.original_doc` — a hash with **string keys**
26
- (set via `JSON.parse` in `DoubleModel#initialize`).
27
-
28
- `HashDiffNesting#change_data` converts each key to symbol via `key.to_sym` and then
29
- looks up the value in `b` (prev_doc) with:
30
- ```ruby
31
- if b&.key?(key) # key is now :name (symbol)
32
- b_value = b[key]
33
- ```
34
-
35
- Since `b` has `'name'` (string), `b.key?(:name)` is **always false**. Every field appears
36
- as "changed" (new value vs nil previous). The diff is wrong.
37
-
38
- ### Fix needed in `DiffService#classic_diff`
39
-
40
- Normalise both docs to symbol keys before comparison:
41
- ```ruby
42
- def classic_diff(flat: flat?, ignore: [])
43
- curr = keys_to_sym_deep(curr_doc(flat: flat) || {})
44
- prev = keys_to_sym_deep(prev_doc(flat: flat) || {})
45
-
46
- data = change_data(curr, prev, ignore: to_a(ignored, ignore))
47
- return nil if data == NO_CHANGES
48
-
49
- id = get_id(curr, exception: false)
50
- id ? { id: id }.merge(data) : data
51
- end
52
- ```
53
-
54
- `keys_to_sym_deep` is available as an instance method via `include HashHelpers`.
55
-
56
- ---
57
-
58
- ## Bug 2 — `dig_path?` Returns True for Any Single-Element Path
59
-
60
- The `DoubleModel::HashHelpers` implementation:
61
- ```ruby
62
- def dig_path?(obj, keys)
63
- return false unless obj.respond_to?(:[])
64
- return true if keys.length == 1 # ← BUG: doesn't check key existence
65
- dig_path?(obj[keys.first], keys[1..])
66
- end
67
- ```
68
-
69
- This always returns `true` for single-element key paths regardless of whether the key
70
- actually exists. For `diff_reduce`, this causes:
71
- - `dig_path?({id:, name:}, ['actionCategory'])` → **true** (wrong! actionCategory not present)
72
- - `dig_set!(result, ['actionCategory'], nil)` adds spurious nil entries to the diff
73
-
74
- **Fix in `HashDiffNesting#dig_path?`:**
75
- ```ruby
76
- def dig_path?(obj, keys)
77
- return false unless obj.is_a?(Hash)
78
- return obj.key?(keys.first) if keys.length == 1 # check actual existence
79
- return false unless obj.key?(keys.first)
80
- dig_path?(obj[keys.first], keys[1..])
81
- end
82
- ```
83
-
84
- ---
85
-
86
- ## Bug 3 — `diff_reduce` Cannot Add New Cascade Keys
87
-
88
- `diff_reduce` was designed to REPLACE existing cascade keys in the flat diff. But
89
- cascade attributes are always STRIPPED from the flat diff (`doc_with_non_cascaded_attributes`).
90
- So the keys are never in the flat diff, and `diff_reduce` never adds them.
91
-
92
- ### Current broken flow for nested change only:
93
- 1. `dueDate` changed on an Action, nothing else changed
94
- 2. `classic_diff(flat: true)` → `nil` (dueDate stripped, no flat-level changes)
95
- 3. `diff_reduce(nil)` → all `dig_path?(nil, ...)` return `false` → cascade never processed
96
- 4. Result: `nil` — the nested change is LOST
97
-
98
- ### Current broken flow for mixed change:
99
- 1. `name` changed AND `dueDate` changed
100
- 2. `classic_diff(flat: true)` → `{id:, name: 'Updated'}` (flat change only)
101
- 3. `diff_reduce({id:, name: 'Updated'})`:
102
- - `dig_path?({id:, name:}, ['dueDate'])` → `false` (dueDate not in flat diff) → skip
103
- 4. Result: `{id:, name: 'Updated'}` — dueDate change LOST
104
-
105
- ### Fix needed in `diff_reduce`:
106
-
107
- Remove the `dig_path?` gate. Instead, ADD cascade diffs directly:
108
- ```ruby
109
- def diff_reduce(init, ignore: [])
110
- subject.cascaded_reduce(init, recurs: false) do |result, obj, _key, key_path, _trace|
111
- next result if key_path.empty?
112
- next result if obj == subject
113
- next result if root?(obj)
114
- next result unless obj.is_a?(Ecoportal::API::Common::GraphQL::Model)
115
-
116
- value = obj.as_update(ignore: ignore)
117
- next result if value.nil?
118
-
119
- result ||= {}
120
- dig_set!(result, key_path, value)
121
- result
122
- end
123
- end
124
- ```
125
-
126
- Additionally, `diff` must ensure `id:` is present even when init was nil:
127
- ```ruby
128
- def diff(flat: flat?, ignore: [])
129
- flat_result = classic_diff(flat: true, ignore: ignore)
130
- return flat_result if flat
131
-
132
- result = diff_reduce(flat_result, ignore: ignore)
133
- return nil if result.nil?
134
-
135
- # Guarantee id: is in result when cascade added something
136
- result[:id] ||= get_id(keys_to_sym_deep(curr_doc(flat: true) || {}), exception: false)
137
- result
138
- end
139
- ```
140
-
141
- ---
142
-
143
- ## Bug 4 — `ecoportal-api-v2` `_cascaded_attributes_trace` (FIXED in 6a2b1b5)
144
-
145
- Block params were `|out, (attribute, obj_k)|` (swapped) — `attribute` received the
146
- trace hash instead of the attribute symbol, causing `TypeError: Hash is not a symbol`.
147
- Fixed to `|(attribute, obj_k), out|`. Requires rebuild of ecoportal-api-v2 gem.
148
-
149
- ---
150
-
151
- ## Missing Methods in `DiffService` (FIXED in 0b68a6b)
152
-
153
- `diff_reduce` calls `dig_path?`, `dig_delete!`, `dig_set!` which are defined in
154
- `DoubleModel::HashHelpers` (ecoportal-api-v2) but NOT included in `DiffService`.
155
-
156
- Added these three methods to `HashDiffNesting::InstanceMethods`. See
157
- `lib/ecoportal/api/common/graphql/model/diffable/hash_diff_nesting.rb`.
158
-
159
- ---
160
-
161
- ## `diff_reduce` — ArrayModel Safety (FIXED in 0b68a6b)
162
-
163
- `passarray :fieldName` creates `ArrayModel` subclasses inheriting from `DoubleModel`.
164
- These respond to `as_update` but use the ecoportal-api-v2 signature (no kwargs).
165
-
166
- Added guard: `next result unless obj.is_a?(Ecoportal::API::Common::GraphQL::Model)`.
167
- This limits cascade-diffing to GraphQL model objects only.
168
-
169
- ---
170
-
171
- ## Recommended Implementation Order for 4.4 Completion
172
-
173
- 1. Fix `DiffService#classic_diff` key normalisation (Bug 1 — most impactful)
174
- 2. Fix `HashDiffNesting#dig_path?` to check key existence (Bug 2)
175
- 3. Redesign `diff_reduce` to ADD rather than REPLACE cascade keys (Bug 3)
176
- 4. Update `diff` to ensure `id:` presence and proper no-change return
177
- 5. Update specs to pass for all scenarios
178
-
179
- ---
180
-
181
- ## File Locations
182
-
183
- | File | Role |
184
- |------|------|
185
- | `lib/.../model/diffable/diff_service.rb` | Main DiffService — `classic_diff`, `diff`, `diff_reduce` |
186
- | `lib/.../model/diffable/classic_diff_service.rb` | Base: `curr_doc`, `prev_doc`, `change_diff` dispatch |
187
- | `lib/.../model/diffable/hash_diff_nesting.rb` | `change_data`, `patch_*`, `dig_*` helpers |
188
- | `lib/.../model/as_input.rb` | `as_input` instance + class methods, `patchVer` injection |
189
- | `lib/.../graphql/logic/input.rb` | `Logic::Input.from_model` base |
190
- | `lib/.../input/contractor_entity/update.rb` | `from_model` override with IdDiff reshaping |
191
- | `spec/.../model/diff_service_spec.rb` | DiffService specs (WIP — partial failures) |
192
- | `spec/.../model/as_input_spec.rb` | AsInput + from_model specs (WIP) |