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.
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.
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}"
# Per-field Localized (ISO 24229): every translatable field is an
# array of { spelling, value } entries. Pick the one you want.
title = decision.title.find { |l| l.spelling == "eng" }&.value
puts " [eng] #{title}"
fra = decision.title.find { |l| l.spelling == "fra" }&.value
puts " [fra] #{fra}" if fra
decision.actions.each do |action|
msg = action.message.find { |l| l.spelling == "eng" }&.value
puts " - #{action.type}: #{msg}"
end
end
# Round-trip back to YAML
puts collection.to_yamlA 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.
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: LocalizedString[] └── 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) ├── scheduled_date_range, occurred_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)
├── title: LocalizedString[] ├── components: MeetingComponent[]
├── subject: LocalizedString[] │ (track, session, debate, breakout, keynote,
├── message: LocalizedString[] │ opening, closing, break, reception, ...)
├── considering: LocalizedString[] ├── deadlines: Deadline[]
├── considerations: Consideration[] ├── attendance: Attendance[]
│ └── message: LocalizedString[] │ (status, role, response, proxy_for)
├── approvals: Approval[] ├── minutes: Minutes[]
│ └── message: LocalizedString[] ├── motions: Motion[]
└── actions: Action[] │ (status: introduced → seconded → debating
└── message: LocalizedString[] │ → question_put → voting → carried/negatived)
├── votings: Voting[]
Motion │ (status: called → in_progress → decided;
├── identifier, urn │ voting_method, counts, casting_vote,
├── text: LocalizedString[] │ vote_records[])
├── mover, seconders[] ├── decisions: Decision[] (inline)
├── status (MotionStatus) ├── declarations: Declaration[] (BS 0:2006 §7.6)
├── introduced_at ├── relations: MeetingRelation[]
├── proposed_decision, resulting_decision └── extensions: MeetingExtension[]
└── votings[]
MeetingSeries (top-level standalone)
Voting ├── identifier, urn
├── status (VotingStatus) ├── name: LocalizedString[]
├── voting_method (VotingMethod) ├── recurrence
├── result (VotingOutcome) ├── meeting_refs[]
├── counts: VotingCounts {ayes, noes, └── extensions: MeetingExtension[]
│ 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`)
Statement (BS 0:2006 §7.6)
├── kind (StatementKind: statement | comment | standpoint)
├── description: LocalizedString[]
├── party: Person[]
└── extensions: MeetingExtension[]
Declaration (BS 0:2006 §7.6)
├── kind (DeclarationKind: conflict_of_interest | ipr)
├── description: LocalizedString[]
├── party: Person[]
├── ipr_subject_ref: EntityRef (only when kind == "ipr")
├── ipr_target_ref: EntityRef (only when kind == "ipr")
└── extensions: MeetingExtension[]
DateTimeRange
├── start: DateTime
└── end: DateTimeEvery 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 pre-1.0 bare value: String wire shape still
parses (routed into the string variant).
Every translatable field is Localized<String|Name>[0..] — an array
with one entry per ISO 24229 *spelling code. There is no separate
Localization[] collection; each field carries its own language tags
inline.
-
LocalizedString—{ spelling, value: String, extensions }. -
LocalizedName—{ spelling, value: Name, extensions }. -
spellingaccepts ISO 24229 spelling-system codes ({lang}-{script}[-{country}][-{extension}], e.g.zho-Hans,ind-Latn-pre1972) AND conversion-system codes ({authority}:{source-spelling}:{target-spelling}:{identifying}, e.g.acadsin:zho-Hani:Latn:2002,UN:ara-Arab:Latn:2017).
Always verbose — single-language data uses the same
[{ spelling, value }] shape as multi-language data. This lets a
single field capture both native-script content and its romanized /
transliterated forms.
Look up an entry by spelling with plain Ruby:
decision.title.find { |l| l.spelling == "eng" }&.value
decision.title.find { |l| l.spelling == "acadsin:zho-Hani:Latn:2002" }&.valueA Venue is one flat class on the wire; the kind field discriminates
physical vs virtual.
-
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 (pure validation via valid?; call
populate_from_registry! separately if you want city/country_code
back-filled from the UN/LOCODE entry).
A bilingual CIML decision in EN + FR, modelled with per-field Localized:
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
title:
- spelling: eng
value: Decision on the renewal of the contract of Mr Anthony Donnellan
- spelling: fra
value: Décision sur le renouvellement du contrat de M. Anthony Donnellan
subject:
- spelling: eng
value: CIML
- spelling: fra
value: CIML
actions:
- type: decides
date_effective:
date: 2025-10-13
type: adoption
message:
- spelling: eng
value: |
The Committee decides to renew the contract of
Mr Anthony Donnellan as BIML Director.
- spelling: fra
value: |
Le Comité décide de renouveler le contrat de
M. Anthony Donnellan en tant que Directeur du BIML.The edoxen executable exposes six commands.
$ 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.
$ edoxen normalize "spec/fixtures/*.yaml" --output clean/
$ edoxen normalize legacy.yaml --inplaceEither --output DIR or --inplace is required (mutually exclusive).
Same shape, for Meeting / MeetingCollection / MeetingSeries 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 — single Meeting, MeetingCollection,
and MeetingSeries are all accepted.
Walks DIRECTORY recursively, classifies every .yaml / .yml as a
Meeting, MeetingCollection, or DecisionCollection, and reports:
-
every
metadata.meeting_urnthat does not resolve to an indexed Meeting; -
every Meeting
urnclaimed by more than one file.
Error messages include file:line and the in-document pointer.
$ edoxen check-links ./spec/fixtures
🔗 Checking cross-document URN links in ./spec/fixtures...
✅ No broken or duplicate links found
$ edoxen check-links ./_data
❌ Found 2 link error(s):
./_data/decisions.yaml:4: metadata.meeting_urn references urn:oiml:ciml:meeting:ciml-99 (no matching Meeting)
./_data/dupe.yaml:7: urn references urn:oiml:ciml:meeting:ciml-55 (duplicate Meeting URN (first seen at ./_data/meeting-55.yaml:4))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. 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:00ZRead 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 # => 7The pre-1.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.
-
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 seven Thor commands (validate,normalize,validate-meetings,normalize-meetings,check-links,unlocode,iata) that delegate their shared scaffolding to the deepEdoxen::Cli::Batchmodule. -
lib/edoxen/venue_validator.rbvalidates polymorphicVenueinstances against theunlocodesandiatagems.valid?is pure;populate_from_registry!is a separate, explicitly-mutating method for callers that want UN/LOCODE → city/country_code back-fill.
Four pairs of runtime specs guard 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. -
Canonical sync:
schema_model_canonical_sync_spec.rbasserts the gem schemas byte-match../edoxen-model/schema/{decision-collection, meeting}.yaml. -
Lutaml sync:
lutaml_ruby_sync_spec.rbwalks every*.lutamlclass and enum and asserts attribute names + collection flags match the Ruby side.
Drift fails CI immediately, not at fixture-validation time.
Edoxen Model 1.0 is the first public release; the intermediate v0.x /
v2.x history never shipped publicly. The migration from a pre-1.0
shape is mechanical:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Per-field `Localized<String |
Name>[0..*]` (ISO 24229); each translatable field carries its own spelling tag inline. |
|
`decision.title.find { |
l |
l.spelling == code }` — direct array lookup, no wrapper. |
(none) |
|
(none) |
|
(none) |
|
(none) |
|
|
|
(none) |
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.