Purpose
Edoxen is a Ruby library for the canonical Edoxen information model — a generic meeting, agenda, motion, voting, and decision model that covers standards bodies (ISO, IEC, ITU, BIPM, OIML, ILO), parliamentary bodies (UK Hansard, HK LegCo, US Congress), technical community meetings (IETF, W3C, Apache), academic conferences (Crossref-registered), corporate boards, and generic web/virtual meetings.
Built on top of the lutaml-model
serialization framework. The information model is defined in
LutaML UML files
(one .lutaml per concept); this gem mirrors that model exactly —
attribute declarations, enum values, and field shapes — so anything
expressible in LutaML is constructable, serializable, and validatable in
Ruby.
Generic core + profile extensions
The core schema is the intersection of all domains. Domain-specific
concepts (Bill, Witness, Petition, Address, Quorum Bell, etc.) live in
profile extensions via the MeetingExtension slot every core entity
carries — the ISO 8601-2 §15 profile mechanism. Adopters register a
profile namespace (legco, us-congress, ietf, oiml) and define
extension kinds within it; consumers ignore profiles they don’t
understand.
Installation
gem 'edoxen'
Then bundle install, or gem install edoxen standalone.
Quick start
require 'edoxen'
yaml = File.read('decisions.yaml')
collection = Edoxen::DecisionCollection.from_yaml(yaml)
collection.decisions.each do |decision|
id = decision.identifier.first
puts "#{id.prefix}/#{id.number}"
loc = decision.primary_localization
puts " [#{loc.language_code}/#{loc.script}] #{loc.title}"
loc.actions.each do |action|
puts " - #{action.type}: #{action.}"
end
end
# Or look up a specific language explicitly:
fra = decision.in_language("fra")
puts " FR: #{fra.title}" if fra
# Round-trip back to YAML
puts collection.to_yaml
The five faces of a meeting
A meeting has five concerns, each modelled as a distinct first-class entity rather than a column on a flat table:
Concern |
What it captures |
Top-level |
Decisions |
Formal outcomes adopted by the meeting: resolution, order, ruling, determination, recommendation, statement, finding, opinion. |
|
Motions + Votings |
The procedural record — who moved what, how the question was put, how members voted, what the chair declared. |
|
Topics |
The subjects of discussion. A topic carries documents, assets, references, and URN links to the motions and decisions it produced. |
|
Meetings |
The event itself: identifier, dates, polymorphic venues, officers, agenda, components, attendance, minutes. |
|
Series |
The recurring parent of meetings — annual plenaries, monthly board meetings, IETF meeting series, etc. |
(top-level standalone) |
A Meeting carries its decisions, motions, and votings directly. The DecisionCollection is the standalone form for publishing decisions without meeting-level detail.
Data model
The full model is in lib/edoxen/*.rb (Ruby) and schema/edoxen.yaml
+ schema/meeting.yaml (JSON-Schema). Both are kept in lockstep by
runtime sync specs.
DecisionCollection MeetingCollection
├── metadata: DecisionMetadata ├── metadata: MeetingCollectionMetadata
│ ├── title / title_localized[] └── meetings: Meeting[]
│ ├── date, source ├── identifier, urn, ordinal
│ ├── source_urls[] ├── series_ref (→ MeetingSeries)
│ ├── city (UN/LOCODE), country_code ├── type, status, visibility
│ └── meeting_urn (back-ref to Meeting) ├── date_range, recurrence
└── decisions: Decision[] ├── venues: Venue[] (polymorphic)
├── identifier: StructuredIdentifier[1..*]| ├── kind: physical | virtual
├── kind (DecisionKind) | ├── name, label, capacity
├── status (DecisionStatus) | ├── (physical) unlocode, iata_code,
├── doi, urn, agenda_item | │ address, country_code, lat, lon
├── dates: DecisionDate[] | └── (virtual) uri, features, passcode,
├── categories, relations, urls ├── officers: Officer[] (role + person + term)
├── brought_by_motions[] ├── hosts: HostRef[] (typed)
├── about_topics[] ├── source_urls[], landing_url, registration_url
├── made_in_component ├── agenda: Agenda (items, status)
└── localizations: Localization[1..*] ├── components: MeetingComponent[]
├── language_code, script │ (track, session, debate, breakout, keynote,
├── title, subject, message │ opening, closing, break, reception, ...)
├── considerations: Consideration[] ├── deadlines: Deadline[]
├── approvals: Approval[] ├── attendance: Attendance[]
└── actions: Action[] │ (status, role, response, proxy_for)
├── minutes: Minutes[]
Motion ├── motions: Motion[]
├── identifier, urn │ (status: introduced → seconded → debating
├── text, mover, seconders[] │ → question_put → voting → carried/negatived)
├── status (MotionStatus) ├── votings: Voting[]
├── introduced_at │ (status: called → in_progress → decided;
├── proposed_decision, resulting_decision │ voting_method, counts, casting_vote,
└── votings[] │ vote_records[])
├── decisions: Decision[] (inline)
Voting ├── localizations: MeetingLocalization[]
├── status (VotingStatus) ├── relations: MeetingRelation[]
├── voting_method (VotingMethod) └── extensions: MeetingExtension[]
├── result (VotingOutcome)
├── counts: VotingCounts {ayes, noes,
│ abstentions, absent}
├── casting_vote: VoteRecord
└── vote_records: VoteRecord[]
MeetingExtension (profile mechanism)
├── profile (e.g. "legco", "ietf", "us-congress")
├── kind (in-profile discriminator)
├── ref (URN to external profile document)
└── attributes: ExtensionAttribute[]
├── key
├── type (string | integer | float | boolean | date | datetime)
└── value / integer_value / float_value / boolean_value /
date_value / date_time_value (one of, per `type`)
Every core entity has an extensions: MeetingExtension[0..*] slot.
Adopters extend the generic core without modifying it.
ExtensionAttribute is polymorphic on value type — consumers read the
typed payload via #typed_value without re-parsing strings back into
Int/Float/Bool/Date. The v2.0 bare value: String wire shape still
parses (routed into the string variant).
Polymorphic Venue
A Venue is one flat class on the wire; the kind field discriminates
physical vs virtual and validators enforce that fields match kind.
-
Physical: UN/LOCODE + IATA + address + geo-coordinates.
-
Virtual: URI + iCalendar-style features + access details.
A meeting can have multiple venues of either kind (hybrid meetings,
multi-room conferences bridged by video, etc.). UN/LOCODE and IATA codes
are validated against the canonical unlocodes and iata gems by
Edoxen::VenueValidator.
Lookup accessors
Direct iteration over localizations[] is discouraged — the
language-preference policy lives behind two accessors:
-
Decision#in_language(code, fallback: false)— exact match by ISO 639-3 code, ornil(or the first declared localization whenfallback: true). -
Decision#primary_localization— English when available, else the first declared localization.
The same pair exists on Meeting (MeetingLocalization).
decision.in_language("fra") # => Localization or nil
decision.in_language("deu", fallback: true)
decision.primary_localization # English-or-first
meeting.chair # => Person (via officers + role)
meeting.secretary # => Person
meeting.officers_with_role(:treasurer) # => [Officer]
meeting.hybrid? # true if both physical + virtual venues
meeting.physical_only? / meeting.virtual_only?
validator = Edoxen::VenueValidator.new(venue)
validator.valid? # checks UN/LOCODE + IATA against the gems
validator.validate(auto_populate: true) # also fills city/country_code from UN/LOCODE
Multilingual by default
Per-language content lives inside localizations[] siblings — one
entry per language. Admin fields (identifier, doi, urn, dates,
meeting) are declared once on the parent; translatable content
(title, subject, considerations, actions, approvals) lives
inside each Localization.
This pattern — borrowed from the glossarist project — lets translators work on each language in isolation while comparing them side-by-side in the same file. Drift shows up on git diff, not on a translator’s spreadsheet.
decisions:
- identifier:
- prefix: CIML
number: "2025-44"
kind: resolution
status: decided
doi: 10.63493/decisions/ciml202544
agenda_item: "16.2"
dates:
- date: 2025-10-13
type: adoption
localizations:
- language_code: eng
script: Latn
title: Decision on the renewal of the contract of Mr Anthony Donnellan
subject: CIML
actions:
- type: decides
date_effective:
date: 2025-10-13
type: adoption
message: |
The Committee decides to renew the contract of
Mr Anthony Donnellan as BIML Director.
- language_code: fra
script: Latn
title: Décision sur le renouvellement du contrat de M. Anthony Donnellan
subject: CIML
actions:
- type: decides
date_effective:
date: 2025-10-13
type: adoption
message: |
Le Comité décide de renouveler le contrat de
M. Anthony Donnellan en tant que Directeur du BIML.
Command-line interface
The edoxen executable exposes six commands.
validate — schema + model validation for Decisions
$ edoxen validate "spec/fixtures/*.yaml"
Runs both Edoxen::SchemaValidator and Edoxen::DecisionCollection.from_yaml
against each matching file. Schema catches additionalProperties,
required, enum, and pattern violations; the model catches structural
problems the schema can’t express.
normalize — round-trip Decision YAML through the model
$ edoxen normalize "spec/fixtures/*.yaml" --output clean/
$ edoxen normalize legacy.yaml --inplace
Either --output DIR or --inplace is required (mutually exclusive).
validate-meetings / normalize-meetings
Same shape, for Meeting/Agenda YAML against schema/meeting.yaml.
$ edoxen validate-meetings "spec/fixtures/meetings/*.yaml"
$ edoxen normalize-meetings "spec/fixtures/meetings/*.yaml" --output clean/
The schema’s root is oneOf — both single-Meeting and MeetingCollection
shapes are accepted.
unlocode CODE / iata CODE — registry lookup
$ edoxen unlocode FRPAR
UN/LOCODE: FRPAR
Name: Paris
Country: FR
...
$ edoxen iata JFK
IATA: JFK
Name: John F. Kennedy International Airport
Country: US
Profile mechanism (ISO 8601-2 §15)
Every core entity has an extensions: MeetingExtension[0..*] slot.
Adopters register a profile namespace and define extension kinds within
it. MeetingExtension carries three identity fields plus a typed
attributes[] list:
-
profile— the namespace (lowercase, hyphen-separated). -
kind— discriminator within the profile. -
ref— URN of an external profile document, when the data lives elsewhere. -
attributes[]— typed key/value pairs (ExtensionAttribute).
ExtensionAttribute is polymorphic on value type (v2.1+). Set type
to one of string | integer | float | boolean | date | datetime and
populate the matching value field:
extensions:
- profile: legco
kind: vote_block
ref: urn:legco:vote-block:2024-01-15:item-5
- profile: ietf
kind: wg_meeting_meta
attributes:
- key: wg_name
type: string
value: quic
- key: draft_name
type: string
value: draft-ietf-quic-v2
- key: quorum
type: integer
integer_value: 7
- key: live_stream
type: boolean
boolean_value: true
- key: start
type: datetime
date_time_value: 2026-07-04T10:00:00Z
Read the typed payload in Ruby via #typed_value:
attr = extension.attributes.find { |a| a.key == "quorum" }
attr.type # => "integer"
attr.integer_value # => 7
attr.typed_value # => 7
The v2.0 bare value: String wire shape still parses (routed into
the string variant; type defaults to string).
Consumers ignore profile extensions they don’t understand. See the HK LegCo profile example for a real-world reference.
Architecture
-
lib/edoxen.rbis the single entry-point. It configuresLutaml::Model::Configandautoload`s every model class and service. No `require_relativein library code — cross-references resolve through Ruby autoload. -
Each model lives in its own file under
lib/edoxen/. Models declareattributeonly —lutaml-modelauto-emits an identity map (wire name = snake_case attribute name) when no explicitkey_valueblock is present. -
lib/edoxen/enums.rbis the single source of truth for every enum value used by the gem. Both the Ruby model (attribute :kind, :string, values: Enums::DECISION_KIND) and the schemas reference the same constants. -
lib/edoxen/error.rbdefines the unifiedEdoxen::ValidationError— produced by bothSchemaValidator(withsource: :schemaor:syntax) and by model parse rescues in the CLI (withsource: :model). -
lib/edoxen/schema_validator.rbis intentionally small: two validate methods, aLineMapmodule for line-accurate error reporting (longest-prefix match — no path-shape hardcoding), and date coercion sojson_schemercan validateformat: dateagainst YAML-loadedDateinstances. -
lib/edoxen/cli.rbexposes six Thor commands (validate,normalize,validate-meetings,normalize-meetings,unlocode,iata) that delegate their shared scaffolding to the deepEdoxen::Cli::Batchmodule. -
lib/edoxen/venue_validator.rbvalidates polymorphicVenueinstances using theunlocodesandiatagems; optionally auto-populatescityandcountry_codefrom the UN/LOCODE registry.
Schema ↔ Ruby invariants
Two pairs of runtime specs guard each side of the schema ↔ Ruby boundary:
-
Decision side:
schema_enum_sync_spec.rb
schema_model_sync_spec.rbagainstschema/edoxen.yaml. -
Meeting side:
schema_meeting_enum_sync_spec.rb
schema_meeting_model_sync_spec.rbagainstschema/meeting.yaml.
Drift fails CI immediately, not at fixture-validation time.
Migrating from v0.x (Resolution → Decision)
v2.0 is a breaking release. The migration is mechanical:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
(none) |
|
(none) |
|
(none) |
|
See the migration guide for full details.
Contributing
Follow the rules in CLAUDE.md:
-
All public methods have specs.
-
Specs use real model instances — never
double(). -
Serialization goes through
lutaml-modelonly — no hand-rolledto_h,from_h,to_yaml, orto_jsonon a model class. Declareattribute; the wire map is auto-emitted. -
Schema and Ruby must agree on enum values and on property shape. Both
schema_enum_sync_specandschema_model_sync_speccatch drift at CI time. -
Library code uses autoload (declared in
lib/edoxen.rb), neverrequire_relative. Nosendto private methods, noinstance_variable_set/get, norespond_to?for type checks. -
All changes go through PRs. Never commit to
main, never push tags, never add AI attribution to commits.
License
BSD-2-Clause. Copyright Ribose Inc.