ecoportal-api-graphql 1.3.11 → 1.3.13
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.
- checksums.yaml +4 -4
- data/.ai-assistance/code/filter_contract_matrix.md +177 -0
- data/.ai-assistance/projects/compat-layer-audit/COMPAT_AUDIT.md +244 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +10 -0
- data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +11 -0
- data/.ai-assistance/projects/template-model-logic/CATEGORY_CATALOG.md +236 -0
- data/.ai-assistance/projects/template-model-logic/CLASSIFICATION_SPIKE.md +243 -0
- data/.ai-assistance/projects/template-model-logic/DESIGN_NOTE.md +154 -0
- data/.ai-assistance/scripts/bridge-inbox-check.sh +75 -0
- data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
- data/.ai-assistance/skills/project-self-docs/SKILL.md +181 -0
- data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
- data/.ai-assistance/standards-version.json +22 -21
- data/.claude/settings.json +150 -146
- data/.gitlab-ci.yml +45 -0
- data/CHANGELOG.md +58 -0
- data/CLAUDE.md +11 -0
- data/docs/self-docs/ARCHITECTURE.md +88 -0
- data/docs/self-docs/CHANGES.jsonl +12 -0
- data/docs/self-docs/COMPLIANCE.md +79 -0
- data/docs/self-docs/CONVENTIONS.md +74 -0
- data/docs/self-docs/INTEGRATIONS.md +65 -0
- data/docs/self-docs/OPERATIONS.md +76 -0
- data/docs/self-docs/OVERVIEW.md +64 -0
- data/docs/self-docs/STATUS.md +73 -0
- data/docs/self-docs/self-docs-index.json +51 -0
- data/docs/worklog.md +15 -20
- data/lib/ecoportal/api/common/graphql/client.rb +2 -0
- data/lib/ecoportal/api/common/graphql/http_client.rb +189 -177
- data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
- data/lib/ecoportal/api/graphql/base/action.rb +43 -43
- data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
- data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
- data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
- data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
- data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
- data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
- data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
- data/lib/ecoportal/api/graphql/base/register.rb +18 -18
- data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
- data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
- data/lib/ecoportal/api/graphql/concerns.rb +14 -14
- data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
- data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
- data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
- data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
- data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
- data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
- data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
- data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
- data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
- data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
- data/lib/ecoportal/api/graphql_version.rb +1 -1
- metadata +20 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fba9a366d06303f179c6b7d841930a66a29b6cc3fe836e06b218b95873bbe77e
|
|
4
|
+
data.tar.gz: 6114ba779317ec55b649e1f7df685c0140573cea073aa07c04b9e4cfa20b433a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d867c896796e9107870a2a49e3cdc75275b0a96559a9b8fe47fedea407ef639f7ff558ce22de798f79f3f011c7d4464a3eacd01a63e764778d2e0359cd8ed4ad
|
|
7
|
+
data.tar.gz: c29f555f19fc3d679f51cb512c417caca1e7e5f0006e9ac35a7ccf4d7f074095011ad4a59128a458b69c178dd538d79bf952d1b26b1557397e0d5fc4c41274d4
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
# Filter Contract Matrix — GraphQL register/page filters
|
|
2
|
+
|
|
3
|
+
**Purpose.** Evidence-based ground-truth mapping of *ES register/page filter operations* to
|
|
4
|
+
their exact params, per register field type, so the compat `FilterTranslator` can be validated
|
|
5
|
+
against the backend rather than conjecture. Built after the Date/Select translator bug
|
|
6
|
+
(commit e45ba58) where the original translation table was never validated.
|
|
7
|
+
|
|
8
|
+
**Authoritative source.** `ecoPortal_master` Rails backend, `NewEp::Es` filter stack:
|
|
9
|
+
|
|
10
|
+
- **Field-type → allowed operations** (the contract a register exposes):
|
|
11
|
+
`app/services/new_ep/es/definitions/page.rb`
|
|
12
|
+
- **Operation → exact params** (constructor kwargs / `argument` declarations):
|
|
13
|
+
`app/services/new_ep/es/filters/pages/**` and `.../filters/global/**`
|
|
14
|
+
- **Operation string → class dispatch:**
|
|
15
|
+
`app/services/new_ep/es/filters/deserializer.rb:51-58` — the `operation` string is
|
|
16
|
+
`camelize`d + suffixed `Filter`, resolved **first** under `NewEp::Es::Filters::Pages::`,
|
|
17
|
+
then falling back to `NewEp::Es::Filters::Global::`. An operation with no matching class
|
|
18
|
+
raises `UnknownFilterError` (`deserializer.rb:59`). This is why `options_filter` /
|
|
19
|
+
`text_filter` (v2 names) are NOT valid GraphQL operations — see §"v2 → GraphQL" below.
|
|
20
|
+
|
|
21
|
+
All line numbers below are `file:line` in `ecoPortal_master` unless noted. GraphQL param names
|
|
22
|
+
match the backend constructor / `argument` declarations exactly.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 1. v2 → GraphQL translator input types (what `Compat::FilterTranslator` handles)
|
|
27
|
+
|
|
28
|
+
`lib/ecoportal/api/graphql/compat/filter_translator.rb` accepts *v2-style* `type:` hashes and
|
|
29
|
+
emits GraphQL `{operation:, params:}` hashes. The v2 `type` names are NOT backend operation
|
|
30
|
+
names; the emitted `operation` names ARE. Every emitted operation below resolves to a real
|
|
31
|
+
backend class (verified via the deserializer dispatch).
|
|
32
|
+
|
|
33
|
+
| v2 `type` | Emitted GraphQL `operation` | Emitted params | Backend class (evidence) | Translator handles? |
|
|
34
|
+
|---|---|---|---|---|
|
|
35
|
+
| `date_filter` (system key, e.g. `created_at`) | `date_filter` | `key, time_zone, gte?(=lbound), lte?(=ubound)` | `Global::DateFilter` — `date_filter.rb:28` (`key, path, gt, gte, lt, lte`) | YES (`filter_translator.rb:110-124`) |
|
|
36
|
+
| `date_filter` (field key, e.g. `date.zXXXX`) | `range_date_filter` | `key, path=membranes.date, time_zone, gte?, lte?` | `Pages::RangeDateFilter` — `range_date_filter.rb:797` (`key, path, gte, lte, gt, lt`) | YES (`filter_translator.rb:110-124`) |
|
|
37
|
+
| `text_filter` | `match_filter` | `key, value(=query)` | `Global::MatchFilter` — `match_filter.rb:6` (`key, value, path`) | YES (`filter_translator.rb:74-82`) |
|
|
38
|
+
| `exact_filter` / `is_filter` | `is_filter` | `key, path?(derived membranes.<type>), value` | `Pages::IsFilter` — `is_filter.rb:258` (`key, path, value`) | YES (`filter_translator.rb:52-57`) |
|
|
39
|
+
| `options_filter` (system key) | `one_of_filter` / `none_of_filter` (exclude) | `key, values(=chosen)` | `Global::OneOfFilter` — `one_of_filter.rb:16` (`key, values`); `Global::NoneOfFilter` — `none_of_filter.rb:6` (`key, values`) | YES (`filter_translator.rb:90-104`) |
|
|
40
|
+
| `options_filter` (field key, e.g. `select_str.zXXXX`) | `has_any_filter` / `doesnt_have_any_filter` (exclude) | `key, path(=membranes.<type>), field_key='selected', values(=chosen)` | `Pages::HasAnyFilter` — `has_any_filter.rb:995` → `Pages::BaseHasAnyFilter` — `base_has_any_filter.rb:10` (`key, path, field_key, values, invert`); `Pages::DoesntHaveAnyFilter` — `doesnt_have_any_filter.rb:1061` | YES (`filter_translator.rb:90-104`) |
|
|
41
|
+
| `tag_filter` | *(passthrough — no rewrite)* | — | n/a (already GraphQL-shaped) | passthrough (`filter_translator.rb:37-39`) |
|
|
42
|
+
| `register_filter` | *(passthrough)* | — | `Pages::RegisterFilter` (`filters/pages/register_filter.rb`) | passthrough |
|
|
43
|
+
| `and_filter` / `or_filter` | *(passthrough)* | — | `Global::AndFilter` / `Global::OrFilter` | passthrough |
|
|
44
|
+
| already-`{operation:,params:}` hash | *(passthrough)* | — | — | passthrough (unmatched `type`) |
|
|
45
|
+
| SearchConf value object (`to_h`, not Hash) | *(passthrough)* | — | — | passthrough (`filter_translator.rb:25`) |
|
|
46
|
+
|
|
47
|
+
### Why `options_filter` / `text_filter` are NOT emitted verbatim
|
|
48
|
+
|
|
49
|
+
- **`options_filter`**: there is NO `Pages::OptionsFilter` or `Global::OptionsFilter` class.
|
|
50
|
+
The closest backend class is `Pages::OptionsFieldFilter` (op `options_field_filter`,
|
|
51
|
+
`options_field_filter.rb:9`, takes `chosen`/`mode`) — a *different* operation the register
|
|
52
|
+
definitions never expose. Emitting `options_filter` verbatim would raise `UnknownFilterError`.
|
|
53
|
+
So the translator rewrites to the backend-blessed `has_any_filter`/`one_of_filter`.
|
|
54
|
+
- **`text_filter`**: a `Pages::TextFilter` class DOES exist (`text_filter.rb:1252`) but its
|
|
55
|
+
constructor takes `key:, query:` (not `value:`) and register definitions never expose a
|
|
56
|
+
`text_filter` operation — they expose `contains_filter`/`is_filter`. The translator therefore
|
|
57
|
+
emits `match_filter` (`Global::MatchFilter`, `match_filter.rb:6`), which is a valid operation
|
|
58
|
+
with `{key, value}`. NOTE: `match_filter` matches the *analyzed* value (partial/full-text);
|
|
59
|
+
for whole-value exact use `is_filter` (see §exact-vs-partial in `search_filters.md`).
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 2. Field-type contract matrix (register/page fields)
|
|
64
|
+
|
|
65
|
+
One row per **(field type, operation)** enumerated from `page.rb`. `key` = field definition key
|
|
66
|
+
(`<type>.zXXXX`); `path` = the nested membranes path the definition hardcodes. `field_key` /
|
|
67
|
+
`values` shown where the operation carries a subfield. "Translator?" marks whether the compat
|
|
68
|
+
`FilterTranslator` currently produces this operation from a v2 input.
|
|
69
|
+
|
|
70
|
+
### 2.1 System / metadata fields (no `membranes.*` path)
|
|
71
|
+
|
|
72
|
+
| Field | v2 type | GraphQL operation(s) | params | page.rb evidence | Translator? |
|
|
73
|
+
|---|---|---|---|---|---|
|
|
74
|
+
| Created on / Updated on | `date_filter` | `date_filter`, `isnt_in_date_filter` | `key=created_at`, gte/lte/time_zone | `page.rb:31-39` | YES (system date branch) |
|
|
75
|
+
| External ID | `exact_filter` | `is_filter`, `isnt_filter`, `is_empty_filter`, `isnt_empty_filter` | `key=external_id`, value | `page.rb:65-70` | YES via `exact_filter`→`is_filter` (no path — system key) |
|
|
76
|
+
| Page ID (`mould_counter`) | — | `is_filter`, `isnt_filter`, `is_empty_filter`, `isnt_empty_filter`, `contains_filter`, `doesnt_contain_filter` | `key=mould_counter`, value | `page.rb:72-79` | is_filter YES; contains_filter NO |
|
|
77
|
+
| Page name (`name`) | `text_filter`/`exact_filter` | `is_filter`, `isnt_filter`, `contains_filter`, `doesnt_contain_filter` | `key=name`, value | `page.rb:81-86` | is_filter YES; match/contains partial: `text_filter`→`match_filter` (valid but `name` uses QueryFilter internally — see NOTE) |
|
|
78
|
+
| Page status (`state`) | `options_filter`/`exact` | `is_filter`, `isnt_filter` | `key=state`, value(`@extras`) | `page.rb:87-99` | via `exact_filter`→`is_filter` YES |
|
|
79
|
+
| System ID (`id`) | `exact_filter` | `is_filter`, `isnt_filter`, `one_of_filter` | `key=id`, value / values | `page.rb:101-105` | is_filter + one_of_filter YES |
|
|
80
|
+
| Assigned to | — | `is_filter`, `isnt_filter`, `has_all/any_filter`, `doesnt_have_all/any_filter` | `key=assigned_to`, field_key=`assigned_to`, values | `page.rb:15-22` | NO (no v2 driver) |
|
|
81
|
+
| Created by | — | `is_filter`, `isnt_filter`, `has_any_filter`, `doesnt_have_any_filter` | `key=creator_id`, field_key=`creator_id` | `page.rb:24-29` | NO |
|
|
82
|
+
| Created via (`source`) | — | `is_filter`, `isnt_filter` | `key=source`, value | `page.rb:41-45` | via is_filter YES (if driven) |
|
|
83
|
+
| Custom tags (`other_tags`) | `tag_filter` | `has_any/all_filter`, `doesnt_have_any/all_filter`, `is_empty_filter`, `isnt_empty_filter` | `key=other_tags`, field_key=`other_tags`, values | `page.rb:56-63` | passthrough (`tag_filter`) |
|
|
84
|
+
| Tags / locations (`all_location_ids`) | `tag_filter` | `has_any/all_filter`, `doesnt_have_any/all_filter`, `is_empty_filter`, `isnt_empty_filter` | `key=all_location_ids`, field_key=`all_location_ids`, values | `page.rb:107-114` | passthrough (values lowercased backend-side: `has_any_filter.rb:1024`) |
|
|
85
|
+
| Current Stage (`active_stage_names`) | — | `has_any/all_filter`, `doesnt_have_any/all_filter` | `key=active_stage_names`, field_key=`active_stage_names`, values | `page.rb:138-143` | NO |
|
|
86
|
+
|
|
87
|
+
NOTE (`name`): `contains_filter` on `name` routes to `Global::MatchFilter` (`contains_filter.rb:453-455`)
|
|
88
|
+
and `is_filter` on `name` routes to `Global::ExactFilter` on `name.exact` (`is_filter.rb:283-284`).
|
|
89
|
+
The translator's `text_filter`→`match_filter{key:'name',value:}` therefore hits the right backend
|
|
90
|
+
class for partial name search.
|
|
91
|
+
|
|
92
|
+
### 2.2 Register data fields (nested `membranes.<type>` path)
|
|
93
|
+
|
|
94
|
+
| Field type | GraphQL operation(s) | params (key/path/field_key/values) | page.rb evidence | Backend op class | Translator? |
|
|
95
|
+
|---|---|---|---|---|---|
|
|
96
|
+
| **date** (`date.zXXXX`) | `range_date_filter`, `isnt_in_range_date_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.date`; range: gte/lte/gt/lt/time_zone | `page.rb:191-209` | `Pages::RangeDateFilter` `range_date_filter.rb:797`; field_key resolved to `value` `range_date_filter.rb:822-828` | YES (`date_filter` field branch → `range_date_filter`) |
|
|
97
|
+
| **select_str** (`select_str.zXXXX`) | `has_any/all_filter`, `doesnt_have_any/all_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.select_str`, field_key=`selected`, values | `page.rb:279-300` | `Pages::HasAnyFilter`→`BaseHasAnyFilter` `base_has_any_filter.rb:10`; search_key=`path.selected` `base_has_any_filter.rb:42-44` | YES (`options_filter` field branch → `has_any_filter`) |
|
|
98
|
+
| **select_num** (`select_num.zXXXX`) | same as select_str, path=`membranes.select_num` | key, path, field_key=`selected`, values | `page.rb:256-277` | `Pages::HasAnyFilter`→`BaseHasAnyFilter` | YES (field branch; caller passes path/`select_num` prefix) |
|
|
99
|
+
| **plain_text** (`plain_text.zXXXX`) | `is_filter`, `isnt_filter`, `is_empty_filter`, `isnt_empty_filter`, `contains_filter`, `doesnt_contain_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.plain_text`; is_filter → `.exact` subfield | `page.rb:145-165` | `is_filter`→`Pages::IsFilter`→`BaseIsFilter` search_key=`exact` `is_filter.rb:315-316`; contains→`Pages::TextFilter` `contains_filter.rb:459-461` | is_filter YES (exact_filter→is_filter); contains NO |
|
|
100
|
+
| **rich_text** (`rich_text.zXXXX`) | `contains_filter`, `doesnt_contain_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.rich_text`; contains → `content` subfield | `page.rb:303-321` | `contains_filter`→`BaseContainsFilter` field_key=`content` `contains_filter.rb:477-478` | NO (no is/exact op; contains only) |
|
|
101
|
+
| **number** (`number.zXXXX`) | `numeric_range_filter`, `isnt_in_numeric_range_filter`, `is_filter`, `isnt_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.number`; range: gte/lte/gt/lt on `.value` | `page.rb:168-188` | `numeric_range_filter`→`Pages::NumericRangeFilter` search_key=`path.value` `numeric_range_filter.rb:941-943` | is_filter YES; numeric_range NO |
|
|
102
|
+
| **gauge** (`gauge.zXXXX`) | `is_filter`, `isnt_filter`, `numeric_range_filter`, `isnt_in_numeric_range_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.gauge` | `page.rb:234-254` | numeric via `NumericRangeFilter`; is via `IsFilter` search_key=`value` `is_filter.rb:317-318` | is_filter YES; numeric_range NO |
|
|
103
|
+
| **file** (`file.zXXXX`) | `has_any_filter`, `doesnt_have_any_filter` (×`file_container_id`, `content_type`), `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.file`, field_key=`file_container_id`\|`content_type`, values | `page.rb:212-232` | `has_any_filter`→`Pages::File::HasAnyFilter` `has_any_filter.rb:1034-1035` | NO |
|
|
104
|
+
| **image_gallery** (`image_gallery.zXXXX`) | `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.image_gallery` | `page.rb:545-561` | `is_empty`→`Pages::ImageGallery::IsEmptyFilter` `is_empty_filter.rb:572-573` | NO |
|
|
105
|
+
| **people** (`people.zXXXX`) | `has_any/all_filter`, `doesnt_have_any/all_filter` (×`id`/`contractor_organization_id`/`schema_id`/`prefilter_location_ids`), `is_direct_supervisor_filter`, `has_direct_supervisor_filter`, `is/isnt_contractor_administrator_filter`, `people_field_contains_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.people`, field_key varies, values | `page.rb:466-499` | `Pages::People::HasAnyFilter` `has_any_filter.rb:1030-1031` | NO |
|
|
106
|
+
| **contractor_entities** (`contractor_entities.zXXXX`) | `has_any/all_filter`, `doesnt_have_any/all_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.contractor_entities`, field_key=`contractor_entities_ids`, values | `page.rb:324-344` | `BaseHasAnyFilter`; `required_data` loads ContractorEntity `base_has_any_filter.rb:27-28` | NO |
|
|
107
|
+
| **cross_reference / page_reference** (`cross_reference.zXXXX`, `any_reference_field`) | `has_any_filter`, `doesnt_have_any_filter`, `has_all_filter`, `doesnt_have_all_filter`, `meets_conditions_filter`(if CRR), `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.cross_references`, field_key=`id`, values=`@page_id`; meets_conditions: `register_id` | `page.rb:502-542` | `Pages::CrossReferences::HasAnyFilter` `has_any_filter.rb:1021-1022` | NO |
|
|
108
|
+
| **actions_list** (`actions_list.zXXXX`) | `has_any/all_filter`, `doesnt_have_any/all_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.actions_list`, field_key=`id`, values=`@action_id` | `page.rb:347-367` | `Pages::ActionsList::HasAnyFilter` `has_any_filter.rb:1032-1033` | NO |
|
|
109
|
+
| **checklist** (`checklist.zXXXX`) | `all_options_ticked_filter`, `not_all_options_ticked_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.checklist` | `page.rb:445-463` | `all_options_ticked_filter` = `Pages::AllOptionsTickedFilter` (`all_options_ticked_filter.rb`) | NO |
|
|
110
|
+
| **geo** (`geo.zXXXX`) | `is_filter`, `isnt_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.geo`; is → `address.exact` subfield | `page.rb:370-388` | `IsFilter` search_key=`address.exact` `is_filter.rb:319-320` | is_filter YES (if driven); rest NO |
|
|
111
|
+
| **signature** (`signature.zXXXX`) | `is_filter`, `isnt_filter` (value=`@user_id`), `range_date_filter`, `isnt_in_range_date_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.signature`; is → `signed_by_id`; range → `date` subfield | `page.rb:391-411` | `IsFilter` search_key=`signed_by_id` `is_filter.rb:323-324`; range → `Pages::Signature::RangeDateFilter` `range_date_filter.rb:810-811` | is_filter/range partial (translator emits generic ops; subfield resolution is backend-side by key prefix) |
|
|
112
|
+
| **mailbox** (`mailbox.zXXXX`) | `range_date_filter`, `isnt_in_range_date_filter`, `has_any/all_filter`, `doesnt_have_any/all_filter` (×`all_recipients`/`sender_email`), `contains_filter`, `doesnt_contain_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.mailbox`, field_key varies, values | `page.rb:414-442` | `Pages::Mailbox::*` `has_any_filter.rb:1019-1020` | NO |
|
|
113
|
+
| **law** (`law.zXXXX`) | `has_any_filter`, `doesnt_have_any_filter`, `is_empty_filter`, `isnt_empty_filter`, `presence_filter`, `not_presence_filter` | key, path=`membranes.law`, field_key=`snippets_ids`, values=`@law_snippet_id` | `page.rb:564-582` | `BaseHasAnyFilter`; `required_data` loads Snippet `base_has_any_filter.rb:29` | NO |
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## 3. Operation → param contract (backend constructor signatures)
|
|
118
|
+
|
|
119
|
+
Cited so the guard spec asserts exact param shapes. All under
|
|
120
|
+
`app/services/new_ep/es/filters/`.
|
|
121
|
+
|
|
122
|
+
| Operation | Namespace / class | Constructor params (evidence) |
|
|
123
|
+
|---|---|---|
|
|
124
|
+
| `date_filter` | `Global::DateFilter` | `key, path?, gt?, gte?, lt?, lte?` — `global/date_filter.rb:28` |
|
|
125
|
+
| `range_date_filter` | `Pages::RangeDateFilter` | `key, path?, gte?, lte?, gt?, lt?` — `pages/range_date_filter.rb:797` |
|
|
126
|
+
| `is_filter` | `Pages::IsFilter` | `key, path?, value?` — `pages/is_filter.rb:258` |
|
|
127
|
+
| `isnt_filter` | `Pages::IsntFilter` | `key, path?, value` — `pages/isnt_filter.rb:360` |
|
|
128
|
+
| `match_filter` | `Global::MatchFilter` | `key, value, path?` — `global/match_filter.rb:6` |
|
|
129
|
+
| `contains_filter` | `Pages::ContainsFilter` | `key, path?, value` — `pages/contains_filter.rb:445` |
|
|
130
|
+
| `has_any_filter` | `Pages::HasAnyFilter` → `BaseHasAnyFilter` | `key, path?, field_key?, values` — `pages/has_any_filter.rb:995`, `pages/base_has_any_filter.rb:10` |
|
|
131
|
+
| `has_all_filter` | `Pages::HasAllFilter` → `BaseHasAllFilter` | `key, path?, field_key?, values` — `pages/has_all_filter.rb:1135`, `pages/base_has_all_filter.rb:59` |
|
|
132
|
+
| `doesnt_have_any_filter` | `Pages::DoesntHaveAnyFilter` | `key, path?, field_key?, values` (invert) — `pages/doesnt_have_any_filter.rb:1061` |
|
|
133
|
+
| `doesnt_have_all_filter` | `Pages::DoesntHaveAllFilter` | `key, path?, field_key?, values` (invert) — `pages/doesnt_have_all_filter.rb:1199` |
|
|
134
|
+
| `one_of_filter` | `Global::OneOfFilter` | `key, values` — `global/one_of_filter.rb:16` |
|
|
135
|
+
| `none_of_filter` | `Global::NoneOfFilter` | `key, values` — `global/none_of_filter.rb:6` |
|
|
136
|
+
| `numeric_range_filter` | `Pages::NumericRangeFilter` | `key, path?, gte?, lte?, gt?, lt?` — `pages/numeric_range_filter.rb:917` |
|
|
137
|
+
| `is_empty_filter` | `Pages::IsEmptyFilter` | `key, path?` (search_key by type) — `pages/is_empty_filter.rb:552` |
|
|
138
|
+
| `presence_filter` | `Pages::PresenceFilter` | `key, path?` — `pages/presence_filter.rb:724` |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 4. Known-UNCOVERED field types / operations (visible gap, not silent)
|
|
143
|
+
|
|
144
|
+
The compat `FilterTranslator` intentionally translates only the v2 filter shapes that real
|
|
145
|
+
consumers emit (`date_filter`, `text_filter`, `exact_filter`/`is_filter`, `options_filter`,
|
|
146
|
+
plus passthroughs). The following register field types / operations have a backend contract
|
|
147
|
+
(cataloged above) but NO translator branch. This is by design — do not add speculative
|
|
148
|
+
translations without a consumer. The guard spec enumerates these so the gap stays visible.
|
|
149
|
+
|
|
150
|
+
**Field types with NO dedicated translator path** (any v2 filter targeting them falls through
|
|
151
|
+
to passthrough — the caller must pass a pre-shaped `{operation:,params:}` hash):
|
|
152
|
+
|
|
153
|
+
- `number` (numeric_range_filter), `gauge` (numeric_range_filter)
|
|
154
|
+
- `rich_text` (contains_filter / doesnt_contain_filter)
|
|
155
|
+
- `file` (has_any_filter on file_container_id / content_type)
|
|
156
|
+
- `image_gallery` (is_empty / presence only)
|
|
157
|
+
- `people`, `contractor_entities`, `cross_reference` / `page_reference`
|
|
158
|
+
- `actions_list`, `checklist`, `mailbox`, `law`, `signature` (range/is subfields)
|
|
159
|
+
- `geo` (is_filter on address.exact — reachable via exact_filter if the caller sets key `geo.zXXXX`)
|
|
160
|
+
|
|
161
|
+
**System fields with NO translator driver:** `assigned_to`, `creator_id` (Created by),
|
|
162
|
+
`active_stage_names` (Current Stage), `mould_counter` contains_filter.
|
|
163
|
+
|
|
164
|
+
**Operations never emitted by the translator** (must be passed pre-shaped): `has_all_filter`,
|
|
165
|
+
`doesnt_have_all_filter`, `numeric_range_filter`, `isnt_in_numeric_range_filter`,
|
|
166
|
+
`isnt_in_range_date_filter`, `contains_filter`, `doesnt_contain_filter`, `is_empty_filter`,
|
|
167
|
+
`isnt_empty_filter`, `presence_filter`, `not_presence_filter`, `meets_conditions_filter`,
|
|
168
|
+
`all_options_ticked_filter`, `people_field_contains_filter`, direct-supervisor / contractor-admin
|
|
169
|
+
filters.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## 5. Change log
|
|
174
|
+
|
|
175
|
+
- 2026-07-08 — Initial matrix built from `page.rb` + `filters/pages/**` + `filters/global/**`.
|
|
176
|
+
Guard spec `spec/ecoportal/api/graphql/compat/filter_translator_spec.rb` extended to pin every
|
|
177
|
+
emitted operation to its backend param contract and to enumerate the uncovered types.
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# GraphQL COMPAT Layer Audit — contract fidelity vs ecoportal-api-v2
|
|
2
|
+
|
|
3
|
+
> READ-ONLY audit. No code changed, no git touched. Date: 2026-07-08.
|
|
4
|
+
> Scope: every place the GraphQL gem re-expresses a v2 public API, measured against the
|
|
5
|
+
> v2 contract as source of truth. Consumers surveyed: `multi_org_api/*/config/ooze_cases`,
|
|
6
|
+
> eco-helpers `usecases/graphql` + `OozeRedirect` + `ooze_samples`.
|
|
7
|
+
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
## 1. Executive verdict — AD-HOC PATCH, not a faithful mirror
|
|
11
|
+
|
|
12
|
+
Oscar is **right**. The compat layer is a set of reactive, per-symptom patches, not a
|
|
13
|
+
contract-faithful mirror of the v2 API. Concrete evidence:
|
|
14
|
+
|
|
15
|
+
1. **Same method, three different return-type decisions, no shared contract.** `get_by_name`
|
|
16
|
+
is a COLLECTION in v2 for components, forces, and bindings, but a SINGLE for stages. The
|
|
17
|
+
gem got forces (`base/force/collection.rb:16` → `select`), bindings
|
|
18
|
+
(`base/force/binding_collection.rb:19` → `select`), and stages
|
|
19
|
+
(`compat/stage_collection.rb:34` → `find`, correct) right — but got **components wrong**
|
|
20
|
+
(`base/page/data_field/collection.rb:35` → `find`) and **sections' `get_by_heading` wrong**
|
|
21
|
+
(`base/page/section_collection.rb:31` → `find`, v2 is `select`). Three files, four
|
|
22
|
+
independent return-type choices. A faithful mirror would have one rule per v2 method; this
|
|
23
|
+
has ad-hoc choices that happen to match in some files and not others.
|
|
24
|
+
|
|
25
|
+
2. **The downstream consumer already contains a workaround for the gem's wrong return type.**
|
|
26
|
+
eco-helpers `OozeRedirect#with_fields` wraps the component lookup:
|
|
27
|
+
`[collection.get_by_name(label)].compact` (`ooze_redirect.rb:187`) — i.e. it treats
|
|
28
|
+
`get_by_name` as returning a SINGLE and re-wraps it into an array to fake the v2 collection
|
|
29
|
+
contract. That is a patch compensating for a patch. When the gem is made faithful (returns a
|
|
30
|
+
collection), this line double-wraps → `[[Field]]`. Whack-a-mole in the flesh.
|
|
31
|
+
|
|
32
|
+
3. **Two conflicting `#components` definitions on the same object.** `Concerns::PageCompat#components`
|
|
33
|
+
returns `dataFields || []` (`page_compat.rb:22`); `Concerns::DataFieldAccess#components`
|
|
34
|
+
returns `field_collection` (`data_field_access.rb:22`). Both are `include`d in
|
|
35
|
+
`Interface::BasePage` (`base_page.rb:6-7`), DataFieldAccess second so it wins — but the dead
|
|
36
|
+
PageCompat version returning a raw array is a fossil of an earlier, incompatible attempt.
|
|
37
|
+
|
|
38
|
+
4. **Methods the v2 contract guarantees are simply absent** (not deliberately dropped —
|
|
39
|
+
nobody enumerated the contract): `DataField::Collection` has no `get_by_id`, no
|
|
40
|
+
`get_by_type` returning a `reference?`-style API, no `unattached`/`multi_section`;
|
|
41
|
+
`SectionCollection` has no `get_by_id`, no `ordered`-by-weight, no `between`. Consumers call
|
|
42
|
+
`components.get_by_id` and `sections.get_by_id` today (§3).
|
|
43
|
+
|
|
44
|
+
5. **`components.add` has different semantics** — v2 `upsert!`s into the live collection so a
|
|
45
|
+
later `get_by_name` finds it; the gem stashes into a private `@additions` array
|
|
46
|
+
(`collection.rb:53-60`) that `each`/`get_by_name` never see.
|
|
47
|
+
|
|
48
|
+
The through-line: each method was added when a specific script broke, choosing whatever return
|
|
49
|
+
shape that one script needed — never derived from the v2 method's documented contract. That is
|
|
50
|
+
the definition of ad-hoc.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## 2. Divergence catalog
|
|
55
|
+
|
|
56
|
+
Severity: **S1** = silent wrong result / breaks live consumers; **S2** = raises / missing method;
|
|
57
|
+
**S3** = latent (no current consumer but contract-divergent).
|
|
58
|
+
|
|
59
|
+
| # | Method | v2 contract (file:line) | gem compat (file:line) | Divergence | Sev | Affected consumers |
|
|
60
|
+
|---|--------|-------------------------|------------------------|------------|-----|--------------------|
|
|
61
|
+
| C1 | `components.get_by_name(name, type:)` | `select` → **Array** (`v2/page/components.rb:37`) | `find` → **single DataField** (`base/page/data_field/collection.rb:35`) | Return type: collection vs single | **S1** | act-gov `.first`/`.last`, farmers `.first`, and ~120 `components.get_by_name(...).first` call-sites across multi_org_api (twg, tng, briscoes, alexander, argenta, whittlesea…); eco-helpers `ooze_redirect.rb:184,187` |
|
|
62
|
+
| C2 | `sections.get_by_heading(h, mild:)` | `ordered.select` → **Array** (`v2/page/sections.rb:61`) | `find` → **single Section** (`base/page/section_collection.rb:31`) | Return type + drops `mild:` + no weight-ordering | **S1** | `tng_env_update_case.rb:19` (`.get_by_heading("Details").first`) |
|
|
63
|
+
| C3 | `components.get_by_id(id)` | `find` → single (`v2/page/components.rb:21`) | **absent** on `DataField::Collection` | Missing method → `NoMethodError` | **S2** | `event_changes_case.rb:50` (`components.get_by_id(fld.reference_id)`); eco-helpers `ooze_update_case.rb:10` |
|
|
64
|
+
| C4 | `sections.get_by_id(id)` | `find` → single (`v2/page/sections.rb:46`) | **absent** on `SectionCollection` | Missing method → `NoMethodError` | **S2** | `TnG_Active_sitefix_part2_case.rb:106` |
|
|
65
|
+
| C5 | `components.add(...)` | `upsert!` into live collection; found by later `get_by_name`/`each` (`v2/page/components.rb:53`) | stashes in private `@additions`; NOT in `@fields`; later `get_by_name`/`each` miss it (`collection.rb:53-60`) | Mutation semantics diverge | S2 | any multi-step add-then-read script; `add` kwargs also differ (`id:` required, `doc:` shape) |
|
|
66
|
+
| C6 | `sections.get_by_type(t)` | `ordered.select` → Array (`v2/page/sections.rb:53`) | `select` → Array but **not weight-ordered** (`section_collection.rb:25`) | Ordering: v2 orders by weight, gem uses response order | S3 | ordering-sensitive section scripts (latent) |
|
|
67
|
+
| C7 | `components.get_by_type(t)` | `select` → Array, no ordering (`v2/page/components.rb:28`) | `select` → Array (`collection.rb:27`) | **Match** (return type) | ok | argenta `remove_edit_events_case.rb:21,29` (uses `.select` on result — works only because both are Arrays) |
|
|
68
|
+
| C8 | `forces.get_by_name(name)` | `select` → Array (`v2/page/forces.rb:36`) | `select` → Array (`base/force/collection.rb:16`) | **Match** | ok | all `forces.get_by_name(...).first` sites |
|
|
69
|
+
| C9 | `force.bindings.get_by_name(name, type:)` | `select` → Array (`v2/page/force/bindings.rb:102`) | `select` → Array but **drops `type:` kwarg** (`binding_collection.rb:19`) | Partial: arity narrower | S3 | bindings callers pass name only today; `type:` unused |
|
|
70
|
+
| C10 | `stages.get_by_name(name)` | `find` → single (`v2/page/stages.rb:17`) | `find` → single (`compat/stage_collection.rb:34`) | **Match** | ok | eco-helpers `ooze_base_case.rb:109,144`, `register_export_case.rb:137` |
|
|
71
|
+
| C11 | `stages[id_or_name]` | v2 CollectionModel `[]` by key (id) | id **or** name (`stage_collection.rb:27-30`) | Superset (name-fallback) — benign but non-contract | S3 | eco-helpers `stages[id] || stages.get_by_name` (belt-and-braces) |
|
|
72
|
+
| C12 | `forces.get_by_id`, `.ordered`, `.bindings_by_reference` | present (`v2/page/forces.rb:29,64,20`) | **absent** on gem `Force::Collection` | Missing methods | S3 | not called by surveyed consumers (latent) |
|
|
73
|
+
| C13 | `bindings.get_by_id / get_by_type / get_by_reference / reference? / by_name / by_reference` | present (`v2/page/force/bindings.rb:82,92,68,61,26,42`) | **absent** on gem `BindingCollection` | Missing methods | S3 | not called by surveyed consumers (latent) |
|
|
74
|
+
| C14 | `sections.ordered` (by weight) | `sort_by weight` (`v2/page/sections.rb:86`) | returns `@sections` as-is (`section_collection.rb:41`) | Ordering not applied | S3 | latent |
|
|
75
|
+
| C15 | `page.components` (page-level) | v2 components collection | **two conflicting defs**: PageCompat returns `dataFields`/`[]` array (`page_compat.rb:22`), DataFieldAccess returns `Collection` (`data_field_access.rb:22`); include order (`base_page.rb:6-7`) makes DataFieldAccess win, PageCompat one is dead code | Latent footgun / dead code | S2 | any refactor reordering includes silently swaps behaviour |
|
|
76
|
+
| C16 | `entry.components.get_by_name(...).detect{…}` | Array#detect works on v2 collection | gem returns single → `.detect` = `Enumerable#detect` on a **DataField** (iterates its doc?) | Return-type breakage | **S1** | `risks_migration_case.rb:89` (`.get_by_name("Review frequency:").detect{…}`) |
|
|
77
|
+
| C17 | `force.bindings.get_by_name(x).empty?` | Array#empty? | gem Array → ok (C8/C9 correct) | ok | tng `tng_env_update_case.rb:112` |
|
|
78
|
+
|
|
79
|
+
### Folded-in prior findings (verified against current code)
|
|
80
|
+
|
|
81
|
+
These are not `get_by_name`-style return-type bugs but are part of the same "compat built ad hoc"
|
|
82
|
+
thread — the read/write CONVERTER contract was never systematically mirrored. Cross-referenced
|
|
83
|
+
against `.ai-assistance/code/model_input_mapping/SYNTHESIS.md` (2026-07-07). **Note:** SYNTHESIS
|
|
84
|
+
marks several as FIXED on `feature/model-input-fixes` (current branch); status below reflects that.
|
|
85
|
+
|
|
86
|
+
| # | Prior finding | Current status (verified) | File:line |
|
|
87
|
+
|---|---------------|---------------------------|-----------|
|
|
88
|
+
| P1 | `passarray`/`embeds_many` don't diff via `as_update` (otherTags, locations, filterTags) | **FIXED** — `DIFF_CLASS = Diffable::LeafDiffService` now on `Interface::BasePage` | `interface/base_page.rb:13` |
|
|
89
|
+
| P2 | `IdDiff` set-diff machinery non-functional (ContractorEntity/Action) | Reported FIXED via `id_diff_fields` macro (SYNTHESIS §3.2); **not re-verified in this pass** — flagged for confirm | — |
|
|
90
|
+
| P3 | File/Image/Contractor readers use write-key → `[]` | Reported FIXED (SYNTHESIS §3.3); Image fragment `images[].id` was the blocker | `base/page/data_field/{file_field,image_gallery,contractor_entities}.rb` |
|
|
91
|
+
| P4 | DataField read-key ≠ write-key (shape asymmetry) | Partly FIXED (CrossReference was template); File/Image/Contractor per P3 | `.ai-assistance/code/refactoring/datafield-readwrite-shape-asymmetry.md` |
|
|
92
|
+
| P5 | `SnakeCamelAccess` is a one-way method_missing shim, no specs | **STILL TRUE** — `concerns/snake_camel_access.rb`; no spec file exists (`Glob spec/**/snake_camel*` → none). One-way (snake→camel only); silently no-ops single-word methods (`parts.length < 2` → returns as-is) | `concerns/snake_camel_access.rb:50-54` |
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
## 3. Comprehensive fix plan (contract-faithful) + spec risk
|
|
97
|
+
|
|
98
|
+
The organising principle: **derive each collection method from the v2 method it mirrors, not from
|
|
99
|
+
the one script that broke.** Recommend a small shared helper module so components/sections/forces/
|
|
100
|
+
bindings collections share one contract per method name.
|
|
101
|
+
|
|
102
|
+
### Fix C1 — `DataField::Collection#get_by_name` → collection (`select`)
|
|
103
|
+
|
|
104
|
+
Faithful fix: return `pool.select { … }` (mirror `v2/page/components.rb:37`), keeping `type:`.
|
|
105
|
+
|
|
106
|
+
**Risks these EXISTING gem specs (they assume single return):**
|
|
107
|
+
- `spec/.../base/page/data_field_spec.rb:397-399` — `coll.get_by_name('name').id` → must become `.first.id`
|
|
108
|
+
- `data_field_spec.rb:408` — `coll.get_by_name('Name').value = 'Bob'` → `.first.value =`
|
|
109
|
+
- `data_field_spec.rb:419` — same
|
|
110
|
+
- `data_field_spec.rb:463` — `field = coll.get_by_name('Name')` → `.first`
|
|
111
|
+
- `base/page/phased/stage_spec.rb:72` — `stage.components.get_by_name('Description').value` → `.first.value`
|
|
112
|
+
- `compat/stage_collection_spec.rb:92` — `stage.components.get_by_name('Description').value` → `.first.value`
|
|
113
|
+
- `compat/stage_view_spec.rb:57` — `view.components.get_by_name('Summary').value` → `.first.value`
|
|
114
|
+
- `input/page/update_spec.rb:136` — `page_with_fields.components.get_by_name('Note').value = 'New'` → `.first.value =`
|
|
115
|
+
- `integration/toocs_submit_replay_spec.rb:38` — `page.components.get_by_name('Coding completed by:')` → `.first`
|
|
116
|
+
|
|
117
|
+
These 9 spec sites LOCK IN the wrong behaviour and must be updated in lockstep with the fix
|
|
118
|
+
(add `.first`). They also confirm the gem's own test suite was written to the ad-hoc behaviour,
|
|
119
|
+
not the v2 contract.
|
|
120
|
+
|
|
121
|
+
**Also requires a coordinated eco-helpers change:** `ooze_redirect.rb:184-187` — line 187
|
|
122
|
+
`[collection.get_by_name(label)].compact` must become `collection.get_by_name(label)` (already an
|
|
123
|
+
Array), and line 184-185 `field = collection.get_by_name(label); … [field] : []` must select over
|
|
124
|
+
the collection. This is the cross-repo lockstep the audit brief warns about: a "just remove
|
|
125
|
+
`.first`" workaround in org scripts is itself a dirty patch that re-breaks here.
|
|
126
|
+
|
|
127
|
+
### Fix C2 — `SectionCollection#get_by_heading` → collection (`select`), add `mild:`, order by weight
|
|
128
|
+
|
|
129
|
+
Faithful: `ordered.select { same_string?(s.heading, h, mild: mild) }`. Add a `same_string?`
|
|
130
|
+
helper (case-insensitive; `mild:` = alnum-only compare per v2). Requires §Fix-C14 (`ordered` by
|
|
131
|
+
weight) for exact parity.
|
|
132
|
+
|
|
133
|
+
**Spec risk:** no existing gem spec asserts `get_by_heading` (Grep found none) → **no green
|
|
134
|
+
specs go red.** Add new specs (§4). Consumer `tng_env_update_case.rb:19` already does `.first`, so
|
|
135
|
+
it starts working correctly.
|
|
136
|
+
|
|
137
|
+
### Fix C3 / C4 — add `get_by_id` to `DataField::Collection` and `SectionCollection`
|
|
138
|
+
|
|
139
|
+
`find { |x| x.id == id }` (mirror v2). **No spec risk** (new methods). Add coverage.
|
|
140
|
+
|
|
141
|
+
### Fix C5 — `components.add` upserts into the live collection
|
|
142
|
+
|
|
143
|
+
Faithful: push the built field into `@fields` (or an upsert-by-id) so later `get_by_name`/`each`
|
|
144
|
+
find it, and keep `dirty_additions` sourced from those. **Spec risk:** check `data_field_spec.rb`
|
|
145
|
+
add-section (`:451-465`) — the deletion test reads `get_by_name('Name')` (existing field, fine);
|
|
146
|
+
the add tests assert `dirty_additions` only, so upserting into `@fields` needs a guard that
|
|
147
|
+
`dirty_inputs` (updates) does not double-count additions. Design the split carefully; lock with a
|
|
148
|
+
"add then get_by_name finds it" spec.
|
|
149
|
+
|
|
150
|
+
### Fix C6 / C14 — order `get_by_type`/`ordered` by weight in `SectionCollection`
|
|
151
|
+
|
|
152
|
+
Only if sections carry a weight in the GraphQL doc (verify fragment exposes it; if not, response
|
|
153
|
+
order is the only signal and this stays a documented gap). **No current spec risk.**
|
|
154
|
+
|
|
155
|
+
### Fix C9 / C12 / C13 — fill missing force/binding contract methods + `type:` on bindings
|
|
156
|
+
|
|
157
|
+
Add `get_by_id`, `get_by_type`, `get_by_reference`, `reference?`, `by_name`, `by_reference`,
|
|
158
|
+
`ordered`, `bindings_by_reference`, and honour `type:` in `binding.get_by_name`. These are S3
|
|
159
|
+
(no live consumer) — do them to make the mirror complete, not urgently. **No spec risk.**
|
|
160
|
+
|
|
161
|
+
### Fix C15 — delete the dead `PageCompat#components`
|
|
162
|
+
|
|
163
|
+
Remove `PageCompat#components` (`page_compat.rb:21-24`); it is shadowed and returns the wrong
|
|
164
|
+
shape if include order ever changes. `page_compat_spec.rb:39-43` asserts `components` is a
|
|
165
|
+
`DataField::Collection` — that stays green (DataFieldAccess provides it). **No spec risk.**
|
|
166
|
+
|
|
167
|
+
### Fix P5 — add specs (and ideally make two-way) for `SnakeCamelAccess`
|
|
168
|
+
|
|
169
|
+
At minimum add characterization specs. Consider reusing eco-helpers' polished hash-level
|
|
170
|
+
snake/camel module rather than the bespoke `method_missing` shim (per memory
|
|
171
|
+
`project_snake_camel_access`). Scope decision for Oscar.
|
|
172
|
+
|
|
173
|
+
### Recommended structural change (kills the class of bug)
|
|
174
|
+
|
|
175
|
+
Extract a shared `Concerns::V2CollectionContract` (or a `Base::Page::CollectionModel`) that
|
|
176
|
+
defines `get_by_id` (single), `get_by_name`/`get_by_heading`/`get_by_type` (collection, per v2),
|
|
177
|
+
`ordered`, `same_string?(mild:)` **once**, and have DataField/Section/Force/Binding collections
|
|
178
|
+
include it. This turns "one return-type decision per file" into "one contract, four includes" —
|
|
179
|
+
the actual remedy for the ad-hoc philosophy.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## 4. Test strategy
|
|
184
|
+
|
|
185
|
+
### Gem rspec additions (lock the CONTRACT, not the current behaviour)
|
|
186
|
+
- **Return-type contract specs**, one per collection, asserting `get_by_name`/`get_by_heading`
|
|
187
|
+
return `Array` (C1, C2) and `get_by_id` return a single (C3, C4). Assert `.first` on a
|
|
188
|
+
multi-match returns the first; assert empty match returns `[]` (not nil) for the collection
|
|
189
|
+
methods — this is the exact shape act-gov `.first`/`.last` and farmers `.first` depend on.
|
|
190
|
+
- **Add-then-find spec** (C5): `coll.add(id:…){…}; expect(coll.get_by_name(label)).to include(the added)`.
|
|
191
|
+
- **`mild:` parity spec** for `get_by_heading` (C2).
|
|
192
|
+
- **Cross-object consistency meta-spec:** a single spec that asserts all four collections agree
|
|
193
|
+
on the return type of `get_by_name` (guards against future drift — the root cause here).
|
|
194
|
+
- Update the 9 spec sites listed under Fix-C1 to `.first` in the SAME commit as the fix.
|
|
195
|
+
- Add `SnakeCamelAccess` characterization spec (P5).
|
|
196
|
+
|
|
197
|
+
### eco-helpers spec additions
|
|
198
|
+
- Update `OozeRedirect#with_fields` (`ooze_redirect.rb:184-189`) and add a spec proving it
|
|
199
|
+
returns the same shape (Array) whether the field exists or not, against a faithful collection.
|
|
200
|
+
- Regression spec replaying an act-gov-style `get_by_name(...).first` / `.last` through the
|
|
201
|
+
OozeRedirect path.
|
|
202
|
+
|
|
203
|
+
### Integration coverage (parity harness, training mini-site)
|
|
204
|
+
- On the all-21-fields template mini-org, run the A/B parity harness (memory
|
|
205
|
+
`project_parity_test_plan`): execute one representative ooze case using `.get_by_name(...).first`
|
|
206
|
+
and `.get_by_heading(...).first` against BOTH v2 and GraphQL, assert identical selected field
|
|
207
|
+
IDs and identical emitted mutation input. **Needs live creds** (mini-site API token) — the only
|
|
208
|
+
part of this plan that cannot run in CI without secrets.
|
|
209
|
+
- Replay act-gov `20240130_act_disease_title_update_case.rb` (`.first`+`.last`) and the farmers
|
|
210
|
+
case in `-simulate` mode post-fix to confirm no `.first`-on-single or double-wrap breakage.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## 5. Open questions / could not verify
|
|
215
|
+
|
|
216
|
+
1. **P2/P3 fix status not re-verified.** SYNTHESIS says the IdDiff macro and File/Image/Contractor
|
|
217
|
+
readers were fixed on this branch; I did not re-open those files this pass. Confirm before
|
|
218
|
+
relying on them.
|
|
219
|
+
2. **Section weight in the GraphQL fragment.** C6/C14 (order-by-weight) only achievable if the
|
|
220
|
+
section fragment actually returns a `weight`/ordering field — not confirmed. If absent, response
|
|
221
|
+
order is the contract and these stay documented gaps.
|
|
222
|
+
3. **`components.add` write path.** Whether an added field can even be created via the current
|
|
223
|
+
`dataFields.updates`/additions mutation shape without a server-side `buildFromTemplate` ID is a
|
|
224
|
+
separate question (the collection comment at `collection.rb:47` says it "must reference an
|
|
225
|
+
existing server-side ID"). Faithful `add` semantics may be bounded by the mutation API.
|
|
226
|
+
4. **Full green-suite baseline.** I did not run `bundle exec rspec`; the 9 spec sites are
|
|
227
|
+
identified statically. Run the suite before/after to get exact red counts.
|
|
228
|
+
5. **`get_by_type` ordering for components (C7).** v2 does not order components by weight either,
|
|
229
|
+
so gem matches — but confirm no consumer relies on component order.
|
|
230
|
+
|
|
231
|
+
---
|
|
232
|
+
|
|
233
|
+
## Appendix — files inspected
|
|
234
|
+
|
|
235
|
+
v2 contract: `ecoportal-api-v2/lib/ecoportal/api/v2/page/{components,forces,sections,stages}.rb`,
|
|
236
|
+
`.../page/force/bindings.rb`.
|
|
237
|
+
Gem compat: `lib/.../base/page/data_field/collection.rb`, `.../base/page/section_collection.rb`,
|
|
238
|
+
`.../base/force/{collection,binding_collection,force}.rb`,
|
|
239
|
+
`.../compat/{pages,registers,stage_collection,stage_view}.rb`,
|
|
240
|
+
`.../concerns/{page_compat,data_field_access,snake_camel_access,deprecation}.rb`,
|
|
241
|
+
`.../interface/base_page.rb`.
|
|
242
|
+
Consumers: `multi_org_api/*/config/ooze_cases/*` (grep, ~120 sites),
|
|
243
|
+
`eco-helpers/lib/eco/api/usecases/graphql/compat/ooze_redirect.rb`, `.../ooze_samples/*`.
|
|
244
|
+
Prior findings: `.ai-assistance/code/model_input_mapping/SYNTHESIS.md`.
|
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
`template-diff-deploy` + `template-maintenance` projects into one plan).
|
|
5
5
|
**Domain reference (read first):** `.ai-assistance/code/template_diff_pairing_domain.md`
|
|
6
6
|
**Code spec:** `.ai-assistance/code/diff_pairing_engine.md`
|
|
7
|
+
**Governing security standard (read before wiring live creds):** `security/ai-service-account-scope`
|
|
8
|
+
in ep-ai-standards. This automation MUST authenticate as a dedicated per-org SERVICE ACCOUNT whose
|
|
9
|
+
grant covers templates/org-config only -- page records (customer data + PII) are structurally out of
|
|
10
|
+
scope, enforced by the credential grant, not by instruction. This is the practitioner discovery point
|
|
11
|
+
for that standard (the template automation is its concrete consumer). The "sandbox creds" in success
|
|
12
|
+
criterion #4 below must be provisioned to that scope. Platform dependency RESOLVED: templates ARE pages
|
|
13
|
+
(`PageUnion`), but `stage.tags` (wrongly deprecated ~21 months ago) has been restored via ecoPortal MR
|
|
14
|
+
!6649, so a rule scoped to template stages can structurally exclude record stages. Defining and
|
|
15
|
+
provisioning the concrete grant is now OUR work (this gem + agent), not a platform blocker -- see the
|
|
16
|
+
service-account grant task in `TODO.md`.
|
|
7
17
|
**Superseded folders (pointers only now):** `template-diff-deploy/`, `template-maintenance/`.
|
|
8
18
|
**Sibling projects:** `qa-services-delivery` (verify), `ooze-graphql-native-migration`,
|
|
9
19
|
`template-csv-pipeline` (memory).
|
|
@@ -90,6 +90,17 @@ emission layer: an ordered `WorkflowCommand` batch with `placeholderId` threadin
|
|
|
90
90
|
---
|
|
91
91
|
|
|
92
92
|
## Cross-cutting
|
|
93
|
+
- [ ] **Service-account grant (gates live use; governed by ep-ai-standards `security/ai-service-account-scope`).**
|
|
94
|
+
This automation must run as a dedicated per-org SERVICE ACCOUNT scoped to templates/org-config only --
|
|
95
|
+
page records out of scope, enforced by the grant (structural PII/compliance boundary, not instruction).
|
|
96
|
+
Platform enabler is in place: `stage.tags` (wrongly deprecated ~21 months ago) restored via ecoPortal
|
|
97
|
+
MR !6649, so a rule scoped to template stages can exclude record stages. OUR remaining work:
|
|
98
|
+
(a) enumerate in-scope object types (templates; and which of registers / stages / tag defs / workflow +
|
|
99
|
+
automation config / custom-field defs -- explicit in/out per type, each a potential path back to records);
|
|
100
|
+
(b) write the concrete `stage.tags` rule and confirm no record stage shares those tags;
|
|
101
|
+
(c) fix the read/write surface per type (edit templates? read-only org-config?);
|
|
102
|
+
(d) provision the per-org account and confirm its `canXXX` set carries NO page-record capability.
|
|
103
|
+
Feeds the "sandbox creds" needed by Phase B2 / Phase 5. Reference: INTENT header + the standard.
|
|
93
104
|
- [ ] Tighten `ecoportal-qa` to consume the gem's page-model instead of its own `TemplateModel`.
|
|
94
105
|
- [ ] Emit `stageId`/`sectionId` back-refs on diff-side `addField`/`addSection` (BUILD already does;
|
|
95
106
|
the diff synthesizer's structural adds still emit only label/placeholder — then threading covers them).
|