# CRM — Linked Open Data for Aesthetic Computer **Status:** ✅ Stage 1 LIVE at https://data.aesthetic.computer (deployed 2026-06-29; **open to all public handles**, In-Copyright by default; `@jeffrey` & `@fifi` upgraded to CC-BY-4.0) · **Owner:** @jeffrey · **Drafted:** 2026-06-28 Live & verified: landing recent-records feed (all public contributors), `/@{handle}` (Person), `/painting/{code}` (image URLs resolve 200), `/mood/{handle}/{rkey}`, `/.well-known/void`, content negotiation (JSON-LD vs. HTML), and opt-out exclusion. Cloudflare `data` A record (→ 209.38.133.33, proxied) created via vault token. **Stage 1 ships in these files:** - `system/backend/linked-art.mjs` — pure Linked Art serializers + license/rights registry (CC + rightsstatements.org InC). - `system/netlify/functions/crm.mjs` — endpoint: path routing, `rightsFor` (open-by-default/In-Copyright, opt-out, CC upgrade), content negotiation, recent-records landing, VoID, 501 SPARQL stub. - `system/tests/linked-art.test.mjs` — `node --test system/tests/linked-art.test.mjs` (7 passing). - `lith/server.mjs` — host rewrite `data.aesthetic.computer/* → /api/crm/*`. - `lith/Caddyfile` — `@data` host block → lith. **Rights controls** (set by hand in `@handles` until a self-serve toggle lands): - opt out: `{$set:{linkedData:{enabled:false}}}` → excluded entirely. - CC upgrade: `{$set:{linkedData:{enabled:true, license:'CC-BY-4.0', optInAt:new Date()}}}`. - default (no flag) → In Copyright. In dev, `?preview=CC-BY-4.0` forces a license for validation. Expose AC users' work as **dereferenceable linked data** so cultural-heritage researchers (Getty, museums, the American Art Collaborative) can discover, cite, and federate over it — e.g. a Getty researcher querying *"all digital paintings produced by @sat in 2026"* against AC's endpoint joined with their own collection. This is the **research/archival sibling of the Bluesky mirror**. Bluesky broadcasts moods to a social firehose; this broadcasts *all* opted-in work to the semantic web. Same architecture (opt-in allowlist, dual-channel, stable external identity), different audience and ontology. --- ## 1. Precedent in the repo — the Bluesky pattern `system/backend/bluesky-mirror.mjs` is the template we are deliberately copying: - **Opt-in allowlist** — only handles in `secrets.bluesky.mirrorHandles` broadcast. - **Dual-channel** — a mood lives in Mongo *and* gets a stable external identity (`atproto.rkey`, `bluesky.uri`) plus a public permalink (`https://aesthetic.computer/moods~{handle}~{rkey}` — `bluesky-mirror.mjs:80`). - **Pull-back** — engagement is re-fetched from a public API (`bluesky-engagement.mjs`). CRM keeps all three properties. The "external identity" becomes an RDF URI; the "broadcast target" becomes a SPARQL store + JSON-LD content negotiation. --- ## 2. Strategic decision: target **Linked Art**, a profile of CIDOC CRM CIDOC CRM (ISO 21127) is the *ontology*. But Getty and peer institutions do not ingest raw CRM RDF in practice — they consume **[Linked Art](https://linked.art)**, a JSON-LD profile of CIDOC CRM with fixed property names (`produced_by`, `carried_out_by`, `classified_as`) and Getty AAT vocabulary terms. > **Decision:** emit **Linked Art JSON-LD** as the primary representation, with > the **CIDOC CRM** classes underneath. A SPARQL endpoint exposes the same > triples. "Through CIDOC CRM" is satisfied by Linked Art; raw-CRM purists can > still query the SPARQL store. Why this matters: Linked Art is what their pipelines actually parse, and it gives us an off-the-shelf `@context`, validators, and example consumers. --- ## 3. URI strategy — a dedicated identity domain: `data.aesthetic.computer` **Decision:** canonical linked-data identifiers live on **`data.aesthetic.computer`**, mirroring Getty's own `data.getty.edu` and Europeana's `data.europeana.eu`. A dedicated domain is the cultural-heritage convention precisely because the identifier must resolve *forever* while the public app (`aesthetic.computer`) keeps changing. Each identity URI carries a `subject_of`/`equivalent` link back to the human-facing page on the apex, so the two never drift. | Entity | Canonical identity URI | Links to (human page) | Source of truth | |---|---|---|---| | Person | `https://data.aesthetic.computer/@sat` | `aesthetic.computer/@sat` | `@handles`, `users` | | Painting | `https://data.aesthetic.computer/painting/{code}` | `aesthetic.computer/painting/{code}` | `paintings` (image at `/media/@sat/painting/{slug}.png`) | | Piece (KidLisp/.mjs) | `https://data.aesthetic.computer/piece/{code}` | `aesthetic.computer/{$code}` | `pieces` / `kidlisp` | | Mood | `https://data.aesthetic.computer/mood/{handle}/{rkey}` | `aesthetic.computer/moods~{handle}~{rkey}` | `moods` (`atproto.rkey`) | Dereferencing rule on `data.aesthetic.computer/{path}`: default (or `Accept: application/ld+json`, `?format=jsonld`) → **Linked Art JSON-LD**; `Accept: text/html` → a small human-readable view that also ``s the apex page. `data.aesthetic.computer/sparql` and `/.well-known/void` complete the hub. > The JSON-LD examples in §4 still show `aesthetic.computer/...` ids for > readability — read them as `data.aesthetic.computer/...` per this table. --- ## 4. Entity mapping — CIDOC CRM classes + Linked Art shape All four entities attribute back to the **Person** node, which is the hub. ### 4.1 Person — `@sat` → `E21 Person` ```json { "@context": "https://linked.art/ns/v1/linked-art.json", "id": "https://aesthetic.computer/@sat", "type": "Person", "_label": "@sat", "identified_by": [ { "type": "Name", "content": "@sat", "classified_as": [{ "id": "http://vocab.getty.edu/aat/300404670", "_label": "primary name" }] } ], "subject_of": [ { "type": "LinguisticObject", "_label": "AC profile", "digitally_carried_by": [{ "type": "DigitalObject", "access_point": [{ "id": "https://aesthetic.computer/@sat" }] }] } ] } ``` - Latest mood can ride along as a `subject_of` LinguisticObject. - **No email, Auth0 sub, or `verifications` data is ever emitted.** Only the public handle and color identity. ### 4.2 Painting → `E22 Human-Made Object` / `D1 Digital Object` Production event `E12 Production` carries the authorship + time-span. ```json { "@context": "https://linked.art/ns/v1/linked-art.json", "id": "https://aesthetic.computer/painting/Abc123", "type": "DigitalObject", "_label": "painting Abc123 by @sat", "classified_as": [ { "id": "http://vocab.getty.edu/aat/300033973", "_label": "paintings (visual works)" }, { "id": "http://vocab.getty.edu/aat/300312038", "_label": "digital images" } ], "produced_by": { "type": "Production", "carried_out_by": [{ "id": "https://aesthetic.computer/@sat", "type": "Person" }], "timespan": { "type": "TimeSpan", "begin_of_the_begin": "2026-03-15T00:00:00Z" }, "took_place_at": [{ "id": "https://aesthetic.computer", "type": "Place", "_label": "Aesthetic Computer" }] }, "subject_to": [{ "type": "Right", "classified_as": [{ "id": "https://creativecommons.org/licenses/by/4.0/", "_label": "CC BY 4.0" }] }], "digitally_shown_by": [{ "type": "DigitalObject", "format": "image/png", "access_point": [{ "id": "https://aesthetic.computer/media/@sat/painting/Abc123.png" }] }] } ``` Source fields: `paintings.code`, `.slug`, `.when`, `.user` (→ resolved to handle). ### 4.3 Piece (KidLisp / .mjs) → `E73 Information Object` + `E65 Creation` Generative-art provenance — a novel, valuable contribution to the CRM graph. The *source code itself* is a `LinguisticObject`; the running program is the `DigitalObject`. Hash gives content-addressed identity. ```json { "@context": "https://linked.art/ns/v1/linked-art.json", "id": "https://aesthetic.computer/$cow", "type": "DigitalObject", "_label": "KidLisp piece $cow by @sat", "classified_as": [ { "id": "http://vocab.getty.edu/aat/300265727", "_label": "software" }, { "id": "http://vocab.getty.edu/aat/300047090", "_label": "generative art" } ], "created_by": { "type": "Creation", "carried_out_by": [{ "id": "https://aesthetic.computer/@sat", "type": "Person" }], "timespan": { "type": "TimeSpan", "begin_of_the_begin": "2026-03-15T00:00:00Z" } }, "identified_by": [{ "type": "Identifier", "content": "", "classified_as": [{ "id": "http://vocab.getty.edu/aat/300435704", "_label": "checksum" }] }], "subject_to": [{ "type": "Right", "classified_as": [{ "id": "https://creativecommons.org/publicdomain/zero/1.0/", "_label": "CC0" }] }], "carries": [{ "type": "LinguisticObject", "content": "(wipe blue) (ink red) ...", "classified_as": [{ "id": "http://vocab.getty.edu/aat/300028676", "_label": "source code" }] }] } ``` Source fields: `pieces.code`, `.source`, `.hash`, `.when`, `.user`, `.trustLevel` (only `trusted` pieces broadcast — natural spam gate). ### 4.4 Mood → `E33 Linguistic Object` + `E65 Creation` Easiest map; already has `atproto.rkey` permalink and a Bluesky cross-ref we can surface as `equivalent`/`subject_of`. ```json { "@context": "https://linked.art/ns/v1/linked-art.json", "id": "https://aesthetic.computer/moods~sat~3k...", "type": "LinguisticObject", "_label": "mood by @sat", "classified_as": [{ "id": "http://vocab.getty.edu/aat/300026032", "_label": "statements" }], "content": "feeling generative today", "language": [{ "id": "http://vocab.getty.edu/aat/300388277", "_label": "English" }], "created_by": { "type": "Creation", "carried_out_by": [{ "id": "https://aesthetic.computer/@sat", "type": "Person" }], "timespan": { "type": "TimeSpan", "begin_of_the_begin": "2026-03-15T00:00:00Z" } }, "subject_of": [{ "type": "DigitalObject", "access_point": [{ "id": "at://did:plc:.../app.bsky.feed.post/..." }] }] } ``` Source fields: `moods.mood`, `.when`, `.user`, `.atproto.rkey`, `.bluesky.uri`. Skip `deleted:true`. --- ## 5. Rights — open records by default, In Copyright (the museum model) The work shown here is *already public* on aesthetic.computer; a linked-data record is a catalog entry describing it (authorship, date, type, image link) — exactly what museums publish for living artists' in-copyright work. So the model is **open by default**, not an opt-in allowlist: - Every public handle's records are live, marked **In Copyright** (`http://rightsstatements.org/vocab/InC/1.0/` — the Europeana/DPLA standard): the record is open + citable, but copyright and the right to sell stay with the artist. This is what Getty pipelines need (a rights statement) without us granting reuse rights on anyone's behalf. - `@handles.linkedData = { enabled, license, optInAt }` is now an **upgrade / opt-out** flag, not a gate: - `enabled === false` → excluded entirely (opt-out). - `license` is a CC key (`CC-BY-4.0`, `CC0-1.0`, …) → that open license. - otherwise (default) → In Copyright. - Why not default to CC-BY: CC-BY grants *third parties* commercial reuse, which would undercut artists' own ability to sell. In Copyright preserves it. - Privacy floor: never emit email, Auth0 `sub`, IP/boot telemetry, chat, or `verifications`. Only public handle + the four content types above. A `prompt`-driven command (`linkdata cc-by` / `linkdata off`) is the natural self-serve toggle later; for now it's set by hand in `@handles`. --- ## 6. The `data.aesthetic.computer` subdomain — wired like `at.aesthetic.computer` The AT subdomain is the exact template (three layers): 1. **Landing page** — `at/landing-page.html` is a human-facing "here's what we offer" page (mission + top users + media feed). LOD gets the sibling `crm/landing-page.html` → served as static `root` for `data.aesthetic.computer/`: what linked open data is, the license, a live SPARQL box, and an example Getty-style federated query. 2. **Backend service reverse-proxied behind it** — the knot does `reverse_proxy localhost:5555` (`at/knot/infra/Caddyfile`). LOD does the same for Oxigraph: `data.aesthetic.computer/sparql → reverse_proxy localhost:7878`, and `/painting/* /piece/* /mood/* /@*` → `reverse_proxy localhost:8888` (the `crm.mjs` serializer on lith). 3. **Cloudflare DNS record + a `lith/Caddyfile` host block.** New `data` record in the `aesthetic.computer` zone (add to `lith/DNS.md`), and a block mirroring the existing `@give`/`@papers` pattern: ```caddyfile @data host data.aesthetic.computer handle @data { handle /sparql* { reverse_proxy localhost:7878 } # Oxigraph handle /.well-known/void { reverse_proxy localhost:8888 } handle /painting/* { reverse_proxy localhost:8888 } # crm.mjs serializer handle /piece/* { reverse_proxy localhost:8888 } handle /mood/* { reverse_proxy localhost:8888 } handle /@* { reverse_proxy localhost:8888 } handle { # static landing root * /opt/ac/system/public/data.aesthetic.computer file_server } } ``` Stage 1 only needs layers 1 + 3 + the `localhost:8888` routes (no Oxigraph); Stage 2 adds the `localhost:7878` proxy. Same staging as below. ## 7. Architecture — staged rollout ### Stage 1 — Subdomain + content negotiation → Linked Art (cheapest, no triple store) - `data` DNS record + `@data` Caddy block (§6) + static `crm/landing-page.html`. - New function `system/netlify/functions/crm.mjs` (routes `/painting/{code}`, `/piece/{code}`, `/mood/{handle}/{rkey}`, `/@{handle}`) + a serializer lib `system/backend/linked-art.mjs` (`paintingToLinkedArt`, `pieceToLinkedArt`, `moodToLinkedArt`, `personToLinkedArt`). - Reuses current Mongo indexes (`paintings.code`, `pieces.code`, `moods.atproto.rkey`). **Pure serializer over data we already have. No triple store, no new process — just lith.** - Validate output against linked.art tooling + a JSON-LD playground. ### Stage 2 — SPARQL endpoint ✅ LIVE - **[Oxigraph](https://github.com/oxigraph/oxigraph)** v0.5.9 (single Rust binary at `/opt/oxigraph`) runs on **lith** bound to `127.0.0.1:7878` under `oxigraph.service`; Caddy proxies only `data.aesthetic.computer/sparql → /query` (read-only — `/update` + `/store` are never exposed; verified 404 publicly). - **ETL** (`crm/build-graph.mjs`): walks MongoDB → runs every entity through the same serializers → expands to CIDOC CRM N-Triples via `rdf.mjs` (bundled linked.art context, offline) → atomic Graph-Store PUT replacing the default graph. `oxigraph-sync.timer` rebuilds every 30 min. Provisioned by `crm/deploy-sparql.fish`. (A full rebuild is ~15 min for ~7.7k entities; a `_firehose`-driven incremental update is a future optimization.) - **Verified live:** 163,994 triples; authorship exact vs. Mongo ground truth (@jeffrey → 409 paintings = 409; distinct productions 2238 = handled-user paintings 2238). Example query on the landing page + VoID `void:sparqlEndpoint`. - **Two bugs found + fixed during rollout:** (1) `jsonld.toRDF` restarts blank labels at `_:b0` per call → concatenation merged every work's production/ time-span into one node → skolemize per doc; (2) the Mongo driver kept the event loop alive so the oneshot never exited (hung `activating`, blocking the timer) → `process.exit(0)` on success. ### Stage 3 — Discovery & images - Sitemap of entity URIs; `robots`/`llms.txt` note pointing crawlers to the LOD. - **IIIF Image API** over the painting CDN — Getty's image tooling is IIIF-native; this is high-leverage for paintings specifically. - Optional: OAI-PMH feed for harvesters; federated-query examples in docs. --- ## 8. Decisions **Resolved:** - **Domain** → `data.aesthetic.computer` (mirrors `data.getty.edu`). ✓ - **Store** → Oxigraph on lith (`localhost:7878`). ✓ - **AAT terms** → all verified against the live Getty SPARQL endpoint (vocab.getty.edu) 2026-06-29. The first draft was almost entirely wrong (`300265727` "software" was actually *wanted posters*); corrected ids are now in `linked-art.mjs` and asserted in the test. No Getty term exists for "source code" or "checksum" — those carry unclassified. ✓ **Still open:** 1. **v1 allowlist vs. self-serve** — curated opt-in to start (per-handle `@handles.linkedData`, set by hand like now), open a `linkdata` prompt command later for self-serve. 2. **License menu** — which CC licenses to offer; default per entity type (paintings CC BY, pieces CC0?). `@jeffrey` is currently all CC-BY-4.0. --- ## 9. Build order 1. ✅ **Stage 0 + serializer lib** — `linkedData` consent gate (in `crm.mjs`) + `linked-art.mjs` with all four mappers + `node --test` fixtures (6 passing). 2. ✅ **Stage 1** — `@data` Caddy block + lith host rewrite + `crm.mjs` serving Linked Art + inline landing + VoID. *Pending:* Cloudflare `data` record + deploy + external validation against linked.art tooling. 3. ✅ **Stage 2** — Oxigraph on lith (`127.0.0.1:7878`) + `crm/build-graph.mjs` ETL + 30-min `oxigraph-sync.timer` + read-only `/sparql` + `void:sparqlEndpoint`. 4. ✅ **Stage 3** — `iiif.mjs` IIIF Presentation v3 Manifest + Image Level-0 `info.json` per painting (dims from the PNG header; validates okay=0-errors on the official IIIF validator) + `/sitemap.xml` (7721 URIs) + Getty-federated query example. Landing redesigned (PALS beacon, "data" corner, auto light/dark, auto-scroll slideshow, Access Points as the pitch, footer link-backs). `data` routes from the AC prompt. All 2929 handles included (0 opt-outs).