Images Pre-Work Audit: shanti_images D7 Content Model
Audience: Developers (Images Phase 1 pilot)
Date: 2026-06-16
Source: Legacy D7 shanti_images module (mandala-legacy/.../modules/custom/shanti_images)
Relates to: ADR 009 (Images is the Phase 1 pilot), Critical Path §9 (audits required before coding)
Purpose
Inventory the real D7 field model behind the Images site so the D11 shanti_image
content type can be scoped from data, not guesswork. This is the one genuinely
unproven unknown for the ADR 009 Step 1a public-plumbing slice. KMaps fields
(Spike 1) and Solr read (Spike 2) are already proven; this audit closes the gap.
Key finding: it's five content types + a sidecar table, not one content type
The Images site is not a single content type. It is a primary type plus three
inline-referenced satellite types and one nested lookup type, all defined by the
shanti_image_type Feature:
| Bundle | Role | # fields | Referenced from |
|---|---|---|---|
shanti_image |
Primary image metadata record | 50 | — (the node users see) |
image_agent |
Person in image provenance (photographer, owner…) | 5 | field_image_agents (inline entity form, required) |
image_descriptions |
Caption/summary/description w/ author + language | 4 | field_image_descriptions (inline entity form) |
external_classification |
One tag in an external scheme (LoC, Getty…) | 3 | field_external_classification (inline entity form) |
external_classification_scheme |
Defines an external scheme | (lookup) | field_external_class_scheme on external_classification (nested) |
The entity graph. Three satellites hang directly off a shanti_image node, and a
fourth type is nested one level deeper under external_classification:
shanti_image
├── field_image_agents → image_agent (REQUIRED, unlimited)
├── field_image_descriptions → image_descriptions (optional, unlimited)
└── field_external_classification → external_classification (optional, unlimited)
└── field_external_class_scheme → external_classification_scheme
Migration implication: an Image is an entity graph, not a flat node. Migrating
one image means migrating the shanti_image node plus its referenced agent /
description / classification nodes (and the nested scheme) and re-establishing the
references.
Decision (2026-06-16): collapse the satellite graph to Paragraphs. Two models were weighed: keep the satellites as separate referenced nodes (D7 parity, lowest migration risk) vs. collapse them into Paragraphs / composite sub-entities (cleaner model, owned lifecycle, inherited access). ADR 010 (Accepted 2026-06-16) settles that "migrate, not improve" (ADR 008) governs user-facing behavior, not internal data modeling — so remodeling the graph is permitted and is not an MVP scope violation. On the merits, the team chose Paragraphs: the deciding factor was debt item #2 (access propagation) — a silent privacy risk far cheaper to get right via inherited access than to retrofit onto independent satellite nodes — reinforced by #1 (no node-space suppression) and #6 (no future node→paragraph re-migration). The choice remains bound by ADR 008's faithful-migration floor (data round-trips identically, stays retrievable).
A later concern — that denormalizing agents might destroy a meaningful shared identity (agent nodes are reused across images) — was tested against the production data and dismissed by the room (2026-06-16): agents are 99.8% per-image, the reuse is a small set of concentrated import defaults ("Unknown" photographer), and D7 exposes no browse-by-agent, so paragraphs lose nothing user-facing. Evidence in Data profile below.
Scope of this decision: it applies to the Images satellite graph only. It sets no precedent for other content types or sites — per ADR 010, each such case is decided on its own merits. Do not cite "Images collapsed to paragraphs" as a general rule.
The debt below is what the keep-as-nodes path would have incurred; it is recorded as the rationale for collapsing, not as accepted debt.
Technical debt of preserving the entity graph (keep-as-nodes path)
The D7 model is already paragraph-shaped, built from the wrong primitive: the satellite
references use Inline Entity Form with allow_existing => 0 (each child created fresh
inside the parent, never shared) and, for agents, delete_references => 1 (deleting the
image deletes its agents). That is a composite/owned relationship emulated with full
nodes. Carrying it forward as nodes in D11 incurs:
- Node-space pollution requiring active suppression. Agent/description/classification nodes are "not viewed directly" (per the module README) yet remain real nodes with nids, URLs, revisions, and access records. They must be actively excluded from Solr indexing, Views listings, sitemaps, JSON:API collections, and pathauto — every surface that lists "nodes" is a potential leak. Paragraphs never enter node-space, so this burden vanishes.
- Access-propagation coupling (security-adjacent).
shanti_imagecarriesgroup_content_access(Visibility, required); each satellite node has independent access. Nothing automatically makes a private image's description node private — the parent's visibility must be propagated to every satellite and kept in sync on edit. Paragraphs inherit parent access for free. This intersects directly with the Step 1b proxy-auth work and solr-proxy visibility filtering (ADR 009); it is silent and a privacy leak if missed — much cheaper to design into Step 1b than retrofit. - Inconsistent orphan lifecycle. Cascade-delete is per-field and not uniform: agents
cascade (
delete_references => 1), external_classification does not (=> 0). Some satellites are orphaned by design; cleanup logic becomes ours to own forever. - IEF dependency for the edit UX. The inline editing experience leans on the Inline Entity Form contrib module (its D11 maturity and quirks) rather than first-class Paragraphs tooling.
- Loose referential integrity. entityreference-by-nid has no DB-level FK; the five-type, dependency-ordered migration must preserve/remap nids exactly or leave a broken graph.
- Likely second migration later. If a post-MVP pass moves these to paragraphs (the natural model), that is a second content migration (node → paragraph) on live data — the most expensive kind. Deferring with interest.
Items #1 and #2 fold into Solr and auth work already on the Phase 1 plan; #2 is the sharp one. The counterweight is real: collapsing now adds target-model design and node→paragraph transform-plugin risk during the pilot meant to establish the shared pattern.
Scope decision: all five content types are in scope for Step 1a
Step 1a (public plumbing) builds and migrates the full entity graph, not just the primary node:
shanti_image(primary)image_agent(required satellite)image_descriptions(satellite)external_classification(satellite)external_classification_scheme(nested lookup)
Rationale: this proves the complete inline-entity-graph migration pattern once, in the
pilot, rather than deferring the satellite complexity into a later parallel track where
it would be rediscovered. The required field_image_agents reference also makes this
non-optional — a valid shanti_image node cannot be created without at least one
image_agent, so that type must exist and migrate first regardless.
Note: "five content types" describes the D7 model. Per the decisions above, the D11 target is:
shanti_image— content type- agents / descriptions / classifications — Paragraph types embedded on
shanti_image(owned, per-image, cascade-deleted with the image) external_classification_scheme— a taxonomy vocabulary, referenced by the classification paragraphs (shared lookup, one canonical record per scheme; see decision below)
The full entity graph's data is in scope for Step 1a either way; the target shape is now settled.
Decision (2026-06-16): external_classification_scheme is a taxonomy vocabulary.
The scheme is a small, editorially-managed, shared controlled list (Getty TGN, LoC, …) —
"contact us to add a scheme," per the D7 UI — referenced by many classification
paragraphs. Modeled as a taxonomy vocabulary (terms carrying the scheme's URL / API
fields), referenced via an entity-reference field on the classification paragraph. Chosen
over a custom content entity because there is no real per-scheme behavior in scope:
link-to-external construction (external ID → URL) rides on a field formatter or small
service, not entity-class logic. Caveat noted: if substantial scheme behavior emerges
later, promoting taxonomy → custom entity is itself a (small, low-volume) migration.
Migration ordering (with the satellites collapsed to paragraphs):
external_classification_scheme→ migrate D7 scheme nodes into taxonomy terms first (no deps); they remain referenced entities, not paragraphs.shanti_image→ sourced from the D7 image node, pulling its D7image_agent/image_descriptions/external_classificationnodes in and transforming each into an embedded paragraph on the image (classification paragraphs reference the scheme term from step 1). No separate pre-migration of the satellite nodes.- Source satellites via the image reference field, not the raw node table — this skips orphaned satellite nodes (~12,091 unreferenced agent nodes, ~17,412 unreferenced description nodes; see Data profile) that should not be migrated.
- Expect fan-out on shared agents: the 146 agents shared across images (esp. the ~3,219-image "Unknown"/default) duplicate into one paragraph per referencing image, so the agent-paragraph count ≈ 111,345, not the 91,852 distinct source agents. Acceptable and expected — not a bug.
- the
shanti_imagessidecar / IIIF identity linkage (below).
The shanti_images sidecar table (not a content type)
shanti_images_schema() defines a custom table that maps a node to its file and its
IIIF-server identity. It is a registry, not Drupal field data:
| Column | Meaning |
|---|---|
siid |
Shanti Image ID (PK, serial) |
fid |
Drupal file id of the uploaded original |
nid |
The shanti_image node it belongs to |
uid |
Uploader |
i3fid |
IIIF file id on the image server, form shanti-image-123 |
filename |
Original filename |
width / height |
Pixel dimensions (backfilled from IIIF) |
created |
Timestamp |
mmsid |
Legacy Media Management System id (0 if not an MMS import) |
Migration implication: this table is the join between a node and the external
IIIF server. Because the IIIF server stays as-is (see decision below), the
i3fid values remain valid and the D11 model must preserve this linkage verbatim
(a field/sub-entity carrying i3fid, dimensions, and mmsid) rather than
reconstruct or re-derive IIIF identity. The mmsid and field_other_ids carry
legacy MMS IDs that external links and several APIs depend on (per the module
README) — they must survive migration.
Decision: the IIIF server stays as-is
The existing external IIIF server is out of scope for the rebuild and stays in
place for the foreseeable future. D11 does not stand up a new IIIF stack or change
the shanti-image-NNN identifier scheme. This turns IIIF from an open design
question into a wiring task: point D11 at the existing IIIF upload/view/delete
URLs (the D7 shanti_images_*_url settings) and keep the i3fid linkage intact
through migration. Consistent with ADR 004
(treat existing external infrastructure as source of truth, don't refactor it for MVP).
shanti_image field inventory (50 fields)
Storage type is the D7 field-base type; cardinality -1 = unlimited.
KMaps fields (ride on Spike 1 — proven)
| field_name | type | card | label | notes |
|---|---|---|---|---|
field_subjects |
shanti_kmaps_fields_default | -1 | Subjects | kmassets subjects domain |
field_places |
shanti_kmaps_fields_default | -1 | Places | kmassets places domain |
field_kmap_terms |
shanti_kmaps_fields_default | -1 | Terms | kmassets terms domain (no search root) |
field_kmap_collections |
shanti_kmaps_fields_default | -1 | Kmap Collections | search root kmapid 2823 |
Image + identity
| field_name | type | card | label |
|---|---|---|---|
field_image |
image | -1 | Image (extensions: jpg jpeg png jp2 tif tiff cr2) |
field_original_filename |
text | 1 | Original Filename |
field_other_ids |
text | -1 | Other IDs (holds legacy MMS IDs) |
Referenced satellite entities
| field_name | type | card | req | label | target |
|---|---|---|---|---|---|
field_image_agents |
entityreference | -1 | 1 | Image Agents | image_agent |
field_image_descriptions |
entityreference | -1 | 0 | Image Descriptions | image_descriptions |
field_external_classification |
entityreference | -1 | 0 | Other Classifications | external_classification |
Access / collections (OG → Group module migration)
| field_name | type | req | label | notes |
|---|---|---|---|---|
group_content_access |
(OG) | 1 | Visibility | OG access — Phase 1b auth concern |
field_og_collection_ref |
(OG) | 0 | Collection | OG membership → Group relationship |
Descriptive metadata
field_image_type (list_text, req), field_image_color (list_text, req),
field_image_quality (list_text, req), field_image_rotation (list_integer, req),
field_keywords (text), field_spot_feature (text), field_physical_size (text),
field_image_materials (text_long), field_image_enhancement (text_long),
field_image_notes (text_long), field_general_note (text),
field_technical_notes (text_long), field_classification_notes (text_long),
field_admin_notes (text_long), field_private_note (text),
field_image_digital (list_boolean), field_noise_reduction (list_boolean).
Rights / provenance
field_copyright_holder (text), field_copyright_date (datetime),
field_copyright_statement (text_long), field_license_url (url, req),
field_rights_notes (text_long), field_organization_name (text),
field_project_name (text), field_sponsor_name (text).
Geo
field_latitude (text), field_longitude (text), field_altitude (number_float),
field_spot_feature (text).
Camera / EXIF (mostly admin-hidden)
field_aperture, field_exposure_bias, field_focal_length (number_float),
field_iso_speed_rating (number_integer), field_lens, field_light_source,
field_metering_mode, field_sensing_method, field_flash_settings,
field_image_capture_device — all single-value text/number.
Satellite type fields
- image_agent:
field_agent_role(list_text),field_agent_place(KMaps, -1),field_agent_dates(datetime),field_agent_dates_approx(text),field_agent_notes(text_long). - image_descriptions:
field_description(text_long),field_summary(text),field_author(text, -1),field_language(KMaps, search root 301). - external_classification:
field_external_class_scheme(entityref →external_classification_scheme),field_external_class_id(text),field_external_class_note(text_long).
Data profile (production dump, 2026-06-11)
Verified against the production Images DB (mandala-prod-images-db_2026-06-11.sql.gz):
111,340 shanti_image nodes. Other bundles: 103,943 image_agent, 72,453
image_descriptions, 13 external_classification, 2 external_classification_scheme,
plus 55 collections / 116 subcollections / 11 asset_link / 6 page.
Required fields: clean — zero migration blockers
All four required list_* fields carry a value on every image (0 missing) with 0
out-of-list values, and the required field_image_agents reference is satisfied on all
111,340 images (0 missing). Distribution is heavily default-skewed (MMS bulk-import
defaults, 2018–19) — low signal, but faithful per ADR 008:
| Required field | values in use | top value | missing | out-of-list |
|---|---|---|---|---|
field_image_type |
8 of 14 | photograph 110,800 (99.5%) | 0 | 0 |
field_image_color |
1 of 2 | color 111,340 (100%; bw unused) |
0 | 0 |
field_image_quality |
4 of 5 | Average 111,284 (99.95%) | 0 | 0 |
field_image_rotation |
4 of 4 | 0º 108,696 (97.6%) | 0 | 0 |
⚠️ field_image_quality keys are capitalized words (Excellent/Good/Average/
Poor/Unusable), not slugs — keep them verbatim in D11; stored content uses these keys.
Satellite volumes & the agent reuse check
- Descriptions: on 48,140 of 111,340 images (43% have none); 55,041 refs — genuinely per-image, not shared. (72,453 description nodes exist but only 55,041 are referenced → ~17,412 orphaned description nodes.)
- External classification: only 8 images, 9 refs, 2 schemes — a tiny slice.
- Agents — paragraph choice confirmed on evidence. 91,852 distinct agents are referenced; 99.8% (91,706) are used by exactly one image. Only 146 are shared across >1 image, absorbing 19,639 refs and topped by a single agent on 3,219 images — the signature of the importer's "Default Photographer / Unknown" fallback, not a normalized identity. D7 exposes no browse-by-agent (only an admin View; agents render inline as provenance). ~12,091 agent nodes are orphaned (103,943 exist, 91,852 referenced). Conclusion: agents are owned per-image data → Paragraphs (see migration notes).
What this audit establishes (ready to scope Step 1a)
- The D11
shanti_imagecontent type field list, types, cardinalities, and required flags. - The full entity graph: three satellites referenced directly from
shanti_image(image_agentrequired,image_descriptions,external_classification) plus the nestedexternal_classification_schemelookup — all five in scope for Step 1a. - 4 KMaps fields confirmed → directly reuse the Spike 1 field type; map to kmassets
subjects/places/terms(+ a collections root). - The IIIF/MMS identity linkage (
shanti_imagestable,i3fid,mmsid,field_other_ids) that must be preserved. - Production-data validation (111,340 images): required fields and the required agent reference are 0-missing / 0-out-of-list, and agents are 99.8% per-image — confirming both a clean migration and the paragraph model. See Data profile.
What this audit does NOT establish (still open)
- IIIF wiring details (not a strategy decision). The IIIF server decision is made
(stays as-is, above), so what remains is implementation: confirm the existing IIIF
upload/view/delete endpoints and credentials are reachable from D11, and port the D7
upload/display path (
shanti_images_*_urlsettings, theshanti_image_formatter) to D11. No new IIIF stack and noshanti-image-NNNidentifier change. - OG → Group migration mapping.
group_content_access(Visibility) andfield_og_collection_refdepend on the collections architecture (Spike 3, still partial) and the proxy-auth contract (ADR 009 Step 1b). Out of scope for Step 1a public plumbing. - JSON/AJAX API parity.
shanti_images_node_json()(api/json/{nid}) and theapi/*family are Phase 5; documented in the module README, not re-derived here. - ~~
list_*allowed-values inventory.~~ RESOLVED (2026-06-16) — option sets extracted fromfield_baseand validated against the production dump: required fields are 0-missing / 0-out-of-list. See Data profile.
Recommended next step
Both modeling decisions are settled: ADR 008 scope by ADR 010 (remodeling permitted), and the satellite graph collapses to Paragraphs (Images-only; above). Ready to scope the ADR 009 Step 1a slice:
- Build the D11
shanti_imagecontent type with the three satellites as paragraph types (agents / descriptions / classifications),external_classification_schemeas a taxonomy vocabulary referenced from the classification paragraph, and the 4 KMaps fields wired to kmassets. - Wire image display to the existing IIIF server (a known quantity, not a decision); the remaining IIIF risk is wiring (open item #1) — verify D11 can reach the endpoints and port the upload/display path.
- Write the D7 → D11 migration as a node→paragraph transform for the satellites, in the dependency order above (source satellites via the image reference to skip orphans; expect shared-agent fan-out); defer OG/auth to Step 1b.
All four pre-build modeling/data questions are now closed (paragraphs, taxonomy scheme, IIIF-as-is, list-value drift). The remaining open items (#1 IIIF wiring, #2 OG/auth,