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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/.ai-assistance/code/filter_contract_matrix.md +177 -0
  3. data/.ai-assistance/projects/compat-layer-audit/COMPAT_AUDIT.md +244 -0
  4. data/.ai-assistance/projects/template-automatic-build-maintenance/INTENT.md +10 -0
  5. data/.ai-assistance/projects/template-automatic-build-maintenance/TODO.md +11 -0
  6. data/.ai-assistance/projects/template-model-logic/CATEGORY_CATALOG.md +236 -0
  7. data/.ai-assistance/projects/template-model-logic/CLASSIFICATION_SPIKE.md +243 -0
  8. data/.ai-assistance/projects/template-model-logic/DESIGN_NOTE.md +154 -0
  9. data/.ai-assistance/scripts/bridge-inbox-check.sh +75 -0
  10. data/.ai-assistance/skills/procedural-memory/SKILL.md +319 -0
  11. data/.ai-assistance/skills/project-self-docs/SKILL.md +181 -0
  12. data/.ai-assistance/skills/project-self-docs/scripts/self_docs_scan.py +378 -0
  13. data/.ai-assistance/standards-version.json +22 -21
  14. data/.claude/settings.json +150 -146
  15. data/.gitlab-ci.yml +45 -0
  16. data/CHANGELOG.md +58 -0
  17. data/CLAUDE.md +11 -0
  18. data/docs/self-docs/ARCHITECTURE.md +88 -0
  19. data/docs/self-docs/CHANGES.jsonl +12 -0
  20. data/docs/self-docs/COMPLIANCE.md +79 -0
  21. data/docs/self-docs/CONVENTIONS.md +74 -0
  22. data/docs/self-docs/INTEGRATIONS.md +65 -0
  23. data/docs/self-docs/OPERATIONS.md +76 -0
  24. data/docs/self-docs/OVERVIEW.md +64 -0
  25. data/docs/self-docs/STATUS.md +73 -0
  26. data/docs/self-docs/self-docs-index.json +51 -0
  27. data/docs/worklog.md +15 -20
  28. data/lib/ecoportal/api/common/graphql/client.rb +2 -0
  29. data/lib/ecoportal/api/common/graphql/http_client.rb +189 -177
  30. data/lib/ecoportal/api/common/graphql/model/diffable.rb +54 -54
  31. data/lib/ecoportal/api/graphql/base/action.rb +43 -43
  32. data/lib/ecoportal/api/graphql/base/contractor_entity/member_changes.rb +67 -67
  33. data/lib/ecoportal/api/graphql/base/page/data_field/collection.rb +7 -0
  34. data/lib/ecoportal/api/graphql/base/page/data_field/contractor_entities.rb +28 -28
  35. data/lib/ecoportal/api/graphql/base/page/data_field/file_field.rb +25 -25
  36. data/lib/ecoportal/api/graphql/base/page/data_field/image_gallery.rb +24 -24
  37. data/lib/ecoportal/api/graphql/base/page/section_collection.rb +85 -79
  38. data/lib/ecoportal/api/graphql/base/preset_view.rb +17 -17
  39. data/lib/ecoportal/api/graphql/base/register.rb +18 -18
  40. data/lib/ecoportal/api/graphql/compat/filter_translator.rb +63 -28
  41. data/lib/ecoportal/api/graphql/concerns/page_compat.rb +51 -51
  42. data/lib/ecoportal/api/graphql/concerns.rb +14 -14
  43. data/lib/ecoportal/api/graphql/file_upload/client.rb +181 -181
  44. data/lib/ecoportal/api/graphql/fragment/page.rb +85 -85
  45. data/lib/ecoportal/api/graphql/input/action/update.rb +14 -14
  46. data/lib/ecoportal/api/graphql/input/contractor_entity/update.rb +41 -41
  47. data/lib/ecoportal/api/graphql/input/location_structure/apply_commands.rb +47 -47
  48. data/lib/ecoportal/api/graphql/input/location_structure/draft/add_commands.rb +49 -49
  49. data/lib/ecoportal/api/graphql/input/location_structure/update_command.rb +27 -27
  50. data/lib/ecoportal/api/graphql/input/search_conf.rb +436 -367
  51. data/lib/ecoportal/api/graphql/interface/location_structure/command.rb +30 -30
  52. data/lib/ecoportal/api/graphql/logic/input.rb +26 -26
  53. data/lib/ecoportal/api/graphql_version.rb +1 -1
  54. metadata +20 -1
@@ -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.
@@ -0,0 +1,243 @@
1
+ # Spike — Reverse-Classifying Existing Template Questions into Archetypes
2
+
3
+ > **Question.** Can we read the ~300 existing templates' questions and cluster them back into
4
+ > "categories/archetypes" — where an archetype = *parent question (Select/Checklist) + dependent
5
+ > fields + the force(s) that reveal/require/compute them per answer*? What does that depend on?
6
+ >
7
+ > **Scope.** Feasibility + prerequisites only. No code changes. Companion to
8
+ > `DESIGN_NOTE.md` (which frames the *authoring* direction); this spike is the *reverse* direction.
9
+ > All code claims are cited `file:line` and verified against the current tree
10
+ > (branch `feature/model-input-fixes`, 2026-07-07).
11
+
12
+ ---
13
+
14
+ ## 1. Feasibility verdict
15
+
16
+ **Partially feasible today; fully feasible with one small, already-triaged query-shape fix.**
17
+
18
+ - The **structural half** of an archetype (parent Select/Checklist + its options + adjacent
19
+ dependent fields, plus `genomeSignature` and `ref` as equivalence signals) is **readable now** —
20
+ the page/template read model and its fragments already fetch everything needed.
21
+ - The **flow half** (which answer reveals/requires/computes which field) lives in **Forces**, and
22
+ **reading forces off a page is the one blocked path** — but the blocker is a *client-side
23
+ query-shape bug*, not a missing backend or a missing model. The Force model, bindings model,
24
+ read fragment, and read query all already exist in the gem; the query and fragment simply select
25
+ fields the schema rejects. The fix is enumerated to the line in `.ai-assistance/projects/TODO.md`.
26
+ - Even once forces are readable, the **force `script` is an AngularJS/LISP string** with no
27
+ structured answer→effect representation available today. Extracting `answer == "Yes" → show(F)`
28
+ requires parsing that string. This is the **honest residual risk** and it does not go away with
29
+ the query fix (see §5, §6).
30
+
31
+ So: **structure-only classification is unblocked and gives a useful first pass; authoritative
32
+ classification (with the flow contract) needs (a) the forces-read fix — cheap — and (b) a force
33
+ script parser — the real work.**
34
+
35
+ ---
36
+
37
+ ## 2. Per-signal reliability assessment
38
+
39
+ Ranked by authoritativeness for identifying an archetype.
40
+
41
+ ### 2.1 Force bindings + script — AUTHORITATIVE, but gated + needs parsing
42
+
43
+ A `Force` connects fields/sections via **bindings** and encodes the show/hide/require/compute logic
44
+ in its `script`. This is the *only* signal that actually tells you the flow contract.
45
+
46
+ - **Model exists.** `Base::Force` exposes `id`, `name`, `script`, `customScript`, `weight`, `url`,
47
+ `globalBindingIds`, and `bindings` — `lib/ecoportal/api/graphql/base/force.rb:19-28`.
48
+ - **Bindings expose the field/section link.** `Base::Force::Binding` carries `name` + `referenceId`
49
+ (the bound field/section id) — `lib/ecoportal/api/graphql/base/force/binding.rb:7-14`. The
50
+ collection can look up by name — `.../binding_collection.rb:19-22`.
51
+ - **The page model surfaces forces** — `Interface::BasePage#forces` wraps `doc['forces']` in a
52
+ `Force::Collection`, and the page carries a `hasForces` boolean —
53
+ `lib/ecoportal/api/graphql/interface/base_page.rb:75-83`.
54
+ - **A read fragment + read query exist.** `Fragment::ForceFields` selects
55
+ `id name script customScript globalBindingIds bindings{...}` —
56
+ `lib/ecoportal/api/graphql/fragment/force.rb:6-26`. `Query::PageWithForces` fetches
57
+ `page { ...PageFields; forces { ...ForceFields } }` —
58
+ `lib/ecoportal/api/graphql/query/page_with_forces.rb:31-38`.
59
+
60
+ **Reliability caveats even once readable:**
61
+ - `bindings` give you *which* fields a force touches (the cluster membership) — high value, structured.
62
+ - The `script` gives you the *per-answer effect* — but only as an unparsed AngularJS/LISP string
63
+ (`script` / `customScript` are opaque text). No structured answer→effect map is exposed. See §5.
64
+ - A force applies to a stage only if *all* its bindings resolve in that stage
65
+ (`10_forces_workflow_builder.md:16-21`), so binding→field resolution must be done per stage.
66
+
67
+ ### 2.2 Field structure / adjacency — AVAILABLE NOW, heuristic only
68
+
69
+ "A Select followed by N fields in the same section" is readable directly from the page/template read
70
+ model: stages → sections → fields.
71
+
72
+ - Sections and their fields are fetched by the page fragment —
73
+ `lib/ecoportal/api/graphql/fragment/pages/common_page_union.rb:105-131` (`sectionUnion` →
74
+ `ContentSection.dataFields` / `SplitSection.left/rightDataFields`).
75
+ - The gem exposes stage→section→field traversal (`page.stages`, `section.components`,
76
+ `get_by_type`, `get_by_name`) — documented in `07_data_fields.md:152-168`.
77
+ - 20+ concrete field types are dispatched via `Base::Page::DataField::TYPE_MAP`
78
+ (`07_data_fields.md:19-45`); `Select`/`Checklist` parents are identifiable by `__typename`.
79
+
80
+ **Reliability: heuristic.** Adjacency correlates with, but does not prove, a conditional
81
+ relationship. A detail field can sit adjacent to its parent for layout reasons with no force, or a
82
+ force can reveal a field in a *different* section. Adjacency gives you *candidate* clusters to then
83
+ confirm against forces. On its own it will both over-group (adjacent-but-independent fields) and
84
+ miss (force reveals a distant field). **Usable for a first-pass bucketing; fragile as ground truth.**
85
+
86
+ ### 2.3 genomeSignature — STRONG-BUT-FALLIBLE equivalence signal (not a clustering signal)
87
+
88
+ - **Fetched today.** The data-field fragment selects `genomeSignature`
89
+ (`fragment/pages/common_page_union.rb:124`) and the model reads it —
90
+ `lib/ecoportal/api/graphql/base/page/data_field.rb:45` (`passthrough :label, :deindex, :genomeSignature`).
91
+ - **Already consumed as a pairing signal.** The diff pairing engine uses it, weighted highest
92
+ (0.5) but explicitly not ground truth — `lib/ecoportal/api/graphql/diff/pairing/signals.rb:24-35`
93
+ and `WEIGHTS` at `:17-22`. The header documents 6 known failure modes
94
+ (`signals.rb:6-12`).
95
+
96
+ **Role for classification: equivalence, not clustering.** genomeSignature tells you *"this field in
97
+ template A is the same field as that field in template B"* — invaluable for saying *"the same
98
+ archetype instance recurs across N templates"* once you've identified it once. It does **not** tell
99
+ you *which fields form a cluster within one template* (that's bindings/adjacency). Treat it as the
100
+ cross-template dedup / recurrence signal, not the intra-template grouping signal. Fallible: reused/
101
+ re-purposed fields keep a stale genome, and newer field types may lack one (`signals.rb:8-12,31`).
102
+
103
+ ### 2.4 Select option sets, labels/descriptions, `ref` — SUPPORTING signals
104
+
105
+ - **Option sets** — the parent's option labels/ids are read as part of the field data
106
+ (`Select` typed field; `07_data_fields.md:59`). Useful to name the archetype (e.g. a Yes/No
107
+ reveal) and to map `reveal_on` answers once the script is parsed.
108
+ - **Labels/descriptions** — `label` is fetched (`common_page_union.rb:123`). The DESIGN_NOTE notes
109
+ the platform's identity trick is a hidden section-marker field + field `description` as keys
110
+ (`DESIGN_NOTE.md:96-98`) — so `description` doubles as an authored archetype/section key when present.
111
+ - **`ref`** — the ES index key, `<type>.<hash_of_label>` (`07_data_fields.md:181-197`). Stable-ish
112
+ per label; **breaks when the label changes** and can be per-instance if a field was script-added
113
+ without copying from template (`07_data_fields.md:194-197`). Weak identity aid, not a cluster key.
114
+
115
+ ---
116
+
117
+ ## 3. Is fixing the forces-read blocker a PREREQUISITE?
118
+
119
+ **For authoritative reverse-classification (archetype WITH its flow contract): YES.**
120
+ The flow half of an archetype is defined by forces (bindings + script). Bindings are the structured
121
+ statement of *which fields move together*; without reading them you are guessing cluster membership
122
+ from adjacency alone. There is no other exposed source of the conditional flow on the platform today
123
+ (the Workflow Builder's conditional-field workflows that would replace forces are still *in
124
+ development* — `10_forces_workflow_builder.md:83-96`, `86-88`).
125
+
126
+ **For a structure-only first pass (adjacency + genome + options): NO.**
127
+ You can bucket questions into *candidate* archetypes from the read model alone (§2.2–2.4) without any
128
+ forces access. This is useful for triage and to size the problem, but it is **fragile** (see §2.2):
129
+ it cannot distinguish "Select with a genuinely conditional follow-up" from "Select that happens to
130
+ sit above an always-visible field," and it will miss cross-section reveals. Any number you report
131
+ from adjacency-only is a *lower-confidence estimate*, not a classification.
132
+
133
+ **Net:** the forces-read fix is a **prerequisite for the real deliverable** but **not** for an
134
+ initial scoping pass. Given the fix is cheap (§4), there is little reason to ship the fragile
135
+ version as anything more than a scoping estimate.
136
+
137
+ ---
138
+
139
+ ## 4. Minimal-unblock sketch (forces read)
140
+
141
+ The blocker is a **client query-shape bug**, fully triaged in `.ai-assistance/projects/TODO.md:14-24`
142
+ (verified there against the live schema on 2026-07-03 — "all THREE are REAL bugs"). Two edits:
143
+
144
+ 1. **`Query::PageWithForces` selects `forces` directly on a union.** The query hangs `forces` off
145
+ the `PageUnion`, which is invalid GraphQL (can't select a field on a bare union) —
146
+ `query/page_with_forces.rb:33-35` (`forces { spread :ForceFields }`). **Fix:** wrap in the shared
147
+ interface — `... on BasePageInterface { forces { ...ForceFields } }` (per TODO.md:16-17).
148
+
149
+ 2. **`Fragment::ForceFields` selects `id` on binding types that have none.** The fragment selects
150
+ `id` inside `... on DataFieldBinding` and `... on SectionBinding`
151
+ (`fragment/force.rb:14-23`), but those types expose only `name`/`referenceId` (no `id`) —
152
+ `TODO.md:16-17`. **Fix:** drop `id` from the binding selections (keep `name`, `referenceId`).
153
+ Note `Base::Force::Binding` declares `passkey :id` (`base/force/binding.rb:8`) — harmless once the
154
+ query stops requesting it, since the model tolerates a missing key.
155
+
156
+ 3. **(eco-helpers, sibling repo — not this gem)** Split the capability gate.
157
+ `OozeRedirect.force_support?` hard-returns `false`, which gates **read and write together**, so
158
+ the broken read disables the working write as collateral — `10_forces_workflow_builder.md:50-54`,
159
+ RCA `.../analysis/2026-07-01-forces-via-workflow-commands-miss-rca.md:28-36,59-70`. For
160
+ *reverse-classification* we only need the **read** path, so this repo's job is items 1–2; the
161
+ eco-helpers gate split matters for the ooze scripts, not for a classifier that calls
162
+ `Query::PageWithForces` directly.
163
+
164
+ **Verification step (not code):** these three queries have no render/validation specs; the same TODO
165
+ item asks to add a render + schema-validation spec alongside the fix and to wire
166
+ `tests/validate_queries.rb` into CI (`TODO.md:5-24`). Do that first so the fix is provably
167
+ schema-valid against a fresh introspected schema before relying on it against 300 templates.
168
+
169
+ **Effort:** two one-line selection edits + specs. Small. The model/fragment/query scaffolding is all
170
+ already present (shipped as WIP alongside the write path in v1.3.11 per `CHANGELOG.md:184`).
171
+
172
+ ---
173
+
174
+ ## 5. Parsing the force script — the real difficulty
175
+
176
+ Reading forces gets you the **bindings** (structured: which fields) and the **script** (unstructured:
177
+ what happens per answer). The archetype's *per-answer effect* — `reveal_on: "Yes" → show + require`
178
+ in the DESIGN_NOTE worked example (`DESIGN_NOTE.md:44-56`) — lives **only in the script string**.
179
+
180
+ - `script`/`customScript` are opaque text (`base/force.rb:20`); the gem treats them as strings it
181
+ round-trips, never parses (write path just sets `customScript` and queues `editForce` —
182
+ `base/force.rb:33-38,55-60`). There is **no structured effect representation** exposed anywhere.
183
+ - The scripts are **AngularJS/LISP snippets** (`10_forces_workflow_builder.md:9`,
184
+ `base/force.rb:6`). Extracting a reliable `answer → {show|hide|require|compute}(field)` mapping
185
+ from arbitrary such snippets is an **interpreter/parser problem**, with a long tail of hand-written
186
+ variants across 300 templates and 10+ orgs (~22 force-dependent ooze cases catalogued —
187
+ RCA `:27`). Expect: bespoke idioms, computed risk-matrix math, multi-condition guards, indirection
188
+ through global bindings (`globalBindingIds` — `base/force.rb:22`).
189
+ - The **bindings alone** are a meaningful partial win: they tell you the *cluster membership* (this
190
+ force ties this Select to these N fields/sections) with high confidence, even if you never parse
191
+ the script. That yields "these fields form a conditional cluster governed by force X" — enough to
192
+ identify *that* an archetype exists and its members, if not its exact per-answer semantics.
193
+
194
+ **Structured alternative:** the **Workflow Builder** conditional-field workflows are the intended
195
+ structured replacement for force scripts — but they are **in development, not deployed**
196
+ (`10_forces_workflow_builder.md:83-96`) and the API is explicitly "not stable enough for scripting
197
+ against its internals" (`:98-104`). So there is **no structured flow representation available today**
198
+ for existing templates; existing conditional logic is in AngularJS forces. Reverse-classification of
199
+ the *installed base* cannot wait for Workflow Builder without leaving the ~300 current templates
200
+ uncovered.
201
+
202
+ ---
203
+
204
+ ## 6. Open risks (honest)
205
+
206
+ 1. **AngularJS script parsing (highest).** No structured effect map exists; extracting
207
+ answer→effect from arbitrary LISP/AngularJS snippets across 300 templates is the dominant unknown.
208
+ Mitigate by (a) classifying on **bindings + adjacency + options** first (cluster membership +
209
+ candidate parent), and (b) parsing scripts only for a **closed catalog** of common idioms,
210
+ flagging the long tail as "custom force — unclassified" rather than mis-parsing. This mirrors the
211
+ DESIGN_NOTE's closed-catalog recommendation (`DESIGN_NOTE.md:126-130`).
212
+ 2. **Forces-read fix unproven against a live org.** The fix is triaged from schema introspection but
213
+ the query has never run green against a force-bearing page (it shipped as WIP —
214
+ `TODO.md:24`, RISKS `.../ooze-graphql-native-migration/RISKS.md:19-30`). Live-test
215
+ `Query::PageWithForces` on a real force-bearing page before trusting bulk output.
216
+ 3. **Binding→field resolution is per-stage and can dangle.** A force shows only if all bindings
217
+ resolve in the stage (`10_forces_workflow_builder.md:16-21`); bindings referencing removed/renamed
218
+ fields will dangle. Classifier must tolerate unresolved `referenceId`s.
219
+ 4. **genomeSignature fallibility for recurrence.** Re-purposed fields keep a stale genome and newer
220
+ types may lack one (`signals.rb:8-12,31`) — so "same archetype across templates" via genome will
221
+ have false matches/misses. Confirm with type+label+options (the existing pairing engine already
222
+ does exactly this weighting — `signals.rb:17-22`).
223
+ 5. **`ref` instability.** Label-hash based and per-instance when script-added
224
+ (`07_data_fields.md:194-197`) — do **not** use `ref` as a cluster or identity key; it is at best a
225
+ weak tiebreaker.
226
+ 6. **Forces are being deprecated mid-flight.** New templates use Workflow Builder, old ones use
227
+ forces (`10_forces_workflow_builder.md:61-65`). A classifier built purely on force-reading will
228
+ have blind spots on newer Workflow-Builder templates once conditional workflows deploy — plan for
229
+ a second reader against `PagesWorkflow` when that API stabilises.
230
+
231
+ ---
232
+
233
+ ## 7. Bottom line
234
+
235
+ - **Structure-only classification:** doable now, no gem change — but a fragile first-pass estimate.
236
+ - **Authoritative classification (with flow):** requires the **forces-read fix** (two one-line
237
+ selection edits in `Query::PageWithForces` / `Fragment::ForceFields`, already triaged in
238
+ `TODO.md:14-24`, plus a render/validation spec) — **cheap and low-risk**.
239
+ - **The genuine hard part** is not the read fix; it is **parsing AngularJS force scripts** into
240
+ answer→effect mappings. Bindings give cluster membership cheaply; the per-answer semantics require
241
+ a parser, best scoped to a **closed catalog** of common idioms with a raw-force escape hatch.
242
+ - **genomeSignature** is the cross-template recurrence signal (already wired in the diff pairing
243
+ engine), not the intra-template grouping signal.
@@ -0,0 +1,154 @@
1
+ # Design Note — A Template Model That Holds the Logic
2
+
3
+ > For the team meeting. Goal: *"identify a template model that holds the logic — not just
4
+ > questions and options, but the fields that get unhidden depending on the answer."*
5
+ > Audience: engineers. Keep it skimmable.
6
+
7
+ ---
8
+
9
+ ## 1. The problem
10
+
11
+ Our template representations today capture **structure** — stages, sections, fields, and (for
12
+ Selects/Checklists) their options. What they do **not** capture is the **conditional flow**: the
13
+ fact that answering a parent question a certain way reveals follow-up fields, makes them required,
14
+ or computes a value. On the platform that flow lives in **Forces** (AngularJS snippets bound to a
15
+ stage: show/hide sections/fields, conditional-required, computed values — being replaced by the
16
+ Workflow Builder's "conditional field workflows", still in development). See
17
+ `.ai-assistance/code/ecoPortal_architecture/10_forces_workflow_builder.md`. A template model that
18
+ omits forces omits the actual behaviour of the form. This meeting is about giving the model a
19
+ first-class place to hold that logic.
20
+
21
+ ---
22
+
23
+ ## 2. The category / composite question archetype
24
+
25
+ **Reframe Oscar's "category" as a composite question archetype.** A category is not a field type —
26
+ it is a **pattern one level above the flat field**:
27
+
28
+ ```
29
+ archetype = parent question (usually Select / Checklist)
30
+ + its options
31
+ + the dependent fields it governs
32
+ + the force(s) that reveal / require / compute those fields per answer
33
+ ```
34
+
35
+ The **category name** identifies the pattern; the category **carries the flow contract** — which
36
+ answer unhides which fields, which answers flip a field to required, which answers drive a computed
37
+ value. It is a **higher-order primitive above the flat field level**: a cluster of fields plus the
38
+ force wiring that moves them together, referenced by name and parameters instead of spelled out.
39
+
40
+ ### Worked example — "conditional-detail Select"
41
+
42
+ Author intent (one row / one macro):
43
+
44
+ ```
45
+ category: conditional_detail_select
46
+ parent_label: "Was anyone injured?"
47
+ options: ["No", "Yes"]
48
+ reveal_on: "Yes"
49
+ detail_field: { label: "Describe the injury", type: plainText, required_when_shown: true }
50
+ ```
51
+
52
+ Expands to the flat model:
53
+
54
+ - a `Select` field `Was anyone injured?` with options `No`, `Yes`
55
+ - a `PlainText` field `Describe the injury`, hidden by default
56
+ - a **force**: `when answer == "Yes"` → `show(detail_field)` + `set_required(detail_field)`
57
+
58
+ One category, authored once, produces **two fields + one force with two effects**. Change the
59
+ category and all of that regenerates coherently.
60
+
61
+ ---
62
+
63
+ ## 3. Where it sits in the pipeline
64
+
65
+ The category is a **macro that expands into `fields + forces`** — it lives *above* the flat IR, not
66
+ beside it. Layering:
67
+
68
+ ```
69
+ requirements
70
+
71
+ form design
72
+
73
+ CATEGORIES (composite archetypes: parent + options + dependents + forces) ← the new layer
74
+ ↓ expand
75
+ flat IR (EPTIR: fields + tags + forces) ← existing (parked)
76
+ ↓ compile
77
+ ecoPortal implementation (WorkflowCommandInput command-bus) ← shipped
78
+ ```
79
+
80
+ The flat IR already exists in draft as **EPTIR** (ecoPortal Template Intermediate Representation,
81
+ parked in `C:\ruby_scripts\git\eptir`, v0.1 / `2026.1` DRAFT). The category layer does not replace
82
+ EPTIR — it **compiles down into it**. `Builder::TemplateBuilder` + `Diff::CommandSynthesizer`
83
+ already compile a flat declarative spec into `WorkflowCommandInput` batches, so the bottom two
84
+ layers are real; categories add a compile step on top.
85
+
86
+ ---
87
+
88
+ ## 4. What it buys us
89
+
90
+ - **Compresses the CSV pipeline.** The CSV→~300-templates work
91
+ (`.ai-assistance/projects/api-v2-to-graphql-migration/notes/csv-template-pipeline-design.md`,
92
+ delivery Sept 2026) today needs one row per field plus separate spelling-out of every dependent
93
+ field and force. A category row references `category + params` and expands to the whole cluster —
94
+ far fewer rows, far less force hand-authoring, mistakes localised to the macro.
95
+ - **A natural pairing / identity anchor.** A category is a cluster of fields that move together, so
96
+ it is the obvious identity unit. It rides directly on the existing identity tricks — the
97
+ hidden section-marker field (section key) and field `description` (field key) — by anchoring the
98
+ whole cluster to one category key instead of tracking N loose fields + forces independently.
99
+ - **A cleaner diff unit.** A change becomes *"this question's category changed"* or *"a category
100
+ parameter changed"* — one semantic delta — instead of a shower of add/remove field + add/edit
101
+ force/binding deltas. This is exactly the granularity the template diff-deploy work
102
+ (`.ai-assistance/projects/template-diff-deploy/`) wants.
103
+
104
+ ---
105
+
106
+ ## 5. The three decisions to nail in the meeting
107
+
108
+ **(a) Grow the forces effect grammar.** EPTIR's forces are modelled as a JSON-Logic `when` plus a
109
+ **closed effect set of `add_tag / remove_tag / set_value`**. That set does **not** include
110
+ `show/hide field|section` or `set_required` — which is precisely the flow Oscar is describing. The
111
+ category concept cannot expand into EPTIR until the effect grammar gains `show_field` / `hide_field`
112
+ / `show_section` / `hide_section` / `set_required` (all still closed, statically analysable). Decide
113
+ this and it flows into the parked EPTIR spec.
114
+
115
+ **(b) Which direction do we solve first — authoring or reverse-classification?** The same taxonomy
116
+ serves two uses:
117
+ - **Authoring** (design → categories → template): forward, and the write path is shipped
118
+ (`WorkflowAddForceInput`/`editForce`/`addBinding` via `executeWorkflowCommands`).
119
+ - **Reverse-classification** (read ~300 existing templates → cluster field+force clusters back into
120
+ categories): depends on **reading forces off a page**, which is the **one blocked path**
121
+ (see §6).
122
+
123
+ Recommend committing to **authoring-first**: it is unblocked, it directly serves the Sept 2026
124
+ CSV delivery, and it lets us validate the taxonomy before we depend on the blocked read path.
125
+
126
+ **(c) Closed catalog vs open user-defined archetypes.** A fixed catalog of ~N archetypes
127
+ (conditional-detail Select, mutually-exclusive Checklist reveal, computed risk matrix, …) vs. an
128
+ open, user-definable system. **Recommend closed-first** given the Sept 2026 deadline: a small closed
129
+ catalog is faster to build, trivial to conformance-test, and covers the common cases; leave an
130
+ escape hatch (raw fields+forces) for the long tail and revisit openness later.
131
+
132
+ ---
133
+
134
+ ## 6. The one concrete blocker
135
+
136
+ **Reading forces off a page is blocked.** `Query::PageWithForces` (used by
137
+ `ForceCompat#with_each_entry`) currently fails **client-side schema validation** (PageUnion
138
+ selections, `id` on binding types) — `10_forces_workflow_builder.md` lines 46-54. **Writing** forces
139
+ works today via the command bus. So authoring-first is fully unblocked; any reverse-classification of
140
+ existing templates is gated on fixing this read query. Name it in the room so ownership is clear.
141
+
142
+ ---
143
+
144
+ ## 7. Recommendation
145
+
146
+ 1. Adopt the **composite question archetype ("category")** as a new layer that compiles down into
147
+ the flat fields+forces IR (EPTIR) — do not fold it into the flat layer.
148
+ 2. **Extend EPTIR's forces effect grammar** with `show/hide field|section` + `set_required`
149
+ (decision 5a) — this is the concrete spec change that unblocks everything else.
150
+ 3. Ship a **closed catalog** of a handful of archetypes (5c), **authoring-first** (5b), targeting the
151
+ CSV pipeline for Sept 2026.
152
+ 4. File **fixing `Query::PageWithForces`** as the tracked prerequisite for reverse-classification
153
+ (§6), but do not let it block the authoring track.
154
+ ```