edoxen 0.5.0 → 0.8.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 (90) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +26 -1
  3. data/.rubocop_todo.yml +1 -1
  4. data/CHANGELOG.md +133 -0
  5. data/CLAUDE.md +10 -6
  6. data/Gemfile +4 -0
  7. data/README.adoc +417 -181
  8. data/TODO.meeting-agenda/02-lutaml-canonical.md +1 -1
  9. data/TODO.meeting-agenda/09-verify-and-release.md +3 -3
  10. data/TODO.meeting-agenda/README.md +2 -2
  11. data/edoxen.gemspec +10 -9
  12. data/lib/edoxen/action.rb +5 -4
  13. data/lib/edoxen/agenda.rb +4 -7
  14. data/lib/edoxen/agenda_item.rb +12 -5
  15. data/lib/edoxen/approval.rb +2 -2
  16. data/lib/edoxen/attendance.rb +29 -0
  17. data/lib/edoxen/body_vocabulary_entry.rb +24 -0
  18. data/lib/edoxen/body_vocabulary_host.rb +40 -0
  19. data/lib/edoxen/cli.rb +186 -108
  20. data/lib/edoxen/consideration.rb +6 -4
  21. data/lib/edoxen/contact.rb +35 -0
  22. data/lib/edoxen/contact_collection.rb +26 -0
  23. data/lib/edoxen/contact_identifier.rb +13 -0
  24. data/lib/edoxen/contact_method.rb +16 -0
  25. data/lib/edoxen/date_time_range.rb +16 -0
  26. data/lib/edoxen/deadline.rb +1 -1
  27. data/lib/edoxen/decision.rb +57 -0
  28. data/lib/edoxen/decision_collection.rb +10 -0
  29. data/lib/edoxen/decision_date.rb +9 -0
  30. data/lib/edoxen/decision_metadata.rb +27 -0
  31. data/lib/edoxen/decision_relation.rb +11 -0
  32. data/lib/edoxen/declaration.rb +25 -0
  33. data/lib/edoxen/entity_ref.rb +74 -0
  34. data/lib/edoxen/enums.rb +129 -8
  35. data/lib/edoxen/error.rb +1 -1
  36. data/lib/edoxen/extension_attribute.rb +78 -0
  37. data/lib/edoxen/host_ref.rb +1 -1
  38. data/lib/edoxen/link_checker.rb +194 -0
  39. data/lib/edoxen/localized_name.rb +16 -0
  40. data/lib/edoxen/localized_string.rb +22 -0
  41. data/lib/edoxen/meeting.rb +42 -36
  42. data/lib/edoxen/meeting_collection.rb +1 -2
  43. data/lib/edoxen/meeting_collection_metadata.rb +4 -2
  44. data/lib/edoxen/meeting_component.rb +37 -0
  45. data/lib/edoxen/meeting_extension.rb +25 -0
  46. data/lib/edoxen/meeting_identifier.rb +1 -1
  47. data/lib/edoxen/meeting_series.rb +26 -0
  48. data/lib/edoxen/minutes.rb +30 -0
  49. data/lib/edoxen/minutes_section.rb +26 -0
  50. data/lib/edoxen/motion.rb +51 -0
  51. data/lib/edoxen/name.rb +29 -0
  52. data/lib/edoxen/officer.rb +19 -0
  53. data/lib/edoxen/officers_host.rb +19 -0
  54. data/lib/edoxen/person.rb +5 -9
  55. data/lib/edoxen/physical_venue.rb +15 -0
  56. data/lib/edoxen/recurrence.rb +27 -0
  57. data/lib/edoxen/recurrence_by_day.rb +10 -0
  58. data/lib/edoxen/reference.rb +1 -1
  59. data/lib/edoxen/reference_data.rb +85 -0
  60. data/lib/edoxen/schema_validator.rb +3 -4
  61. data/lib/edoxen/source_url.rb +4 -4
  62. data/lib/edoxen/statement.rb +21 -0
  63. data/lib/edoxen/structured_identifier.rb +2 -2
  64. data/lib/edoxen/topic.rb +39 -0
  65. data/lib/edoxen/topic_asset.rb +14 -0
  66. data/lib/edoxen/topic_document.rb +16 -0
  67. data/lib/edoxen/venue.rb +79 -0
  68. data/lib/edoxen/venue_collection.rb +18 -0
  69. data/lib/edoxen/venue_validator.rb +74 -0
  70. data/lib/edoxen/version.rb +1 -1
  71. data/lib/edoxen/virtual_venue.rb +15 -0
  72. data/lib/edoxen/vote_record.rb +29 -0
  73. data/lib/edoxen/voting.rb +45 -0
  74. data/lib/edoxen/voting_counts.rb +23 -0
  75. data/lib/edoxen.rb +53 -14
  76. data/schema/edoxen.yaml +1378 -247
  77. data/schema/meeting.yaml +1258 -201
  78. data/sig/edoxen.rbs +581 -1
  79. metadata +82 -21
  80. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  81. data/lib/edoxen/localization.rb +0 -18
  82. data/lib/edoxen/location.rb +0 -15
  83. data/lib/edoxen/meeting_localization.rb +0 -15
  84. data/lib/edoxen/resolution.rb +0 -43
  85. data/lib/edoxen/resolution_collection.rb +0 -10
  86. data/lib/edoxen/resolution_date.rb +0 -11
  87. data/lib/edoxen/resolution_metadata.rb +0 -22
  88. data/lib/edoxen/resolution_relation.rb +0 -11
  89. data/lib/edoxen/resolution_set.rb +0 -17
  90. data/lib/edoxen/schedule_item.rb +0 -16
data/README.adoc CHANGED
@@ -1,26 +1,36 @@
1
1
  = Edoxen
2
2
 
3
- https://github.com/metanorma/edoxen[image:https://img.shields.io/github/stars/metanorma/edoxen.svg?style=social[GitHub Stars]]
4
- image:https://img.shields.io/github/license/metanorma/edoxen.svg[License]
5
- image:https://img.shields.io/github/actions/workflow/status/metanorma/edoxen/test.yml?branch=main[Build Status]
3
+ https://github.com/edoxen/edoxen[image:https://img.shields.io/github/stars/edoxen/edoxen.svg?style=social[GitHub Stars]]
4
+ image:https://img.shields.io/github/license/edoxen/edoxen.svg[License]
5
+ image:https://img.shields.io/github/actions/workflow/status/edoxen/edoxen/test.yml?branch=main[Build Status]
6
6
  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 resolutions and decisions. It is built on top of the
12
- https://github.com/lutaml/lutaml[ lutaml-model] serialization framework.
13
-
14
- The information model is defined in
15
- https://github.com/metanorma/edoxen-model/tree/main/models[ LutaML UML
16
- files] (one `.lutaml` per concept). This gem mirrors that model exactly —
17
- attribute declarations, enum values, and field shapes — so that anything
18
- you can express in LutaML you can also construct, serialize, and validate
19
- in Ruby.
20
-
21
- The intended users are standards organizations and governance bodies that
22
- need to publish structured records of formal decision-making. Real-world
23
- samples (ISO/TC 154, CIPM, OIML CIML) ship in `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.
24
34
 
25
35
  == Installation
26
36
 
@@ -37,150 +47,278 @@ Then `bundle install`, or `gem install edoxen` standalone.
37
47
  ----
38
48
  require 'edoxen'
39
49
 
40
- yaml = File.read('resolutions.yaml')
41
- collection = Edoxen::ResolutionCollection.from_yaml(yaml)
50
+ yaml = File.read('decisions.yaml')
51
+ collection = Edoxen::DecisionCollection.from_yaml(yaml)
42
52
 
43
- collection.resolutions.each do |resolution|
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
53
+ collection.decisions.each do |decision|
54
+ id = decision.identifier.first
47
55
  puts "#{id.prefix}/#{id.number}"
48
56
 
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}"
57
+ # Per-field Localized (ISO 24229): every translatable field is an
58
+ # array of { spelling, value } entries. Pick the one you want.
59
+ title = decision.title.find { |l| l.spelling == "eng" }&.value
60
+ puts " [eng] #{title}"
53
61
 
54
- loc.actions.each do |action|
55
- puts " - #{action.type}: #{action.message}"
56
- end
62
+ fra = decision.title.find { |l| l.spelling == "fra" }&.value
63
+ puts " [fra] #{fra}" if fra
57
64
 
58
- # Or look up a specific language explicitly:
59
- fra = resolution.in_language("fra")
60
- puts " FR: #{fra.title}" if fra
65
+ decision.actions.each do |action|
66
+ msg = action.message.find { |l| l.spelling == "eng" }&.value
67
+ puts " - #{action.type}: #{msg}"
68
+ end
61
69
  end
62
70
 
63
71
  # Round-trip back to YAML
64
72
  puts collection.to_yaml
65
73
  ----
66
74
 
75
+ == The five faces of a meeting
76
+
77
+ A meeting has **five concerns**, each modelled as a distinct first-class
78
+ entity rather than a column on a flat table:
79
+
80
+ [cols="1,3,2"]
81
+ |===
82
+
83
+ | Concern | What it captures | Top-level
84
+
85
+ | *Decisions*
86
+ | Formal outcomes adopted by the meeting: resolution, order, ruling,
87
+ determination, recommendation, statement, finding, opinion.
88
+ | `DecisionCollection`
89
+
90
+ | *Motions* + *Votings*
91
+ | The procedural record — who moved what, how the question was put, how
92
+ members voted, what the chair declared.
93
+ | `Meeting.motions[]` / `Meeting.votings[]`
94
+
95
+ | *Topics*
96
+ | The subjects of discussion. A topic carries documents, assets,
97
+ references, and URN links to the motions and decisions it produced.
98
+ | `AgendaItem.topics[]`
99
+
100
+ | *Meetings*
101
+ | The event itself: identifier, dates, polymorphic venues, officers,
102
+ agenda, components, attendance, minutes.
103
+ | `MeetingCollection`
104
+
105
+ | *Series*
106
+ | The recurring parent of meetings — annual plenaries, monthly board
107
+ meetings, IETF meeting series, etc.
108
+ | (top-level standalone)
109
+
110
+ |===
111
+
112
+ A Meeting carries its decisions, motions, and votings directly. The
113
+ DecisionCollection is the standalone form for publishing decisions
114
+ without meeting-level detail.
115
+
67
116
  == Data model
68
117
 
69
118
  The full model is in `lib/edoxen/*.rb` (Ruby) and `schema/edoxen.yaml`
70
- (JSON-Schema). Both are kept in lockstep by
71
- `spec/edoxen/schema_enum_sync_spec.rb`.
119
+ + `schema/meeting.yaml` (JSON-Schema). Both are kept in lockstep by
120
+ runtime sync specs.
72
121
 
73
122
  [source]
74
123
  ----
75
- ResolutionCollection
76
- ├── metadata: ResolutionMetadata
77
- ├── title / title_localized[]
78
- ├── date
79
- ├── source
80
- ├── source_urls: SourceUrl[]
81
- ├── city, country_code
82
- └── resolutions: Resolution[]
83
- ├── identifier: StructuredIdentifier[1..*]
84
- ├── type: ResolutionType (resolution | recommendation | decision | declaration)
85
- ├── doi, urn, agenda_item
86
- ├── dates: ResolutionDate[]
87
- ├── categories: String[]
88
- ├── meeting: MeetingIdentifier
89
- ├── relations: ResolutionRelation[]
90
- ├── urls: Url[]
91
- └── localizations: Localization[1..*]
92
- ├── language_code (ISO 639-3)
93
- ├── script (ISO 15924)
94
- ├── title, subject, message, considering
95
- ├── considerations: Consideration[]
96
- ├── approvals: Approval[]
97
- └── actions: Action[]
124
+ DecisionCollection MeetingCollection
125
+ ├── metadata: DecisionMetadata ├── metadata: MeetingCollectionMetadata
126
+ ├── title: LocalizedString[] └── meetings: Meeting[]
127
+ ├── date, source ├── identifier, urn, ordinal
128
+ ├── source_urls[] ├── series_ref (→ MeetingSeries)
129
+ ├── city (UN/LOCODE), country_code ├── type, status, visibility
130
+ └── meeting_urn (back-ref to Meeting) ├── scheduled_date_range, occurred_date_range, recurrence
131
+ └── decisions: Decision[] ├── venues: Venue[] (polymorphic)
132
+ ├── identifier: StructuredIdentifier[1..*]| ├── kind: physical | virtual
133
+ ├── kind (DecisionKind) | ├── name, label, capacity
134
+ ├── status (DecisionStatus) | ├── (physical) unlocode, iata_code,
135
+ ├── doi, urn, agenda_item | │ address, country_code, lat, lon
136
+ ├── dates: DecisionDate[] | └── (virtual) uri, features, passcode,
137
+ ├── categories, relations, urls ├── officers: Officer[] (role + person + term)
138
+ ├── brought_by_motions[] ├── hosts: HostRef[] (typed)
139
+ ├── about_topics[] ├── source_urls[], landing_url, registration_url
140
+ ├── made_in_component ├── agenda: Agenda (items, status)
141
+ ├── title: LocalizedString[] ├── components: MeetingComponent[]
142
+ ├── subject: LocalizedString[] │ (track, session, debate, breakout, keynote,
143
+ ├── message: LocalizedString[] │ opening, closing, break, reception, ...)
144
+ ├── considering: LocalizedString[] ├── deadlines: Deadline[]
145
+ ├── considerations: Consideration[] ├── attendance: Attendance[]
146
+ └── message: LocalizedString[] │ (status, role, response, proxy_for)
147
+ ├── approvals: Approval[] ├── minutes: Minutes[]
148
+ │ └── message: LocalizedString[] ├── motions: Motion[]
149
+ └── actions: Action[] │ (status: introduced → seconded → debating
150
+ └── message: LocalizedString[] │ → question_put → voting → carried/negatived)
151
+ ├── votings: Voting[]
152
+ Motion │ (status: called → in_progress → decided;
153
+ ├── identifier, urn │ voting_method, counts, casting_vote,
154
+ ├── text: LocalizedString[] │ vote_records[])
155
+ ├── mover, seconders[] ├── decisions: Decision[] (inline)
156
+ ├── status (MotionStatus) ├── declarations: Declaration[] (BS 0:2006 §7.6)
157
+ ├── introduced_at ├── relations: MeetingRelation[]
158
+ ├── proposed_decision, resulting_decision └── extensions: MeetingExtension[]
159
+ └── votings[]
160
+ MeetingSeries (top-level standalone)
161
+ Voting ├── identifier, urn
162
+ ├── status (VotingStatus) ├── name: LocalizedString[]
163
+ ├── voting_method (VotingMethod) ├── recurrence
164
+ ├── result (VotingOutcome) ├── meeting_refs[]
165
+ ├── counts: VotingCounts {ayes, noes, └── extensions: MeetingExtension[]
166
+ │ abstentions, absent}
167
+ ├── casting_vote: VoteRecord
168
+ └── vote_records: VoteRecord[]
169
+
170
+ MeetingExtension (profile mechanism)
171
+ ├── profile (e.g. "legco", "ietf", "us-congress")
172
+ ├── kind (in-profile discriminator)
173
+ ├── ref (URN to external profile document)
174
+ └── attributes: ExtensionAttribute[]
175
+ ├── key
176
+ ├── type (string | integer | float | boolean | date | datetime)
177
+ └── value / integer_value / float_value / boolean_value /
178
+ date_value / date_time_value (one of, per `type`)
179
+
180
+ Statement (BS 0:2006 §7.6)
181
+ ├── kind (StatementKind: statement | comment | standpoint)
182
+ ├── description: LocalizedString[]
183
+ ├── party: Person[]
184
+ └── extensions: MeetingExtension[]
185
+
186
+ Declaration (BS 0:2006 §7.6)
187
+ ├── kind (DeclarationKind: conflict_of_interest | ipr)
188
+ ├── description: LocalizedString[]
189
+ ├── party: Person[]
190
+ ├── ipr_subject_ref: EntityRef (only when kind == "ipr")
191
+ ├── ipr_target_ref: EntityRef (only when kind == "ipr")
192
+ └── extensions: MeetingExtension[]
193
+
194
+ DateTimeRange
195
+ ├── start: DateTime
196
+ └── end: DateTime
98
197
  ----
99
198
 
100
- Every translatable field on a Resolution is wrapped inside one of its
101
- `localizations[]` entries. Language-agnostic admin fields
102
- (`identifier`, `doi`, `urn`, `agenda_item`, `dates`, `categories`,
103
- `meeting`, `relations`, `urls`) live on the parent `Resolution`.
199
+ Every core entity has an `extensions: MeetingExtension[0..*]` slot.
200
+ Adopters extend the generic core without modifying it.
201
+
202
+ `ExtensionAttribute` is polymorphic on value type — consumers read the
203
+ typed payload via `#typed_value` without re-parsing strings back into
204
+ Int/Float/Bool/Date. The pre-1.0 bare `value: String` wire shape still
205
+ parses (routed into the string variant).
206
+
207
+ === Per-field multilingual (ISO 24229)
104
208
 
105
- === Lookup accessors
209
+ Every translatable field is `Localized<String|Name>[0..*]` — an array
210
+ with one entry per ISO 24229 *spelling* code. There is no separate
211
+ `Localization[]` collection; each field carries its own language tags
212
+ inline.
106
213
 
107
- Direct iteration over `localizations[]` is discouraged the
108
- language-preference policy lives behind two accessors:
214
+ * `LocalizedString` `{ spelling, value: String, extensions }`.
215
+ * `LocalizedName` `{ spelling, value: Name, extensions }`.
216
+ * `spelling` accepts ISO 24229 *spelling-system codes*
217
+ (`{lang}-{script}[-{country}][-{extension}]`, e.g. `zho-Hans`,
218
+ `ind-Latn-pre1972`) AND *conversion-system codes*
219
+ (`{authority}:{source-spelling}:{target-spelling}:{identifying}`,
220
+ e.g. `acadsin:zho-Hani:Latn:2002`, `UN:ara-Arab:Latn:2017`).
109
221
 
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.
222
+ Always verbose single-language data uses the same
223
+ `[{ spelling, value }]` shape as multi-language data. This lets a
224
+ single field capture both native-script content and its romanized /
225
+ transliterated forms.
226
+
227
+ Look up an entry by spelling with plain Ruby:
228
+
229
+ [source,ruby]
230
+ ----
231
+ decision.title.find { |l| l.spelling == "eng" }&.value
232
+ decision.title.find { |l| l.spelling == "acadsin:zho-Hani:Latn:2002" }&.value
233
+ ----
234
+
235
+ === Polymorphic Venue
236
+
237
+ A `Venue` is one flat class on the wire; the `kind` field discriminates
238
+ physical vs virtual.
239
+
240
+ * Physical: UN/LOCODE + IATA + address + geo-coordinates.
241
+ * Virtual: URI + iCalendar-style features + access details.
242
+
243
+ A meeting can have multiple venues of either kind (hybrid meetings,
244
+ multi-room conferences bridged by video, etc.). UN/LOCODE and IATA codes
245
+ are validated against the canonical `unlocodes` and `iata` gems by
246
+ `Edoxen::VenueValidator` (pure validation via `valid?`; call
247
+ `populate_from_registry!` separately if you want city/country_code
248
+ back-filled from the UN/LOCODE entry).
249
+
250
+ === Meeting helpers
116
251
 
117
252
  [source,ruby]
118
253
  ----
119
- resolution.in_language("fra") # => Localization or nil
120
- resolution.in_language("deu", fallback: true)
121
- resolution.primary_localization # English-or-first
254
+ meeting.chair # => Person (via officers + role)
255
+ meeting.officers_with_role(:treasurer) # => [Officer]
122
256
  ----
123
257
 
124
- == Multilingual resolution sets
258
+ `OfficersHost` is mixed into `Meeting` and `MeetingComponent`; both
259
+ expose `chair` (the first officer with `role: "chair"`) and
260
+ `officers_with_role(role)`.
261
+
262
+ == Multilingual example
263
+
264
+ A bilingual CIML decision in EN + FR, modelled with per-field Localized:
125
265
 
126
266
  [source,yaml]
127
267
  ----
128
- resolutions:
268
+ decisions:
129
269
  - identifier:
130
270
  - prefix: CIML
131
271
  number: "2025-44"
132
- doi: 10.63493/resolutions/ciml202544
272
+ kind: resolution
273
+ status: decided
274
+ doi: 10.63493/decisions/ciml202544
133
275
  agenda_item: "16.2"
134
276
  dates:
135
277
  - date: 2025-10-13
136
- type: discussed
137
- localizations:
138
- - language_code: eng
139
- script: Latn
140
- title: Decision on the renewal of the contract of Mr Anthony Donnellan
141
- subject: CIML
142
- actions:
143
- - type: decides
144
- date_effective:
145
- date: 2025-10-13
146
- type: adoption
147
- message: |
278
+ type: adoption
279
+ title:
280
+ - spelling: eng
281
+ value: Decision on the renewal of the contract of Mr Anthony Donnellan
282
+ - spelling: fra
283
+ value: Décision sur le renouvellement du contrat de M. Anthony Donnellan
284
+ subject:
285
+ - spelling: eng
286
+ value: CIML
287
+ - spelling: fra
288
+ value: CIML
289
+ actions:
290
+ - type: decides
291
+ date_effective:
292
+ date: 2025-10-13
293
+ type: adoption
294
+ message:
295
+ - spelling: eng
296
+ value: |
148
297
  The Committee decides to renew the contract of
149
298
  Mr Anthony Donnellan as BIML Director.
150
- - language_code: fra
151
- script: Latn
152
- title: Décision sur le renouvellement du contrat de M. Anthony Donnellan
153
- subject: CIML
154
- actions:
155
- - type: decides
156
- date_effective:
157
- date: 2025-10-13
158
- type: adoption
159
- message: |
299
+ - spelling: fra
300
+ value: |
160
301
  Le Comité décide de renouveler le contrat de
161
302
  M. Anthony Donnellan en tant que Directeur du BIML.
162
303
  ----
163
304
 
164
305
  == Command-line interface
165
306
 
166
- The `edoxen` executable exposes four commands — two for Resolution
167
- collections, two for Meeting/Agenda documents.
307
+ The `edoxen` executable exposes six commands.
168
308
 
169
- === `validate` — JSON-Schema validation + model parsing for Resolutions
309
+ === `validate` — schema + model validation for Decisions
170
310
 
171
311
  [source,sh]
172
312
  ----
173
313
  $ edoxen validate "spec/fixtures/*.yaml"
174
314
  ----
175
315
 
176
- Runs both `Edoxen::SchemaValidator` and `Edoxen::ResolutionCollection.from_yaml`
177
- against each matching file. The schema catches `additionalProperties`,
316
+ Runs both `Edoxen::SchemaValidator` and `Edoxen::DecisionCollection.from_yaml`
317
+ against each matching file. Schema catches `additionalProperties`,
178
318
  `required`, enum, and pattern violations; the model catches structural
179
319
  problems the schema can't express.
180
320
 
181
- Exit code is non-zero if any file fails.
182
-
183
- === `normalize` — round-trip Resolution YAML through the model
321
+ === `normalize` round-trip Decision YAML through the model
184
322
 
185
323
  [source,sh]
186
324
  ----
@@ -188,92 +326,120 @@ $ edoxen normalize "spec/fixtures/*.yaml" --output clean/
188
326
  $ edoxen normalize legacy.yaml --inplace
189
327
  ----
190
328
 
191
- Loads each YAML file, parses it through the Ruby model, and writes the
192
- result back. Either `--output DIR` or `--inplace` is required (mutually
193
- exclusive).
329
+ Either `--output DIR` or `--inplace` is required (mutually exclusive).
330
+
331
+ === `validate-meetings` / `normalize-meetings`
194
332
 
195
- === `validate-meetings` validate Meeting/Agenda YAML
333
+ Same shape, for Meeting / MeetingCollection / MeetingSeries YAML against
334
+ `schema/meeting.yaml`.
196
335
 
197
336
  [source,sh]
198
337
  ----
199
338
  $ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"
339
+ $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
200
340
  ----
201
341
 
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.
342
+ The schema's root is `oneOf` single `Meeting`, `MeetingCollection`,
343
+ and `MeetingSeries` are all accepted.
344
+
345
+ === `check-links DIRECTORY` — cross-document URN audit
205
346
 
206
- === `normalize-meetings` round-trip Meeting YAML through the model
347
+ Walks `DIRECTORY` recursively, classifies every `*.yaml` / `*.yml` as a
348
+ Meeting, MeetingCollection, or DecisionCollection, and reports:
349
+
350
+ * every `metadata.meeting_urn` that does not resolve to an indexed
351
+ Meeting;
352
+ * every Meeting `urn` claimed by more than one file.
353
+
354
+ Error messages include file:line and the in-document pointer.
207
355
 
208
356
  [source,sh]
209
357
  ----
210
- $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
358
+ $ edoxen check-links ./spec/fixtures
359
+ 🔗 Checking cross-document URN links in ./spec/fixtures...
360
+ ✅ No broken or duplicate links found
361
+
362
+ $ edoxen check-links ./_data
363
+ ❌ Found 2 link error(s):
364
+ ./_data/decisions.yaml:4: metadata.meeting_urn references urn:oiml:ciml:meeting:ciml-99 (no matching Meeting)
365
+ ./_data/dupe.yaml:7: urn references urn:oiml:ciml:meeting:ciml-55 (duplicate Meeting URN (first seen at ./_data/meeting-55.yaml:4))
211
366
  ----
212
367
 
213
- Same shape as `normalize`, but for Meeting/Agenda documents.
214
-
215
- == Meeting & Agenda model
368
+ === `unlocode CODE` / `iata CODE` registry lookup
216
369
 
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]
370
+ [source,sh]
222
371
  ----
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)
372
+ $ edoxen unlocode FRPAR
373
+ UN/LOCODE: FRPAR
374
+ Name: Paris
375
+ Country: FR
376
+ ...
377
+
378
+ $ edoxen iata JFK
379
+ IATA: JFK
380
+ Name: John F. Kennedy International Airport
381
+ Country: US
250
382
  ----
251
383
 
252
- === Linking Meetings to ResolutionCollections
384
+ == Profile mechanism (ISO 8601-2 §15)
385
+
386
+ Every core entity has an `extensions: MeetingExtension[0..*]` slot.
387
+ Adopters register a profile namespace and define extension kinds within
388
+ it. `MeetingExtension` carries three identity fields plus a typed
389
+ `attributes[]` list:
253
390
 
254
- Meetings and ResolutionCollections are separate documents joined by URN:
391
+ * `profile` the namespace (lowercase, hyphen-separated).
392
+ * `kind` — discriminator within the profile.
393
+ * `ref` — URN of an external profile document, when the data lives
394
+ elsewhere.
395
+ * `attributes[]` — typed key/value pairs (`ExtensionAttribute`).
255
396
 
256
- - `Meeting.resolution_refs: [urn:...]`
257
- - `ResolutionCollection.metadata.meeting_urn: urn:...`
397
+ `ExtensionAttribute` is polymorphic on value type. Set `type`
398
+ to one of `string | integer | float | boolean | date | datetime` and
399
+ populate the matching value field:
258
400
 
259
- The join is by URN because the two have different lifetimes — agendas
260
- exist weeks before a meeting, resolutions only after adoption.
401
+ [source,yaml]
402
+ ----
403
+ extensions:
404
+ - profile: legco
405
+ kind: vote_block
406
+ ref: urn:legco:vote-block:2024-01-15:item-5
407
+ - profile: ietf
408
+ kind: wg_meeting_meta
409
+ attributes:
410
+ - key: wg_name
411
+ type: string
412
+ value: quic
413
+ - key: draft_name
414
+ type: string
415
+ value: draft-ietf-quic-v2
416
+ - key: quorum
417
+ type: integer
418
+ integer_value: 7
419
+ - key: live_stream
420
+ type: boolean
421
+ boolean_value: true
422
+ - key: start
423
+ type: datetime
424
+ date_time_value: 2026-07-04T10:00:00Z
425
+ ----
261
426
 
262
- === Lookup accessors (parallel to the Resolution side)
427
+ Read the typed payload in Ruby via `#typed_value`:
263
428
 
264
429
  [source,ruby]
265
430
  ----
266
- meeting = Edoxen::Meeting.from_yaml(File.read('meeting.yaml'))
431
+ attr = extension.attributes.find { |a| a.key == "quorum" }
432
+ attr.type # => "integer"
433
+ attr.integer_value # => 7
434
+ attr.typed_value # => 7
435
+ ----
267
436
 
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
437
+ The pre-1.0 bare `value: String` wire shape still parses (routed into
438
+ the string variant; `type` defaults to `string`).
272
439
 
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
- ----
440
+ Consumers ignore profile extensions they don't understand. See
441
+ https://github.com/edoxen/edoxen-model/tree/main/references/profiles[the
442
+ HK LegCo profile example] for a real-world reference.
277
443
 
278
444
  == Architecture
279
445
 
@@ -284,40 +450,110 @@ collection.find_by_identifier(prefix: "CIML", number: "56")
284
450
  * Each model lives in its own file under `lib/edoxen/`. Models declare
285
451
  `attribute` only — `lutaml-model` auto-emits an identity map (wire
286
452
  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.
453
+ is present.
289
454
  * `lib/edoxen/enums.rb` is the single source of truth for every enum
290
- value used by the gem. Both the Ruby model (`attribute :type, :string,
291
- values: Enums::ACTION_TYPE`) and the schemas reference the
292
- same constants.
455
+ value used by the gem. Both the Ruby model (`attribute :kind, :string,
456
+ values: Enums::DECISION_KIND`) and the schemas reference the same
457
+ constants.
293
458
  * `lib/edoxen/error.rb` defines the unified `Edoxen::ValidationError`
294
459
  — produced by both `SchemaValidator` (with `source: :schema` or
295
460
  `:syntax`) and by model parse rescues in the CLI (with `source:
296
- :model`). Carries `file`, `line`, `column`, `pointer`,
297
- `message_text`, and `source`.
461
+ `:model`).
298
462
  * `lib/edoxen/schema_validator.rb` is intentionally small: two
299
463
  validate methods, a `LineMap` module for line-accurate error
300
464
  reporting (longest-prefix match — no path-shape hardcoding), and
301
465
  date coercion so `json_schemer` can validate `format: date` against
302
466
  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
467
+ * `lib/edoxen/cli.rb` exposes seven Thor commands (`validate`,
468
+ `normalize`, `validate-meetings`, `normalize-meetings`, `check-links`,
469
+ `unlocode`, `iata`) that delegate their shared scaffolding to the deep
307
470
  `Edoxen::Cli::Batch` module.
471
+ * `lib/edoxen/venue_validator.rb` validates polymorphic `Venue`
472
+ instances against the `unlocodes` and `iata` gems. `valid?` is pure;
473
+ `populate_from_registry!` is a separate, explicitly-mutating method
474
+ for callers that want UN/LOCODE → city/country_code back-fill.
308
475
 
309
476
  === Schema ↔ Ruby invariants
310
477
 
311
- Two pairs of runtime specs guard each side of the schema ↔ Ruby
312
- boundary:
478
+ Four pairs of runtime specs guard the schema ↔ Ruby boundary:
313
479
 
314
- * Resolution side: `schema_enum_sync_spec.rb` +
480
+ * Decision side: `schema_enum_sync_spec.rb` +
315
481
  `schema_model_sync_spec.rb` against `schema/edoxen.yaml`.
316
482
  * Meeting side: `schema_meeting_enum_sync_spec.rb` +
317
483
  `schema_meeting_model_sync_spec.rb` against `schema/meeting.yaml`.
484
+ * Canonical sync: `schema_model_canonical_sync_spec.rb` asserts the
485
+ gem schemas byte-match `../edoxen-model/schema/{decision-collection,
486
+ meeting}.yaml`.
487
+ * Lutaml sync: `lutaml_ruby_sync_spec.rb` walks every `*.lutaml`
488
+ class and enum and asserts attribute names + collection flags match
489
+ the Ruby side.
318
490
 
319
491
  Drift fails CI immediately, not at fixture-validation time.
320
492
 
493
+ == Migrating from pre-1.0 (Resolution → Decision)
494
+
495
+ Edoxen Model 1.0 is the first public release; the intermediate `v0.x` /
496
+ `v2.x` history never shipped publicly. The migration from a pre-1.0
497
+ shape is mechanical:
498
+
499
+ [cols="1,3"]
500
+ |===
501
+
502
+ | `Resolution` class
503
+ | `Decision` class with `kind: resolution`
504
+
505
+ | `ResolutionType` enum (4 values)
506
+ | `DecisionKind` enum (9 values: resolution, order, ruling, determination,
507
+ recommendation, statement, finding, opinion, other)
508
+
509
+ | `ResolutionCollection`
510
+ | `DecisionCollection`
511
+
512
+ | `Meeting.virtual: Boolean`
513
+ | `Meeting.venues: Venue[]` (polymorphic)
514
+
515
+ | `Meeting.chair` / `Meeting.secretary` (direct)
516
+ | `Meeting.officers: Officer[]` (role discriminates); `Meeting#chair`
517
+ accessor via `OfficersHost`
518
+
519
+ | `Meeting.schedule[]` (ScheduleItem)
520
+ | `Meeting.components: MeetingComponent[]` (flat)
521
+
522
+ | `Resolution` alone
523
+ | `Decision` + `Motion` + `Voting` (procedural core)
524
+
525
+ | `localizations: Localization[1..*]` per entity
526
+ | Per-field `Localized<String|Name>[0..*]` (ISO 24229); each
527
+ translatable field carries its own spelling tag inline.
528
+
529
+ | `decision.in_language(code)` / `decision.primary_localization`
530
+ | `decision.title.find { |l| l.spelling == code }` — direct array
531
+ lookup, no wrapper.
532
+
533
+ | (none)
534
+ | `Topic` + `TopicDocument` + `TopicAsset`
535
+
536
+ | (none)
537
+ | `MeetingSeries`, `Recurrence` (ISO 8601-2 §13)
538
+
539
+ | (none)
540
+ | `MeetingExtension` (profile mechanism, ISO 8601-2 §15)
541
+
542
+ | (none)
543
+ | `ContactCollection`, `VenueCollection` (scoped-URN registries)
544
+
545
+ | `Meeting.date_range` (single range)
546
+ | `Meeting.scheduled_date_range` + `Meeting.occurred_date_range`
547
+ (split scheduled vs actual; occurred uses DateTimeRange for
548
+ sub-day precision)
549
+
550
+ | (none)
551
+ | `Statement`, `Declaration`, `DateTimeRange` — BS 0:2006 §7.6
552
+ meeting-minutes concepts integrated into core. Statements and
553
+ declarations attach to `Meeting`, `Topic`, and `MinutesSection`.
554
+
555
+ |===
556
+
321
557
  == Contributing
322
558
 
323
559
  Follow the rules in `CLAUDE.md`: