ecoportal-api-graphql 1.3.12 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 74b442ca224440bee94f3f88522a4233b995d187db1b70ba31a0801e0691d3eb
4
- data.tar.gz: 5d5d05fdf46fac5a32a12c5d4c8e47c21b913332bbe04c40a3ab81beeacbab9a
3
+ metadata.gz: fba9a366d06303f179c6b7d841930a66a29b6cc3fe836e06b218b95873bbe77e
4
+ data.tar.gz: 6114ba779317ec55b649e1f7df685c0140573cea073aa07c04b9e4cfa20b433a
5
5
  SHA512:
6
- metadata.gz: 7d667e7ed09fc6ed6fd200695567eb25a8bb5f4f41b3b98882e04d84acd1775c1a17dc7d424e82f26b7f89d4f826bb46c25fddc0ef5fc526e8731aaccd73193b
7
- data.tar.gz: 324b54e96de9f7ae82e71bf1b2c0427fa6fd4ed99d0153ec836a85cf68d76d5ce8f8462865b29cddd446015caa2b4a5fcaf940c5c2866970d7814a2d0495a73b
6
+ metadata.gz: d867c896796e9107870a2a49e3cdc75275b0a96559a9b8fe47fedea407ef639f7ff558ce22de798f79f3f011c7d4464a3eacd01a63e764778d2e0359cd8ed4ad
7
+ data.tar.gz: c29f555f19fc3d679f51cb512c417caca1e7e5f0006e9ac35a7ccf4d7f074095011ad4a59128a458b69c178dd538d79bf952d1b26b1557397e0d5fc4c41274d4
@@ -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`.
@@ -0,0 +1,236 @@
1
+ # Category Catalog — Composite Question Archetypes (FIRST DRAFT)
2
+
3
+ > Companion to `DESIGN_NOTE.md`. This is the **closed first set** of concrete "category"
4
+ > archetypes for ecoPortal templates. A category is a reusable composite question pattern —
5
+ > a **parametrizable macro** authored once that compiles down into flat `fields + forces`.
6
+ > Authoring-first (per DESIGN_NOTE §5b): every archetype here is expressible via the
7
+ > shipped `WorkflowCommandInput` command bus write path.
8
+
9
+ ---
10
+
11
+ ## How archetypes compile to `fields + forces`
12
+
13
+ An archetype is a macro. Given its parameters (labels, options, which effect), it emits an
14
+ **ordered batch of `WorkflowCommand` inputs** — the same batch `Builder::TemplateBuilder`
15
+ already produces. The expansion has two halves:
16
+
17
+ 1. **Structure** → `addField` (per dependent field, with `fieldType`/`label`/`stageId`/`sectionId`/`column`),
18
+ `addSelectFieldOption` (per option), `addGaugeFieldStop` (per gauge band),
19
+ `addSection`/`addStageSection` (when the archetype owns a whole revealable section),
20
+ and follow-up `editFieldConfiguration` for `description` (the identity/CSV token),
21
+ `tooltip`, and per-type `byType` config. Field structure is placeholder-threaded
22
+ (`placeholderId`) so intra-batch references resolve before the server assigns real ids.
23
+ 2. **Flow** → one or more **forces** bound to the dependent fields. A force is an AngularJS/Lisp
24
+ snippet (`addForce {name, customScript}`) plus `addBinding {forceId, fieldId, name}` per field
25
+ it reads or writes. The force encodes the answer→effect mapping: `show`/`hide`/`disable`/`clear`,
26
+ `select`/`set`, `add-tag`/`remove-tag`, `accent` (color), and matrix lookups. In the
27
+ **EPTIR flat IR** target this same flow is modelled declaratively as a JSON-Logic `when`
28
+ condition + a closed effect (`show_field`/`hide_field`/`show_section`/`set_required`/`set_value`/
29
+ `add_tag`), which is then lowered to a force snippet at compile time.
30
+
31
+ > **Hard constraint (VERIFIED, `workflow-command-guide.md` §"Schema realities", `add_field.rb`):**
32
+ > `required` cannot be set through the command bus **anywhere** — not on `addField`, not on
33
+ > `editFieldConfiguration` top-level, not on any `byType` sub-input. So any archetype effect that
34
+ > "makes a field required" MUST be realised as **force-level conditional-required** (the force
35
+ > refuses to let the stage submit / flags the field), NOT as a static template `required` flag.
36
+ > Every `required_when_shown` below means *force-enforced*, not *template-attribute*.
37
+
38
+ ---
39
+
40
+ ## Summary table
41
+
42
+ | # | Name | Parent question | Core effect | Grounding |
43
+ |---|------|-----------------|-------------|-----------|
44
+ | 1 | `conditional-detail-select` | Select (single) | reveal + require detail field on chosen answer(s) | **Well-grounded** (DESIGN_NOTE worked example; `numeric_behaviour_select`, `Bay_change_options`) |
45
+ | 2 | `yes-no-with-evidence` | Select (Yes/No) or Checklist | reveal File/ImageGallery (+ note) on "Yes" | **Well-grounded** (File/Image field types heavily used; special case of #1) |
46
+ | 3 | `checklist-with-followup` | Checklist (multi) | each unchecked/"No" item reveals its own follow-up field | **Plausible** (Checklist type real; per-item force wiring inferred) |
47
+ | 4 | `multi-select-fanout` | Select (multiple) | each selected option enables/disables its own section | **Well-grounded** (`replace_risk` `update-unit` per-`category` section enable/disable) |
48
+ | 5 | `risk-matrix` | 2× Select (likelihood, consequence) | compute rating via lookup table → Gauge + level Select + color + risk tags | **Well-grounded** (`replace_risk_case.rb` production force; `update_severity_force`) |
49
+ | 6 | `select-drives-gauge` | Select (single) | map chosen option → numeric Gauge value + band color | **Well-grounded** (Gauge stops in TemplateBuilder; simpler slice of #5) |
50
+ | 7 | `computed-number` | Number(s) / Select | compute a Number/Gauge from arithmetic over inputs | **Well-grounded** (`overall-risk-mitigation` = arithmetic diff of ratings in `replace_risk`) |
51
+ | 8 | `person-plus-role` | People | reveal a role/detail field per attached person or role Select | **Plausible** (People field + config real; pairing force inferred) |
52
+ | 9 | `cross-reference-with-detail` | CrossReference | on attach, reveal detail/linked fields; optionally pull linked values | **Well-grounded** (CrossReference 2nd most-used; `risk_mapping` attaches hazards/controls; `addLinkedFieldConfig`) |
53
+ | 10 | `select-drives-tags` | Select / Checklist | chosen answer(s) add/remove page tags (register membership) | **Well-grounded** (`update_severity_force` NOTIFIABLE; `tag-on-level` in `replace_risk`; memory: register membership via tags) |
54
+ | 11 | `signoff-block` | Signature (+ People + Date) | reveal/require signature block when a gate answer is set | **Plausible** (Signature field real; conditional-required is force-level, see constraint) |
55
+
56
+ Grounding legend: **Well-grounded** = the field shapes AND the force/flow behaviour were seen in
57
+ production ooze scripts or are directly emitted by `TemplateBuilder`. **Plausible** = field types
58
+ are real and the pattern is idiomatic, but the exact force wiring was inferred, not observed.
59
+
60
+ ---
61
+
62
+ ## 1. `conditional-detail-select`
63
+
64
+ | Aspect | Spec |
65
+ |---|---|
66
+ | **Intent / when to use** | The canonical "if you picked X, tell us more". A single-select gate whose non-default answer(s) reveal and (force-)require a detail field. The workhorse archetype. |
67
+ | **Parent question type** | `Select` (single; `config.select.multiple = false`) |
68
+ | **Dependent fields** | 1+ detail fields — typically `PlainText` (free text) or `RichText` (formatted), hidden by default. Role: capture the "more info". |
69
+ | **Flow / force behaviour** | On chosen answer(s): `show(detail)` + (force-enforced) required. On other answers: `hide(detail)` + `clear(detail)`. Mirrors `replace_risk`'s `(do fld (hide) (clear))` idiom. |
70
+ | **`when` (JSON-Logic)** | `{"in": [{"var": "parent.selected"}, ["Yes"]]}` → effects `[{show_field: detail}, {set_required: detail}]`; else `[{hide_field: detail}, {set_value: {field: detail, value: null}}]` |
71
+ | **Parameters** | `parent_label`, `options[]`, `reveal_on[]` (subset of options), `detail_fields[] {label, type, required_when_shown}` |
72
+ | **Compiles to** | `addField(Select)` + `addSelectFieldOption`×N + `editFieldConfiguration(select.multiple=false)`; `addField(PlainText/RichText)` per detail (hidden); one `addForce` + `addBinding` for parent and each detail. |
73
+ | **Notes / open Qs** | Default hidden state: is "hidden until shown" a template attribute or purely force-driven? `editFieldConfiguration` has `hideView` (base key) — likely the static default; force flips it. Needs confirmation. |
74
+
75
+ ## 2. `yes-no-with-evidence`
76
+
77
+ | Aspect | Spec |
78
+ |---|---|
79
+ | **Intent / when to use** | "Did X happen? If yes, attach proof." Compliance/audit checks that need a photo, file, or short note only on the affirmative branch. |
80
+ | **Parent question type** | `Select` (Yes/No) or a single `Checklist` item |
81
+ | **Dependent fields** | `File` and/or `ImageGallery` (evidence) + optional `PlainText` (note). Hidden by default. |
82
+ | **Flow / force behaviour** | `answer == "Yes"` → `show(evidence)` + force-required (at least one attachment); `"No"` → `hide` + `clear`. |
83
+ | **`when` (JSON-Logic)** | `{"==": [{"var": "parent.selected"}, "Yes"]}` → `[{show_field: evidence}, {set_required: evidence}]` |
84
+ | **Parameters** | `parent_label`, `evidence_kind` (`file`\|`image`\|`both`), `note?` (bool), `required_when_shown` |
85
+ | **Compiles to** | Select + 2 options; `addField(File)` / `addField(ImageGallery)` + optional `PlainText`; force with bindings. |
86
+ | **Notes / open Qs** | Specialisation of #1 with fixed Yes/No parent and attachment detail. File/Image field read/write key asymmetry is a known gem bug (memory `model_input_converter_mapping`) but does not affect **template-build** authoring. |
87
+
88
+ ## 3. `checklist-with-followup`
89
+
90
+ | Aspect | Spec |
91
+ |---|---|
92
+ | **Intent / when to use** | An inspection checklist where any item marked non-compliant ("No"/unchecked) must open a corrective-detail field for that item. |
93
+ | **Parent question type** | `Checklist` (multi-item, each item = label + checked) |
94
+ | **Dependent fields** | One follow-up field per item (usually `PlainText`), hidden until its item triggers. |
95
+ | **Flow / force behaviour** | For each item: `item.checked == false` (or a "fail" state) → `show(followup_i)` + force-required; else `hide` + `clear`. Iterated in the force the way `replace_risk`'s `map` over unit lists does. |
96
+ | **`when` (JSON-Logic)** | per item: `{"==": [{"var": "checklist.items.i.checked"}, false]}` → `[{show_field: followup_i}, {set_required: followup_i}]` |
97
+ | **Parameters** | `checklist_label`, `items[] {label}`, `followup_type`, `fail_state` (`unchecked`\|`checked`) |
98
+ | **Compiles to** | `addField(Checklist)` (items are the checklist's own config); N `addField(PlainText)`; one force binding all items + all follow-ups. |
99
+ | **Notes / open Qs** | Checklist item ids are instance-assigned; force must bind by ref/label. Whether "N follow-ups + 1 force" or "N small forces" is cleaner is an open authoring choice. |
100
+
101
+ ## 4. `multi-select-fanout`
102
+
103
+ | Aspect | Spec |
104
+ |---|---|
105
+ | **Intent / when to use** | A multi-select "which of these apply?" where **each** selected option turns on a dedicated section/field-group; unselected ones are disabled/cleared. |
106
+ | **Parent question type** | `Select` (multiple; `config.select.multiple = true`) or `Checklist` |
107
+ | **Dependent fields** | One **section** (or field cluster) per option — e.g. the `replace_risk` per-`category` units (`equ`, `haz`, `cvp`, `env`, `peo`), each with its own like/consequence/gauge/rating fields. |
108
+ | **Flow / force behaviour** | Directly from `replace_risk`'s `update-unit`: `(active (=sel category kind))` → if active, evaluate/show the section; if not, `(map-1 disable isec rsec)` + `(map-1 clear ...)`. |
109
+ | **`when` (JSON-Logic)** | per option: `{"in": ["equ", {"var": "parent.selected"}]}` → `[{show_section: sec_equ}]`; else `[{hide_section: sec_equ}, {set_value: {field: sec_equ.*, value: null}}]` |
110
+ | **Parameters** | `parent_label`, `options[]`, `section_template` (fields each option's section contains), `multiple: true` |
111
+ | **Compiles to** | Select(multiple) + options; per option a `addSection`+`addStageSection`+ its `addField`s; one force reading the parent and binding every fanned-out section. |
112
+ | **Notes / open Qs** | This is the compositional heart of the risk template — #5 is usually **nested inside** each fanned-out section. Section-level show/hide is the effect that most needs adding to EPTIR's grammar (DESIGN_NOTE §5a). |
113
+
114
+ ## 5. `risk-matrix`
115
+
116
+ | Aspect | Spec |
117
+ |---|---|
118
+ | **Intent / when to use** | Classic likelihood × consequence risk rating. Two selects feed a lookup matrix that computes a rating, maps it to a level + color, and tags the page by severity. The single richest observed archetype. |
119
+ | **Parent question type** | Two `Select` fields — `likelihood` and `consequence` (each single-select, ordered options 0..N). |
120
+ | **Dependent fields** | `Gauge` (rating value + colored bands), a level `Select` (Low/Med/High/Very — auto-selected, locked), optional hidden `Number` rating field. |
121
+ | **Flow / force behaviour** | Straight from `replace_risk_case.rb`: `(risk-rating con lik)` indexes a hard-coded matrix; `(risk-level rating)` buckets it; `set-risk` does `(do gau (lock) (set n))`, `(do lev (lock) (clear) (select level) (set-color level))`; `tag-on-level` adds `INHERENT HIGH`/`RESIDUAL HIGH` tags. |
122
+ | **`when` (JSON-Logic)** | Not a boolean gate — a **computed** effect: `{set_value: {field: gauge, value: {"matrix_lookup": [{"var": "likelihood.weight"}, {"var": "consequence.weight"}, <matrix>]}}}` + threshold→level + threshold→tag rules. |
123
+ | **Parameters** | `likelihood_options[]`, `consequence_options[]`, `matrix[][]` (rating table), `level_bands[] {min, label, color, tag?}`, `gauge_max` |
124
+ | **Compiles to** | 2× Select+options; `addField(Gauge)` + `addGaugeFieldStop` per band (threshold+color); level Select+options; `editFieldConfiguration(gauge.max)`; one force encoding the matrix + level LUT + tag rules, bound to all fields. |
125
+ | **Notes / open Qs** | Real forces support **inherent vs residual** pairs and `min-rating-difference` "uncontrolled risk" logic (`uncontrolled` fn) — a richer parametrization than a single matrix. Draft models one matrix; flag the inherent/residual variant as a parameter to add. Matrix values are the archetype's key parameter. |
126
+
127
+ ## 6. `select-drives-gauge`
128
+
129
+ | Aspect | Spec |
130
+ |---|---|
131
+ | **Intent / when to use** | A single categorical answer maps to a numeric score shown on a colored gauge (severity score, maturity level). The 1-dimensional slice of #5. |
132
+ | **Parent question type** | `Select` (single) with weighted options. |
133
+ | **Dependent fields** | `Gauge` (score + bands). |
134
+ | **Flow / force behaviour** | `set(gauge, selected_option.weight)` + gauge stops color it; lock the gauge. Same `(do gau (lock) (set n))` idiom. |
135
+ | **`when` (JSON-Logic)** | `{set_value: {field: gauge, value: {"var": "parent.selected.weight"}}}` |
136
+ | **Parameters** | `parent_label`, `options[] {label, weight}`, `gauge_max`, `bands[] {threshold, color}` |
137
+ | **Compiles to** | Select + weighted options (`addSelectFieldOption {label, weight}`); `addField(Gauge)` + `addGaugeFieldStop`s + `editFieldConfiguration(gauge.max)`; force binding select→gauge. |
138
+ | **Notes / open Qs** | Option `weight` is a real `addSelectFieldOption` key — the natural carrier of the numeric mapping, so the force may just read the weight. Confirm gauge can be force-`set` from weight without an explicit LUT. |
139
+
140
+ ## 7. `computed-number`
141
+
142
+ | Aspect | Spec |
143
+ |---|---|
144
+ | **Intent / when to use** | A read-only field whose value is arithmetic over other fields (totals, differences, days-between, cost = qty × rate). |
145
+ | **Parent question type** | None (no gate) — inputs are Number/Date/Select fields; the archetype is the **output** + its formula. |
146
+ | **Dependent fields** | Output `Number` or `Gauge` (locked, hidden-from-edit). |
147
+ | **Flow / force behaviour** | Force computes and `set`s the output, then `(lock)`. Grounded in `replace_risk`'s `overall-risk-mitigation` = `(- max-irating max-rrating)` written via `(do overall-risk-mitigation (lock) (set maxs-difference))`. |
148
+ | **`when` (JSON-Logic)** | `{set_value: {field: out, value: {"-": [{"var": "a"}, {"var": "b"}]}}}` (or `*`, `+`, date-diff op) |
149
+ | **Parameters** | `inputs[]` (field refs), `formula` (arithmetic expr), `output_type` (`number`\|`gauge`) |
150
+ | **Compiles to** | `addField(Number/Gauge)` (output); force reads inputs, computes, sets+locks output. |
151
+ | **Notes / open Qs** | Formula grammar needs bounding (arithmetic + date-diff only?). Overlaps #5 — a risk matrix is a specialised computed value; keep separate because #7 is gate-free and formula-driven. |
152
+
153
+ ## 8. `person-plus-role`
154
+
155
+ | Aspect | Spec |
156
+ |---|---|
157
+ | **Intent / when to use** | "Who was involved, and in what capacity" — a People field paired with a role/relationship qualifier per person (witness/injured/first-aider). |
158
+ | **Parent question type** | `People` (single or multi) |
159
+ | **Dependent fields** | A role `Select` (or `PlainText`) capturing the capacity; optionally revealed only once a person is attached. |
160
+ | **Flow / force behaviour** | `people.count > 0` → `show(role)`; optional force-required. |
161
+ | **`when` (JSON-Logic)** | `{">": [{"var": "people.count"}, 0]}` → `[{show_field: role}, {set_required: role}]` |
162
+ | **Parameters** | `people_label`, `singular` (bool), `role_options[]` (or free text), `person_schema_id?` |
163
+ | **Compiles to** | `addField(People)` + `editFieldConfiguration(people.singular/personSchemaId)`; role Select/PlainText; force binding people→role. |
164
+ | **Notes / open Qs** | **Plausible, not observed** — People fields are heavily used (40 hits) but a per-person role force wasn't seen. Per-person (vs per-field) role capture may need a repeating/table structure rather than a single Select; flag for design. |
165
+
166
+ ## 9. `cross-reference-with-detail`
167
+
168
+ | Aspect | Spec |
169
+ |---|---|
170
+ | **Intent / when to use** | Attach related records from another register (hazards, controls, assets, permits) and reveal/pull detail once attached. The register-linking backbone. |
171
+ | **Parent question type** | `CrossReference` (to a target register) |
172
+ | **Dependent fields** | Detail fields shown on attach; and/or **linked fields** that mirror values from the referenced page (`addLinkedFieldConfig`). |
173
+ | **Flow / force behaviour** | On attach → `show(detail)`; linked fields auto-populate from the source via linked-field config (not a force). `risk_mapping_case.rb` attaches "Associated hazard" + "Attach controls" cross-refs by id. |
174
+ | **`when` (JSON-Logic)** | `{">": [{"var": "xref.count"}, 0]}` → `[{show_field: detail}]`; linked-field pull is config, not `when`. |
175
+ | **Parameters** | `xref_label`, `register_id`, `single_select_mode`, `display_fields[]`, `linked_fields[] {source_field_key}`, `detail_fields[]` |
176
+ | **Compiles to** | `addField(CrossReference)` + `editFieldConfiguration(crossReference {registerId, singleSelectMode, displayFields, hideCreate, hideAttach})`; `addLinkedFieldConfig {sourceFieldKey, crossReferenceId}` per linked field; optional detail fields + force. |
177
+ | **Notes / open Qs** | Two distinct mechanisms here: **linked fields** (declarative config, pull values) vs **forces** (reveal detail). CrossReference is the 2nd most-used type (66 hits) so this archetype is high-value. Register membership interplay via tags overlaps #10. |
178
+
179
+ ## 10. `select-drives-tags`
180
+
181
+ | Aspect | Spec |
182
+ |---|---|
183
+ | **Intent / when to use** | Answering a question changes the page's **tags**, which drives register membership, visibility, and policy routing (notifiable events, high-risk escalation, moving a record between registers). |
184
+ | **Parent question type** | `Select` (single/multi) or `Checklist` |
185
+ | **Dependent fields** | None structural — the effect is on page tags. May pair with a hidden marker. |
186
+ | **Flow / force behaviour** | Chosen answer → `add-tag`/`remove-tag`. Directly from `update_severity_force` (severity → `NOTIFIABLE`) and `replace_risk`'s `tag-on-level` (`(tag-off ...) (tag-on tag)`). |
187
+ | **`when` (JSON-Logic)** | `{"in": [{"var": "severity.selected"}, ["major", "critical"]]}` → `[{add_tag: "NOTIFIABLE"}]`; else `[{remove_tag: "NOTIFIABLE"}]` |
188
+ | **Parameters** | `parent_label`, `option_to_tags` (map answer → tag set), `mutually_exclusive` (bool — clear siblings first) |
189
+ | **Compiles to** | Select/Checklist + options; force reads answer, adds/removes tags. No new fields. |
190
+ | **Notes / open Qs** | `add_tag`/`remove_tag` ARE in EPTIR's existing closed effect set (DESIGN_NOTE §5a) — this archetype compiles cleanly **today**, unlike the show/hide ones. Tag→register-membership is a platform rule (memory `register_membership_via_tags`), so this archetype has real downstream reach. |
191
+
192
+ ## 11. `signoff-block`
193
+
194
+ | Aspect | Spec |
195
+ |---|---|
196
+ | **Intent / when to use** | A review/approval gate: capture signature + approver + date, revealed/required once the record reaches an approvable state. |
197
+ | **Parent question type** | `Signature` (with companion `People` approver + `Date`) — often gated by a prior Select ("Ready for sign-off?"). |
198
+ | **Dependent fields** | `Signature`, `People` (approver), `Date` (signed-on). |
199
+ | **Flow / force behaviour** | Gate answer set → `show`/force-require the block; before that, hidden. Note the platform also has native stage sign-off (`editRequiredSignOffs`) — the archetype is the **in-form** variant. |
200
+ | **`when` (JSON-Logic)** | `{"==": [{"var": "gate.selected"}, "Ready"]}` → `[{show_field: signature}, {set_required: signature}, {show_field: approver}, {show_field: date}]` |
201
+ | **Parameters** | `gate_label?`, `require_approver` (bool), `require_date` (bool) |
202
+ | **Compiles to** | Optional gate Select; `addField(Signature)` + `addField(People)` + `addField(Date)`; force binding gate→block. |
203
+ | **Notes / open Qs** | **Plausible, not observed** in ooze scripts. Overlaps with native stage sign-off (`editRequiredSignOffs`, `editRequiredSignOffs` command) and workflow task sign-off — decide whether "sign-off" belongs at the **field/force** layer or the **stage/task** layer. Likely the latter for true approvals; keep this archetype for lightweight in-form acknowledgement. |
204
+
205
+ ---
206
+
207
+ ## Cross-cutting notes
208
+
209
+ - **`required` is force-only.** Repeated because it shapes every reveal archetype: the template
210
+ cannot carry a `required` flag through the command bus, so "required when shown" is always the
211
+ force refusing submission / flagging the field. If the future Workflow Builder conditional-field
212
+ feature (in development, `10_forces_workflow_builder.md`) lands a native conditional-required,
213
+ these archetypes should retarget it and drop the hand-authored force.
214
+ - **Effect grammar gap.** Of the effects these archetypes need — `show_field`, `hide_field`,
215
+ `show_section`, `hide_section`, `set_required`, `set_value`, `add_tag`, `remove_tag` — only
216
+ `set_value`/`add_tag`/`remove_tag` are in EPTIR's current closed set. #1–#9 and #11 depend on
217
+ adding the show/hide/require effects (DESIGN_NOTE §5a). #10 compiles today.
218
+ - **Reverse-classification is blocked.** Clustering the ~300 existing templates back into these
219
+ archetypes needs reading forces off a page, which is the one blocked path
220
+ (`Query::PageWithForces`, DESIGN_NOTE §6). This catalog is therefore an **authoring-first**
221
+ taxonomy; treat archetype detection from existing forces as future work.
222
+ - **Section vs field ownership.** #4 (fanout) and #11 (block) reveal whole **sections**;
223
+ #1/#2/#3/#8 reveal individual **fields**. The macro needs to know which it owns so it can emit
224
+ `addSection`/`addStageSection` vs plain `addField` and bind the force at the right granularity.
225
+
226
+ ## Patterns seen repeatedly in real ooze scripts
227
+
228
+ - **Weighted single-selects feeding computed risk** is the dominant real pattern (`replace_risk`,
229
+ `update_severity_force`): likelihood/consequence selects → matrix → gauge + level + color + tags.
230
+ - **`(do <field> (lock) (clear) (select ...) / (set ...) (deindex-empty) (hide))`** is the universal
231
+ force idiom for computed/reveal fields — lock, clear, write, (optionally) hide, and drop empties
232
+ from the index. Every archetype's expansion should assume outputs are locked.
233
+ - **Tags as the escalation/routing mechanism** (`NOTIFIABLE`, `INHERENT HIGH`) appears in both
234
+ incident and risk templates — #10 is genuinely load-bearing, not decorative.
235
+ - **Cross-reference attach + per-answer section enable/disable** (`risk_mapping`, `replace_risk`
236
+ per-`category` units) confirms #4 and #9 as real composite shapes, not just single fields.