Skip to content

Add compound (hierarchical) metadata foundation#7479

Open
laritakr wants to merge 34 commits into
mainfrom
nested-compound-metadata-foundation
Open

Add compound (hierarchical) metadata foundation#7479
laritakr wants to merge 34 commits into
mainfrom
nested-compound-metadata-foundation

Conversation

@laritakr

@laritakr laritakr commented Jun 4, 2026

Copy link
Copy Markdown
Contributor

Summary

Introduces compound (hierarchical) metadata — a repeatable list of entries, each a set of named sub-properties — defined entirely in schema YAML with no per-field code, on works and collections.

Details

  • A compound is a type: hash, multiple: true parent property; its members are flat top-level properties pointing back with subproperty_of: <parent>. The generic form, populator, indexer, and show renderer all read the declaration from the schema.
  • Sub-property types: open text, controlled (inline values: or a QA authority:), auto-linked url, and work_or_url (search readable internal works or collections, or enter an external URL).
  • Per-sub-property Solr indexing (index_keys:/indexing:) and show display (display:); optional group: clustering and form: { cols:, as: } layout.
  • Compounds render inline or as a card (view: { display: card }), and sit in the form's primary or "Additional fields" section per form: { primary: }.
  • Sub-properties (and whole compounds) can be marked required:; on save the form blocks with one error per compound, and required fields show a *.
  • Works identically under HYRAX_FLEXIBLE=false and true; an m3 profile validator catches a misconfigured compound on save.
  • Ships sample compounds (participants, identifiers, compound_rights, relationships) on works and collections; an app removes or overrides them in its own schema.
  • Hyrax::CompoundFieldBehavior is the generic, schema-driven member of the same Field Behavior family as the hand-written based_near/redirects behaviors; the docs explain when to use each.

Screenshots

Work & forms with compound metadata Screenshot 2026-06-04 at 12 10 29 PM Screenshot 2026-06-04 at 12 10 44 PM Screenshot 2026-06-04 at 4 37 39 PM Screenshot 2026-06-04 at 4 37 19 PM
Collection show page with compound metadata Screenshot 2026-06-03 at 5 44 35 PM Screenshot 2026-06-04 at 4 37 03 PM

laritakr and others added 6 commits June 4, 2026 15:25
Hyrax can read compound (hierarchical) metadata declarations off a
resource's schema, returning each compound's sub-fields, groups, and
display mode in both flexible and non-flexible modes.

This is the read-only foundation the compound form, indexer, and show
renderer build on; the compound_metadata_enabled? config flag (default
on) gates whether the rest of the feature activates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Resource edit forms render a repeatable, schema-driven section per
compound attribute, with add/remove-row controls and a generic
populator that converts the nested-attributes payload to the persisted
array-of-hashes shape. Each sub-field renders by its declared type
(text, controlled select, url, or a work-or-url picker).

Compounds are kept out of the primary/secondary scalar terms so they
render only through the compound section. The wiring is a no-op until a
schema declares a compound, in both flexible and non-flexible modes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compound metadata round-trips through storage: a read-path concern
guards against Valkyrie's single-element-array key-splay on reload, and
an indexer mixin writes each sub-field's declared Solr fields plus a
JSON display blob the show page reads from.

A SolrDocument compound_attribute declarer parses that blob back into an
array of hashes. The indexer mixin is included on the base work and
collection indexers but is a no-op until a schema declares a compound.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Show pages render compound metadata through a generic renderer: each
entry's populated sub-fields display as a labeled block, controlled
sub-fields show their term rather than the stored id, urls are linked,
and a work-or-url value links to the internal work or the external URL.

A compound can render inline in the metadata list or, with
view: { display: card }, as its own card on works and collections. The
work-or-url picker searches readable works by any indexed term or a
partial title.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hyrax ships four sample compounds (agent, identifiers, compound_rights,
relationships) on works and collections, demonstrating every sub-field
type. They are declared once in the schema — compound_metadata.yaml in
non-flexible mode and the m3 profile in flexible mode — and wired onto
the base work, collection, and their SolrDocument readers.

This turns the feature on (gated by compound_metadata_enabled?, default
true). A flexible-mode validator rejects a misconfigured compound when
an m3 profile is saved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a reference for declaring compound (hierarchical) metadata in
schema YAML — sub-field types, indexing, inline vs. card display, the
work-or-url picker, profile validation, and Bulkrax round-tripping —
and points the field-behaviors guide at it as the preferred path for
new multi-sub-field compounds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@laritakr laritakr added the notes-minor Release Notes: Non-breaking features label Jun 4, 2026
@laritakr laritakr changed the title Add compound (hierarchical) metadata foundation :construction Add compound (hierarchical) metadata foundation Jun 4, 2026
@laritakr laritakr changed the title :construction Add compound (hierarchical) metadata foundation 🚧 Add compound (hierarchical) metadata foundation Jun 4, 2026
@laritakr laritakr marked this pull request as draft June 4, 2026 20:29
The sample compound now uses the attribute name `agents`, avoiding a
collision with the internal ACL `agent` attribute that shadowed it in
the Valkyrie permissive schema.

The collision dropped one entry from the permissive schema (and broke
its spec); the sub-field names (agent_name, agent_role) are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

Test Results

    17 files  ±  0      17 suites  ±0   3h 25m 51s ⏱️ - 4m 57s
 7 761 tests +165   7 454 ✅ +164  307 💤 +1  0 ❌ ±0 
26 386 runs  +676  25 791 ✅ +674  595 💤 +2  0 ❌ ±0 

Results for commit 197e231. ± Comparison against base commit edb0db3.

This pull request removes 452 and adds 617 tests. Note that renamed tests count towards both.
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f18764cac80>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f2d2e732e60>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f6b072593a0>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fa9f39b6910>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f187650dc10>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f2d2d7b0640>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f6b0729e248>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fa9f3f0d850>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy AdminSet: a4139154-f1f2-4ea1-8512-fa898c1fdcba
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: 33d535e4-d95c-4ee9-bae5-0afb0308e833
…
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007f316f65a160>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fcff7636a40>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007fd5b0ff5798>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplate:0x00007ff0b6575798>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007f316f66d5d0>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fcff79a4880>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007fd5b1004108>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to create #<Hyrax::PermissionTemplateAccess:0x00007ff0b659aa20>
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy AdminSet: fa9ae133-90c4-44b5-ae33-11c4d652bfa0
spec.abilities.ability_spec ‑ Hyrax::Ability AdminSets and PermissionTemplates a user without edit access is expected not to be able to destroy Hyrax::AdministrativeSet: 1cf8954f-576a-41b1-bd5c-8e73c980440a
…

♻️ This comment has been updated with latest results.

laritakr and others added 18 commits June 4, 2026 18:41
Compound metadata fields no longer show up twice on the collection form,
the collection creation page now displays validation errors, and those
error messages read cleanly.

The collection form was listing each compound field both in its own
section and again as an ordinary single field; when a sub-field was
required, the duplicate stopped the form from saving. The create page
also wasn't showing error messages at all, and the messages it built
read awkwardly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound can now mark sub-fields required (filled in every entry) and
mark itself required (at least one entry). On save the form blocks with
one error per compound, naming the field; required fields show a * on
the form. Works the same in both flexible and non-flexible modes, on
works and collections.

The rules live in Hyrax::CompoundEntryValidation, a plain object
decoupled from the form so they can be reused and tested directly;
Hyrax::CompoundEntryValidator wires it onto the resource form. The
shipped sample compounds require their sub-fields within an entry but
remain optional overall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Compound metadata cards now render on show pages in flexible mode. The
card helpers resolved the compound declarations from the resource class,
but in flexible mode the class carries none — they are applied per
instance at load — so no cards appeared. They now resolve from the show
document's indexed schema_version, reading that version's attributes
without loading the resource; non-flexible mode still reads the class.

Also corrects a collection-controller spec that doubled the form errors
object with a class lacking full_messages, matching the real Reform
errors object the controller now reads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Inline compound metadata (e.g. agents, identifiers) now appears on a
collection's show page in flexible mode. The presenter resolved its
displayed terms and compound readers from the collection class, but a
flexible schema is per-resource — keyed by the document's indexed
schema_version and mutable at runtime — so the class often carries
none. It now resolves them from the backing document, the same way the
compound cards already do.
The instance term list now derives from the class-level terms plus the
collection's compound terms, instead of a hardcoded constant. This
restores the effect of a downstream override of the class `terms`
method (e.g. one that removes a term), which the previous instance
implementation bypassed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three fixes for compound cards on show pages:

- Sub-field specs now resolve from the backing document, not the class,
  so in flexible mode a card's controlled terms display their labels and
  a work-or-url value links correctly (the class carries no compounds in
  flexible mode, so the renderer was getting raw values).
- A work-or-url value that is neither a URL nor a resolvable indexed work
  now renders as plain text instead of a broken link.
- Compound values sit flush with the value column instead of carrying a
  fixed indent that misaligned under downstream layouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound rendered as a card showed its label twice — once as the card
title and again as the field label inside the body. The card body now
renders the entry values only, via a new value_only option on
attribute_to_html, leaving the card title as the single label.
A long unbreakable value such as a rights-statement or license URL link
was shrinking the flex label column and shifting the value column left,
so compound rows no longer lined up with the rows above them. Hold the
label column fixed and let the value column shrink so long URLs wrap
within it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Compounds use a declared display_label, resolved the same way ordinary
  properties are (the rights sample now shows "Rights"); the card title
  uses it and no longer duplicates the field label.
- A controlled sub-field whose value is a URI (rights statement, license)
  links the term to that URI; a work-or-url value that resolves to no
  indexed work renders as plain text instead of a broken link.
- Inline compound values align with the surrounding metadata: work-page
  values share the ul.tabular wrapper, and collection values resolve
  their definition from the document so they render correctly in
  flexible mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SolrDocument now exposes a reader for every compound in its active
schema, resolved dynamically. An application that declares its own
compounds renders them on the show page with no per-document setup.
The shipped sample compound `agents` is now `participants`.

Agents as a term has too many other uses and meanings. Additionally,
it also refers to sipity agents, permissions agents, Fedora ACl agents,
etc, meaning it could create a collision of terms & break the app.
The SolrDocument now defines a real reader method for each compound it
carries (its `<name>_json_ss` blob) when the document is built, so an
application's own compounds render on the show page with no per-document
declaration, in both flex modes.
Collection show pages now render when a compound is declared for the
collection but the record has no entries for it, instead of raising
NoMethodError.

The collection presenter lists every declared compound among its terms,
but the SolrDocument only defines a reader for a compound that has
indexed data. Reading a declared-but-empty compound now returns no
entries rather than calling an undefined method.
Compound (hierarchical) metadata now calls its nested members
"subproperties" instead of "subfields" — the YAML key is
`subproperties:`, and the supporting classes, methods, i18n keys, and
CSS classes follow the same name.

This is a vocabulary rename only; the declaration structure, the
normalized internal shape, indexing, rendering, and validation are
unchanged. It sets up a later change that expresses subproperties as
composable, first-class properties.
A compound's members are now declared as separate top-level properties
that point back to the parent with `subproperty_of: <parent>`, rather
than nested in a `subproperties:` block. Each subproperty carries its
own group membership (`group:`) and form placement (`form: { cols: }`),
and a parent's `groups:` block names the groups. Subproperties are not
standalone resource attributes — the schema loaders fold them into the
parent, so storage, indexing, and rendering are unchanged.

This is the foundation for deeper nesting and for subproperties that
carry their own form/display options. The previous nested `subproperties:`
format is replaced (not kept for back-compat); both flex modes use the
same flat declaration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Flexible (m3 profile) compound subproperties may omit `display_label`,
`available_on`, and `range`: the profile JSON schema exempts entries
declaring `subproperty_of`, and a subproperty inherits its parent
compound's class scope. The flexible schema model no longer requires a
`range` (keeping the declared `type:` when absent) and skips registering
subproperties as standalone class attributes.

Without this, a profile using the flat compound format fails validation
and the app raises on boot building attributes for a subproperty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A `work_or_url` subproperty now resolves an internal id to the right
show page — a work to its work page, a collection to its collection
page — instead of the model-agnostic catalog route, using the
document's Wings-aware model resolution. The picker search also returns
collections alongside works, so a related item can be either.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A compound now renders in the form's primary section when it declares
`form: { primary: true }`, and in "Additional fields" otherwise —
matching how scalar terms split. The work and collection forms render
`primary_compound_terms` up top and `secondary_compound_terms` in the
collapsible section. The shipped samples demonstrate both: participants
and relationships are primary; identifiers and compound_rights are
additional.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
laritakr and others added 6 commits June 6, 2026 01:20
The m3 schema loader could call `attributes_for` on a nil schema:
`find_by(id: version)` misses on an unknown version and
`create_default_schema` returns nil when a row already exists, so on a
fresh database or early boot the loader raised `NoMethodError`. It now
falls back to the latest schema and, failing that, the default attribute
set.
The redirects and compound form populators read their submitted
nested-attributes fragment through a shared
Hyrax::CompoundRowPlumbing module rather than each defining its own
identical fragment-coercion helpers.

Each populator keeps its own row-drop rule and value normalization;
only the shape coercion (unwrapping ActionController::Parameters) is
shared, so form behavior is unchanged in both flex modes.
The compound and field-behavior docs now live at documentation/ (no
longer under forms/) and spell out when to declare a compound versus
hand-write a field behavior: redirects and based_near stay field
behaviors because moving them onto the compound machinery would
change persisted shape or behavior, not refactor it.

References to the two docs areupdated to the new paths.
🚧 Rework yaml structure for compound metadata
The feature was on by default and had no reason to be toggleable;
applications that want to drop the sample compounds override the
schema in their own app instead.
@laritakr laritakr changed the title 🚧 Add compound (hierarchical) metadata foundation Add compound (hierarchical) metadata foundation Jun 6, 2026
@laritakr laritakr marked this pull request as ready for review June 6, 2026 18:43
Documentation was moved and a few places were missed.
@laritakr laritakr requested a review from orangewolf June 6, 2026 19:01
@jrochkind

Copy link
Copy Markdown
Contributor

Amazing!

Compounds render inline or as a card

Is there (already?) an easy way to provide custom rendering? (with a custom view_component I guess?). Seems likely to be a need for this.

A work create that fails validation re-renders the form with its
errors instead of raising, because rebuild_form no longer wraps a
Hyrax::ChangeSet in the legacy FailedSubmissionFormWrapper.

The wrapper exists for ActiveFedora forms and reads permitted_params,
which a Valkyrie ChangeSet does not define, so wrapping one raised
ArgumentError and turned every validation error into a 500. The
ChangeSet already holds its submitted, validated state, mirroring the
guard already present in after_update_error.
@laritakr

laritakr commented Jun 8, 2026

Copy link
Copy Markdown
Contributor Author

Amazing!

Compounds render inline or as a card

Is there (already?) an easy way to provide custom rendering? (with a custom view_component I guess?). Seems likely to be a need for this.

I agree that we need to expand the view abilities... this is likely the next step (and a good stepping off point for anyone else who wants to contribute to this work. Getting the foundation in place was a big enough piece that I just wanted to get the basics going before trying to present them!)

A compound's form placement (e.g. `form: { primary: true }`) now takes
effect for new and edit forms as soon as a new metadata profile is
loaded, with no process restart, matching how other properties already
behave.
}
document.addEventListener('turbolinks:load', bindAll);

document.addEventListener('click', function(event) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function has a complexity of 11 complexity

// rebinding. The sentinel guards against the IIFE running twice (Turbolinks
// re-evaluates module scripts on some navigation paths), which would stack
// duplicate listeners. Mirrors hyrax/redirects.js.
(function() {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Move the invocation into the parens that contain the function wrap-iife

.hyrax-compound-entry + .hyrax-compound-entry {
margin-top: 0.5rem;
padding-top: 0.5rem !important;
border-top: 1px solid $gray-200 !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used

// A light divider between entries.
.hyrax-compound-entry + .hyrax-compound-entry {
margin-top: 0.5rem;
padding-top: 0.5rem !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used


// A light divider between entries.
.hyrax-compound-entry + .hyrax-compound-entry {
margin-top: 0.5rem;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Properties should be ordered border-top, margin-top, padding-top

.hyrax-compound-subproperty,
.hyrax-compound-subproperty-label,
.hyrax-compound-subproperty-value {
border: 0 !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used
Properties should be ordered background, border, margin, padding

// inline so each sub-property reads as "Label: value" on one wrapping line.
.hyrax-compound-entry,
.hyrax-compound-subproperty {
display: block !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used

display: block !important;
flex-direction: column;
border: 0 !important;
padding: 0 !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used

.hyrax-compound-values {
display: block !important;
flex-direction: column;
border: 0 !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used

// in _collections.scss) that would otherwise turn each entry into a bordered
// flex row. These rules override it regardless of source order.
.hyrax-compound-values {
display: block !important;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

!important should not be used
Properties should be ordered border, display, flex-direction, max-width, padding

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

notes-minor Release Notes: Non-breaking features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants