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
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "date"
5
+
6
+ module Edoxen
7
+ # Walks a directory tree of Meeting and DecisionCollection YAML
8
+ # files and asserts that every cross-document URN link resolves.
9
+ #
10
+ # Invariants enforced:
11
+ #
12
+ # * Every Meeting#urn (if set) is unique across the scanned tree.
13
+ # * Every DecisionCollection#metadata.meeting_urn (if set)
14
+ # resolves to a Meeting whose urn matches.
15
+ #
16
+ # Use from CLI / scripts via the public `check` class method. Returns
17
+ # an array of `LinkError` records (empty = ok).
18
+ #
19
+ # All file IO is read-only. No edits to the YAMLs.
20
+ #
21
+ # Architecture: two passes.
22
+ #
23
+ # 1. `index_pass` walks every file, classifies its top-level shape
24
+ # via one of three MECE predicates (`meeting_collection_shape?`,
25
+ # `meeting_shape?`, `decision_collection_shape?`), and records
26
+ # URN → IndexEntry pairs. Duplicate URNs are reported here.
27
+ # 2. `verify_pass` checks every cross-document link in the indexed
28
+ # collections against the indexed meetings.
29
+ #
30
+ # Adding a new link type is one new field-reading line in
31
+ # `index_pass` (if the source side needs indexing) plus one
32
+ # iteration in `verify_pass`. Adding a new top-level shape is one
33
+ # new predicate + one new branch in `index_pass`.
34
+ class LinkChecker
35
+ # Internal: file + line pair carried by the URN indexes.
36
+ IndexEntry = Struct.new(:file, :line)
37
+
38
+ # Cross-document link failure. `line` is 1-based. `pointer` is the
39
+ # dotted in-document path that carries the bad reference (e.g.
40
+ # `metadata.meeting_urn` or `meetings[2].urn`).
41
+ LinkError = Struct.new(:file, :line, :pointer, :urn, :kind) do
42
+ def message
43
+ "#{file}:#{line}: #{pointer} references #{urn} (#{kind})"
44
+ end
45
+
46
+ alias_method :to_clickable_format, :message
47
+ end
48
+
49
+ # Walk `dir` (recursive scan of *.yaml and *.yml), classify each
50
+ # file, and assert every cross-document URN resolves to a real
51
+ # record.
52
+ #
53
+ # @return [Array<LinkError>] empty when all links resolve.
54
+ def self.check(dir)
55
+ new(dir).check
56
+ end
57
+
58
+ def initialize(dir)
59
+ @dir = dir
60
+ @meetings_by_urn = {} # urn → IndexEntry(file, line)
61
+ @collections_by_meeting_urn = {} # meeting_urn → IndexEntry(file, line)
62
+ end
63
+
64
+ def check
65
+ errors = []
66
+ index_pass(errors)
67
+ verify_pass(errors)
68
+ errors
69
+ end
70
+
71
+ private
72
+
73
+ # Phase 1: walk the tree, classify each file, populate the URN
74
+ # indexes. Reports duplicate-URN errors as they surface — the
75
+ # first claim keeps the index slot; subsequent claimants are
76
+ # rejected and flagged, so dangling-link reports always point at
77
+ # the authoritative source.
78
+ def index_pass(errors)
79
+ # `Dir.glob` order is filesystem-dependent; the duplicate-URN
80
+ # specs assert on which file is the "first claim", so sort
81
+ # explicitly for determinism.
82
+ Dir.glob(File.join(@dir, "**", "*.{yaml,yml}")).sort.each do |file| # rubocop:disable Lint/RedundantDirGlobSort
83
+ content = File.read(file)
84
+ data = safe_load_yaml(content)
85
+ next unless data.is_a?(Hash)
86
+
87
+ line_map = SchemaValidator::LineMap.build(content)
88
+
89
+ case data
90
+ when ->(d) { meeting_collection_shape?(d) }
91
+ index_meeting_collection(data, file, line_map, errors)
92
+ when ->(d) { meeting_shape?(d) }
93
+ line = line_of(line_map, "/urn")
94
+ register_meeting(data["urn"], file, line, "urn", errors) if data["urn"]
95
+ when ->(d) { decision_collection_shape?(d) }
96
+ index_decision_collection(data, file, line_map)
97
+ end
98
+ end
99
+ end
100
+
101
+ # Phase 2: every cross-document link is checked here. New link
102
+ # types land as additional iterations over the relevant index.
103
+ def verify_pass(errors)
104
+ @collections_by_meeting_urn.each do |meeting_urn, entry|
105
+ next if @meetings_by_urn.key?(meeting_urn)
106
+
107
+ errors << LinkError.new(entry.file, entry.line, "metadata.meeting_urn", meeting_urn,
108
+ "no matching Meeting")
109
+ end
110
+ end
111
+
112
+ # ---- Shape predicates (MECE) --------------------------------------
113
+ #
114
+ # The three predicates partition the universe of edoxen YAML
115
+ # shapes that participate in cross-document URN linking. Files
116
+ # that match none of them (e.g. MeetingSeries, random `_data/*.yaml`)
117
+ # are silently skipped.
118
+
119
+ # A MeetingCollection wraps its meetings under a top-level
120
+ # `meetings:` array. Its own top level carries no `identifier` or
121
+ # `type`, so this predicate is disjoint from `meeting_shape?`.
122
+ def meeting_collection_shape?(data)
123
+ data["meetings"].is_a?(Array)
124
+ end
125
+
126
+ # A standalone Meeting carries `identifier` (array) and `type`.
127
+ # MeetingSeries has `identifier` + `kind` (no `type`), so it is
128
+ # correctly excluded — MeetingSeries does not yet participate in
129
+ # cross-document URN linking.
130
+ def meeting_shape?(data)
131
+ data["identifier"].is_a?(Array) && data.key?("type")
132
+ end
133
+
134
+ # A DecisionCollection carries `decisions` or `resolutions`. The
135
+ # earlier `metadata.is_a?(Hash)` branch was too permissive — it
136
+ # matched any file with a top-level `metadata:` key, including
137
+ # unrelated YAML. A real DecisionCollection always has one of the
138
+ # two collection keys.
139
+ def decision_collection_shape?(data)
140
+ data["decisions"].is_a?(Array) || data["resolutions"].is_a?(Array)
141
+ end
142
+
143
+ # ---- Indexers -----------------------------------------------------
144
+
145
+ # Index each nested meeting that satisfies `meeting_shape?` — the
146
+ # same bar applied to standalone meeting files — so a malformed
147
+ # entry carrying only a `urn` cannot make a dangling link appear
148
+ # to resolve. Duplicate URNs are reported via `register_meeting`.
149
+ def index_meeting_collection(data, file, line_map, errors)
150
+ data["meetings"].each_with_index do |meeting, idx|
151
+ next unless meeting.is_a?(Hash) && meeting_shape?(meeting)
152
+ next unless meeting["urn"]
153
+
154
+ line = line_of(line_map, "/meetings/#{idx}/urn")
155
+ register_meeting(meeting["urn"], file, line, "meetings[#{idx}].urn", errors)
156
+ end
157
+ end
158
+
159
+ def index_decision_collection(data, file, line_map)
160
+ meeting_urn = data.dig("metadata", "meeting_urn")
161
+ return unless meeting_urn
162
+
163
+ line = line_of(line_map, "/metadata/meeting_urn")
164
+ @collections_by_meeting_urn[meeting_urn] = IndexEntry.new(file, line)
165
+ end
166
+
167
+ # Record a Meeting#urn → file/line entry. A second claim on the
168
+ # same URN is reported as a duplicate-URN error and the index
169
+ # keeps the first claim (so dangling-link reports point at the
170
+ # authoritative source, not the most-recent overwrite).
171
+ def register_meeting(urn, file, line, pointer, errors)
172
+ prior = @meetings_by_urn[urn]
173
+ if prior
174
+ errors << LinkError.new(file, line, pointer, urn,
175
+ "duplicate Meeting URN (first seen at #{prior.file}:#{prior.line})")
176
+ return
177
+ end
178
+
179
+ @meetings_by_urn[urn] = IndexEntry.new(file, line)
180
+ end
181
+
182
+ # ---- Helpers ------------------------------------------------------
183
+
184
+ def line_of(line_map, json_pointer)
185
+ SchemaValidator::LineMap.locate(json_pointer, line_map).first
186
+ end
187
+
188
+ def safe_load_yaml(content)
189
+ YAML.safe_load(content, permitted_classes: [Date, Time])
190
+ rescue Psych::Exception, ArgumentError
191
+ nil
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One language-specific value of a translatable Name field.
5
+ # Mirrors LocalizedString but carries a structured Name value rather
6
+ # than a plain String.
7
+ #
8
+ # Use case: a single Contact can be known by different names in
9
+ # different languages — `张建方` (zho-Hans) and `Jianfang Zhang`
10
+ # (acadsin:zho-Hani:Latn:2002 Pinyin romanization) are the same person.
11
+ class LocalizedName < Lutaml::Model::Serializable
12
+ attribute :spelling, :string
13
+ attribute :value, Name
14
+ attribute :extensions, MeetingExtension, collection: true
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One language-specific value of a translatable String field.
5
+ # Replaces the v2.x pattern of a separate MeetingLocalization/
6
+ # Localization/ComponentLocalization object that duplicated the
7
+ # entity's translatable fields.
8
+ #
9
+ # `spelling` is an ISO 24229 spelling/conversion system code:
10
+ # - Spelling system: {lang}-{script}[-{country}][-{extension}]
11
+ # e.g. `eng`, `zho-Hans`, `ind-Latn-pre1972`
12
+ # - Conversion system: {authority}:{source}:{target}:{identifying}
13
+ # e.g. `acadsin:zho-Hani:Latn:2002`
14
+ #
15
+ # Always verbose — single-language data uses the same
16
+ # `[{ spelling, value }]` shape as multi-language data.
17
+ class LocalizedString < Lutaml::Model::Serializable
18
+ attribute :spelling, :string
19
+ attribute :value, :string
20
+ attribute :extensions, MeetingExtension, collection: true
21
+ end
22
+ end
@@ -1,68 +1,74 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # A single Meeting — the event that produces Resolutions. Carries
5
- # identity, time, venue, officers, agenda, schedule, deadlines, and
6
- # URN links to one or more ResolutionCollection documents.
4
+ # A single Meeting (event) produces Decisions via Motions and Votings.
5
+ # Carries identity, time, polymorphic venues, officers, agenda, components,
6
+ # attendance, minutes, and URN links to one or more DecisionCollection
7
+ # documents.
7
8
  #
8
- # Meetings and ResolutionCollections are kept as separate documents
9
- # and joined by URN (Meeting.resolution_refs ↔
10
- # ResolutionCollection.metadata.meeting_urn) because they have
11
- # different lifetimes: agendas exist weeks before a meeting;
12
- # resolutions only after adoption.
9
+ # 1.0 (per-field localization, ISO 24229):
10
+ # - Removed `localizations[]` collection.
11
+ # - Added per-field LocalizedString for title, general_area,
12
+ # practical_info, note.
13
13
  class Meeting < Lutaml::Model::Serializable
14
+ include OfficersHost
15
+
14
16
  attribute :identifier, StructuredIdentifier, collection: true
15
17
  attribute :urn, :string
16
18
  attribute :ordinal, :integer
19
+ attribute :series_ref, :string
17
20
  attribute :type, :string, values: Enums::MEETING_TYPE
18
21
  attribute :status, :string, values: Enums::MEETING_STATUS
19
- attribute :year, :integer
20
-
21
- attribute :date_range, DateRange
22
+ attribute :visibility, :string, values: Enums::VISIBILITY
23
+ attribute :body_type, :string
22
24
 
23
- attribute :committee, :string
24
- attribute :committee_group, :string
25
+ attribute :title, LocalizedString, collection: true
26
+ attribute :scheduled_date_range, DateRange
27
+ attribute :occurred_date_range, DateTimeRange
28
+ attribute :recurrence, Recurrence
25
29
 
26
- attribute :venues, Location, collection: true
27
- attribute :general_area, :string
30
+ attribute :venues, Venue, collection: true
31
+ attribute :general_area, LocalizedString, collection: true
32
+ attribute :practical_info, LocalizedString, collection: true
28
33
  attribute :city, :string
29
34
  attribute :country_code, :string
30
- attribute :virtual, :boolean
31
35
 
32
- attribute :chair, Person
33
- attribute :secretary, Person
34
- attribute :host, :string
36
+ attribute :committee, :string
37
+ attribute :committee_group, :string
38
+
39
+ attribute :officers, Officer, collection: true
35
40
  attribute :hosts, HostRef, collection: true
36
41
 
37
42
  attribute :source_urls, SourceUrl, collection: true
38
43
  attribute :landing_url, :string
39
44
  attribute :registration_url, :string
45
+ attribute :note, LocalizedString, collection: true
46
+ attribute :contact, Contact
40
47
 
41
48
  attribute :agenda, Agenda
42
- attribute :schedule, ScheduleItem, collection: true
49
+ attribute :components, MeetingComponent, collection: true
43
50
  attribute :deadlines, Deadline, collection: true
44
51
 
45
- attribute :localizations, MeetingLocalization, collection: true
46
- attribute :relations, MeetingRelation, collection: true
47
- attribute :resolution_refs, :string, collection: true
52
+ attribute :attendance, Attendance, collection: true
53
+ attribute :minutes, Minutes, collection: true
54
+ attribute :declarations, Declaration, collection: true
48
55
 
49
- # Lookup by ISO 639-3 language code. Mirrors Resolution#in_language.
50
- def in_language(code, fallback: false)
51
- match = localizations&.find { |loc| loc.language_code == code.to_s }
52
- return match if match
56
+ # Outcomes (canonical location on Meeting)
57
+ attribute :decisions, Decision, collection: true
58
+ attribute :motions, Motion, collection: true
59
+ attribute :votings, Voting, collection: true
53
60
 
54
- fallback ? localizations&.first : nil
55
- end
56
-
57
- # English when available, else the first declared localization.
58
- def primary_localization
59
- in_language("eng", fallback: true)
60
- end
61
+ attribute :relations, MeetingRelation, collection: true
62
+ attribute :extensions, MeetingExtension, collection: true
61
63
 
62
- # Find an agenda item by label. Returns nil when no agenda or no
63
- # matching label.
64
64
  def find_agenda_item(label)
65
65
  agenda&.find_item(label)
66
66
  end
67
+
68
+ def city_entry
69
+ return nil if city.nil? || city.to_s.empty?
70
+
71
+ Edoxen::ReferenceData.find_unlocode(city)
72
+ end
67
73
  end
68
74
  end
@@ -1,8 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Edoxen
4
- # Top-level container for many Meetings. Parallel to
5
- # ResolutionCollection.
4
+ # Top-level container for many Meetings. Parallel to DecisionCollection.
6
5
  class MeetingCollection < Lutaml::Model::Serializable
7
6
  attribute :metadata, MeetingCollectionMetadata
8
7
  attribute :meetings, Meeting, collection: true
@@ -2,10 +2,12 @@
2
2
 
3
3
  module Edoxen
4
4
  # Top-level wrapper for many Meetings in a single YAML file. Parallel
5
- # to ResolutionCollection. The metadata block carries display-level
5
+ # to DecisionCollection. The metadata block carries display-level
6
6
  # info (title, source); per-meeting identity lives on each Meeting.
7
7
  class MeetingCollectionMetadata < Lutaml::Model::Serializable
8
- attribute :title, :string
8
+ include BodyVocabularyHost
9
+
10
+ attribute :title, LocalizedString, collection: true
9
11
  attribute :source, :string
10
12
  end
11
13
  end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingComponent — flat sub-event of a Meeting.
5
+ # Replaces legacy ScheduleItem.
6
+ #
7
+ # 1.0 (per-field localization, ISO 24229):
8
+ # - Removed `localizations[]` collection.
9
+ # - Per-field LocalizedString for title, description, time_label.
10
+ class MeetingComponent < Lutaml::Model::Serializable
11
+ include OfficersHost
12
+
13
+ attribute :identifier, :string
14
+ attribute :urn, :string
15
+ attribute :kind, :string, values: Enums::COMPONENT_KIND
16
+ # Identifier of the body this component belongs to. Optional;
17
+ # usually inherited from the parent Meeting.
18
+ attribute :body_type, :string
19
+ attribute :title, LocalizedString, collection: true
20
+ attribute :description, LocalizedString, collection: true
21
+ attribute :starts_at, :date_time
22
+ attribute :ends_at, :date_time
23
+ attribute :time_label, LocalizedString, collection: true
24
+ attribute :venue_refs, :string, collection: true
25
+ attribute :officers, Officer, collection: true
26
+ attribute :agenda_ref, :string
27
+ attribute :minutes_ref, :string
28
+ attribute :attendance_refs, :string, collection: true
29
+ attribute :extensions, MeetingExtension, collection: true
30
+
31
+ def duration_seconds
32
+ return nil unless starts_at && ends_at
33
+
34
+ ends_at.to_time - starts_at.to_time
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingExtension — the profile mechanism (ISO 8601-2 §15).
5
+ # Every core entity has an `extensions: MeetingExtension[0..*]` slot.
6
+ # Adopters register their own profile namespace (e.g. "legco",
7
+ # "us-congress", "ietf") and define `kind` values within it.
8
+ #
9
+ # Field semantics (tightened 1.0, per 1.0 design review):
10
+ #
11
+ # profile — the profile namespace (lowercase, hyphen-separated).
12
+ # kind — discriminator within the profile.
13
+ # ref — URN of the profile document this extension references.
14
+ # attributes — typed key/value pairs (ExtensionAttribute).
15
+ #
16
+ # Recursion (`extensions: MeetingExtension[0..*]`) was removed in
17
+ # 1.0 — no documented use case. Profiles needing nesting can encode
18
+ # it via dotted keys ("vote.count", "vote.method") in `attributes[]`.
19
+ class MeetingExtension < Lutaml::Model::Serializable
20
+ attribute :profile, :string
21
+ attribute :kind, :string
22
+ attribute :ref, :string
23
+ attribute :attributes, ExtensionAttribute, collection: true
24
+ end
25
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Edoxen
4
4
  # Identifier of a meeting (venue + date). Singular — the meeting a
5
- # particular Resolution belongs to.
5
+ # particular Decision belongs to.
6
6
  class MeetingIdentifier < Lutaml::Model::Serializable
7
7
  attribute :venue, :string
8
8
  attribute :date, :date
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # MeetingSeries — parent of recurring Meeting instances.
5
+ # Crossref `proceedings-series`, IETF meeting series, Apache Board
6
+ # meetings, etc. Carries a `Recurrence` (ISO 8601-2 §13) rule and
7
+ # a list of member Meeting URNs.
8
+ class MeetingSeries < Lutaml::Model::Serializable
9
+ attribute :identifier, StructuredIdentifier, collection: true
10
+ # Identifier of the body this series represents (e.g. 'ciml').
11
+ # Meetings and Decisions belonging to this series inherit the body
12
+ # via the series_ref join. Optional; most consumers have a single
13
+ # body and leave this unset.
14
+ attribute :body_type, :string
15
+ attribute :urn, :string
16
+ attribute :name, LocalizedString, collection: true
17
+ attribute :description, LocalizedString, collection: true
18
+ attribute :recurrence, Recurrence
19
+ attribute :term, :string
20
+ attribute :contact, Contact
21
+ attribute :hosts, HostRef, collection: true
22
+ attribute :kind, :string
23
+ attribute :meeting_refs, :string, collection: true
24
+ attribute :extensions, MeetingExtension, collection: true
25
+ end
26
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # The narrative record of a Meeting — what was said, by whom, in
5
+ # what order, with what outcome. Mirrors
6
+ # edoxen-model/models/minutes.lutaml (P2.8 from
7
+ # TODO.meeting-agenda/10-future-enhancements.md).
8
+ #
9
+ # Distinct from Agenda (the business order drafted before the
10
+ # meeting) and from DecisionCollection (the formal decisions
11
+ # adopted). The Minutes are the running record.
12
+ #
13
+ # Sections are typically keyed by agenda-item number so consumers
14
+ # can join Minutes ↔ AgendaItem ↔ Decision by `number`/`label`.
15
+ class Minutes < Lutaml::Model::Serializable
16
+ attribute :identifier, StructuredIdentifier, collection: true
17
+ attribute :urn, :string
18
+ attribute :spelling, :string
19
+ attribute :source_doc, :string
20
+ attribute :source_pages, :string
21
+ attribute :sections, MinutesSection, collection: true
22
+
23
+ def find_section(number)
24
+ return nil if number.nil?
25
+
26
+ target = number.to_s
27
+ sections&.find { |s| s.number.to_s == target }
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # One section of a Meeting's minutes — typically tied to an agenda
5
+ # item by its `number` field. Carries the narrative as Markdown
6
+ # (the format the GLM-OCR pipeline emits) plus optional page range
7
+ # for provenance back to the source PDF.
8
+ #
9
+ # Localization lives on the parent Minutes document, not on each
10
+ # section — Minutes has a per-document language_code; the section
11
+ # lookup is `Minutes#find_section(number)`. Earlier copies of this
12
+ # class inherited `in_language`/`primary_localization` from
13
+ # ScheduleItem/Meeting, but those methods referenced an undeclared
14
+ # `localizations` attribute and raised NameError on every call.
15
+ # Removed 2026-07-05.
16
+ class MinutesSection < Lutaml::Model::Serializable
17
+ attribute :number, :string
18
+ attribute :title, LocalizedString, collection: true
19
+ attribute :narrative, LocalizedString, collection: true
20
+ attribute :page_start, :integer
21
+ attribute :page_end, :integer
22
+ attribute :references, Reference, collection: true
23
+ attribute :statements, Statement, collection: true
24
+ attribute :topic_ref, :string
25
+ end
26
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Motion — a procedural act that brings a Decision. "I move that..."
5
+ # Distinct from a Decision (formal outcome) and from a TopicDocument
6
+ # (written text). A Motion does NOT require any document.
7
+ #
8
+ # State machine (MotionStatus):
9
+ # introduced → seconded → debating → question_put → voting
10
+ # ├── carried → resulting_decision set
11
+ # ├── negatived
12
+ # ├── withdrawn
13
+ # └── lapsed
14
+ class Motion < Lutaml::Model::Serializable
15
+ attribute :identifier, :string
16
+ attribute :urn, :string
17
+ attribute :text, LocalizedString, collection: true
18
+ attribute :mover, Person
19
+ attribute :seconders, Person, collection: true
20
+ attribute :status, :string, values: Enums::MOTION_STATUS
21
+ attribute :introduced_at, :date_time
22
+ attribute :proposed_decision, :string
23
+ attribute :resulting_decision, :string
24
+ # Pilot EntityRef field (1.0, 1.0 design review). Parallel to
25
+ # `resulting_decision` (String). Prefer the typed form in new code;
26
+ # the bare String form is removed in 1.0.
27
+ attribute :resulting_decision_ref, EntityRef
28
+ attribute :votings, :string, collection: true
29
+ attribute :extensions, MeetingExtension, collection: true
30
+
31
+ def carried?
32
+ status == "carried"
33
+ end
34
+
35
+ def pending?
36
+ status && !Enums::MOTION_TERMINAL.include?(status)
37
+ end
38
+
39
+ # --- 1.0 derivation accessor (1.0 design review) --------------------
40
+ # Storage side: Voting.on_motion (SSOT for the Motion→Voting
41
+ # relationship). This computed method returns the Voting instances
42
+ # in `meeting` whose `on_motion` points at this Motion's URN.
43
+ # The stored `votings[]` field remains on the wire for back-compat
44
+ # through v2.x; 1.0 removes it and this becomes the only path.
45
+ def votings_in(meeting:)
46
+ return [] unless meeting && urn
47
+
48
+ meeting.votings.select { |voting| voting.on_motion == urn }
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Structured personal/organisational name. VCARD conventions
5
+ # (RFC 6350): separate structured components (N) from a pre-formatted
6
+ # display string (FN). Either or both may be populated.
7
+ #
8
+ # `formatted` is the display string when callers have it pre-built;
9
+ # the structured fields (`family`, `given`, `additional`, `prefix`,
10
+ # `suffix`) let adopters store parsed components for sorting, indexing,
11
+ # or locale-aware rendering.
12
+ class Name < Lutaml::Model::Serializable
13
+ attribute :formatted, :string
14
+ attribute :family, :string
15
+ attribute :given, :string
16
+ attribute :additional, :string
17
+ attribute :prefix, :string
18
+ attribute :suffix, :string
19
+ attribute :extensions, MeetingExtension, collection: true
20
+
21
+ # Convenience: returns `formatted` if present, else builds one from
22
+ # the structured components. Useful for callers that always want a
23
+ # display string regardless of how the name was authored.
24
+ def display
25
+ formatted || [prefix, given, additional, family, suffix]
26
+ .reject { |s| s.to_s.empty? }.join(" ")
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Officer — a person holding a structural role in a Meeting.
5
+ # Replaces legacy Meeting#chair and Meeting#secretary direct shortcuts.
6
+ # One list, role discriminates. Open for adopter extension via `other`.
7
+ class Officer < Lutaml::Model::Serializable
8
+ attribute :role, :string, values: Enums::OFFICER_ROLE
9
+ attribute :person, Person
10
+ attribute :term_start, :date
11
+ attribute :term_end, :date
12
+ attribute :extensions, MeetingExtension, collection: true
13
+
14
+ def current?(date = Date.today)
15
+ (term_start.nil? || date >= term_start) &&
16
+ (term_end.nil? || date <= term_end)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Edoxen
4
+ # Mixed into entities that carry an `officers` collection. Provides
5
+ # `officers_with_role(role)` and the `chair` accessor so Meeting and
6
+ # MeetingComponent share a single implementation.
7
+ #
8
+ # The including class must declare an `officers` attribute whose
9
+ # entries expose `role` and `person`.
10
+ module OfficersHost
11
+ def officers_with_role(role)
12
+ (officers || []).select { |o| o.role == role.to_s }
13
+ end
14
+
15
+ def chair
16
+ officers_with_role("chair").first&.person
17
+ end
18
+ end
19
+ end