edoxen 0.3.1 → 0.5.0

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: a87eaf24182b1fd7400efbd2af10681f4059a35cd57c807759a8015a59a1c0f1
4
- data.tar.gz: 53ede8ad87d1a25bbf766d7196e6437a8455f431af313ba5b9752cbd42d17050
3
+ metadata.gz: f82e1428e5307aac53edfd1f1cdcdac401b7bae76b03ab102acf0a0a42da5382
4
+ data.tar.gz: 13a900902194b1420fb9fe0f9b4fd7382c30f0803770f399c58f3b5053bf763d
5
5
  SHA512:
6
- metadata.gz: a14ac6664ffa8ec1f1fcd31ace23257a740dcad3fab41ead54d542440a53618ac8ad46bb495afeb44c62db7b850d35d789ee7a888245f4e6b3ac0afe092310ee
7
- data.tar.gz: 991a18091cb7ec21ddfe70b2691244b3e8a6ba869135545f6249ffa35d56439afbf578a867d56bdce6c3555f177c59f43c73f8c27abb119527af36c42e6b17fd
6
+ metadata.gz: a7e099bea2ef576a990544b0d68f527c52e99e617f8f16122f798d16638acc671468859f987d25ca2391fbd62da645e38a3e13c324127cb74a9287cf1e650cdd
7
+ data.tar.gz: b80c4a49ffc97adacf9871a49de120fad0725c871a2f4a4a4dd31ced9d68fa4ffb4f54c5bd6d5550eefa54b5cbbdd83f779dcc434c366a0cd9740ab6cba63376
data/README.adoc CHANGED
@@ -41,16 +41,23 @@ yaml = File.read('resolutions.yaml')
41
41
  collection = Edoxen::ResolutionCollection.from_yaml(yaml)
42
42
 
43
43
  collection.resolutions.each do |resolution|
44
- prefix = resolution.identifier.first.prefix
45
- number = resolution.identifier.first.number
46
- puts "#{prefix}/#{number}"
47
-
48
- resolution.localizations.each do |loc|
49
- puts " [#{loc.language_code}/#{loc.script}] #{loc.title}"
50
- loc.actions.each do |action|
51
- puts " - #{action.type}: #{action.message}"
52
- end
44
+ # identifier is StructuredIdentifier[1..*] — a Resolution can carry
45
+ # its TC number, SC number, and any cross-cutting reference number.
46
+ id = resolution.identifier.first
47
+ puts "#{id.prefix}/#{id.number}"
48
+
49
+ # Canonical rendering — English when available, else the first
50
+ # declared localization. See "Lookup accessors" below.
51
+ loc = resolution.primary_localization
52
+ puts " [#{loc.language_code}/#{loc.script}] #{loc.title}"
53
+
54
+ loc.actions.each do |action|
55
+ puts " - #{action.type}: #{action.message}"
53
56
  end
57
+
58
+ # Or look up a specific language explicitly:
59
+ fra = resolution.in_language("fra")
60
+ puts " FR: #{fra.title}" if fra
54
61
  end
55
62
 
56
63
  # Round-trip back to YAML
@@ -95,6 +102,25 @@ Every translatable field on a Resolution is wrapped inside one of its
95
102
  (`identifier`, `doi`, `urn`, `agenda_item`, `dates`, `categories`,
96
103
  `meeting`, `relations`, `urls`) live on the parent `Resolution`.
97
104
 
105
+ === Lookup accessors
106
+
107
+ Direct iteration over `localizations[]` is discouraged — the
108
+ language-preference policy lives behind two accessors:
109
+
110
+ * `Resolution#in_language(code, fallback: false)` — exact match by
111
+ ISO 639-3 code, or `nil` (or the first declared localization when
112
+ `fallback: true`).
113
+ * `Resolution#primary_localization` — English when available, else
114
+ the first declared localization. Mirrors the glossarist
115
+ LocalizedConcept "preferred language" notion.
116
+
117
+ [source,ruby]
118
+ ----
119
+ resolution.in_language("fra") # => Localization or nil
120
+ resolution.in_language("deu", fallback: true)
121
+ resolution.primary_localization # English-or-first
122
+ ----
123
+
98
124
  == Multilingual resolution sets
99
125
 
100
126
  [source,yaml]
@@ -137,9 +163,10 @@ resolutions:
137
163
 
138
164
  == Command-line interface
139
165
 
140
- The `edoxen` executable exposes two commands.
166
+ The `edoxen` executable exposes four commands — two for Resolution
167
+ collections, two for Meeting/Agenda documents.
141
168
 
142
- === `validate` — JSON-Schema validation + model parsing
169
+ === `validate` — JSON-Schema validation + model parsing for Resolutions
143
170
 
144
171
  [source,sh]
145
172
  ----
@@ -153,7 +180,7 @@ problems the schema can't express.
153
180
 
154
181
  Exit code is non-zero if any file fails.
155
182
 
156
- === `normalize` — round-trip every fixture through the model
183
+ === `normalize` — round-trip Resolution YAML through the model
157
184
 
158
185
  [source,sh]
159
186
  ----
@@ -162,28 +189,134 @@ $ edoxen normalize legacy.yaml --inplace
162
189
  ----
163
190
 
164
191
  Loads each YAML file, parses it through the Ruby model, and writes the
165
- result back. Used to migrate between model versions. Either `--output DIR`
166
- or `--inplace` is required (mutually exclusive).
192
+ result back. Either `--output DIR` or `--inplace` is required (mutually
193
+ exclusive).
194
+
195
+ === `validate-meetings` — validate Meeting/Agenda YAML
196
+
197
+ [source,sh]
198
+ ----
199
+ $ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"
200
+ ----
201
+
202
+ Validates against `schema/meeting.yaml` and parses through
203
+ `Edoxen::Meeting` (single-Meeting files) or `Edoxen::MeetingCollection`
204
+ (wrappers). The schema's root is `oneOf` — both shapes are accepted.
205
+
206
+ === `normalize-meetings` — round-trip Meeting YAML through the model
207
+
208
+ [source,sh]
209
+ ----
210
+ $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
211
+ ----
212
+
213
+ Same shape as `normalize`, but for Meeting/Agenda documents.
214
+
215
+ == Meeting & Agenda model
216
+
217
+ The Meeting model represents the *event* that produces Resolutions,
218
+ plus the agenda document that orders its business. Mirrors
219
+ `edoxen-model/models/meeting*.lutaml`.
220
+
221
+ [source]
222
+ ----
223
+ MeetingCollection
224
+ ├── metadata: MeetingCollectionMetadata
225
+ └── meetings: Meeting[]
226
+ ├── identifier: StructuredIdentifier[1..*]
227
+ ├── urn, ordinal, type (MeetingType), status (MeetingStatus), year
228
+ ├── date_range: DateRange {start, end}
229
+ ├── committee, committee_group
230
+ ├── venues: Location[] (multi-venue supported)
231
+ ├── general_area, city (IATA), country_code (ISO 3166-1), virtual
232
+ ├── chair, secretary: Person
233
+ ├── host, hosts: HostRef[] (typed: national_body/liaison/associate/organizer)
234
+ ├── source_urls: SourceUrl[] (with `kind` discriminator)
235
+ ├── landing_url, registration_url
236
+ ├── agenda: Agenda
237
+ │ ├── identifier, status (AgendaStatus), source_doc
238
+ │ ├── items: AgendaItem[]
239
+ │ │ ├── label, kind (numbered/unnumbered/header/opening/closing)
240
+ │ │ ├── title, description
241
+ │ │ ├── references: Reference[]
242
+ │ │ ├── outcome (discussed/resolved/deferred/adopted/withdrawn)
243
+ │ │ └── resolution_ref (URN link to a Resolution)
244
+ │ └── opening_session, closing_session: ScheduleItem
245
+ ├── schedule: ScheduleItem[] (the time-bound timetable)
246
+ ├── deadlines: Deadline[]
247
+ ├── localizations: MeetingLocalization[] (per-language content)
248
+ ├── relations: MeetingRelation[] (continues_from, joint_with, ...)
249
+ └── resolution_refs: String[] (URN links to ResolutionCollections)
250
+ ----
251
+
252
+ === Linking Meetings to ResolutionCollections
253
+
254
+ Meetings and ResolutionCollections are separate documents joined by URN:
255
+
256
+ - `Meeting.resolution_refs: [urn:...]`
257
+ - `ResolutionCollection.metadata.meeting_urn: urn:...`
258
+
259
+ The join is by URN because the two have different lifetimes — agendas
260
+ exist weeks before a meeting, resolutions only after adoption.
261
+
262
+ === Lookup accessors (parallel to the Resolution side)
263
+
264
+ [source,ruby]
265
+ ----
266
+ meeting = Edoxen::Meeting.from_yaml(File.read('meeting.yaml'))
267
+
268
+ meeting.in_language("fra") # => MeetingLocalization or nil
269
+ meeting.in_language("deu", fallback: true) # falls back to first
270
+ meeting.primary_localization # English-or-first
271
+ meeting.find_agenda_item("5.2") # AgendaItem by label
272
+
273
+ collection = Edoxen::MeetingCollection.from_yaml(...)
274
+ collection.find_by_urn("urn:oiml:ciml:meeting:ciml-56")
275
+ collection.find_by_identifier(prefix: "CIML", number: "56")
276
+ ----
167
277
 
168
278
  == Architecture
169
279
 
170
280
  * `lib/edoxen.rb` is the single entry-point. It configures
171
281
  `Lutaml::Model::Config` and `autoload`s every model class and service.
172
- * Each model lives in its own file under `lib/edoxen/`. No `require_relative`
173
- — all cross-references resolve through Ruby autoload.
282
+ No `require_relative` in library code cross-references resolve
283
+ through Ruby autoload.
284
+ * Each model lives in its own file under `lib/edoxen/`. Models declare
285
+ `attribute` only — `lutaml-model` auto-emits an identity map (wire
286
+ name = snake_case attribute name) when no explicit `key_value` block
287
+ is present. Add a `key_value do; map "wire", to: :attr; end` block
288
+ only when the wire name differs from the attribute name.
174
289
  * `lib/edoxen/enums.rb` is the single source of truth for every enum
175
290
  value used by the gem. Both the Ruby model (`attribute :type, :string,
176
- values: Enums::ACTION_TYPE`) and `schema/edoxen.yaml` refer to the
177
- same constant; `spec/edoxen/schema_enum_sync_spec.rb` asserts equality
178
- at runtime.
179
- * `lib/edoxen/schema_validator.rb` is intentionally small: a
180
- `SchemaValidator::ValidationError`, a `validate_file` method, a
181
- `validate_content` method, and a `LineMap` module for line-accurate
182
- error reporting. Adding new collection paths never requires touching
183
- it the lookup is longest-prefix match against an indent-heuristic
184
- line map.
185
- * `lib/edoxen/cli.rb` is the Thor surface that glues SchemaValidator and
186
- `ResolutionCollection.from_yaml` together for the two CLI commands.
291
+ values: Enums::ACTION_TYPE`) and the schemas reference the
292
+ same constants.
293
+ * `lib/edoxen/error.rb` defines the unified `Edoxen::ValidationError`
294
+ produced by both `SchemaValidator` (with `source: :schema` or
295
+ `:syntax`) and by model parse rescues in the CLI (with `source:
296
+ :model`). Carries `file`, `line`, `column`, `pointer`,
297
+ `message_text`, and `source`.
298
+ * `lib/edoxen/schema_validator.rb` is intentionally small: two
299
+ validate methods, a `LineMap` module for line-accurate error
300
+ reporting (longest-prefix match no path-shape hardcoding), and
301
+ date coercion so `json_schemer` can validate `format: date` against
302
+ YAML-loaded `Date` instances.
303
+ * `lib/edoxen/cli.rb` exposes four Thor commands (`validate`,
304
+ `normalize`, `validate-meetings`, `normalize-meetings`) that
305
+ delegate their shared scaffolding
306
+ (expand/sort/empty/header/loop/tally/summary/exit) to the deep
307
+ `Edoxen::Cli::Batch` module.
308
+
309
+ === Schema ↔ Ruby invariants
310
+
311
+ Two pairs of runtime specs guard each side of the schema ↔ Ruby
312
+ boundary:
313
+
314
+ * Resolution side: `schema_enum_sync_spec.rb` +
315
+ `schema_model_sync_spec.rb` against `schema/edoxen.yaml`.
316
+ * Meeting side: `schema_meeting_enum_sync_spec.rb` +
317
+ `schema_meeting_model_sync_spec.rb` against `schema/meeting.yaml`.
318
+
319
+ Drift fails CI immediately, not at fixture-validation time.
187
320
 
188
321
  == Contributing
189
322
 
@@ -192,10 +325,16 @@ Follow the rules in `CLAUDE.md`:
192
325
  * All public methods have specs.
193
326
  * Specs use real model instances — never `double()`.
194
327
  * Serialization goes through `lutaml-model` only — no hand-rolled
195
- `to_h`, `from_h`, `to_yaml`, or `to_json` on a model class.
196
- * Schema and Ruby enum values must agree; the schema_sync_spec catches
197
- drift.
198
- * All changes go through PRs. Never commit to `main`, never push tags.
328
+ `to_h`, `from_h`, `to_yaml`, or `to_json` on a model class. Declare
329
+ `attribute`; the wire map is auto-emitted.
330
+ * Schema and Ruby must agree on enum values *and* on property shape.
331
+ Both `schema_enum_sync_spec` and `schema_model_sync_spec` catch
332
+ drift at CI time.
333
+ * Library code uses autoload (declared in `lib/edoxen.rb`), never
334
+ `require_relative`. No `send` to private methods, no
335
+ `instance_variable_set`/`get`, no `respond_to?` for type checks.
336
+ * All changes go through PRs. Never commit to `main`, never push tags,
337
+ never add AI attribution to commits.
199
338
 
200
339
  == License
201
340
 
@@ -0,0 +1,132 @@
1
+ # Phase 1 — Design decisions
2
+
3
+ Priority: **P0** (canonical — blocks every later phase)
4
+
5
+ ## Goal
6
+
7
+ Lock down the conceptual model before any code lands. Every later
8
+ phase references these decisions; if they shift, downstream work
9
+ reopens.
10
+
11
+ ## Decisions
12
+
13
+ ### D1 — Meeting is the event; Agenda is a document
14
+
15
+ The previous draft conflated these. Split:
16
+
17
+ - **Meeting** — the event itself. Identity, time, venue, officers,
18
+ schedule, deadlines, links to Resolutions.
19
+ - **Agenda** — the business order document produced for the Meeting.
20
+ Carries items, opening/closing sessions, status (draft/final), and a
21
+ pointer to the source PDF.
22
+
23
+ A Meeting *has* an Agenda (`Meeting.agenda: Agenda`). An Agenda is
24
+ meaningless without a Meeting but may be versioned independently
25
+ (draft → final → amended).
26
+
27
+ ### D2 — Schedule ≠ Agenda
28
+
29
+ - **Schedule** — the time-bound timetable: date, time slot, room,
30
+ event title. Lives on `Meeting.schedule: ScheduleItem[]`.
31
+ - **Agenda** — the topic-bound business order: numbered/unnumbered
32
+ items, opening session, closing session. Lives on `Meeting.agenda`.
33
+
34
+ ISO/TC 154's data already separates these (`schedule[]` and
35
+ `agenda.{opening_session, closing_session, wg_meetings}`). Don't
36
+ collapse them.
37
+
38
+ ### D3 — Linking to Resolutions is by URN, not embedding
39
+
40
+ - `Meeting.resolution_refs: String[]` — URNs of resolution
41
+ collections produced by this meeting.
42
+ - `ResolutionCollection.metadata.meeting_urn: String` — back-reference.
43
+
44
+ Rationale: meetings and resolutions have different lifetimes (an
45
+ agenda exists weeks before the meeting; resolutions exist only after
46
+ adoption). Embedding forces re-serialization of the whole meeting when
47
+ one resolution is amended. URN linking matches how both repos already
48
+ work (ISO: `_data/events/` separate from `_data/resolutions/`; OIML:
49
+ the metadata block of a ResolutionCollection already comments its
50
+ meeting URN).
51
+
52
+ ### D4 — Localization parallels the Resolution pattern
53
+
54
+ `Meeting.localizations: MeetingLocalization[]` carries per-language
55
+ `title`, `general_area`, `practical_info`. ISO 639-3 + ISO 15924,
56
+ identical to `Localization` for Resolutions.
57
+
58
+ The schedule itself stays language-agnostic at the structural level
59
+ (date, time, room); localized `event` and `description` text live
60
+ inside `MeetingLocalization`. **v1 decision:** keep schedule
61
+ monolingual on `ScheduleItem` (event/description as plain strings)
62
+ and only localize at the Meeting level. If a future caller needs
63
+ per-schedule-item localization, add `ScheduleItemLocalization`
64
+ then — don't pre-bake it.
65
+
66
+ ### D5 — Reuse existing edoxen types
67
+
68
+ | Concept | Reuse |
69
+ |---|---|
70
+ | Identifier | `StructuredIdentifier` |
71
+ | Source URL | `SourceUrl` (extend with optional `kind` in this work) |
72
+ | Validation error | `Edoxen::ValidationError` |
73
+ | CLI scaffold | `Edoxen::Cli::Batch` |
74
+
75
+ ### D6 — Meeting/Agenda have their own top-level containers
76
+
77
+ - `MeetingCollection` — top-level wrapper for a YAML containing many
78
+ meetings (parallel to `ResolutionCollection`).
79
+ - Single-meeting files are also valid (`Meeting` directly under root,
80
+ detected by `oneOf` in JSON-Schema).
81
+
82
+ ### D7 — Wire-name are snake_case
83
+
84
+ LUTAML uses camelCase notation; YAML wire is snake_case. Same
85
+ convention as the rest of edoxen.
86
+
87
+ ### D8 — All enums closed and synced
88
+
89
+ New enums land in `Edoxen::Enums` and the schema's `$defs/<EnumName>`
90
+ blocks. Both `schema_enum_sync_spec` and `schema_model_sync_spec`
91
+ extend to cover them.
92
+
93
+ ## Type inventory (MECE check)
94
+
95
+ | Concept | Owns |
96
+ |---|---|
97
+ | `Meeting` | the event (identity, time, venue, officers, agenda, schedule) |
98
+ | `MeetingLocalization` | per-language content for a Meeting |
99
+ | `MeetingCollection` | top-level wrapper for many Meetings |
100
+ | `MeetingRelation` | directed link between two Meetings |
101
+ | `Agenda` | the business order document |
102
+ | `AgendaItem` | one entry on an Agenda |
103
+ | `Location` | venue geography |
104
+ | `Person` | identity + contact + affiliation |
105
+ | `HostRef` | typed reference to an organization |
106
+ | `ScheduleItem` | one entry in the timetable |
107
+ | `Deadline` | a time-bound requirement |
108
+ | `DateRange` | start + end pair |
109
+ | `Reference` | generic document reference (used by AgendaItem) |
110
+
111
+ 13 new classes. No overlaps.
112
+
113
+ ## Enums
114
+
115
+ | Enum | Values |
116
+ |---|---|
117
+ | `MEETING_TYPE` | plenary, working_group, task_group, ad_hoc, joint, conference_session |
118
+ | `MEETING_STATUS` | upcoming, completed, cancelled |
119
+ | `AGENDA_STATUS` | draft, final, amended, cancelled, superseded |
120
+ | `AGENDA_ITEM_KIND` | numbered, unnumbered, header, opening, closing |
121
+ | `AGENDA_ITEM_OUTCOME` | discussed, resolved, deferred, adopted, withdrawn |
122
+ | `HOST_TYPE` | national_body, liaison, associate, organizer |
123
+ | `MEETING_RELATION_TYPE` | continues_from, continues_to, joint_with, supersedes, supersedes_by, rescheduled_from, rescheduled_to |
124
+ | `SOURCE_URL_KIND` | agenda_pdf, minutes_pdf, resolutions_pdf, report_pdf, register_url, landing_page |
125
+
126
+ 8 new enums.
127
+
128
+ ## Acceptance criteria
129
+
130
+ - [x] D1–D8 written
131
+ - [x] Type inventory listed
132
+ - [x] Enum inventory listed
@@ -0,0 +1,61 @@
1
+ # Phase 2 — Canonical LUTAML in edoxen-model
2
+
3
+ Priority: **P0**
4
+
5
+ ## Goal
6
+
7
+ Land the canonical LutaML UML definitions in
8
+ `/Users/mulgogi/src/mn/edoxen-model/models/`. The Ruby gem mirrors
9
+ these files 1:1.
10
+
11
+ ## Deliverables — one `.lutaml` per concept
12
+
13
+ ### Top-level
14
+
15
+ - `meeting_collection.lutaml` — top-level wrapper.
16
+ - `meeting.lutaml` — the event class.
17
+ - `meeting_localization.lutaml` — per-language content.
18
+ - `meeting_relation.lutaml` — cross-meeting link.
19
+
20
+ ### Sub-structures
21
+
22
+ - `agenda.lutaml`
23
+ - `agenda_item.lutaml`
24
+ - `schedule_item.lutaml`
25
+ - `deadline.lutaml`
26
+ - `date_range.lutaml`
27
+ - `location.lutaml`
28
+ - `person.lutaml`
29
+ - `host_ref.lutaml`
30
+ - `reference.lutaml`
31
+
32
+ ### Enums (one per file, mirroring existing convention)
33
+
34
+ - `meeting_type.lutaml`
35
+ - `meeting_status.lutaml`
36
+ - `agenda_status.lutaml`
37
+ - `agenda_item_kind.lutaml`
38
+ - `agenda_item_outcome.lutaml`
39
+ - `host_type.lutaml`
40
+ - `meeting_relation_type.lutaml`
41
+ - `source_url_kind.lutaml`
42
+
43
+ ### Removed
44
+
45
+ - `meeting-model.adoc` — superseded; the LUTAML files are now
46
+ authoritative. Move to `meeting-model.adoc.deprecated` rather than
47
+ delete (history preservation).
48
+
49
+ ## Attribute shape per class
50
+
51
+ (See Phase 1 for the canonical field list. Each `.lutaml` declares
52
+ attributes exactly matching the Ruby model in Phase 3 — types are
53
+ LUTAML notation, wire names are snake_case per D7.)
54
+
55
+ ## Acceptance criteria
56
+
57
+ - [ ] 21 new `.lutaml` files in `models/`.
58
+ - [ ] `README.adoc` updated: new "Meeting model" section, file index
59
+ extended.
60
+ - [ ] Old `meeting-model.adoc` renamed to `.deprecated`.
61
+ - [ ] Commit + PR to `metanorma/edoxen-model`.
@@ -0,0 +1,73 @@
1
+ # Phase 3 — Ruby model mirror in edoxen
2
+
3
+ Priority: **P0**
4
+
5
+ ## Goal
6
+
7
+ One Ruby class per LUTAML file. autoload entries in `lib/edoxen.rb`.
8
+ No `require_relative` in library code. No `send` to private, no
9
+ `instance_variable_set`/`get`, no `respond_to?` for typing.
10
+
11
+ ## Deliverables — `lib/edoxen/*.rb`
12
+
13
+ ### New files
14
+
15
+ - `meeting_collection.rb` — top-level wrapper.
16
+ - `meeting.rb` — the event class.
17
+ - `meeting_localization.rb`
18
+ - `meeting_relation.rb`
19
+ - `agenda.rb`
20
+ - `agenda_item.rb`
21
+ - `schedule_item.rb`
22
+ - `deadline.rb`
23
+ - `date_range.rb`
24
+ - `location.rb`
25
+ - `person.rb`
26
+ - `host_ref.rb`
27
+ - `reference.rb`
28
+
29
+ ### Modified files
30
+
31
+ - `lib/edoxen.rb` — add 13 autoload entries.
32
+ - `lib/edoxen/enums.rb` — add 8 new enum constants.
33
+ - `lib/edoxen/resolution_metadata.rb` — add `meeting_urn: String`
34
+ field (back-reference to a Meeting).
35
+ - `lib/edoxen/source_url.rb` — add optional `kind: String, values:
36
+ Enums::SOURCE_URL_KIND` field (orthogonal addition; existing
37
+ fixtures without it still parse).
38
+
39
+ ### Class shape
40
+
41
+ Each class is `Lutaml::Model::Serializable` with:
42
+
43
+ - `attribute :name, Type, **opts` declarations only.
44
+ - No explicit `key_value do; map ...; end` block — lutaml-model
45
+ auto-emits identity maps (established in v0.3.1 refactor).
46
+ - Enum-typed attributes use `values: Enums::FOO`.
47
+ - Convenience accessors added where the lookup is non-trivial
48
+ (parallel to `Resolution#in_language`).
49
+
50
+ ### Convenience accessors
51
+
52
+ - `Meeting#in_language(code, fallback: false)` — exact-match lookup
53
+ on `localizations`.
54
+ - `Meeting#primary_localization` — English-or-first.
55
+ - `Meeting#find_agenda_item(label)` — by label string.
56
+ - `MeetingCollection#find_by_urn(urn)` — URN lookup.
57
+ - `MeetingCollection#find_by_identifier(prefix:, number:)` —
58
+ structured-identifier lookup.
59
+
60
+ ### Invariants
61
+
62
+ - `Meeting.identifier` is `StructuredIdentifier[1..*]` (required by
63
+ schema; permissive in model).
64
+ - `Meeting.localizations[*].language_code` matches `^[a-z]{3}$`
65
+ (schema-enforced).
66
+ - `Meeting.date_range` is a `DateRange` with optional `start`/`end`.
67
+
68
+ ## Acceptance criteria
69
+
70
+ - [ ] 13 new Ruby files, each ≤40 lines.
71
+ - [ ] No `require_relative` in any new file.
72
+ - [ ] `bundle exec rspec` passes (existing 197 examples).
73
+ - [ ] `bundle exec rubocop` clean.
@@ -0,0 +1,34 @@
1
+ # Phase 4 — JSON-Schema
2
+
3
+ Priority: **P0**
4
+
5
+ ## Goal
6
+
7
+ A separate `schema/meeting.yaml` for Meeting/Agenda validation. The
8
+ existing `schema/edoxen.yaml` (Resolutions) is untouched.
9
+
10
+ ## Why a separate schema file
11
+
12
+ - Resolutions and Meetings are different document types with
13
+ different lifecycles.
14
+ - Different CLI subcommands validate each.
15
+ - Avoids touching the existing schema (which is referenced by 1,241
16
+ OIML fixtures via `yaml-language-server: $schema=`).
17
+
18
+ ## Deliverables
19
+
20
+ - `schema/meeting.yaml` — full JSON-Schema (draft-07).
21
+ - Root: `oneOf` — `MeetingCollection` OR `Meeting`.
22
+ - `$defs` for every class + every enum.
23
+ - `additionalProperties: false` everywhere.
24
+ - `required` on identifier + type for Meeting.
25
+ - `pattern` on `language_code` (`^[a-z]{3}$`), `script`
26
+ (`^[A-Z][a-z]{3}$`), `country_code` (`^[A-Z]{2}$`).
27
+ - `minItems: 1` on `Meeting.identifier`.
28
+
29
+ ## Acceptance criteria
30
+
31
+ - [ ] `schema/meeting.yaml` exists and is valid JSON-Schema draft-07.
32
+ - [ ] `Edoxen::SchemaValidator.new("path/to/meeting.yaml")` loads it.
33
+ - [ ] All Phase 5 fixtures validate cleanly.
34
+ - [ ] All Phase 6 specs (including schema enum sync) pass.
@@ -0,0 +1,43 @@
1
+ # Phase 5 — Fixtures
2
+
3
+ Priority: **P0** for canonical, **P1** for richer ones.
4
+
5
+ ## Goal
6
+
7
+ Real-world fixture YAMLs in `spec/fixtures/meetings/` that exercise
8
+ every shape the model supports.
9
+
10
+ ## Deliverables
11
+
12
+ ### P0 — required for canonical
13
+
14
+ - `spec/fixtures/meetings/simple-meeting.yaml` — minimal valid
15
+ Meeting (identifier, type, date_range, one venue, one localization,
16
+ one agenda item).
17
+ - `spec/fixtures/meetings/ciml-56-meeting.yaml` — OIML CIML 56th
18
+ meeting: bilingual (EN/FR), structured identifier, source URLs with
19
+ kind, resolution_ref URN linking to a ResolutionCollection.
20
+ - `spec/fixtures/meetings/isotc154-plenary-42-meeting.yaml` — ISO/TC
21
+ 154 42nd plenary: multi-day date_range, multi-venue, typed hosts,
22
+ full schedule with 9 entries, deadlines, chair/secretary.
23
+
24
+ ### P1 — useful coverage
25
+
26
+ - `spec/fixtures/meetings/virtual-meeting.yaml` — virtual meeting
27
+ (no venues, country_code empty).
28
+ - `spec/fixtures/meetings/multi-collection.yaml` — a
29
+ MeetingCollection wrapping multiple meetings.
30
+
31
+ ## Fixture invariants
32
+
33
+ - All fixtures MUST validate against `schema/meeting.yaml`.
34
+ - All fixtures MUST round-trip through the Ruby model
35
+ (`Meeting.from_yaml(...).to_yaml` re-parses identically).
36
+ - Real-world fixtures (ciml-56, isotc154-42) MUST reflect actual
37
+ published data — no fabricated committee names or dates.
38
+
39
+ ## Acceptance criteria
40
+
41
+ - [ ] At least the three P0 fixtures exist.
42
+ - [ ] `bundle exec exe/edoxen validate-meetings "spec/fixtures/meetings/*.yaml"` passes on all of them.
43
+ - [ ] Each fixture is referenced by at least one round-trip spec.
@@ -0,0 +1,52 @@
1
+ # Phase 6 — Specs
2
+
3
+ Priority: **P0**
4
+
5
+ ## Goal
6
+
7
+ Comprehensive spec coverage. One spec file per new model class, plus
8
+ CLI and schema-sync coverage. Real model instances only — no
9
+ `double()`.
10
+
11
+ ## Deliverables — `spec/edoxen/meeting_*_spec.rb` (one per class)
12
+
13
+ For each of the 13 new classes:
14
+
15
+ - Construction with explicit attrs.
16
+ - Round-trip YAML serialization.
17
+ - Enum coverage where applicable (one example per enum value).
18
+ - Real-instance types (e.g., `Meeting#agenda` returns an `Edoxen::Agenda`).
19
+
20
+ ## Deliverables — additional specs
21
+
22
+ - `spec/edoxen/meeting_collection_spec.rb` — top-level container,
23
+ round-trips, lookup helpers (`find_by_urn`, `find_by_identifier`).
24
+ - `spec/edoxen/meeting_lookup_spec.rb` — `Meeting#in_language`,
25
+ `Meeting#primary_localization`, `Meeting#find_agenda_item`.
26
+ - `spec/edoxen/schema_meeting_enum_sync_spec.rb` — every enum in
27
+ `schema/meeting.yaml` `$defs` matches the corresponding
28
+ `Edoxen::Enums::*` frozen array.
29
+ - `spec/edoxen/schema_meeting_model_sync_spec.rb` — every Ruby
30
+ Meeting-related class has matching properties/collection flags in
31
+ `schema/meeting.yaml` `$defs`.
32
+ - `spec/edoxen/cli_meetings_spec.rb` — `validate-meetings` and
33
+ `normalize-meetings` subcommands, spawned via `Open3.capture3`
34
+ against the new fixtures.
35
+ - `spec/edoxen/schema_validator_meeting_spec.rb` — SchemaValidator
36
+ constructed with the meeting schema path; happy path + each error
37
+ type (additionalProperties, required, enum, pattern, type).
38
+
39
+ ## Style rules
40
+
41
+ - Real model instances only — no `double()`.
42
+ - Each example ≤ 10 lines.
43
+ - Round-trip examples cover every fixture.
44
+
45
+ ## Acceptance criteria
46
+
47
+ - [ ] One spec file per new class (13 files).
48
+ - [ ] +5 additional specs (collection, lookup, schema enum sync,
49
+ schema model sync, CLI).
50
+ - [ ] +1 schema_validator spec for the meeting schema path.
51
+ - [ ] `bundle exec rspec` — 0 failures (existing 197 + new ≈ 80+).
52
+ - [ ] `bundle exec rubocop` clean.