edoxen 0.7.2 → 2.1.1

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +17 -1
  3. data/.rubocop_todo.yml +1 -1
  4. data/CHANGELOG.md +246 -0
  5. data/README.adoc +308 -153
  6. data/edoxen.gemspec +10 -10
  7. data/lib/edoxen/action.rb +1 -1
  8. data/lib/edoxen/agenda.rb +4 -7
  9. data/lib/edoxen/agenda_item.rb +10 -3
  10. data/lib/edoxen/approval.rb +1 -1
  11. data/lib/edoxen/attendance.rb +16 -10
  12. data/lib/edoxen/body_vocabulary_entry.rb +24 -0
  13. data/lib/edoxen/body_vocabulary_host.rb +29 -0
  14. data/lib/edoxen/cli.rb +117 -120
  15. data/lib/edoxen/component_localization.rb +11 -0
  16. data/lib/edoxen/consideration.rb +2 -2
  17. data/lib/edoxen/contact.rb +24 -0
  18. data/lib/edoxen/contact_identifier.rb +13 -0
  19. data/lib/edoxen/contact_method.rb +16 -0
  20. data/lib/edoxen/decision.rb +69 -0
  21. data/lib/edoxen/decision_collection.rb +10 -0
  22. data/lib/edoxen/decision_date.rb +9 -0
  23. data/lib/edoxen/{resolution_metadata.rb → decision_metadata.rb} +5 -6
  24. data/lib/edoxen/decision_relation.rb +11 -0
  25. data/lib/edoxen/entity_ref.rb +74 -0
  26. data/lib/edoxen/enums.rb +117 -9
  27. data/lib/edoxen/error.rb +1 -1
  28. data/lib/edoxen/extension_attribute.rb +78 -0
  29. data/lib/edoxen/host_ref.rb +1 -1
  30. data/lib/edoxen/link_checker.rb +8 -8
  31. data/lib/edoxen/localization.rb +2 -2
  32. data/lib/edoxen/localization_host.rb +23 -0
  33. data/lib/edoxen/meeting.rb +63 -39
  34. data/lib/edoxen/meeting_collection.rb +1 -2
  35. data/lib/edoxen/meeting_collection_metadata.rb +3 -1
  36. data/lib/edoxen/meeting_component.rb +41 -0
  37. data/lib/edoxen/meeting_extension.rb +25 -0
  38. data/lib/edoxen/meeting_identifier.rb +1 -1
  39. data/lib/edoxen/meeting_localization.rb +6 -1
  40. data/lib/edoxen/meeting_series.rb +21 -0
  41. data/lib/edoxen/minutes.rb +2 -2
  42. data/lib/edoxen/minutes_section.rb +8 -11
  43. data/lib/edoxen/motion.rb +51 -0
  44. data/lib/edoxen/name.rb +29 -0
  45. data/lib/edoxen/officer.rb +19 -0
  46. data/lib/edoxen/officers_host.rb +19 -0
  47. data/lib/edoxen/person.rb +5 -9
  48. data/lib/edoxen/physical_venue.rb +15 -0
  49. data/lib/edoxen/recurrence.rb +27 -0
  50. data/lib/edoxen/recurrence_by_day.rb +10 -0
  51. data/lib/edoxen/reference_data.rb +35 -41
  52. data/lib/edoxen/schema_validator.rb +2 -3
  53. data/lib/edoxen/source_url.rb +3 -3
  54. data/lib/edoxen/structured_identifier.rb +2 -2
  55. data/lib/edoxen/topic.rb +37 -0
  56. data/lib/edoxen/topic_asset.rb +14 -0
  57. data/lib/edoxen/topic_document.rb +16 -0
  58. data/lib/edoxen/venue.rb +80 -0
  59. data/lib/edoxen/venue_validator.rb +56 -0
  60. data/lib/edoxen/version.rb +1 -1
  61. data/lib/edoxen/virtual_venue.rb +15 -0
  62. data/lib/edoxen/vote_record.rb +17 -13
  63. data/lib/edoxen/voting.rb +45 -0
  64. data/lib/edoxen/voting_counts.rb +23 -0
  65. data/lib/edoxen.rb +40 -13
  66. data/schema/edoxen.yaml +792 -145
  67. data/schema/meeting.yaml +600 -142
  68. data/sig/edoxen.rbs +581 -1
  69. metadata +57 -20
  70. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  71. data/lib/edoxen/location.rb +0 -15
  72. data/lib/edoxen/resolution.rb +0 -43
  73. data/lib/edoxen/resolution_collection.rb +0 -10
  74. data/lib/edoxen/resolution_date.rb +0 -11
  75. data/lib/edoxen/resolution_relation.rb +0 -11
  76. data/lib/edoxen/resolution_set.rb +0 -17
  77. data/lib/edoxen/schedule_item.rb +0 -27
  78. data/lib/edoxen/schedule_item_localization.rb +0 -21
data/README.adoc CHANGED
@@ -7,24 +7,30 @@ image:https://img.shields.io/gem/v/edoxen.svg[RubyGems Version]
7
7
 
8
8
  == Purpose
9
9
 
10
- Edoxen is a Ruby library for the canonical Edoxen information model of
11
- formal proceedings — meetings, agendas, minutes, attendance, votes,
12
- and the resolutions adopted by standards bodies and governance
13
- organisations. It is built on top of the
14
- https://github.com/lutaml/lutaml[ lutaml-model] serialization framework.
15
-
16
- The information model is defined in
17
- https://github.com/edoxen/edoxen-model/tree/main/models[ LutaML UML
18
- files] (one `.lutaml` per concept). This gem mirrors that model exactly —
19
- attribute declarations, enum values, and field shapes — so that anything
20
- you can express in LutaML you can also construct, serialize, and validate
21
- in Ruby.
22
-
23
- The intended users are standards organizations and governance bodies that
24
- need to publish structured records of formal proceedings — the full
25
- lifecycle from draft agenda through adopted resolution, in any ISO 639-3
26
- language. Real-world samples (ISO/TC 154, CIPM, OIML CIML) ship in
27
- `spec/fixtures/`.
10
+ Edoxen is a Ruby library for the canonical Edoxen information model
11
+ a **generic meeting, agenda, motion, voting, and decision model** that
12
+ covers standards bodies (ISO, IEC, ITU, BIPM, OIML, ILO), parliamentary
13
+ bodies (UK Hansard, HK LegCo, US Congress), technical community meetings
14
+ (IETF, W3C, Apache), academic conferences (Crossref-registered),
15
+ corporate boards, and generic web/virtual meetings.
16
+
17
+ Built on top of the https://github.com/lutaml/lutaml[lutaml-model]
18
+ serialization framework. The information model is defined in
19
+ https://github.com/edoxen/edoxen-model/tree/main/models[LutaML UML files]
20
+ (one `.lutaml` per concept); this gem mirrors that model exactly
21
+ attribute declarations, enum values, and field shapes — so anything
22
+ expressible in LutaML is constructable, serializable, and validatable in
23
+ Ruby.
24
+
25
+ === Generic core + profile extensions
26
+
27
+ The core schema is the **intersection** of all domains. Domain-specific
28
+ concepts (Bill, Witness, Petition, Address, Quorum Bell, etc.) live in
29
+ **profile extensions** via the `MeetingExtension` slot every core entity
30
+ carries — the ISO 8601-2 §15 profile mechanism. Adopters register a
31
+ profile namespace (`legco`, `us-congress`, `ietf`, `oiml`) and define
32
+ extension kinds within it; consumers ignore profiles they don't
33
+ understand.
28
34
 
29
35
  == Installation
30
36
 
@@ -41,103 +47,208 @@ Then `bundle install`, or `gem install edoxen` standalone.
41
47
  ----
42
48
  require 'edoxen'
43
49
 
44
- yaml = File.read('resolutions.yaml')
45
- collection = Edoxen::ResolutionCollection.from_yaml(yaml)
50
+ yaml = File.read('decisions.yaml')
51
+ collection = Edoxen::DecisionCollection.from_yaml(yaml)
46
52
 
47
- collection.resolutions.each do |resolution|
48
- # identifier is StructuredIdentifier[1..*] — a Resolution can carry
49
- # its TC number, SC number, and any cross-cutting reference number.
50
- id = resolution.identifier.first
53
+ collection.decisions.each do |decision|
54
+ id = decision.identifier.first
51
55
  puts "#{id.prefix}/#{id.number}"
52
56
 
53
- # Canonical rendering — English when available, else the first
54
- # declared localization. See "Lookup accessors" below.
55
- loc = resolution.primary_localization
57
+ loc = decision.primary_localization
56
58
  puts " [#{loc.language_code}/#{loc.script}] #{loc.title}"
57
59
 
58
60
  loc.actions.each do |action|
59
61
  puts " - #{action.type}: #{action.message}"
60
62
  end
61
-
62
- # Or look up a specific language explicitly:
63
- fra = resolution.in_language("fra")
64
- puts " FR: #{fra.title}" if fra
65
63
  end
66
64
 
65
+ # Or look up a specific language explicitly:
66
+ fra = decision.in_language("fra")
67
+ puts " FR: #{fra.title}" if fra
68
+
67
69
  # Round-trip back to YAML
68
70
  puts collection.to_yaml
69
71
  ----
70
72
 
73
+ == The five faces of a meeting
74
+
75
+ A meeting has **five concerns**, each modelled as a distinct first-class
76
+ entity rather than a column on a flat table:
77
+
78
+ [cols="1,3,2"]
79
+ |===
80
+
81
+ | Concern | What it captures | Top-level
82
+
83
+ | *Decisions*
84
+ | Formal outcomes adopted by the meeting: resolution, order, ruling,
85
+ determination, recommendation, statement, finding, opinion.
86
+ | `DecisionCollection`
87
+
88
+ | *Motions* + *Votings*
89
+ | The procedural record — who moved what, how the question was put, how
90
+ members voted, what the chair declared.
91
+ | `Meeting.motions[]` / `Meeting.votings[]`
92
+
93
+ | *Topics*
94
+ | The subjects of discussion. A topic carries documents, assets,
95
+ references, and URN links to the motions and decisions it produced.
96
+ | `AgendaItem.topics[]`
97
+
98
+ | *Meetings*
99
+ | The event itself: identifier, dates, polymorphic venues, officers,
100
+ agenda, components, attendance, minutes.
101
+ | `MeetingCollection`
102
+
103
+ | *Series*
104
+ | The recurring parent of meetings — annual plenaries, monthly board
105
+ meetings, IETF meeting series, etc.
106
+ | (top-level standalone)
107
+
108
+ |===
109
+
110
+ A Meeting carries its decisions, motions, and votings directly. The
111
+ DecisionCollection is the standalone form for publishing decisions
112
+ without meeting-level detail.
113
+
71
114
  == Data model
72
115
 
73
116
  The full model is in `lib/edoxen/*.rb` (Ruby) and `schema/edoxen.yaml`
74
- (JSON-Schema). Both are kept in lockstep by
75
- `spec/edoxen/schema_enum_sync_spec.rb`.
117
+ + `schema/meeting.yaml` (JSON-Schema). Both are kept in lockstep by
118
+ runtime sync specs.
76
119
 
77
120
  [source]
78
121
  ----
79
- ResolutionCollection
80
- ├── metadata: ResolutionMetadata
81
- ├── title / title_localized[]
82
- ├── date
83
- ├── source
84
- ├── source_urls: SourceUrl[]
85
- ├── city, country_code
86
- └── resolutions: Resolution[]
87
- ├── identifier: StructuredIdentifier[1..*]
88
- ├── type: ResolutionType (resolution | recommendation | decision | declaration)
89
- ├── doi, urn, agenda_item
90
- ├── dates: ResolutionDate[]
91
- ├── categories: String[]
92
- ├── meeting: MeetingIdentifier
93
- ├── relations: ResolutionRelation[]
94
- ├── urls: Url[]
95
- └── localizations: Localization[1..*]
96
- ├── language_code (ISO 639-3)
97
- ├── script (ISO 15924)
98
- ├── title, subject, message, considering
99
- ├── considerations: Consideration[]
100
- ├── approvals: Approval[]
101
- └── actions: Action[]
122
+ DecisionCollection MeetingCollection
123
+ ├── metadata: DecisionMetadata ├── metadata: MeetingCollectionMetadata
124
+ ├── title / title_localized[] └── meetings: Meeting[]
125
+ ├── date, source ├── identifier, urn, ordinal
126
+ ├── source_urls[] ├── series_ref (→ MeetingSeries)
127
+ ├── city (UN/LOCODE), country_code ├── type, status, visibility
128
+ └── meeting_urn (back-ref to Meeting) ├── date_range, recurrence
129
+ └── decisions: Decision[] ├── venues: Venue[] (polymorphic)
130
+ ├── identifier: StructuredIdentifier[1..*]| ├── kind: physical | virtual
131
+ ├── kind (DecisionKind) | ├── name, label, capacity
132
+ ├── status (DecisionStatus) | ├── (physical) unlocode, iata_code,
133
+ ├── doi, urn, agenda_item | │ address, country_code, lat, lon
134
+ ├── dates: DecisionDate[] | └── (virtual) uri, features, passcode,
135
+ ├── categories, relations, urls ├── officers: Officer[] (role + person + term)
136
+ ├── brought_by_motions[] ├── hosts: HostRef[] (typed)
137
+ ├── about_topics[] ├── source_urls[], landing_url, registration_url
138
+ ├── made_in_component ├── agenda: Agenda (items, status)
139
+ └── localizations: Localization[1..*] ├── components: MeetingComponent[]
140
+ ├── language_code, script(track, session, debate, breakout, keynote,
141
+ ├── title, subject, message │ opening, closing, break, reception, ...)
142
+ ├── considerations: Consideration[] ├── deadlines: Deadline[]
143
+ ├── approvals: Approval[] ├── attendance: Attendance[]
144
+ └── actions: Action[] │ (status, role, response, proxy_for)
145
+ ├── minutes: Minutes[]
146
+ Motion ├── motions: Motion[]
147
+ ├── identifier, urn │ (status: introduced → seconded → debating
148
+ ├── text, mover, seconders[] │ → question_put → voting → carried/negatived)
149
+ ├── status (MotionStatus) ├── votings: Voting[]
150
+ ├── introduced_at │ (status: called → in_progress → decided;
151
+ ├── proposed_decision, resulting_decision │ voting_method, counts, casting_vote,
152
+ └── votings[] │ vote_records[])
153
+ ├── decisions: Decision[] (inline)
154
+ Voting ├── localizations: MeetingLocalization[]
155
+ ├── status (VotingStatus) ├── relations: MeetingRelation[]
156
+ ├── voting_method (VotingMethod) └── extensions: MeetingExtension[]
157
+ ├── result (VotingOutcome)
158
+ ├── counts: VotingCounts {ayes, noes,
159
+ │ abstentions, absent}
160
+ ├── casting_vote: VoteRecord
161
+ └── vote_records: VoteRecord[]
162
+
163
+ MeetingExtension (profile mechanism)
164
+ ├── profile (e.g. "legco", "ietf", "us-congress")
165
+ ├── kind (in-profile discriminator)
166
+ ├── ref (URN to external profile document)
167
+ └── attributes: ExtensionAttribute[]
168
+ ├── key
169
+ ├── type (string | integer | float | boolean | date | datetime)
170
+ └── value / integer_value / float_value / boolean_value /
171
+ date_value / date_time_value (one of, per `type`)
102
172
  ----
103
173
 
104
- Every translatable field on a Resolution is wrapped inside one of its
105
- `localizations[]` entries. Language-agnostic admin fields
106
- (`identifier`, `doi`, `urn`, `agenda_item`, `dates`, `categories`,
107
- `meeting`, `relations`, `urls`) live on the parent `Resolution`.
174
+ Every core entity has an `extensions: MeetingExtension[0..*]` slot.
175
+ Adopters extend the generic core without modifying it.
176
+
177
+ `ExtensionAttribute` is polymorphic on value type — consumers read the
178
+ typed payload via `#typed_value` without re-parsing strings back into
179
+ Int/Float/Bool/Date. The v2.0 bare `value: String` wire shape still
180
+ parses (routed into the string variant).
181
+
182
+ === Polymorphic Venue
183
+
184
+ A `Venue` is one flat class on the wire; the `kind` field discriminates
185
+ physical vs virtual and validators enforce that fields match `kind`.
186
+
187
+ * Physical: UN/LOCODE + IATA + address + geo-coordinates.
188
+ * Virtual: URI + iCalendar-style features + access details.
189
+
190
+ A meeting can have multiple venues of either kind (hybrid meetings,
191
+ multi-room conferences bridged by video, etc.). UN/LOCODE and IATA codes
192
+ are validated against the canonical `unlocodes` and `iata` gems by
193
+ `Edoxen::VenueValidator`.
108
194
 
109
195
  === Lookup accessors
110
196
 
111
197
  Direct iteration over `localizations[]` is discouraged — the
112
198
  language-preference policy lives behind two accessors:
113
199
 
114
- * `Resolution#in_language(code, fallback: false)` — exact match by
200
+ * `Decision#in_language(code, fallback: false)` — exact match by
115
201
  ISO 639-3 code, or `nil` (or the first declared localization when
116
202
  `fallback: true`).
117
- * `Resolution#primary_localization` — English when available, else
118
- the first declared localization. Mirrors the glossarist
119
- LocalizedConcept "preferred language" notion.
203
+ * `Decision#primary_localization` — English when available, else
204
+ the first declared localization.
205
+
206
+ The same pair exists on `Meeting` (`MeetingLocalization`).
120
207
 
121
208
  [source,ruby]
122
209
  ----
123
- resolution.in_language("fra") # => Localization or nil
124
- resolution.in_language("deu", fallback: true)
125
- resolution.primary_localization # English-or-first
210
+ decision.in_language("fra") # => Localization or nil
211
+ decision.in_language("deu", fallback: true)
212
+ decision.primary_localization # English-or-first
213
+
214
+ meeting.chair # => Person (via officers + role)
215
+ meeting.secretary # => Person
216
+ meeting.officers_with_role(:treasurer) # => [Officer]
217
+ meeting.hybrid? # true if both physical + virtual venues
218
+ meeting.physical_only? / meeting.virtual_only?
219
+
220
+ validator = Edoxen::VenueValidator.new(venue)
221
+ validator.valid? # checks UN/LOCODE + IATA against the gems
222
+ validator.validate(auto_populate: true) # also fills city/country_code from UN/LOCODE
126
223
  ----
127
224
 
128
- == Multilingual resolution sets
225
+ == Multilingual by default
226
+
227
+ Per-language content lives inside `localizations[]` siblings — one
228
+ entry per language. Admin fields (`identifier`, `doi`, `urn`, `dates`,
229
+ `meeting`) are declared once on the parent; translatable content
230
+ (`title`, `subject`, `considerations`, `actions`, `approvals`) lives
231
+ inside each `Localization`.
232
+
233
+ This pattern — borrowed from the
234
+ https://github.com/glossarist/concept-model[glossarist] project — lets
235
+ translators work on each language in isolation while comparing them
236
+ side-by-side in the same file. Drift shows up on git diff, not on a
237
+ translator's spreadsheet.
129
238
 
130
239
  [source,yaml]
131
240
  ----
132
- resolutions:
241
+ decisions:
133
242
  - identifier:
134
243
  - prefix: CIML
135
244
  number: "2025-44"
136
- doi: 10.63493/resolutions/ciml202544
245
+ kind: resolution
246
+ status: decided
247
+ doi: 10.63493/decisions/ciml202544
137
248
  agenda_item: "16.2"
138
249
  dates:
139
250
  - date: 2025-10-13
140
- type: discussed
251
+ type: adoption
141
252
  localizations:
142
253
  - language_code: eng
143
254
  script: Latn
@@ -167,24 +278,21 @@ resolutions:
167
278
 
168
279
  == Command-line interface
169
280
 
170
- The `edoxen` executable exposes four commands — two for Resolution
171
- collections, two for Meeting/Agenda documents.
281
+ The `edoxen` executable exposes six commands.
172
282
 
173
- === `validate` — JSON-Schema validation + model parsing for Resolutions
283
+ === `validate` — schema + model validation for Decisions
174
284
 
175
285
  [source,sh]
176
286
  ----
177
287
  $ edoxen validate "spec/fixtures/*.yaml"
178
288
  ----
179
289
 
180
- Runs both `Edoxen::SchemaValidator` and `Edoxen::ResolutionCollection.from_yaml`
181
- against each matching file. The schema catches `additionalProperties`,
290
+ Runs both `Edoxen::SchemaValidator` and `Edoxen::DecisionCollection.from_yaml`
291
+ against each matching file. Schema catches `additionalProperties`,
182
292
  `required`, enum, and pattern violations; the model catches structural
183
293
  problems the schema can't express.
184
294
 
185
- Exit code is non-zero if any file fails.
186
-
187
- === `normalize` — round-trip Resolution YAML through the model
295
+ === `normalize` round-trip Decision YAML through the model
188
296
 
189
297
  [source,sh]
190
298
  ----
@@ -192,92 +300,96 @@ $ edoxen normalize "spec/fixtures/*.yaml" --output clean/
192
300
  $ edoxen normalize legacy.yaml --inplace
193
301
  ----
194
302
 
195
- Loads each YAML file, parses it through the Ruby model, and writes the
196
- result back. Either `--output DIR` or `--inplace` is required (mutually
197
- exclusive).
303
+ Either `--output DIR` or `--inplace` is required (mutually exclusive).
198
304
 
199
- === `validate-meetings` — validate Meeting/Agenda YAML
305
+ === `validate-meetings` / `normalize-meetings`
306
+
307
+ Same shape, for Meeting/Agenda YAML against `schema/meeting.yaml`.
200
308
 
201
309
  [source,sh]
202
310
  ----
203
311
  $ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"
312
+ $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
204
313
  ----
205
314
 
206
- Validates against `schema/meeting.yaml` and parses through
207
- `Edoxen::Meeting` (single-Meeting files) or `Edoxen::MeetingCollection`
208
- (wrappers). The schema's root is `oneOf` — both shapes are accepted.
315
+ The schema's root is `oneOf` — both single-Meeting and MeetingCollection
316
+ shapes are accepted.
209
317
 
210
- === `normalize-meetings` round-trip Meeting YAML through the model
318
+ === `unlocode CODE` / `iata CODE` registry lookup
211
319
 
212
320
  [source,sh]
213
321
  ----
214
- $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
322
+ $ edoxen unlocode FRPAR
323
+ UN/LOCODE: FRPAR
324
+ Name: Paris
325
+ Country: FR
326
+ ...
327
+
328
+ $ edoxen iata JFK
329
+ IATA: JFK
330
+ Name: John F. Kennedy International Airport
331
+ Country: US
215
332
  ----
216
333
 
217
- Same shape as `normalize`, but for Meeting/Agenda documents.
334
+ == Profile mechanism (ISO 8601-2 §15)
218
335
 
219
- == Meeting & Agenda model
336
+ Every core entity has an `extensions: MeetingExtension[0..*]` slot.
337
+ Adopters register a profile namespace and define extension kinds within
338
+ it. `MeetingExtension` carries three identity fields plus a typed
339
+ `attributes[]` list:
220
340
 
221
- The Meeting model represents the *event* that produces Resolutions,
222
- plus the agenda document that orders its business. Mirrors
223
- `edoxen-model/models/meeting*.lutaml`.
341
+ * `profile` the namespace (lowercase, hyphen-separated).
342
+ * `kind` discriminator within the profile.
343
+ * `ref` — URN of an external profile document, when the data lives
344
+ elsewhere.
345
+ * `attributes[]` — typed key/value pairs (`ExtensionAttribute`).
224
346
 
225
- [source]
347
+ `ExtensionAttribute` is polymorphic on value type (v2.1+). Set `type`
348
+ to one of `string | integer | float | boolean | date | datetime` and
349
+ populate the matching value field:
350
+
351
+ [source,yaml]
226
352
  ----
227
- MeetingCollection
228
- ├── metadata: MeetingCollectionMetadata
229
- └── meetings: Meeting[]
230
- ├── identifier: StructuredIdentifier[1..*]
231
- ├── urn, ordinal, type (MeetingType), status (MeetingStatus), year
232
- ├── date_range: DateRange {start, end}
233
- ├── committee, committee_group
234
- ├── venues: Location[] (multi-venue supported)
235
- ├── general_area, city (UN/LOCODE, e.g. FRPAR), country_code (ISO 3166-1), virtual
236
- ├── chair, secretary: Person
237
- ├── host, hosts: HostRef[] (typed: national_body/liaison/associate/organizer)
238
- ├── source_urls: SourceUrl[] (with `kind` discriminator)
239
- ├── landing_url, registration_url
240
- ├── agenda: Agenda
241
- │ ├── identifier, status (AgendaStatus), source_doc
242
- │ ├── items: AgendaItem[]
243
- │ │ ├── label, kind (numbered/unnumbered/header/opening/closing)
244
- │ │ ├── title, description
245
- │ │ ├── references: Reference[]
246
- │ │ ├── outcome (discussed/resolved/deferred/adopted/withdrawn)
247
- │ │ └── resolution_ref (URN link to a Resolution)
248
- │ └── opening_session, closing_session: ScheduleItem
249
- ├── schedule: ScheduleItem[] (the time-bound timetable)
250
- ├── deadlines: Deadline[]
251
- ├── localizations: MeetingLocalization[] (per-language content)
252
- ├── relations: MeetingRelation[] (continues_from, joint_with, ...)
253
- └── resolution_refs: String[] (URN links to ResolutionCollections)
353
+ extensions:
354
+ - profile: legco
355
+ kind: vote_block
356
+ ref: urn:legco:vote-block:2024-01-15:item-5
357
+ - profile: ietf
358
+ kind: wg_meeting_meta
359
+ attributes:
360
+ - key: wg_name
361
+ type: string
362
+ value: quic
363
+ - key: draft_name
364
+ type: string
365
+ value: draft-ietf-quic-v2
366
+ - key: quorum
367
+ type: integer
368
+ integer_value: 7
369
+ - key: live_stream
370
+ type: boolean
371
+ boolean_value: true
372
+ - key: start
373
+ type: datetime
374
+ date_time_value: 2026-07-04T10:00:00Z
254
375
  ----
255
376
 
256
- === Linking Meetings to ResolutionCollections
257
-
258
- Meetings and ResolutionCollections are separate documents joined by URN:
259
-
260
- - `Meeting.resolution_refs: [urn:...]`
261
- - `ResolutionCollection.metadata.meeting_urn: urn:...`
262
-
263
- The join is by URN because the two have different lifetimes — agendas
264
- exist weeks before a meeting, resolutions only after adoption.
265
-
266
- === Lookup accessors (parallel to the Resolution side)
377
+ Read the typed payload in Ruby via `#typed_value`:
267
378
 
268
379
  [source,ruby]
269
380
  ----
270
- meeting = Edoxen::Meeting.from_yaml(File.read('meeting.yaml'))
381
+ attr = extension.attributes.find { |a| a.key == "quorum" }
382
+ attr.type # => "integer"
383
+ attr.integer_value # => 7
384
+ attr.typed_value # => 7
385
+ ----
271
386
 
272
- meeting.in_language("fra") # => MeetingLocalization or nil
273
- meeting.in_language("deu", fallback: true) # falls back to first
274
- meeting.primary_localization # English-or-first
275
- meeting.find_agenda_item("5.2") # AgendaItem by label
387
+ The v2.0 bare `value: String` wire shape still parses (routed into
388
+ the string variant; `type` defaults to `string`).
276
389
 
277
- collection = Edoxen::MeetingCollection.from_yaml(...)
278
- collection.find_by_urn("urn:oiml:ciml:meeting:ciml-56")
279
- collection.find_by_identifier(prefix: "CIML", number: "56")
280
- ----
390
+ Consumers ignore profile extensions they don't understand. See
391
+ https://github.com/edoxen/edoxen-model/tree/main/references/profiles[the
392
+ HK LegCo profile example] for a real-world reference.
281
393
 
282
394
  == Architecture
283
395
 
@@ -288,40 +400,83 @@ collection.find_by_identifier(prefix: "CIML", number: "56")
288
400
  * Each model lives in its own file under `lib/edoxen/`. Models declare
289
401
  `attribute` only — `lutaml-model` auto-emits an identity map (wire
290
402
  name = snake_case attribute name) when no explicit `key_value` block
291
- is present. Add a `key_value do; map "wire", to: :attr; end` block
292
- only when the wire name differs from the attribute name.
403
+ is present.
293
404
  * `lib/edoxen/enums.rb` is the single source of truth for every enum
294
- value used by the gem. Both the Ruby model (`attribute :type, :string,
295
- values: Enums::ACTION_TYPE`) and the schemas reference the
296
- same constants.
405
+ value used by the gem. Both the Ruby model (`attribute :kind, :string,
406
+ values: Enums::DECISION_KIND`) and the schemas reference the same
407
+ constants.
297
408
  * `lib/edoxen/error.rb` defines the unified `Edoxen::ValidationError`
298
409
  — produced by both `SchemaValidator` (with `source: :schema` or
299
410
  `:syntax`) and by model parse rescues in the CLI (with `source:
300
- :model`). Carries `file`, `line`, `column`, `pointer`,
301
- `message_text`, and `source`.
411
+ :model`).
302
412
  * `lib/edoxen/schema_validator.rb` is intentionally small: two
303
413
  validate methods, a `LineMap` module for line-accurate error
304
414
  reporting (longest-prefix match — no path-shape hardcoding), and
305
415
  date coercion so `json_schemer` can validate `format: date` against
306
416
  YAML-loaded `Date` instances.
307
- * `lib/edoxen/cli.rb` exposes four Thor commands (`validate`,
308
- `normalize`, `validate-meetings`, `normalize-meetings`) that
309
- delegate their shared scaffolding
310
- (expand/sort/empty/header/loop/tally/summary/exit) to the deep
417
+ * `lib/edoxen/cli.rb` exposes six Thor commands (`validate`,
418
+ `normalize`, `validate-meetings`, `normalize-meetings`, `unlocode`,
419
+ `iata`) that delegate their shared scaffolding to the deep
311
420
  `Edoxen::Cli::Batch` module.
421
+ * `lib/edoxen/venue_validator.rb` validates polymorphic `Venue`
422
+ instances using the `unlocodes` and `iata` gems; optionally
423
+ auto-populates `city` and `country_code` from the UN/LOCODE registry.
312
424
 
313
425
  === Schema ↔ Ruby invariants
314
426
 
315
427
  Two pairs of runtime specs guard each side of the schema ↔ Ruby
316
428
  boundary:
317
429
 
318
- * Resolution side: `schema_enum_sync_spec.rb` +
430
+ * Decision side: `schema_enum_sync_spec.rb` +
319
431
  `schema_model_sync_spec.rb` against `schema/edoxen.yaml`.
320
432
  * Meeting side: `schema_meeting_enum_sync_spec.rb` +
321
433
  `schema_meeting_model_sync_spec.rb` against `schema/meeting.yaml`.
322
434
 
323
435
  Drift fails CI immediately, not at fixture-validation time.
324
436
 
437
+ == Migrating from v0.x (Resolution → Decision)
438
+
439
+ v2.0 is a breaking release. The migration is mechanical:
440
+
441
+ [cols="1,3"]
442
+ |===
443
+
444
+ | `Resolution` class
445
+ | `Decision` class with `kind: resolution`
446
+
447
+ | `ResolutionType` enum (4 values)
448
+ | `DecisionKind` enum (9 values: resolution, order, ruling, determination,
449
+ recommendation, statement, finding, opinion, other)
450
+
451
+ | `ResolutionCollection`
452
+ | `DecisionCollection`
453
+
454
+ | `Meeting.virtual: Boolean`
455
+ | `Meeting.venues: Venue[]` (polymorphic)
456
+
457
+ | `Meeting.chair` / `Meeting.secretary`
458
+ | `Meeting.officers: Officer[]` (role discriminates)
459
+
460
+ | `Meeting.schedule[]` (ScheduleItem)
461
+ | `Meeting.components: MeetingComponent[]` (flat)
462
+
463
+ | `Resolution` alone
464
+ | `Decision` + `Motion` + `Voting` (procedural core)
465
+
466
+ | (none)
467
+ | `Topic` + `TopicDocument` + `TopicAsset`
468
+
469
+ | (none)
470
+ | `MeetingSeries`, `Recurrence` (ISO 8601-2 §13)
471
+
472
+ | (none)
473
+ | `MeetingExtension` (profile mechanism, ISO 8601-2 §15)
474
+
475
+ |===
476
+
477
+ See the https://github.com/edoxen/edoxen.github.io/blob/main/docs/migration-v2.md[migration
478
+ guide] for full details.
479
+
325
480
  == Contributing
326
481
 
327
482
  Follow the rules in `CLAUDE.md`:
data/edoxen.gemspec CHANGED
@@ -8,25 +8,24 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["Ribose Inc."]
9
9
  spec.email = ["open.source@ribose.com"]
10
10
 
11
- spec.summary = "Edoxen is a set of information models used for representing resolution and decision information."
11
+ spec.summary = "Edoxen is a generic information model for meetings, agendas, and decisions."
12
12
  spec.description = <<~HEREDOC
13
- Edoxen provides a Ruby library for working with resolution models, allowing
14
- users to create, manipulate, and serialize resolution data in a structured
15
- format. It is built on top of the lutaml-model serialization framework,
16
- which provides a flexible and extensible way to define data models and
17
- serialize them to YAML or JSON formats.
13
+ Edoxen provides a Ruby library for working with a generic meeting/decision
14
+ model, allowing users to create, manipulate, and serialize meeting, agenda,
15
+ motion, voting, and decision data in a structured format. Built on top of
16
+ the lutaml-model serialization framework, with profile-based customization
17
+ (ISO 8601-2 §15) for domain-specific extensions.
18
18
  HEREDOC
19
19
 
20
20
  spec.homepage = "https://github.com/metanorma/edoxen"
21
21
  spec.license = "BSD-2-Clause"
22
- spec.required_ruby_version = ">= 3.0.0"
22
+ spec.required_ruby_version = ">= 3.1.0"
23
23
 
24
24
  spec.metadata["homepage_uri"] = spec.homepage
25
25
  spec.metadata["source_code_uri"] = "https://github.com/metanorma/edoxen"
26
26
  spec.metadata["changelog_uri"] = "https://github.com/metanorma/edoxen"
27
+ spec.metadata["rubygems_mfa_required"] = "true"
27
28
 
28
- # Specify which files should be added to the gem when it is released.
29
- # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
30
29
  spec.files = Dir.chdir(__dir__) do
31
30
  `git ls-files -z`.split("\x0").reject do |f|
32
31
  (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
@@ -39,5 +38,6 @@ Gem::Specification.new do |spec|
39
38
  spec.add_dependency "json_schemer", "~> 2.0"
40
39
  spec.add_dependency "lutaml-model", "~> 0.7"
41
40
  spec.add_dependency "thor", "~> 1.0"
42
- spec.add_dependency "unlocodes", "~> 0.1"
41
+ spec.add_dependency "unlocodes", "~> 0.3"
42
+ spec.add_dependency "iata", "~> 0.1"
43
43
  end
data/lib/edoxen/action.rb CHANGED
@@ -5,7 +5,7 @@ module Edoxen
5
5
  # Localization to express the multilingual part of an action.
6
6
  class Action < Lutaml::Model::Serializable
7
7
  attribute :type, :string, values: Enums::ACTION_TYPE
8
- attribute :date_effective, ResolutionDate
8
+ attribute :date_effective, DecisionDate
9
9
  attribute :message, :string
10
10
  end
11
11
  end