| name | linked-resources |
| description | Work with the LinkedResource model, the LinkedResource::Source registry, the Linkable concern, and the BatchMatchableToWikidata concern in XRONOS. Use when adding new linked data sources (Pleiades, Vici.org, OpenContext, iDAI.gazetteer, etc.), working with the curation dashboard, refactoring the linked_resources form, or attaching the linkable macro to a new model. |
| license | MIT |
| metadata | {"domain":"xronos","layer":"model"} |
What I do
- Document the polymorphic
LinkedResource model and its linked_resources table
- Document the
LinkedResource::Source registry and the per-source configuration pattern
- Document the
Linkable concern and the linkable_to macro
- Document the
BatchMatchableToWikidata concern as the canonical pattern for SPARQL-based enrichment
- Document the canonical testing strategy (test source + parameterized real-source coverage, no per-source parallel host classes)
- Flag the async-enrichment principle: XRONOS never blocks user-facing actions on external services
When to use me
Use this when:
- Adding a new linked-data source (Pleiades, Vici.org, OpenContext, iDAI.gazetteer, etc.)
- Wiring the linkable macro into a new model (C14, Context, …)
- Refactoring the linked_resources form to be source-selectable
- Writing or debugging a rake task / background job that enriches records from an external API
- Debugging the curation dashboard nav, badges, or per-source scopes
The architecture, in three pieces
┌────────────────────────┐
│ LinkedResource::Source │ ← per-source config (PORO)
│ (registry PORO) │ id_pattern, url_template, …
└────────────┬───────────┘
│ looked up by name
▼
┌────────────────────┐ has_many ┌──────────────────┐
│ Host model (Site) │ ◄────────── │ LinkedResource │ ← single table,
│ │ polymorphic│ │ polymorphic
│ include Linkable │ │ external_id │
│ linkable_to :foo │ │ source │
│ │ │ status │ (pending/approved)
└────────────────────┘ └──────────────────┘
│
│ include
▼
┌─────────────────────────────────┐
│ BatchMatchableToWikidata │ ← optional concern
│ (and future BatchMatchableTo…) │ for SPARQL/API enrichment
└─────────────────────────────────┘
The three pieces are independent:
LinkedResource is the storage model — doesn't know about specific sources.
LinkedResource::Source is the registry — knows per-source config, doesn't know about models.
Linkable is the per-host concern — generates per-source methods, looks up sources by name.
Data model
LinkedResource is a polymorphic join to any model. The linkable_type / linkable_id columns store the parent. The class column (source) and the id column (external_id) identify the linked-data entity.
| Column | Type | Notes |
|---|
linkable_type | string | e.g. "Site" |
linkable_id | bigint | parent's id |
source | string | must match a registered LinkedResource::Source#name (validated) |
external_id | string | the source's id, stored as-is (e.g. "Q123456" for Wikidata) |
status | string | "pending" (default) or "approved" |
data | jsonb | optional metadata cached from the source (label, description, …) |
Source id is stored and printed as-is. Do not prefix/suffix at display time; validate the full format via LinkedResource::Source#id_pattern.
Naming conventions
Per AGENTS.md:
| Layer | Where | Example |
|---|
| Per-source registry PORO | app/models/<model>/source.rb (nested) | LinkedResource::Source |
| Per-source concern | app/models/concerns/batch_matchable_to_<source>.rb | BatchMatchableToWikidata |
| Host concern | app/models/concerns/linkable.rb | Linkable |
A new source typically needs only the registry entry. The Linkable macro picks it up automatically — no per-source methods to write. A matching concern (BatchMatchableToPleiades, etc.) is only needed if the source has an enrichment flow that warrants its own SPARQL/API logic.
The registry: LinkedResource::Source
A PORO nested under LinkedResource (per the PORO-under-AR convention). Holds the per-source configuration.
Each known source is a separate file at app/models/linked_resource/sources/<key>.rb, defining a module LinkedResource::Sources::<KeyCamelized> that exposes an ATTRIBUTES constant (a frozen hash) and calls Source.register at the bottom to register itself when Zeitwerk loads the file. The LinkedResource model keeps a KNOWN_SOURCES whitelist that triggers the load (via const_get) and then asserts each source actually registered — so a missing or misbehaving source module fails loudly at class-load time rather than silently at use-time. The class body re-evaluates on every Zeitwerk reload, so the registry is repopulated in development without a separate to_prepare / after_initialize hook.
Pleiades (no brand logo) is a representative example:
class LinkedResource
module Sources
module Pleiades
ATTRIBUTES = {
name: "Pleiades",
url_template: "https://pleiades.stoa.org/places/%<id>s",
id_pattern: /\A\d+\z/,
description: "Pleiades place resource"
}.freeze
LinkedResource::Source.register(:pleiades, **ATTRIBUTES)
end
end
end
class LinkedResource < ApplicationRecord
KNOWN_SOURCES = %i[wikidata pleiades vici opencontext idai_gazetteer].freeze
KNOWN_SOURCES.each do |key|
Sources.const_get(key.to_s.camelize)
next if Source.known?(key.to_s)
raise "Known source :#{key} is not registered. Check that " \
"app/models/linked_resource/sources/#{key}.rb defines module " \
"LinkedResource::Sources::#{key.to_s.camelize} and calls " \
"LinkedResource::Source.register(#{key.inspect}, **ATTRIBUTES)."
end
end
id_prefix is not a thing — ids are stored as the user sees them. The url_template interpolates the full id via %<id>s (or %{id}, both work via Kernel#format).
Registry API (in app/models/linked_resource/source.rb):
LinkedResource::Source.register(:key, **attrs) — register
LinkedResource::Source.find(name_or_key) — by "Pleiades" (name) or :pleiades (key); returns nil if unknown
LinkedResource::Source.known?(name) — boolean
LinkedResource::Source.all — array of all registered
LinkedResource::Source.reset! — for tests
LinkedResource::Source::UUID_PATTERN — shared id_pattern regex (RFC 4122) for any source with UUID-based ids (e.g. OpenContext). Avoids duplicating the regex across source files.
- Instance:
source.url_for(id), source.valid_id?(id). The optional icon attribute is a slug matching an SVG at app/assets/images/simple_icons/<slug>.svg (see "Icons" below).
Icons
The icon for a source is rendered by the linked_resource_icon(source_name) helper in app/helpers/linked_resources_helper.rb.
- Source has a brand logo (set the
icon attribute to a slug): the helper calls simple_icon(slug), which embeds the SVG at app/assets/images/simple_icons/<slug>.svg. Example: Wikidata → icon: "wikidata" → simple_icon "wikidata". The SVG should be a viewBox="0 0 24 24" shape that inherits currentColor so it matches the surrounding text color on both light and dark backgrounds. Wikidata, Wikipedia, Wikimedia Commons, and Zenodo all came from SimpleIcons; OpenContext was rasterized from its PNG brand mark with potrace.
- Source has no logo (omit the
icon attribute): the helper falls back to bs_icon 'share', a Bootstrap Icons share icon, as a generic placeholder. Example: Pleiades and Vici.org have no icon attribute, so their linked-resource card shows the share icon next to the source name.
The Linkable concern
Linkable is mixed into any model that can have linked resources. The concern declares the polymorphic has_many :linked_resources, as: :linkable, dependent: :destroy association (so the host model doesn't have to), and the linkable_to macro generates per-source shortcut methods from the registry.
class Site < ApplicationRecord
include Linkable
linkable_to :wikidata
end
linkable_to :wikidata generates:
Site#wikidata_link — reader for the LinkedResource
Site#missing_wikidata_link? / Site#pending_wikidata_link? — predicates
Site.missing_wikidata_link / Site.pending_wikidata_link — scopes
- Registers both issue symbols in
Site.linked_resource_issues (the class-level array)
Adding Pleiades is a one-liner (linkable_to :pleiades); all the per-source methods appear automatically. No has_many :linked_resources line is needed on the host — include Linkable provides it.
⛔ The async enrichment principle (CRITICAL)
XRONOS must never be blocked by, or made reliant on, an external request. External services (Wikidata, Pleiades, Geocoder, GBIF, Wikipedia, Nominatim, …) are enrichment layers. They add value; they must never gate a user action.
Three concrete rules:
-
Enrichment produces pending records, not synchronous data. The site show page never makes a SPARQL call. BatchMatchableToWikidata.wikidata_match_candidates_batch creates LinkedResource records with status: "pending" and stores the suggestion in data. The user (curator) reviews and approves later. The page is fast and works offline.
-
Heavy enrichment runs in background jobs. Site::FetchDescriptionJob pre-warms the Site::Description cache so the first request to the lazy turbo-frame hits a warm cache. Cache TTL is 7 days. Never call a Wikimedia / Wikidata / GBIF / Geocoder API synchronously from a controller action.
-
Errors are silent, not fatal. A SPARQL timeout, a GBIF 500, a Geocoder failure — all return empty / null. The user sees a record with no enrichment, not a 500 page. BatchMatchableToWikidata rescues Net::OpenTimeout, Net::ReadTimeout, SocketError and returns an empty hash.
When writing any new external-service integration:
- Ask: "what happens to the user if this endpoint is down for a week?" If the answer is "they can't use the app", you have a problem.
- Wrap every HTTP call in a rescue returning a safe default.
- Set a short timeout (≤ 5s) so a slow service doesn't hold up a worker.
- Use
Rails.cache to memoize external responses; default TTL ≥ 1 day.
- If a record needs to be enriched on user-visible display, do it once at save and store the result. Never fetch on every request.
Adding a new source: worked example (Pleiades)
Most sources need only steps 1–2. Add a concern only if you need SPARQL/API enrichment.
-
Create a source module at app/models/linked_resource/sources/<key>.rb (e.g. pleiades.rb). The filename is the source key in lowercase; the module name is the camelized form. The module exposes an ATTRIBUTES hash and calls Source.register at the bottom to register itself when loaded. If the source has a brand logo, set icon: "<slug>" (see "Icons" above); otherwise omit it and the helper will fall back to the share icon. For sources whose ids are standard UUIDs, use LinkedResource::Source::UUID_PATTERN instead of writing the regex inline.
class LinkedResource
module Sources
module Pleiades
ATTRIBUTES = {
name: "Pleiades",
url_template: "https://pleiades.stoa.org/places/%<id>s",
id_pattern: /\A\d+\z/,
description: "Pleiades place resource"
}.freeze
LinkedResource::Source.register(:pleiades, **ATTRIBUTES)
end
end
end
class LinkedResource
module Sources
module Opencontext
ATTRIBUTES = {
name: "OpenContext",
url_template: "https://opencontext.org/subjects/%<id>s",
id_pattern: LinkedResource::Source::UUID_PATTERN,
icon: "opencontext",
description: "OpenContext record"
}.freeze
LinkedResource::Source.register(:opencontext, **ATTRIBUTES)
end
end
end
-
Add the key to LinkedResource::KNOWN_SOURCES in app/models/linked_resource.rb. The model iterates this list to trigger each source's load and then asserts the registration actually happened. Forgetting the Source.register call (or getting the key wrong) will raise a loud error at class-load time, not at use-time.
-
Add linkable_to :pleiades to the host model (e.g. Site). This generates pleiades_link, missing_pleiades_link?, pending_pleiades_link?, the corresponding scopes, and registers the issue symbols so the per-site view and curation dashboard auto-populate. The same source can be linked from multiple host models — linkable_to :opencontext on Site and on C14 are independent calls that each generate their own per-model methods, all backed by the same registered source and the same UUID-pattern id validation. The host model needs include Linkable (or some other concern that declares has_many :linked_resources, as: :linkable); the macro itself only needs linkable_to calls.
-
If the source has a brand logo (i.e. you set icon: in step 1), add the SVG at app/assets/images/simple_icons/<slug>.svg matching the icon slug. Drop in a SimpleIcons SVG if the brand has one there, or vectorize the brand's PNG with potrace (see the OpenContext icon as a worked example). If the source has no logo, omit the icon attribute and skip this step.
-
Test it — no per-source parallel coverage. The macro and registry are tested once with a test source (see "Testing strategy" below); real sources are covered by parameterized tests that iterate KNOWN_SOURCES. Add a new source: (a) append the key to KNOWN_SOURCES, (b) add a (name, id, expected_url) row to the URL-templates test, (c) optionally add the host-model's linkable_to line. The site page's "Add {source} link" button and the missing/pending badges appear automatically; no view changes needed.
Testing strategy
The registry, the macro, and the per-source attributes are tested in two layers that are deliberately non-overlapping. Adding a new source should not add per-source parallel coverage; the canonical tests already cover the macro, and the real-source tests already cover all sources by iterating.
Layer 1: canonical tests with a test source (one source, full coverage)
The macro and registry are generic — they don't care about Wikidata vs. Pleiades vs. Vici.org. So the canonical tests use a test source registered in the test file itself, not a real source. This isolates the tests from any external service and makes them self-documenting.
Registry tests (test/models/linked_resource/source_test.rb):
- Register a
:source_test source in setup with the full set of attributes (name, url_template, id_pattern, icon, description).
- Exercise every method of the registry API:
register, find, known?, all, reset!, plus the PORO methods url_for, valid_id?, has_logo?.
- The teardown removes the source so it doesn't leak into other tests.
- These 13-ish tests cover the registry fully. New source files don't need their own registry tests.
Macro tests (test/models/concerns/linkable_test.rb):
- Register the test source at the top of the file (before the class definition) so the test host class can use
linkable_to :source_test at class-definition time. Also re-register in setup because source_test.rb's teardown removes the source from the registry, and LinkedResource's validation looks the source up in the registry at validation time.
- Define a single
LinkableTestHost with linkable_to :source_test. The macro generates source_test_link, missing_source_test_link?, pending_source_test_link?, the two scopes, and registers the two issue symbols.
- All macro tests (reader, predicates, scopes, issue registration, unknown-source error) use this single host class.
- These 13-ish tests cover the macro fully. New source files don't need their own test host classes.
Layer 2: parameterized real-source coverage (loop, not per-source tests)
Real source files (app/models/linked_resource/sources/*.rb) are thin — just an ATTRIBUTES hash and a Source.register call. Configuration typos (wrong URL template, wrong key) are caught by two small tests in source_test.rb that iterate:
test 'all KNOWN_SOURCES are registered at boot' do
LinkedResource::KNOWN_SOURCES.each do |key|
assert LinkedResource::Source.known?(key.to_s),
"Source :#{key} is in KNOWN_SOURCES but not registered"
end
end
test 'known sources have expected URL templates' do
{
'Wikidata' => [ 'Q123', 'https://www.wikidata.org/wiki/Q123' ],
'Pleiades' => [ '687917', 'https://pleiades.stoa.org/places/687917' ],
'Vici.org' => [ '57205', 'https://vici.org/vici/57205/' ],
'OpenContext' => [ '8606d40b-1bad-4a8f-99c7-bcab8b794d6e',
'https://opencontext.org/subjects/8606d40b-1bad-4a8f-99c7-bcab8b794d6e' ]
}.each do |name, (id, expected_url)|
assert_equal expected_url, LinkedResource::Source.find(name).url_for(id),
"URL template for #{name} should produce #{expected_url}"
end
end
When you add a source, add one row to the URL-templates hash. The "all known sources are registered" test picks up the new source automatically from KNOWN_SOURCES — no edit needed.
Why not per-source parallel coverage?
The macro is pure string interpolation ("#{key}_link", scope "missing_#{key}_link", etc.). Testing it with :wikidata proves it works for :pleiades and :vici and every future source. A separate test host class per source runs the same code path with a different key — it's repetition, not coverage.
Configuration typos (wrong URL, wrong regex) are caught by:
- App boot: the
KNOWN_SOURCES loop in LinkedResource raises if a source file is missing or doesn't register.
- Code review: the source file is ~20 lines.
- The two parameterized tests above.
None of these are silent failures that only a per-source test would catch.
When to deviate
Add a per-source test only when the source's behavior is genuinely source-specific and not exercised by the canonical tests. Examples that would justify a per-source test:
- A non-trivial
id_pattern (e.g. something more nuanced than /\A\d+\z/ — add boundary cases).
- A source that overrides macro behavior (none today, but possible if
Linkable grows per-source hooks).
- A
BatchMatchableTo* concern with source-specific SPARQL/API logic (the existing BatchMatchableToWikidata has its own tests).
A simple integer pattern, a url_template, a has_logo flag — none of these warrant a per-source test. The parameterized coverage is enough.
The curation dashboard — Wikidata-only by design
The LinkedResources::SitesController dashboard (mounted at /linked_resources/sites/:issue) is currently Wikidata-only. Adding per-source dashboards is a separate decision; the data is already there (Site.linked_resource_issues is the array of every registered source's issue symbols). Talk to the user before exposing per-source views — they may want a per-source nav, a combined view, or neither.
The BatchMatchableToWikidata pattern (template for new enrichments)
If a new source has an enrichment flow (e.g. matching C14s to Pleiades, finding Vici.org references by name), model it on BatchMatchableToWikidata:
- New concern
app/models/concerns/batch_matchable_to_<source>.rb
- Class methods:
<source>_match_candidates_batch(records) and helpers
- Constants:
USER_AGENT (use Xronos::USER_AGENT), <SOURCE>_<THING>_WIKIDATA_ID (or similar)
- The
execute_*_request method sets short timeouts (≤ 5s) and rescues network errors
- The match candidates method only creates pending records — never updates existing ones, never makes blocking writes the user is waiting on
- Models include the concern:
include BatchMatchableToPleiades and add linkable_to :pleiades
Common pitfalls
- Do not synchronously call external services in controllers or views.
- Do not block user-visible pages on external response time. If a controller or view needs enriched data, pre-warm it in a job.
- Do not prefix or strip the source id at display time. Store and print the full id (e.g.
Q123456).
- Do not add per-source methods to the host model by hand. Use
linkable_to :source so the macro generates them.
- Do not create a
WikidataLink, PleiadesLink, etc. model. Use the polymorphic LinkedResource with source: "Pleiades".
- Do not store ontology URLs in columns. The source registry is the single source of truth for URL templates.
- Do not
find_or_create_by! a LinkedResource on the user's hot path. It's fine in a job; it's not fine in a controller.
- Do not use the
wikidata_link_attributes strong-params key. There is no WikidataLink model.
File map
app/models/linked_resource.rb — the polymorphic model (also registers KNOWN_SOURCES in its class body)
app/models/linked_resource/source.rb — the registry PORO
app/models/linked_resource/sources/ — one file per known source, each defining a module under LinkedResource::Sources::<KeyCamelized> with an ATTRIBUTES constant
app/models/concerns/linkable.rb — the linkable_to macro; also declares has_many :linked_resources, as: :linkable, dependent: :destroy for any host that includes it
app/models/concerns/batch_matchable_to_wikidata.rb — SPARQL enrichment for Wikidata
app/models/site/description.rb — lazy Wikipedia + Wikimedia images, cached 7 days
app/jobs/site/fetch_description_job.rb — pre-warms the description cache
app/controllers/linked_resources_controller.rb — CRUD
app/controllers/linked_resources/sites_controller.rb — curation dashboard
app/views/linked_resources/ — CRUD and curation views
app/helpers/linked_resources_helper.rb — linked_resource_icon (logo SVG from app/assets/images/simple_icons/, or bs_icon 'share' fallback for sources without one), linked_resource_badge (issue badge)
lib/xronos.rb — Xronos::USER_AGENT, Xronos::CONTACT_EMAIL
test/models/linked_resource_test.rb
test/models/linked_resource/source_test.rb
test/models/concerns/linkable_test.rb
test/models/site/description_test.rb
test/jobs/site/fetch_description_job_test.rb
test/controllers/linked_resources_controller_test.rb
Maintaining this skill
If you add a new source, refactor LinkedResource or LinkedResource::Source, change the linkable_to macro, or add a new BatchMatchableTo* concern, update this skill file so future agents use the correct patterns. The async-enrichment principle is non-negotiable — if you're tempted to relax it, talk to the user first.