edoxen 0.7.2 → 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 (86) 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/README.adoc +414 -182
  6. data/edoxen.gemspec +10 -10
  7. data/lib/edoxen/action.rb +5 -4
  8. data/lib/edoxen/agenda.rb +4 -7
  9. data/lib/edoxen/agenda_item.rb +12 -5
  10. data/lib/edoxen/approval.rb +2 -2
  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 +40 -0
  14. data/lib/edoxen/cli.rb +175 -115
  15. data/lib/edoxen/consideration.rb +6 -4
  16. data/lib/edoxen/contact.rb +35 -0
  17. data/lib/edoxen/contact_collection.rb +26 -0
  18. data/lib/edoxen/contact_identifier.rb +13 -0
  19. data/lib/edoxen/contact_method.rb +16 -0
  20. data/lib/edoxen/date_time_range.rb +16 -0
  21. data/lib/edoxen/deadline.rb +1 -1
  22. data/lib/edoxen/decision.rb +57 -0
  23. data/lib/edoxen/decision_collection.rb +10 -0
  24. data/lib/edoxen/decision_date.rb +9 -0
  25. data/lib/edoxen/decision_metadata.rb +27 -0
  26. data/lib/edoxen/decision_relation.rb +11 -0
  27. data/lib/edoxen/declaration.rb +25 -0
  28. data/lib/edoxen/entity_ref.rb +74 -0
  29. data/lib/edoxen/enums.rb +123 -9
  30. data/lib/edoxen/error.rb +1 -1
  31. data/lib/edoxen/extension_attribute.rb +78 -0
  32. data/lib/edoxen/host_ref.rb +1 -1
  33. data/lib/edoxen/link_checker.rb +143 -34
  34. data/lib/edoxen/localized_name.rb +16 -0
  35. data/lib/edoxen/localized_string.rb +22 -0
  36. data/lib/edoxen/meeting.rb +34 -42
  37. data/lib/edoxen/meeting_collection.rb +1 -2
  38. data/lib/edoxen/meeting_collection_metadata.rb +4 -2
  39. data/lib/edoxen/meeting_component.rb +37 -0
  40. data/lib/edoxen/meeting_extension.rb +25 -0
  41. data/lib/edoxen/meeting_identifier.rb +1 -1
  42. data/lib/edoxen/meeting_series.rb +26 -0
  43. data/lib/edoxen/minutes.rb +3 -4
  44. data/lib/edoxen/minutes_section.rb +12 -13
  45. data/lib/edoxen/motion.rb +51 -0
  46. data/lib/edoxen/name.rb +29 -0
  47. data/lib/edoxen/officer.rb +19 -0
  48. data/lib/edoxen/officers_host.rb +19 -0
  49. data/lib/edoxen/person.rb +5 -9
  50. data/lib/edoxen/physical_venue.rb +15 -0
  51. data/lib/edoxen/recurrence.rb +27 -0
  52. data/lib/edoxen/recurrence_by_day.rb +10 -0
  53. data/lib/edoxen/reference.rb +1 -1
  54. data/lib/edoxen/reference_data.rb +34 -44
  55. data/lib/edoxen/schema_validator.rb +3 -4
  56. data/lib/edoxen/source_url.rb +4 -4
  57. data/lib/edoxen/statement.rb +21 -0
  58. data/lib/edoxen/structured_identifier.rb +2 -2
  59. data/lib/edoxen/topic.rb +39 -0
  60. data/lib/edoxen/topic_asset.rb +14 -0
  61. data/lib/edoxen/topic_document.rb +16 -0
  62. data/lib/edoxen/venue.rb +79 -0
  63. data/lib/edoxen/venue_collection.rb +18 -0
  64. data/lib/edoxen/venue_validator.rb +74 -0
  65. data/lib/edoxen/version.rb +1 -1
  66. data/lib/edoxen/virtual_venue.rb +15 -0
  67. data/lib/edoxen/vote_record.rb +17 -13
  68. data/lib/edoxen/voting.rb +45 -0
  69. data/lib/edoxen/voting_counts.rb +23 -0
  70. data/lib/edoxen.rb +47 -15
  71. data/schema/edoxen.yaml +1376 -246
  72. data/schema/meeting.yaml +1207 -272
  73. data/sig/edoxen.rbs +581 -1
  74. metadata +62 -22
  75. data/lib/edoxen/_metadata.rb.deprecated +0 -57
  76. data/lib/edoxen/localization.rb +0 -18
  77. data/lib/edoxen/location.rb +0 -15
  78. data/lib/edoxen/meeting_localization.rb +0 -15
  79. data/lib/edoxen/resolution.rb +0 -43
  80. data/lib/edoxen/resolution_collection.rb +0 -10
  81. data/lib/edoxen/resolution_date.rb +0 -11
  82. data/lib/edoxen/resolution_metadata.rb +0 -30
  83. data/lib/edoxen/resolution_relation.rb +0 -11
  84. data/lib/edoxen/resolution_set.rb +0 -17
  85. data/lib/edoxen/schedule_item.rb +0 -27
  86. 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,150 +47,278 @@ 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
56
- 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}"
57
61
 
58
- loc.actions.each do |action|
59
- puts " - #{action.type}: #{action.message}"
60
- end
62
+ fra = decision.title.find { |l| l.spelling == "fra" }&.value
63
+ puts " [fra] #{fra}" if fra
61
64
 
62
- # Or look up a specific language explicitly:
63
- fra = resolution.in_language("fra")
64
- 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
65
69
  end
66
70
 
67
71
  # Round-trip back to YAML
68
72
  puts collection.to_yaml
69
73
  ----
70
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
+
71
116
  == Data model
72
117
 
73
118
  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`.
119
+ + `schema/meeting.yaml` (JSON-Schema). Both are kept in lockstep by
120
+ runtime sync specs.
76
121
 
77
122
  [source]
78
123
  ----
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[]
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
197
+ ----
198
+
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)
208
+
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.
213
+
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`).
221
+
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
102
233
  ----
103
234
 
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`.
235
+ === Polymorphic Venue
108
236
 
109
- === Lookup accessors
237
+ A `Venue` is one flat class on the wire; the `kind` field discriminates
238
+ physical vs virtual.
110
239
 
111
- Direct iteration over `localizations[]` is discouraged the
112
- language-preference policy lives behind two accessors:
240
+ * Physical: UN/LOCODE + IATA + address + geo-coordinates.
241
+ * Virtual: URI + iCalendar-style features + access details.
113
242
 
114
- * `Resolution#in_language(code, fallback: false)` exact match by
115
- ISO 639-3 code, or `nil` (or the first declared localization when
116
- `fallback: true`).
117
- * `Resolution#primary_localization` English when available, else
118
- the first declared localization. Mirrors the glossarist
119
- LocalizedConcept "preferred language" notion.
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
120
251
 
121
252
  [source,ruby]
122
253
  ----
123
- resolution.in_language("fra") # => Localization or nil
124
- resolution.in_language("deu", fallback: true)
125
- resolution.primary_localization # English-or-first
254
+ meeting.chair # => Person (via officers + role)
255
+ meeting.officers_with_role(:treasurer) # => [Officer]
126
256
  ----
127
257
 
128
- == 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:
129
265
 
130
266
  [source,yaml]
131
267
  ----
132
- resolutions:
268
+ decisions:
133
269
  - identifier:
134
270
  - prefix: CIML
135
271
  number: "2025-44"
136
- doi: 10.63493/resolutions/ciml202544
272
+ kind: resolution
273
+ status: decided
274
+ doi: 10.63493/decisions/ciml202544
137
275
  agenda_item: "16.2"
138
276
  dates:
139
277
  - date: 2025-10-13
140
- type: discussed
141
- localizations:
142
- - language_code: eng
143
- script: Latn
144
- title: Decision on the renewal of the contract of Mr Anthony Donnellan
145
- subject: CIML
146
- actions:
147
- - type: decides
148
- date_effective:
149
- date: 2025-10-13
150
- type: adoption
151
- 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: |
152
297
  The Committee decides to renew the contract of
153
298
  Mr Anthony Donnellan as BIML Director.
154
- - language_code: fra
155
- script: Latn
156
- title: Décision sur le renouvellement du contrat de M. Anthony Donnellan
157
- subject: CIML
158
- actions:
159
- - type: decides
160
- date_effective:
161
- date: 2025-10-13
162
- type: adoption
163
- message: |
299
+ - spelling: fra
300
+ value: |
164
301
  Le Comité décide de renouveler le contrat de
165
302
  M. Anthony Donnellan en tant que Directeur du BIML.
166
303
  ----
167
304
 
168
305
  == Command-line interface
169
306
 
170
- The `edoxen` executable exposes four commands — two for Resolution
171
- collections, two for Meeting/Agenda documents.
307
+ The `edoxen` executable exposes six commands.
172
308
 
173
- === `validate` — JSON-Schema validation + model parsing for Resolutions
309
+ === `validate` — schema + model validation for Decisions
174
310
 
175
311
  [source,sh]
176
312
  ----
177
313
  $ edoxen validate "spec/fixtures/*.yaml"
178
314
  ----
179
315
 
180
- Runs both `Edoxen::SchemaValidator` and `Edoxen::ResolutionCollection.from_yaml`
181
- 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`,
182
318
  `required`, enum, and pattern violations; the model catches structural
183
319
  problems the schema can't express.
184
320
 
185
- Exit code is non-zero if any file fails.
186
-
187
- === `normalize` — round-trip Resolution YAML through the model
321
+ === `normalize` round-trip Decision YAML through the model
188
322
 
189
323
  [source,sh]
190
324
  ----
@@ -192,92 +326,120 @@ $ edoxen normalize "spec/fixtures/*.yaml" --output clean/
192
326
  $ edoxen normalize legacy.yaml --inplace
193
327
  ----
194
328
 
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).
329
+ Either `--output DIR` or `--inplace` is required (mutually exclusive).
198
330
 
199
- === `validate-meetings` — validate Meeting/Agenda YAML
331
+ === `validate-meetings` / `normalize-meetings`
332
+
333
+ Same shape, for Meeting / MeetingCollection / MeetingSeries YAML against
334
+ `schema/meeting.yaml`.
200
335
 
201
336
  [source,sh]
202
337
  ----
203
338
  $ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"
339
+ $ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
204
340
  ----
205
341
 
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.
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
346
+
347
+ Walks `DIRECTORY` recursively, classifies every `*.yaml` / `*.yml` as a
348
+ Meeting, MeetingCollection, or DecisionCollection, and reports:
209
349
 
210
- === `normalize-meetings` round-trip Meeting YAML through the model
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.
211
355
 
212
356
  [source,sh]
213
357
  ----
214
- $ 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))
215
366
  ----
216
367
 
217
- Same shape as `normalize`, but for Meeting/Agenda documents.
218
-
219
- == Meeting & Agenda model
220
-
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`.
368
+ === `unlocode CODE` / `iata CODE` registry lookup
224
369
 
225
- [source]
370
+ [source,sh]
226
371
  ----
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)
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
254
382
  ----
255
383
 
256
- === Linking Meetings to ResolutionCollections
384
+ == Profile mechanism (ISO 8601-2 §15)
257
385
 
258
- Meetings and ResolutionCollections are separate documents joined by URN:
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:
259
390
 
260
- - `Meeting.resolution_refs: [urn:...]`
261
- - `ResolutionCollection.metadata.meeting_urn: 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`).
262
396
 
263
- The join is by URN because the two have different lifetimes — agendas
264
- exist weeks before a meeting, resolutions only after adoption.
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:
400
+
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
+ ----
265
426
 
266
- === Lookup accessors (parallel to the Resolution side)
427
+ Read the typed payload in Ruby via `#typed_value`:
267
428
 
268
429
  [source,ruby]
269
430
  ----
270
- 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
+ ----
271
436
 
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
437
+ The pre-1.0 bare `value: String` wire shape still parses (routed into
438
+ the string variant; `type` defaults to `string`).
276
439
 
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
- ----
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.
281
443
 
282
444
  == Architecture
283
445
 
@@ -288,40 +450,110 @@ collection.find_by_identifier(prefix: "CIML", number: "56")
288
450
  * Each model lives in its own file under `lib/edoxen/`. Models declare
289
451
  `attribute` only — `lutaml-model` auto-emits an identity map (wire
290
452
  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.
453
+ is present.
293
454
  * `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.
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.
297
458
  * `lib/edoxen/error.rb` defines the unified `Edoxen::ValidationError`
298
459
  — produced by both `SchemaValidator` (with `source: :schema` or
299
460
  `:syntax`) and by model parse rescues in the CLI (with `source:
300
- :model`). Carries `file`, `line`, `column`, `pointer`,
301
- `message_text`, and `source`.
461
+ `:model`).
302
462
  * `lib/edoxen/schema_validator.rb` is intentionally small: two
303
463
  validate methods, a `LineMap` module for line-accurate error
304
464
  reporting (longest-prefix match — no path-shape hardcoding), and
305
465
  date coercion so `json_schemer` can validate `format: date` against
306
466
  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
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
311
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.
312
475
 
313
476
  === Schema ↔ Ruby invariants
314
477
 
315
- Two pairs of runtime specs guard each side of the schema ↔ Ruby
316
- boundary:
478
+ Four pairs of runtime specs guard the schema ↔ Ruby boundary:
317
479
 
318
- * Resolution side: `schema_enum_sync_spec.rb` +
480
+ * Decision side: `schema_enum_sync_spec.rb` +
319
481
  `schema_model_sync_spec.rb` against `schema/edoxen.yaml`.
320
482
  * Meeting side: `schema_meeting_enum_sync_spec.rb` +
321
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.
322
490
 
323
491
  Drift fails CI immediately, not at fixture-validation time.
324
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
+
325
557
  == Contributing
326
558
 
327
559
  Follow the rules in `CLAUDE.md`: