# OpenTechEvents (OTE Spec) — full corpus
Generated from the repository. Source of truth: https://github.com/OpenTechEvents/opentechevents-spec
Spec version: v0.3. The spec is a draft (0.x) and fields can still change.
Each section below is a verbatim file from the repo, in the order a reader new to OTE should
meet them. Answer from these files and say which one you used; if something is not here, say so
rather than inventing a field.
==============================================================================
SECTION: Start here
==============================================================================
## overview.md
Source: README.md — What OTE is, who it is for, and how a community adopts it.
```markdown
# OpenTechEvents — OTE Spec
> 🌐 **[opentechevents.org](https://opentechevents.org)** — web del proyecto (código en [`docs/`](docs/)).
Una especificación estándar y abierta para describir, publicar y compartir **eventos de comunidades tecnológicas** (meetups, conferencias, talleres, eventos online y presenciales).
El objetivo es ofrecer un formato único al que cualquier comunidad pueda adherirse, que se adapte a todos los formatos de evento y que sea altamente compatible con estándares y herramientas ya existentes (RSS, iCalendar, etc.), de modo que publicar y descubrir eventos deje de ser un trabajo manual y repetitivo.
La propuesta nace desde [**Community Builders (ComBuildersES)**](https://github.com/ComBuildersES) y vive ya en su propia organización, [**OpenTechEvents**](https://github.com/OpenTechEvents), con vocación internacional: aunque impulsada inicialmente desde la comunidad hispana, la especificación se diseña para ser usable por cualquier comunidad del mundo.
---
## El problema
Cada vez hay **más plataformas, más directorios y más herramientas** para crear y comunicar eventos, y cada vez es más accesible montar herramientas personalizadas. Esto, que en principio es positivo, tiene un contrapunto importante:
- **Los eventos están cada vez más diluidos y dispersos.** La información vive fragmentada en Meetup, Eventbrite, LinkedIn, webs propias, repositorios de GitHub, formularios de terceros, etc.
- **Para quien organiza/dinamiza**, dar difusión a un evento es cada vez más complejo: hay que dar de alta el mismo evento en múltiples plataformas, con formatos distintos, lo que multiplica el esfuerzo y el mantenimiento.
- **Para quien asiste**, mantenerse al día exige monitorizar muchas plataformas y directorios distintos para no perderse nada.
No falta información: falta **interoperabilidad**.
## La propuesta
Una especificación estándar que permita:
1. **Describir un evento una sola vez**, en un formato único y bien definido.
2. **Automatizar la difusión** de ese evento hacia múltiples directorios y plataformas a la vez.
3. **Transformar esos datos** a estándares ya consolidados (RSS, iCalendar…) para que sean compatibles, sin fricción, con las herramientas que la gente ya usa.
La idea: que una comunidad publique sus datos una vez y un ecosistema de herramientas se encargue del resto —ingerir, exportar, transformar y publicar— en cada destino.
## Principios de diseño
- **Universal por formato.** Debe servir igual para un meetup recurrente, una conferencia de varios días, un evento online o uno híbrido.
- **Compatibilidad ante todo.** Pensada para convivir y mapearse fácilmente a estándares existentes (RSS, iCalendar/ICS, JSON-LD / schema.org `Event`, etc.).
- **Fácil de adoptar.** Baja barrera de entrada para comunidades pequeñas; sin imponer herramientas concretas.
- **Reutilizable y automatizable.** Diseñada desde el principio para alimentar un ecosistema de herramientas de import/export y publicación.
- **Abierta.** Sin restricciones de uso, siguiendo la filosofía de estándares como RSS o iCalendar.
## Ecosistema de herramientas (visión)
Una vez estabilizada la especificación, el objetivo es construir un ecosistema que resuelva los problemas anteriores. Casos de uso previstos:
- **Importar / ingerir** eventos desde fuentes existentes hacia el formato OTE.
- **Exportar / transformar** a RSS, iCalendar y otros formatos compatibles.
- **Automatizar la publicación** en múltiples destinos: Meetup, LinkedIn, Eventbrite, repositorios de GitHub que aceptan *Pull Requests*, webs con formularios de alta, etc.
👉 El catálogo vivo de herramientas se mantiene en la [web](https://opentechevents.org/#tools) desde [`docs/data/tools.json`](docs/data/tools.json).
## La especificación
👉 **[OTE Spec v0.3](spec/v0.3/README.md)** — schemas ejecutables (JSON Schema 2020-12), prosa normativa y ejemplos validados en CI. Las [v0.1](spec/v0.1/README.md) y [v0.2](spec/v0.2/README.md) quedan congeladas; qué cambió, en el [CHANGELOG](CHANGELOG.md).
📄 **[Ejemplos prácticos](https://opentechevents.org/examples/)** — un documento entero por cada caso real: meetups pequeños y recurrentes, conferencias, eventos online e híbridos, multi-parte, hackathones, coorganizados y feeds. Todos salen de [`spec/v0.3/examples/`](spec/v0.3/examples/) y se validan en CI, así que se copian tal cual.
```bash
npm install @opentechevents/schema
```
```text
https://opentechevents.org/schema/v0.3/event.schema.json
https://opentechevents.org/schema/v0.3/feed.schema.json
```
> 🚧 **`0.x` puede romper sin previo aviso.** Se publica para que existan implementaciones reales —empezando por el importador de `.ics`— y para que rompan lo que esté mal. El debate sigue abierto en los issues [#5](https://github.com/OpenTechEvents/opentechevents-spec/issues/5) y [#6](https://github.com/OpenTechEvents/opentechevents-spec/issues/6).
¿Tienes un feed y quieres comprobarlo? `npm run validate -- mi-feed.json`.
🤖 **¿Prefieres preguntárselo a una IA?** El botón *Pregunta a una IA* de [opentechevents.org](https://opentechevents.org/) abre una conversación en tu asistente —en tu cuenta, sin API keys ni backend nuestro— con un mensaje que le manda leer antes de responder [`/llms.txt`](https://opentechevents.org/llms.txt) (índice de todas las fuentes) o [`/llms-full.txt`](https://opentechevents.org/llms-full.txt) (el repositorio entero: spec, schemas, ejemplos, herramientas e investigación). Ambos los genera `npm run build-llms` desde los ficheros del repo, así que nunca dicen algo que la spec no diga.
## Estado del proyecto
🚧 **Fase inicial.** Existe una **v0.3 implementable** y el trabajo se centra ahora en **construir sobre ella** (el agregador y su importador de `.ics`) para descubrir qué está mal. El nombre, el alcance y la gobernanza siguen siendo provisionales y abiertos a debate; la [licencia](#licencia) ya está decidida.
## Roadmap
1. ✅ **Investigación inicial** — análisis de estándares existentes (RSS, iCalendar, schema.org/Event…), plataformas y casos de uso reales. → [research/](research/README.md)
2. ✅ **v0.1 de la especificación** — modelo de datos mínimo, JSON Schema ejecutable y ejemplos. → [spec/v0.1/](spec/v0.1/README.md)
3. 🔜 **Validación con implementaciones reales** — el [agregador](https://github.com/OpenTechEvents/opentechevents-data) y su importador de `.ics` son el banco de pruebas: si el modelo no soporta una ingesta real, el modelo está mal. De ahí salieron la [v0.2](CHANGELOG.md) (`tags`, `location.geo`, `updatedAt`) y la v0.3 (`organizers`). Sigue abierto.
4. 🔜 **Adopción** — comunidades publicando feeds y directorios consumiéndolos. Sin datos reales, el estándar es teoría.
5. 🔜 **Ecosistema de herramientas** — ingesta, transformación y publicación automatizada. → [catálogo en la web](https://opentechevents.org/#tools)
## Estructura del repositorio
> El repositorio crecerá a medida que avance el proyecto. Estructura prevista:
- `README.md` — este documento.
- `CONTRIBUTING.md` — cómo participar en el diseño de la especificación.
- `research/` — resultados de la investigación inicial: análisis de plataformas, directorios y estándares.
- `spec/` — la especificación. Por ahora un **borrador inicial** del modelo de datos para ilustrar la idea.
- `docs/data/` — datos que alimentan la web pública: adoptantes, consumidores y catálogo de herramientas.
## De qué depende el éxito
Un estándar no vale por estar bien escrito, sino por **cuánta gente lo usa**. Es un efecto de red: cada comunidad, herramienta y difusión suma valor para todas las demás. El éxito de OTE depende de:
- **Una buena especificación, definida con apoyo.** Que cubra las necesidades reales de los distintos formatos y comunidades, diseñada de forma abierta y con suficientes manos y puntos de vista. Una spec pobre no la adopta nadie.
- **Adopción por comunidades y plataformas.** Que la implementen de verdad: que publiquen sus eventos en este formato (que expongan el esquema/feed) y que las plataformas y directorios lo acepten como entrada/salida. Sin datos reales, el estándar es teoría.
- **Un ecosistema de herramientas amplio y versátil.** Cuantas más herramientas existan —para ingerir, exportar, transformar, validar y publicar—, más fácil y atractivo es adoptarlo. La cantidad **y** la versatilidad importan: cubrir más plataformas, formatos y casos de uso baja la barrera de entrada.
- **Difusión.** Que se hable de él: blogs, charlas en meetups y conferencias, documentación, ejemplos. Una vía concreta y de bajo coste: que las webs que lo adopten muestren un **logo/badge** enlazando a la URL donde se puede **consumir su feed** (como en su día los botones de RSS), haciendo visible el estándar y facilitando que otros lo descubran y reutilicen.
Estas piezas se refuerzan entre sí: más adopción atrae más herramientas, más herramientas facilitan la adopción, y la difusión alimenta ambas. Por eso el [roadmap](#roadmap) prioriza primero una **buena especificación** y, sobre ella, el **ecosistema** y su **difusión**.
## Organización y gobernanza
Esta propuesta se impulsa desde [Community Builders (ComBuildersES)](https://github.com/ComBuildersES) y tiene ya **casa propia**: la organización [OpenTechEvents](https://github.com/OpenTechEvents) en GitHub y el dominio [opentechevents.org](https://opentechevents.org).
La estructura prevista dentro de la organización:
- este repositorio para **la especificación**, la web y los proyectos/comunidades adheridos a ella,
- posiblemente otro para **los datos**,
- y repositorios independientes para las **diferentes herramientas** del ecosistema.
El reparto exacto de repos y el modelo de gobernanza a largo plazo siguen abiertos: forma parte de lo que queremos consensuar con la comunidad.
## Preguntas frecuentes (FAQ)
**¿Qué es OTE exactamente?**
Una **especificación** (un formato de datos), no una plataforma ni una app. Define cómo describir un evento para que sea reutilizable e interoperable.
**¿Compite con Meetup, Eventbrite, Luma…?**
No. El objetivo es **interoperar** con ellas: describir el evento una vez y poder publicarlo/transformarlo hacia esas plataformas y directorios, no sustituirlas.
**¿Reemplaza a RSS o iCalendar?**
No. OTE se diseña para ser **compatible** y convertible a esos estándares. Un feed OTE puede exportarse a RSS, JSON Feed o iCal para consumirse con las herramientas que ya usas (lector RSS, app de calendario).
**¿Tengo que abandonar mis herramientas actuales?**
No. La idea es justo la contraria: que tus datos fluyan hacia las herramientas y plataformas que ya usas.
**Como comunidad, ¿qué gano adhiriéndome?**
Publicar tus eventos **una sola vez** y automatizar su difusión a múltiples directorios y plataformas, en lugar de dar de alta cada evento manualmente en cada sitio.
**Como asistente/usuario, ¿qué gano?**
Poder **suscribirte a feeds** y filtrar los eventos que te interesan, sin vigilar decenas de plataformas y directorios por separado.
**¿Esto es de Community Builders? ¿Es un estándar oficial ya?**
Lo impulsa [Community Builders](https://github.com/ComBuildersES) con vocación internacional y se desarrolla en su propia organización, [OpenTechEvents](https://github.com/OpenTechEvents), pero **no es un estándar oficial ni estable todavía**: está en fase de diseño y todo es provisional.
**¿Está listo para usarse en producción?**
Todavía no. Estamos diseñando la especificación (versión `0.x`, inestable). El [borrador del modelo](spec/) es ilustrativo y cambiará.
**¿Qué relación tiene con el directorio de comunidades de Community Builders?**
OTE describe **eventos**; el directorio describe **comunidades** (organizadores). Un evento *referencia* a su comunidad por un identificador global, sin acoplarse a ningún directorio concreto. Ese directorio es un **registro compatible** de referencia, no un requisito.
**¿Cómo puedo participar?**
Ver [Cómo contribuir](#cómo-contribuir).
## Cómo contribuir
El proyecto está en fase de diseño y **toda aportación es bienvenida**: experiencias, necesidades de tu comunidad, referencias de estándares y propuestas concretas. Lo que más falta ahora son **casos reales que rompan el modelo** y **gente que diga en público que adoptaría esto** — sin eso, ningún directorio se molesta en leer el formato.
**Publicar un feed no es el único modo de ayudar, ni el primero:**
| Qué | Cuánto cuesta | Por dónde |
| --- | --- | --- |
| Comprometerte a adoptarla cuando sea estable | 2 min | [issue de apoyo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=supporter.yml) |
| Dar un testimonio publicable | 5 min | [Discussions](https://github.com/OpenTechEvents/opentechevents-spec/discussions) |
| Contar un evento que la spec no sabe describir | 10 min | [issue de caso real](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=case.yml) |
| Difundirlo o presentarnos a alguien | variable | [issue de embajador](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=ambassador.yml) |
| Publicar un feed / consumirlos / montar una herramienta | ~1 h y más | [CONTRIBUTING.md](CONTRIBUTING.md) |
¿Prefieres hablarlo en una llamada de 20 minutos en vez de escribir un issue? [Reserva hueco](https://calendar.app.google/ZQuRkVw53h8nC2uQA); y si no te encaja ninguno, dilo en [Discussions](https://github.com/OpenTechEvents/opentechevents-spec/discussions).
👉 La lista completa está en [**CONTRIBUTING.md**](CONTRIBUTING.md): cómo apoyar sin publicar nada, cómo debatir la spec, cómo adherirse, cómo aparecer en la web, cómo traducir y cómo reclamar una herramienta del ecosistema.
## Contribuidores
[](#contribuidores)
Gracias a todas las personas que contribuyen a este proyecto ([clave de emojis](https://allcontributors.org/docs/en/emoji-key)):
Este proyecto sigue la especificación [all-contributors](https://github.com/all-contributors/all-contributors): se reconoce **cualquier tipo de contribución**, no solo código.
## Licencia
Dos licencias, ambas permisivas. Detalle y motivos en [LICENSE](LICENSE).
- **La especificación** (prosa, investigación, web): [**CC0-1.0**](LICENSES/CC0-1.0.txt) — dominio público. Implementar un estándar no debería exigir permiso ni atribución a nadie.
- **Los schemas y el código**: [**MIT**](LICENSES/MIT.txt). No van en CC0 porque **CC0 no concede derechos de patente explícitos** y hay políticas corporativas que prohíben consumir código bajo esa licencia — justo la barrera que no queremos delante de quien quiera implementar OTE.
**La licencia de tus datos es otra cosa** y la eliges tú, en el campo `license` de cada evento o feed. La spec recomienda **`CC-BY-4.0`** (cubre el derecho *sui generis* de bases de datos de la UE, cosa que la 3.0 no hace) o **`CC0-1.0`**. Desaconseja las licencias *share-alike* (`CC-BY-SA`, `ODbL`): contagian la obligación a cualquier feed agregado que incluya tus eventos e impiden que otros directorios los reutilicen.
```
## spec-v0.3.md
Source: spec/v0.3/README.md — The specification itself for v0.3: every field, and the rules a validator cannot check — why an id must never change, why a cancelled event stays published.
```markdown
# OTE Spec v0.3.0
> 🚧 **Borrador. Inestable.** `0.x` significa que **puede romper sin previo aviso**. Se publica para que existan implementaciones reales (empezando por el importador de `.ics`) y para que rompan lo que esté mal. Discusión: [#5 (evento)](https://github.com/OpenTechEvents/opentechevents-spec/issues/5) y [#6 (feed)](https://github.com/OpenTechEvents/opentechevents-spec/issues/6).
Especificación mínima para describir eventos de comunidades técnicas y publicarlos en un feed reutilizable.
| Artefacto | Qué es |
| --- | --- |
| [`event.schema.json`](event.schema.json) | **Normativo, ejecutable.** JSON Schema (draft 2020-12) de un evento. |
| [`feed.schema.json`](feed.schema.json) | **Normativo, ejecutable.** JSON Schema de una colección de eventos. |
| [`event.recommended.schema.json`](event.recommended.schema.json) [`feed.recommended.schema.json`](feed.recommended.schema.json) | **Normativos, ejecutables.** Perfiles de **calidad, no de validez**: los campos sin los que el evento no se puede descubrir ni seguir. Fallar aquí produce **avisos**, nunca un rechazo. Ver [Campos recomendados](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados). |
| Este documento | **Normativo, no ejecutable.** Las reglas que un validador no puede comprobar. |
| [`examples/`](examples/) | Ejemplos, **validados en CI**. Si no pasan el validador, el build falla. |
| [`DECISIONS.md`](DECISIONS.md) | **No normativo.** Por qué esta spec es como es, en inglés: decisiones de diseño con sus alternativas descartadas y la condición bajo la que valdría la pena reabrirlas. |
Los `$id` son las URLs bajo las que se publican los schemas:
```text
https://opentechevents.org/schema/v0.3/event.schema.json
https://opentechevents.org/schema/v0.3/feed.schema.json
https://opentechevents.org/schema/v0.3/event.recommended.schema.json
https://opentechevents.org/schema/v0.3/feed.recommended.schema.json
```
**Una vez publicada, una versión no se toca.** Las `v0.1` y `v0.2` siguen congeladas en [`spec/v0.1/`](../v0.1/) y [`spec/v0.2/`](../v0.2/); los cambios futuros irán a `spec/v0.4/`. Es lo que permite que un documento diga `specVersion: "0.3.0"` y un consumidor sepa dentro de tres años contra qué validarlo. Qué cambió entre versiones vive en el [CHANGELOG](../../CHANGELOG.md).
## Consumir los schemas
**Como paquete** (recomendado para implementaciones: te ata a una versión, no a lo que hoy haya en una URL):
```bash
npm install @opentechevents/schema
```
```js
import Ajv2020 from "ajv/dist/2020.js";
import addFormats from "ajv-formats";
import { eventSchema, feedSchema, annotationKeywords } from "@opentechevents/schema";
const ajv = new Ajv2020({ strict: true, strictRequired: false });
addFormats(ajv);
for (const kw of annotationKeywords) ajv.addKeyword(kw); // anotaciones: no restringen nada
ajv.addSchema(eventSchema); // el feed referencia al evento por $id: regístralo antes
const validateFeed = ajv.compile(feedSchema);
```
Los schemas llevan **anotaciones** que ningún keyword estándar sabe decir: `x-inheritsFrom` nombra el campo del feed del que hereda un campo del evento (`event.license` → `feed.license`), porque ese valor por defecto no es un literal, es lo que declare el feed que lo envuelve. No restringen nada: un validador que las ignore acepta exactamente los mismos documentos. JSON Schema permite keywords desconocidos, así que la mayoría de validadores no necesitan hacer nada; Ajv en `strict: true` se niega a compilar un schema que los lleve, y por eso `annotationKeywords` viene en el paquete.
Los perfiles de calidad vienen en el mismo paquete, y se usan **aparte** de la validación: lo que devuelven son avisos, no errores.
```js
import { eventRecommendedSchema } from "@opentechevents/schema";
const validateEvent = ajv.compile(eventSchema);
const checkEvent = ajv.compile(eventRecommendedSchema); // referencia al evento por $id, ya registrado
if (validateEvent(event) && !checkEvent(event)) {
warn(checkEvent.errors); // publícalo igual: sigue siendo válido
}
```
**Por URL** (para editores, CI de terceros o quien no use npm):
```text
https://opentechevents.org/schema/v0.3/event.schema.json
https://opentechevents.org/schema/v0.3/feed.schema.json
```
## Validar este repo
```bash
npm install
npm run validate
```
## El evento
Obligatorio: `id`, `name`, `startDate`, `timezone` — y, en un documento suelto, `specVersion` y `license`.
Todo lo demás es opcional. **Deliberadamente**: la mayoría de los `.ics` publicados no traen ni URL ni descripción, y una spec que los exija obliga al importador a descartar el evento o a inventarse el dato. Ninguna de las dos cosas es aceptable.
Opcional **no** quiere decir prescindible, y ahí entra el segundo nivel: los [campos recomendados](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados).
Ejemplo mínimo real, con esos seis campos y nada más: [`examples/event-minimal.json`](examples/event-minimal.json) — es un fichero validado en CI, así que enlazarlo en vez de copiarlo aquí es lo que garantiza que este ejemplo nunca se desincronice del schema real.
### Válido no es lo mismo que útil: los campos recomendados
Nuevo en la v0.3. Un evento con solo los seis campos obligatorios es **válido** y, para el problema que esta spec existe para resolver, **casi inservible**: no se puede filtrar por tema, no se sabe si se puede ir, y en RSS no hay ni enlace que pinchar. Bajar el listón de la validez fue una decisión correcta; dejar ahí la conversación, no.
Por eso hay **dos schemas y dos preguntas distintas**:
| Schema | La pregunta que responde | Qué pasa si falla |
| --- | --- | --- |
| [`event.schema.json`](event.schema.json) | ¿Es esto un evento OTE? | **Error.** El documento se rechaza. |
| [`event.recommended.schema.json`](event.recommended.schema.json) | ¿Sirve para algo? | **Aviso.** El documento sigue siendo válido. |
**Regla normativa: una herramienta MAY avisar de un campo recomendado que falta, y MUST NOT rechazar el documento por ello.** Convertir una recomendación en un error reintroduce por la puerta de atrás justo lo que la permisividad evita: quien importa un `.ics` pelado se ve obligado a inventarse el dato o a tirar el evento. Que no se pueda rechazar el documento no obliga a nadie a **listarlo**: eso es [otra decisión, y es de quien consume](#sin-url-ni-location-válido-pero-descartable).
Los perfiles son schemas normales, publicados bajo su propio `$id` y distribuidos en el paquete npm. Referencian a los de base por `$ref`, así que hay que registrar primero los de base:
```text
https://opentechevents.org/schema/v0.3/event.recommended.schema.json
https://opentechevents.org/schema/v0.3/feed.recommended.schema.json
```
```bash
npm run validate -- mi-feed.json # errores y avisos, en la misma pasada
```
#### Qué se recomienda, y por qué ese y no otro
El criterio **no** es «estaría bien tenerlo»: es **qué se rompe en los tres destinos si falta**, y si la ausencia impide descubrir o seguir el evento.
| Campo | Qué se pierde sin él |
| --- | --- |
| `url` | RSS y Atom no tienen otro sitio donde llevar el enlace: la entrada deja de ser pinchable. Es lo que convierte un dato en algo a lo que ir — y sin él ni `location`, [un agregador puede descartar el evento](#sin-url-ni-location-válido-pero-descartable). |
| `description` | Lo que muestra literalmente todo destino: `DESCRIPTION` en iCal, el cuerpo de la entrada en RSS/Atom, el snippet en schema.org. |
| `image` | La imagen es lo que hace que el evento **se vea** donde se lista: Google la pide para el `Event` (como recomendada), y es lo único que llena una tarjeta en cualquier interfaz. Además, el aviso es **accionable**: las cinco plataformas estudiadas ya emiten una, así que quien no la manda casi siempre la tiene y no la ha mapeado. |
| `location` | Google lo exige para el `Event`; en iCal es `LOCATION`. Sin él nadie puede contestar «¿me pilla cerca?» — y es [la última reserva cuando no hay `url`](#sin-url-ni-location-válido-pero-descartable). |
| `attendanceMode` | La primera pregunta de quien busca: ¿puedo ir desde casa? No se deriva de `location` de forma fiable — [por eso son campos distintos](#location-y-attendancemode-no-son-redundantes). |
| `tags` | **El campo del descubrimiento por interés.** Sin él, filtrar por tema exige adivinar a partir del título. Va a `CATEGORIES` en iCal y a `keywords` en schema.org. |
| `languages` | Un evento en un idioma que no hablas es ruido, y no es el título quien lo dice. |
| `organizers` | A quién sigues y de quién te fías. Sin él, un feed de agregador atribuye todo a quien agrega. |
| `updatedAt` | Es lo que hace posible **la suscripción**: sin él, un consumidor no puede sincronizar de forma incremental y tiene que releerlo todo cada vez. |
| `endDate` | Solo si `startDate` lleva hora: sin él el cliente de calendario se inventa la duración. En un evento de todo el día su ausencia ya significa «acaba el día que empieza», y avisar ahí sería ruido. |
| `cfp.closesAt` | **Solo si hay `cfp`.** Sin fecha límite, la pregunta que el campo existe para responder —¿sigue abierto?— se queda sin respuesta, y un consumidor ve un enlace que pudo cerrar hace meses. Accionable por definición: quien abre una convocatoria sabe cuándo la cierra. |
`cfp.closesAt` es **la única recomendación anidada, y la única condicional junto a `endDate`**: solo se pide cuando existe el campo padre, porque a un meetup pedirle una fecha límite de CFP es un aviso que nadie puede atender. El perfil lo expresa con un `if`/`then`, no con prosa, así que un checker cualquiera lo aplica sin saber nada de esta página.
**Lo recomendado es `location`, no `location.address`.** Qué hace falta saber del sitio depende del tipo de evento: a uno online la dirección postal no le aplica, y a un meetup en un bar el nombre del bar es todo lo que hay y todo lo que hace falta. `address` es lo que necesita quien exporta a schema.org para que Google valide la dirección por partes ([detalle abajo](#locationaddress-la-dirección-que-se-valida-por-partes)), y es una mejora real cuando se tiene — pero un aviso que la mitad de los eventos no puede atender es un aviso que enseña a ignorar los avisos.
**`textLanguage` tampoco es recomendado**, y es el que más cerca estuvo: cuesta una línea por feed y desbloquea cosas reales (el `lang` del HTML, la voz del lector de pantalla, la ordenación alfabética). Se queda fuera por el mismo criterio que todo lo demás: **quien importa un `.ics` no lo tiene** —Google Calendar no emite el parámetro `LANGUAGE`— y el único modo de atender el aviso sería adivinar el idioma a partir del texto. Un aviso que solo se puede callar inventando no es accionable. Para quien escribe su propio feed, en cambio, la recomendación de esta página se sostiene sola: **decláralo**.
**`offers`, `cfp` y `eligibility` tampoco son recomendados**, y no es un descuido. `cfp` porque la inmensa mayoría de los eventos no tiene convocatoria: avisar de su ausencia sería avisar a cada meetup de que no es una conferencia. `eligibility` porque quien importa un `.ics` no tiene forma de saber si hay puerta, y un aviso ahí solo puede atenderse **inventando** un `open` que nadie ha afirmado — que es justo lo que el campo existe para evitar. `offers` porque el aviso no es accionable de forma fiable — un exportador de `.ics` no tiene el precio en ninguna parte, y ninguna de las cinco fuentes estudiadas lo emite de forma universal. Recomendar es prometer que **quien publica puede arreglarlo**; donde no se puede, un aviso solo enseña a ignorar los avisos.
El perfil del feed es corto a propósito ([`feed.recommended.schema.json`](feed.recommended.schema.json)): `url` y `description`. Casi toda la calidad de un feed está en sus eventos, y un checker aplica el perfil de evento a cada uno por separado — **con la herencia ya resuelta**, o todo evento de un feed comunitario avisaría por unos `organizers` que el feed ya declaró.
#### Sin `url` ni `location`: válido pero descartable
Que ninguna herramienta pueda **rechazar** un documento por un campo recomendado no significa que todo evento válido tenga derecho a ser publicado por otros. Son dos decisiones distintas, y conviene decirlo sin rodeos:
| Decisión | Quién la toma | Qué dice esta spec |
| --- | --- | --- |
| ¿Es válido el documento? | El validador | Lo decide [`event.schema.json`](event.schema.json), y solo él. Un campo recomendado que falta **nunca** invalida nada. |
| ¿Le doy visibilidad? | Quien consume: agregador, directorio, calendario, buscador | Es **suyo**. La validez no obliga a nadie a listar un evento. |
**Regla normativa: un consumidor o agregador MAY descartar —o dejar sin listar, o listar al final— un evento que no traiga ni `url`, ni `location`, ni `cfp.url`, y cuyo feed tampoco declare `url`.** No es un incumplimiento de la spec: es la consecuencia de que no haya **ningún** sitio a donde mandar a quien lea el anuncio.
El motivo es que un evento así no responde a ninguna de las dos preguntas que hacen que un anuncio sirva: **«¿dónde amplío información?»** (`url`) y **«¿dónde se celebra?»** (`location`). Sin ninguna de las dos, lo único que queda es un nombre y una fecha. Un agregador que lo liste no está dando visibilidad al evento: está publicando algo que frustra a quien lo pincha —porque no hay nada que pinchar— y gastando el hueco de una tarjeta en un dato que nadie puede usar. **Anunciar un evento al que no se puede ir ni sobre el que se puede leer más no es difundirlo.**
La cadena de reservas importa, y por eso la regla es tan concreta:
- **`url` es la respuesta normal.** Es lo que convierte el dato en algo a lo que ir.
- **`location`** salva el caso del `.ics` pelado: casi todo `.ics` real trae `LOCATION` aunque no traiga `URL`, y con un sitio y una hora ya se puede aparecer. `location.onlineUrl` cuenta doble: es sitio **y** enlace.
- **`cfp.url`** salva a la conferencia cuyo único enlace publicado, de momento, es la convocatoria.
- **`feed.url`** salva al resto: un evento sin enlace propio dentro de un feed que sí lo tiene **sigue siendo navegable** —«visto en X»—, y descartarlo sería castigar una jerarquía que funciona. Es la razón por la que la regla no se limita al evento.
Para quien publica, el arreglo es de una línea: si el evento no tiene página propia, manda `url` apuntando a la de la comunidad, o declara `url` en el feed. Cualquiera de las dos basta.
#### Dos ausencias que son la parte interesante
**`status` no es recomendado**, y es el único campo de la spec con valor por defecto. Escribir `"status": "scheduled"` no añade información: es lo que ya significa su ausencia. Lo que de verdad importa de `status` es una acción, no un campo — **actualizarlo cuando el evento se cae** —, y eso ningún schema lo puede comprobar: el documento que anuncia un evento cancelado como si nada es indistinguible del correcto hasta que alguien se planta en una puerta cerrada. Sigue siendo [la regla más importante de esta spec](#status-un-evento-cancelado-sigue-publicado); simplemente no es una regla que un perfil pueda vigilar.
**`feed.organizers` tampoco**, y por el motivo contrario: un agregador **debe** omitirlo para que cada evento declare el suyo. Un aviso ahí empujaría a quien agrega a atribuirse eventos que no organiza — corrompiendo exactamente el dato que el campo existe para proteger. Una recomendación que, seguida al pie de la letra, empeora los datos, es una recomendación mal puesta.
#### Qué promete este nivel, y qué no
La lista **puede crecer** en versiones futuras: recomendar algo no cuesta la compatibilidad de nadie, porque nada deja de validar. Lo que **no** hará es convertirse en la vía por la que un campo opcional pasa a obligatorio de tapadillo: ascender un campo al núcleo obligatorio sigue siendo un cambio que rompe, y va con su versión y su entrada en el [CHANGELOG](../../CHANGELOG.md). Recomendado es un nivel estable, no una sala de espera.
### El orden de los campos: no es normativo, pero hay uno
JSON no tiene orden: un documento con las claves barajadas es **exactamente igual de válido**, y ningún consumidor debe depender de cómo vengan colocadas. Aun así, la spec declara los campos en un orden concreto, y lo respetan el schema, los ejemplos y la [referencia generada](reference.es.md):
```text
specVersion ← contra qué versión se valida
id, url, name, description, image, organizers ← qué es y quién lo hace
startDate, endDate, timezone ← cuándo
attendanceMode, location, eligibility ← ¿puedo ir?
tags, languages, textLanguage ← ¿me interesa, y en qué idioma está escrito?
offers, cfp ← ¿cuánto cuesta, y puedo participar?
status, partOf ← qué le ha pasado, y de qué forma parte
license, source, updatedAt ← datos sobre el dato
translations ← todo lo de arriba, en otro idioma
```
Se lee como se rellena: primero lo que hace falta para **anunciar** el evento, al final la fontanería que solo importa a quien lo consume. `eligibility` va con `attendanceMode` y `location` —y no pegado a `offers`, donde también encajaría— porque las tres contestan **la misma pregunta**: si el evento está a tu alcance y si te dejan entrar. El precio viene después, y solo importa si la respuesta fue sí. `textLanguage` va pegado a `languages` para que la [diferencia entre los dos](#textlanguage-y-translations-en-qué-idioma-está-escrito-esto) se vea en la misma pantalla, y `translations` va **al final**, después incluso de la fontanería: es un bloque voluminoso que repite campos ya declarados arriba, y ponerlo en medio enterraría las veinte líneas que todo consumidor lee de verdad. No es alfabético, que separaría `startDate` de `endDate` y `id` de `url`; ni por obligatoriedad, que cambiaría en cada versión que recomiende un campo nuevo.
Importa porque es lo que se ve en los tres sitios donde alguien mira de verdad: el **ejemplo que copia**, la **tabla de referencia** —generada leyendo el schema en orden de declaración— y el **autocompletado del editor**. Que los tres coincidan es la diferencia entre una forma que se memoriza y una que hay que consultar cada vez. `npm run validate` lo comprueba en los ejemplos de esta versión.
Para tu feed es una **sugerencia, no una regla**: publicar en otro orden no rompe nada ni produce avisos.
### `id` y `url` empiezan siendo iguales, pero no son lo mismo
`url` es **dónde se describe el evento hoy**. `id` es **qué evento es esto, para siempre**.
Si una comunidad se muda de plataforma a dominio propio, `url` cambia y **`id` no puede cambiar**: es lo que permite a un consumidor *actualizar* el evento que ya tenía en vez de crear un duplicado. Un `id` se acuña una vez, bajo un dominio que controla quien publica (el DNS ya garantiza unicidad: no hace falta registro central), y no se reescribe jamás. «Controlar el dominio» no exige ser su dueño: una página canónica en una plataforma que se usa (Meetup, GitHub Pages, LinkedIn) sirve igual — lo que hace falta es que esa URL sea estable y que nadie más pueda acabar con la misma. Por eso `id`, igual que `url`, tiene que ser una URL HTTP(S): un identificador de otro tipo (`urn:`, `mailto:`) no da esa garantía de unicidad sin registro, y el validador lo rechaza. Detalle en [DECISIONS.md, D020](DECISIONS.md#d020--id-and-partofid-must-be-an-https-url-not-any-uri-scheme).
Nadie debería teclear un `id` a mano: las herramientas lo derivan de la URL canónica del evento (propia o de la plataforma que se use), o lo acuñan como `/events//-` cuando el evento no tiene página propia.
Ningún campo URL del schema (`id`, `url`, `location.onlineUrl`, `organizers[].url`, `offers[].url`, etc.) admite credenciales embebidas en la autoridad (`https://user:pass@...`): son enlaces públicos de descubrimiento, no canales autenticados, y publicar un secreto ahí lo filtra a quien lea el feed. Detalle en [DECISIONS.md, D026](DECISIONS.md#d026--https-url-fields-must-not-carry-embedded-userinfo-credentials).
**Dentro de un mismo feed, dos eventos no pueden compartir `id`** — el validador lo comprueba (igualdad exacta de cadena, sin normalizar URIs). No es la deduplicación heurística entre fuentes que la spec deja fuera de alcance ([más abajo](#lo-que-la-v03-no-resuelve)): es una sola fuente contradiciendo, en el mismo documento, la identidad que ella misma acuñó. Detalle en [DECISIONS.md, D011](DECISIONS.md#d011--no-two-events-in-a-feed-may-share-an-id-compared-by-exact-string-equality).
### Fechas: reloj de pared, no instantes
`startDate` y `endDate` llevan **la hora que aparece en el cartel**, en la zona horaria del evento. **Nunca llevan offset UTC** (`+02:00` ni `Z`): eso lo aporta `timezone`. El schema rechaza un offset dentro de `startDate`.
Dos formas, y **ambas fechas deben usar la misma**:
- **Todo el día**: `"2026-10-15"`.
- **Con hora**: `"2026-10-15T09:00"` — **sin segundos**: es la hora que aparece en un cartel, nunca un instante técnico ([DECISIONS.md, D004](DECISIONS.md#d004--dateTime-has-no-seconds-and-enddate-must-not-precede-startdate)).
Mezclar (`startDate` fecha, `endDate` fecha-hora) es inválido. Si `endDate` falta, el evento termina el día que empieza. Si `endDate` está presente, **no puede ser anterior a `startDate`** — un evento no termina antes de empezar; el schema lo rechaza.
**Para un evento de todo el día, `endDate` es INCLUSIVO: nombra el último día en que ocurre el evento, no el día siguiente.** `startDate: "2026-10-16"` + `endDate: "2026-10-17"` es un evento de **dos días** (16 y 17), no de uno. Es la misma convención que usan Google y schema.org para `endDate` — y la contraria a la de iCalendar: RFC 5545 define `DTEND;VALUE=DATE` como el final **no inclusivo**, con este mismo ejemplo en su propio texto: un evento del 28 de junio al 8 de julio inclusive se codifica como `DTEND;VALUE=DATE:20070709` — el día **siguiente** al último. Por eso la conversión no es copiar el valor:
| | OTE `endDate` | iCalendar `DTEND;VALUE=DATE` |
| --- | --- | --- |
| Exportar | `2026-10-17` | súmale 1 día → `20261018` |
| Importar | réstale 1 día → `2026-10-17` | `20261018` |
Copiar el valor sin este ajuste acorta en un día todo evento OTE de varios días al pasar a iCalendar, y lo alarga en uno al volver. Los valores con hora no llevan esta ambigüedad — un `endDate` con hora ya es un instante exacto, sin que quepan dos lecturas. Detalle en [DECISIONS.md, D013](DECISIONS.md#d013--all-day-enddate-is-inclusive-icalendar-dtend-is-not).
`timezone` (IANA, `Europe/Madrid`) es **siempre obligatoria**. Con hora, es lo que convierte el reloj de pared en un instante inequívoco. En eventos de todo el día, **contextualiza** la fecha: dice a qué región pertenece ese día — **no la desplaza**. Un consumidor **no debe** convertir un evento de todo el día a otra zona horaria.
**"Instante inequívoco" tiene una excepción real: las dos noches al año en que la zona cambia de horario.** Al atrasar el reloj (verano→invierno), una hora local se repite y nombra dos instantes distintos; al adelantarlo (invierno→verano), hay una hora que no llega a existir nunca. Rarísimo en la práctica —nadie programa una charla a las 2:30 de la madrugada a propósito—, pero puede colarse sin que nadie lo mire: un script que genera fechas de una serie de sesiones sin tener en cuenta esa noche concreta, o un `.ics` importado con ese mismo problema. La resolución es la misma que ya usa RFC 5545 §3.3.5 — cualquier herramienta iCalendar existente ya la aplica sin cambiar nada — con sus mismos ejemplos oficiales:
- **Hora repetida**: cuenta la **primera** ocurrencia. `TZID=America/New_York:20071104T013000` son las 1:30 del 4 de noviembre de 2007 en **EDT** (UTC−04:00), no en EST.
- **Hora inexistente**: se interpreta con el offset vigente **antes** del salto. `TZID=America/New_York:20070311T023000` son, en realidad, las 3:30 EDT (UTC−04:00) — una hora después de las 1:30 EST.
Detalle en [DECISIONS.md, D014](DECISIONS.md#d014--dst-ambiguous-or-nonexistent-local-times-resolve-per-rfc-5545-335).
La única fecha con offset en toda la spec es `source.retrievedAt` (y `updatedAt` en el feed): son metadatos, instantes reales, no cosas que le pasan a la gente en un sitio.
Todas las fechas y horas de esta spec, en cualquier campo, tienen que ser **calendáricamente reales** — nada de meses, días u horas que no existen — y `timezone` tiene que ser una **zona IANA real**, canónica o alias histórico. Ambas cosas las comprueba el validador, no son solo una recomendación de esta página. Por qué (y por qué la primera versión de la comprobación de `timezone` estaba mal) está en [DECISIONS.md, D001 y D002](DECISIONS.md#d001--temporal-fields-must-be-calendar-valid-not-just-lexically-shaped).
### Recurrencia y eventos multi-parte: `partOf`
Nuevo en la v0.3, opcional. Y lo primero que hay que decir es lo que **no** es: **no es una regla de recurrencia**.
**Un documento = una ocurrencia. Quien publica expande.** Un meetup mensual no es un documento con una regla: son doce documentos, cada uno con su `id`, sus fechas y su `status`. Un study jam de tres sesiones en sábados no consecutivos son tres documentos. `partOf` solo dice **de qué conjunto forman parte**:
```json
"partOf": {
"id": "https://rustmadrid.example/meetups",
"name": "Rust Madrid — meetup mensual",
"url": "https://rustmadrid.example/meetups"
}
```
Solo `id` es obligatorio. `name` y `url` evitan que un consumidor tenga que resolver el `id` para poder agrupar. El `id` sigue las mismas reglas que el del evento (URI bajo un dominio propio, acuñado una vez) y **no tiene por qué resolver** a un documento OTE. **Lo que no puede ser es el mismo `id` del propio evento** — una ocurrencia no puede ser el conjunto al que pertenece; el validador lo rechaza. Detalle en [DECISIONS.md, D012](DECISIONS.md#d012--an-events-partofid-must-not-equal-its-own-id).
**`type`: `series` o `multipart`** (por defecto `series`). No es decoración: cambia la traducción.
- **`series`** — ocurrencias independientes que comparten identidad: el meetup de junio y el de julio. Cada una se anuncia, se asiste y se cancela por separado.
- **`multipart`** — partes de **un solo evento** repartido en fechas no consecutivas: un study jam de tres sesiones en sábados no consecutivos, con una sola inscripción. Las partes no son eventos independientes aunque tengan fecha propia.
Lo que `multipart` **no** arregla es «una sola inscripción»: eso no es una fecha. `offers` describe el precio y el registro **de cada documento**, no una inscripción compartida entre las partes — y la v0.3 no la modela. No lo resuelvas deformando el campo de tiempo.
⚠️ **Un evento multi-parte NO se expresa con un `startDate` en la primera parte y un `endDate` en la última.** `startDate: "2026-03-07"` + `endDate: "2026-03-21"` afirma un evento continuo de quince días — falso en los tres destinos, y en el calendario de quien se suscriba ocupa dos semanas enteras. Tres partes, tres documentos.
**Traducción a los tres formatos, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | `superEvent` → `EventSeries` (`series`) o `Event` (`multipart`, con las partes como su `subEvent`) | Ninguna en el modelo. Google **no lee** `superEvent`, pero tampoco lo necesita: ya recibe una ocurrencia por documento, que es exactamente lo que pide. |
| **iCal** | `RELATED-TO;RELTYPE=PARENT:` en cada `VEVENT` | Soporte desigual entre clientes: quien no lo entienda ve N eventos correctos. Nunca `RRULE`: la expansión ya ocurrió. |
| **Atom / RSS** | Sin equivalente — se ignora | Total, e **inocua**: la entrada sigue describiendo un evento con su fecha real. |
Que degrade a *ignorado* en los tres es justamente el diseño. Un campo de fechas que se ignora produce datos falsos; un campo de identidad que se ignora produce datos incompletos. Solo el segundo es aceptable.
#### Por qué no `eventSchedule` de schema.org
Se valoró sustituir `timezone` + `startDate` + `endDate` por un `eventSchedule` al estilo de schema.org (`repeatFrequency`, `byDay`, `scheduleTimezone`…). Se descarta, por cuatro razones:
1. **Mete un motor de expansión dentro de un fichero.** El feed es un formato de intercambio, no una API: el consumidor lee, no calcula. Con una regla, todo consumidor —incluido el script de treinta líneas que pinta un listado— pasa a necesitar aritmética de calendario: DST, `exceptDate`, series infinitas, semántica de `"2MO"`. Es la razón de que toda librería de iCal pese lo que pesa.
2. **RSS/Atom no pueden expresarla.** No modelan recurrencia. Quien exporte tiene que expandir igualmente: la expansión ocurre siempre, y la única pregunta es **quién** la hace. Que la haga quien publica —una vez, con el dato delante— y no cada consumidor por su cuenta, cada uno con su bug.
3. **Rompe reglas que esta spec ya tiene.** Un `id` estable no sobrevive a N ocurrencias bajo un mismo documento (haría falta un equivalente de `RECURRENCE-ID`), y `status: cancelled` deja de ser expresable por ocurrencia sin inventar excepciones y sobrescrituras. Cancelar **la sesión de agosto** volvería a ser imposible: exactamente el problema que `status` existe para resolver.
4. **No hay productor real.** De las cinco fuentes estudiadas ([`research/findings/json-ld-event-platforms.md`](../../research/findings/json-ld-event-platforms.md)) —Meetup, Eventbrite, Luma, Guild y el ejemplo canónico de Google— **ninguna** emite `eventSchedule`. Todas emiten fecha plana por ocurrencia, incluida la sesión *semanal* de Luma, que es recurrente de verdad. Y Google, que es quien consume schema.org a escala, pide explícitamente un `Event` por fecha. Adoptarlo sería el diseño especulativo que la sección de Extensiones prohíbe.
#### Reglas para quien expande
- **Series infinitas: horizonte acotado.** Un `RRULE` sin `UNTIL` ni `COUNT` no se puede expandir entero. Expande un horizonte razonable —**12 meses o las próximas 12 ocurrencias** es la recomendación— y vuelve a publicar al regenerar el feed. Un feed no es un calendario perpetuo.
- **`id` por ocurrencia.** Si la ocurrencia tiene página propia, su URL. Si no, `/` o `#` — que es, literalmente, lo que `RECURRENCE-ID` hace en iCal. Lo que no vale es reutilizar el `id` de la serie en las doce: un consumidor las colapsaría en un solo evento.
- **Las excepciones ya no son excepciones.** Tras expandir, `EXDATE` es *no emitir ese documento* y una ocurrencia movida es *un documento con otra fecha*. Si ya se publicó y luego se cae, `status: cancelled` — no borrarlo.
- **Guardar la regla original es opcional, y con prefijo.** Si tu importador quiere no perderla para poder hacer round-trip, `"ics:rrule": "FREQ=MONTHLY;BYDAY=2MO"` es vocabulario externo (ver [Extensiones](#extensiones)): informativo, y **ningún consumidor de OTE está obligado a expandirlo**. Si sobrevive al uso real, se graduará.
### `location` y `attendanceMode` no son redundantes
Responden a preguntas distintas:
- **`location`** son **hechos observables**: ¿hay sitio físico?, ¿hay URL para conectarse? Puede estar incompleta.
- **`attendanceMode`** es **la intención de quien organiza**: qué tipo de evento es esto. No depende de que la URL de conexión sea pública todavía.
Casi siempre se podrían derivar el uno del otro, y coinciden. El campo existe para cuando **la derivación falla**. **Si se contradicen, manda `attendanceMode`.**
**`attendanceMode` no tiene valor por defecto.** Ausente significa **desconocido**, no `in-person`. Un valor por defecto dejaría que cualquier productor que simplemente *no tiene* el dato emitiera uno falso sin enterarse: un formulario en blanco, un CMS exportando de una plantilla, un importador leyendo un formato que no sabe expresarlo — **iCalendar, el formato de eventos más publicado del mundo, no modela la modalidad en absoluto**. Callarse y decir `in-person` son afirmaciones distintas, y solo una es honesta.
Si `location` está presente, debe traer al menos `venue` o `onlineUrl`. Un `location: {}` es inválido: no dice nada, y decir nada ya se hace omitiendo el campo. **`address` y `geo` no cuentan** para esa regla: describen la sede que `venue` nombra, no la sustituyen.
Esta independencia es deliberada y no se toca: pero el perfil recomendado sí avisa (nunca invalida) cuando falta el detalle concreto que el `attendanceMode` declarado necesita — `onlineUrl` para `online`, `venue` para `in-person`, ambos para `hybrid`. Ver [DECISIONS.md#D025](DECISIONS.md#d025--recommended-tier-warning-when-attendancemode-lacks-its-matching-location-detail).
### `location.address`: la dirección que se valida por partes
Nueva en la v0.3, opcional, y **hermana de `venue`, no sustituta**:
```json
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"region": "Comunidad de Madrid",
"postalCode": "28005",
"country": "ES"
},
"geo": { "lat": 40.4081, "lon": -3.7188 }
}
```
**Por qué se añade.** `venue` es una cadena, y una cadena no se puede validar por partes. Al traducir a schema.org, la sede se convierte en un `Place`, y la dirección de un `Place` es un `PostalAddress` con cinco subcampos que **Google comprueba uno a uno** para el rich result de `Event`. Con solo `venue`, un exportador tiene dos salidas: emitir `address` como texto suelto —válido en schema.org, no validado por Google— o *adivinar* dónde acaba la calle y empieza la ciudad partiendo por comas. Lo segundo es inventar datos, que es justo lo que esta spec no quiere provocar. De las cinco fuentes estudiadas ([`research/findings/json-ld-event-platforms.md`](../../research/findings/json-ld-event-platforms.md)), **cuatro emiten `PostalAddress`** con sus subcampos: es un campo que ya existe ahí fuera, no una idea.
**Por qué `venue` sigue estando, y sigue siendo el que manda.** Porque los otros dos destinos no saben qué hacer con una dirección por partes: `LOCATION` de iCal es **una línea de texto libre**, y RSS/Atom no modelan direcciones en absoluto. Alguien tiene que producir esa línea, y la escribe mejor quien organiza («Campus Madrid, Calle de Moreno Nieto 2, Madrid») que un exportador uniendo partes con comas, que es lo que da direcciones como la de Meetup: `"calle de raimundo lulio, 9 28010, madrid, españa, Madrid"`. Los dos campos dicen el mismo sitio a propósito: **`venue` para leer, `address` para procesar**.
**Todas las partes son opcionales, y omitir es la forma correcta de no saber.** Una clave ausente significa desconocido. `""` y `null` **no son válidos** —cada parte es una cadena de al menos un carácter—, y esa es una decisión con caso real detrás: Guild emite hoy un `PostalAddress` con los cinco subcampos a `null`, que es publicar un desconocido con forma de dato. `"address": {}` también se rechaza, por el mismo motivo que `location: {}`.
**`country` es un código ISO 3166-1 alfa-2 en mayúsculas** (`ES`, `US`), y es la única parte con formato exigido. Un nombre de país tiene una grafía por idioma —«España», «Spain», «Espagne»— y un consumidor que agrupe eventos por país vería tres países donde hay uno. Convertir el nombre en código es **consultar una tabla, no inventar**: por eso aquí sí se exige, y no se exige en `region`, donde no hay tabla universal que valga (provincia, estado, condado o *Land*, según el país). El validador comprueba el código contra la lista de asignados vigentes, no solo su forma — mismo patrón que `timezone` y `currency`: ver [DECISIONS.md, D006](DECISIONS.md#d006--locationaddresscountry-must-be-a-real-currently-assigned-iso-3166-1-code). **Error frecuente: Reino Unido es `GB`, no `UK`** — «UK» no es un código ISO 3166-1, es de los reservados «indeterminadamente» precisamente por su uso extendido fuera del estándar.
**`locality` y `region` se escriben UNA vez, y no se traducen.** «València» y «Valencia», «Girona» y «Gerona», «Donostia» y «San Sebastián» son grafías reales del mismo sitio, y aquí no hay tabla que consultar como la de `country`. La regla, que es recomendación y no validación: **escribe la grafía más reconocible para la audiencia mayoritaria del evento**, la que esa gente teclearía al buscar. No pongas las dos, no las metas en `translations` —[no está cubierto a propósito](#traducciones-locales-el-texto-que-vive-dentro-de-un-objeto)— y recuerda que quien necesite precisión sin idioma ya la tiene: `location.geo` no tiene grafías.
**No se modelan** ni `addressType`, ni segunda línea de dirección, ni `postOfficeBoxNumber`: `street` es una línea, y las plantas o puertas van en ella. Ningún productor real emite más, y la sección de [Extensiones](#extensiones) prohíbe el diseño especulativo.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | `location.address` → `PostalAddress`: `street` → `streetAddress`, `locality` → `addressLocality`, `region` → `addressRegion`, `postalCode` → `postalCode`, `country` → `addressCountry`. `venue` sigue siendo `Place.name` | Ninguna. Es 1:1 — y es el único mapeo que hace que Google valide la dirección. |
| **iCal** | `LOCATION:` — la dirección **no viaja por partes** | **Toda la estructura.** iCalendar no modela direcciones ([RFC 5545](https://www.rfc-editor.org/rfc/rfc5545) solo tiene `LOCATION`, texto libre). Un exportador puede anexar las partes al `LOCATION` si `venue` no las incluye ya; lo que no debe es duplicar la dirección detrás de un nombre que ya la lleva. |
| **RSS / Atom** | Nada nativo: va dentro del texto del ítem | **Toda la estructura**, igual que en iCal. Ninguno de los dos modela lugares. |
Los nombres cortos (`street`, `locality`) frente a los de schema.org (`streetAddress`, `addressLocality`) son la misma decisión que `geo: { lat, lon }` frente a `GeoCoordinates`: **el objeto ya se llama `address`**, y repetir el prefijo en cada clave es ruido. El mapeo es literal y está en la tabla de arriba.
### `status`: un evento cancelado sigue publicado
Seis valores, alineados con el enum `eventStatus` de schema.org más el `TENTATIVE` de iCal:
| Valor | Qué afirma |
| --- | --- |
| **`scheduled`** *(por defecto)* | Confirmado, en la fecha y el sitio que dice el documento. |
| **`tentative`** | Anunciado pero **sin confirmar**: falta cerrar fecha, sede o ambas. |
| **`cancelled`** | No se celebra. Punto. |
| **`postponed`** | Aplazado **sin fecha nueva todavía**. |
| **`rescheduled`** | Aplazado **y ya con fecha nueva**, que es la que lleva el documento. |
| **`moved-online`** | Se mantiene, pero lo que era presencial pasa a ser online. |
Un evento **cancelado, pospuesto o movido debe seguir en el feed**. Borrarlo en silencio deja a quien se suscribió con un evento muerto en su calendario y sin forma de enterarse. El `status` **es** la forma de enterarse.
**`postponed` y `rescheduled` no son sinónimos, y la diferencia está en las fechas del propio documento.** `postponed` conserva las **fechas antiguas** — no te inventes una nueva ni borres `startDate` (es obligatorio): el evento sigue apuntando a un día que ya no vale, y eso es exactamente lo que `postponed` está diciendo. Al confirmar la nueva fecha, se **actualizan** `startDate`/`endDate` y se pasa a `rescheduled`. Como el `id` no cambia, un consumidor actualiza el evento que ya tenía en vez de duplicarlo.
> La v0.3 **no modela la fecha anterior** (el `previousStartDate` de schema.org). No hay productor real que la emita hoy, y `updatedAt` ya dice que algo cambió. Si te hace falta, es candidata a núcleo por la vía de siempre: un campo sin prefijo, en producción, y se gradúa si sobrevive.
**`moved-online` debería traer `location.onlineUrl` y `attendanceMode: "online"`.** Debería, no debe: el schema no lo exige porque el enlace de conexión a menudo no es público todavía (llega por email a quien se registró), y una spec que lo exigiera obligaría a quien importa a inventárselo o a descartar el evento. Si `location.venue` se queda ahí como rastro de dónde iba a ser, no pasa nada: [manda `attendanceMode`](#location-y-attendancemode-no-son-redundantes), y dice `online`.
**Por qué `tentative`, si schema.org no lo tiene.** Porque `status` es el **único campo de la spec con valor por defecto**: ausente significa `scheduled`, no «desconocido». Sin `tentative`, quien importa un `.ics` con `STATUS:TENTATIVE` —que es el estado que emite cualquier calendario para lo que aún no está cerrado— solo puede **ascender el evento a confirmado**, que es afirmar algo que nadie afirmó. Es el mismo argumento por el que `attendanceMode` no tiene valor por defecto: callarse y decir `scheduled` son afirmaciones distintas, y solo una es honesta.
`tentative` describe **el evento**, no la calidad del dato: se usa cuando quien organiza aún no ha cerrado fecha o sede, no cuando quien importa no las tiene claras. Y no es un estado permanente: en cuanto se confirma, se pasa a `scheduled`.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| OTE | schema.org `eventStatus` | iCal `STATUS` | Pérdida |
| --- | --- | --- | --- |
| `scheduled` | `EventScheduled` | `CONFIRMED` | Ninguna. |
| `tentative` | `EventScheduled` | `TENTATIVE` | **En schema.org.** No tiene equivalente: se emite `EventScheduled` y el matiz se pierde. El único que viaja mejor a iCal que a schema.org. |
| `cancelled` | `EventCancelled` | `CANCELLED` | Ninguna. |
| `postponed` | `EventPostponed` | `TENTATIVE` | **En iCal**, que no distingue aplazado de sin confirmar. Google sí lee `EventPostponed`. |
| `rescheduled` | `EventRescheduled` | `CONFIRMED` *(con la fecha nueva)* | Ninguna en las fechas; el hecho de que hubo un cambio se pierde en iCal. |
| `moved-online` | `EventMovedOnline` + `location` `VirtualLocation` | `CONFIRMED` *(con la URL en `LOCATION`/`URL`)* | **En iCal**, que no distingue una sede online de una física. |
**RSS y Atom no tienen `status` en absoluto.** No hay campo donde ponerlo, así que quien exporte debe llevarlo al **título** de la entrada (`[CANCELADO] Rust Madrid — junio`) o a las primeras líneas del contenido. Un canal de anuncios que anuncia un evento cancelado exactamente igual que uno confirmado es peor que no anunciarlo.
### `license` y `source`: qué se puede reutilizar, y de dónde salió
`license` es la licencia de **estos datos**, no del evento. SPDX (`CC0-1.0`, `CC-BY-4.0`) o una URL. Va en SPDX y no en prosa (`CC BY 4.0`) porque un importador tiene que compararla contra una allowlist, y para eso necesita un identificador, no una frase — y el validador la comprueba de verdad contra la [SPDX License List](https://spdx.org/licenses/) oficial, no solo por su forma. Identificadores retirados (*deprecated*) siguen siendo válidos, porque la propia SPDX los sigue publicando como tales; lo que no vale es uno inventado. Detalle en [DECISIONS.md, D008](DECISIONS.md#d008--license-validates-simple-spdx-identifiers-against-the-real-spdx-license-list).
**El perfil recomendado avisa (sin invalidar) si `license` tiene una cláusula que puede bloquear a un directorio o agregador**: *NonCommercial* descarta directamente cualquier directorio comercial, *NoDerivatives* bloquea el reformateo/traducción que hace cualquier agregador, y *ShareAlike* (incluido `ODbL`) es "viral" para una base de datos combinada — mezclar tu evento con otros podría obligar a todo el feed agregado a adoptar tu licencia. Las licencias de software copyleft (GPL y familia) tampoco entran en lo recomendado: su mecánica está pensada para "distribuir el Programa", algo legalmente ambiguo aplicado a un documento JSON, y esa ambigüedad ya es motivo suficiente para que el equipo legal de un directorio decline en vez de arriesgarse. Nada de esto invalida el documento — solo `CC0-1.0`, `CC-BY-*` (sin NC/ND/SA), `PDDL-1.0` y `ODC-By-1.0` se libran del aviso.
**La compatibilidad entre licencias de distintos eventos de un mismo feed es responsabilidad de quien agrega**, no algo que esta spec compruebe ni imponga: cada evento es una obra independiente, y `license` se puede sobreescribir por evento precisamente porque no todas las comunidades quieren los mismos términos. Quien construya un agregador y quiera combinar o redistribuir el feed entero como una sola cosa tiene que mirar la licencia de **cada evento**, no solo la del feed — la herencia (`x-inheritsFrom`) da un valor por defecto, no una garantía de que todos los eventos compartan licencia.
`source` es **obligatoria cuando el evento se importó o agregó** de otro sitio (un `.ics`, Meetup, otro directorio). Se omite cuando quien organiza describe su propio evento: **es** la fuente.
**Dentro de `source` basta con `name` o con `url`** —lo ideal es las dos—, y el schema rechaza una `source` que no lleve ninguna: una procedencia que no apunta a nada no es una procedencia. Exigir el `name` sería peor que aceptar la `url`: quien importa un `.ics` siempre conoce la dirección que descargó y a menudo no tiene nombre de publicador que leer (el `X-WR-CALNAME` de iCalendar es opcional), así que el requisito se cumpliría **inventándolo**, y una fuente inventada es peor que una fuente dada solo como enlace. Es la misma regla que siguen `offers` con `price` y `url`, y `location` con `venue` y `onlineUrl`.
`source.license` (lo que la fuente permite) y `license` (lo que este documento permite) son campos distintos y **no tienen por qué coincidir**. Pero los términos de la fuente **restringen lo que puede republicarse**: declarar una `license` no concede derechos que la fuente nunca dio.
### `organizers`: quién organiza — y las tres cosas que no es
Nuevo en la v0.3. Opcional, y **una lista**, no un objeto:
```json
"organizers": [
{ "name": "GDG Madrid", "url": "https://gdgmadrid.example", "email": "hola@gdgmadrid.example" },
{ "name": "Python Madrid", "url": "https://www.meetup.com/python-madrid/" },
{ "type": "person", "name": "Ada Lovelace", "url": "https://ada.example" }
]
```
Solo `name` es obligatorio. `url`, `email` y `type` (`organization` por defecto, o `person`) son opcionales. **Y nada más**: ni logo, ni identificadores. El campo describe **quién organiza y dónde escribirle**, no su ficha completa.
**Es una lista porque la co-organización es lo normal**, no la excepción: dos comunidades que juntan meetup, una comunidad y su anfitrión. Luma ya emite `organizer` como array (organización + persona) y schema.org lo acepta. Ensanchar un objeto a lista más tarde habría sido un cambio que rompe; nace lista. **El orden es significativo**: el primero es el principal, y es el único que sobrevive a iCal. Repetir exactamente el mismo organizador en la lista es inválido: no añade información, solo obliga a quien exporta a deduplicar. Ver [DECISIONS.md, D027](DECISIONS.md#d027--organizers-must-not-carry-exact-duplicate-entries).
Tres confusiones que conviene desactivar antes de que ocurran:
1. **`organizers` no es `source`.** Quién hace el evento vs. de dónde salieron los datos. Un evento de PyAlmería recogido de Meetup tiene `organizers: [PyAlmería]` y `source: { name: "Meetup" }`. Son ortogonales y a menudo aparecen los dos.
2. **`organizers` no son ponentes.** Quien da la charla no está modelado en la v0.3 (ver «Lo que la v0.3 no resuelve»). Meter a un ponente en `organizers` corrompe el dato para todo el que lo consuma.
3. **`feed.organizers` no es `feed.title`/`feed.url`.** `title`/`url` nombran a **quien publica** el feed; `organizers`, a **quien organiza** los eventos. En un feed de una sola comunidad coinciden. En un feed de agregador **no**, y ahí está justo el valor del campo: sin él, un consumidor no tiene más remedio que caer en `feed.title` y atribuirle al agregador todos los eventos que agrega.
**Herencia: reemplazo, no fusión.** Igual que `license`, `feed.organizers` es el valor por defecto de todo evento que no declare el suyo. Un evento que **sí** lo declara **sustituye la lista entera**; no se suma a la heredada. Con fusión no habría forma de *quitar* un organizador heredado, y un evento invitado dentro del feed de una comunidad acabaría atribuido a quien no lo organiza. La consecuencia práctica: en el evento co-organizado de un feed comunitario hay que **repetir** la comunidad del feed junto a la invitada — ver [`examples/feed-community.json`](examples/feed-community.json).
#### `email`: la dirección que hace válido el `ORGANIZER` de iCal
Opcional, **una sola dirección**, y sin el prefijo `mailto:` — lo añade quien exporta:
```json
"organizers": [
{ "name": "GDG Madrid", "url": "https://gdgmadrid.example", "email": "hola@gdgmadrid.example" }
]
```
**Entra porque hay productor real, y es el de siempre: `.ics`.** Un `VEVENT` publicado trae `ORGANIZER;CN="Rust Madrid":mailto:hola@rustmadrid.example` con muchísima frecuencia, y hasta ahora el importador **tenía el dato delante y no había dónde ponerlo** — el mismo motivo exacto por el que `tags`, `location.geo` y `updatedAt` entraron en la v0.2. La consecuencia era que `.ics` → OTE → `.ics` **perdía el `ORGANIZER`**, la única pérdida que esta página califica de **grave**. Un formato que no puede dar la vuelta a la fuente más publicada del mundo tiene un agujero, no una decisión.
**Y arregla un segundo destino de rebote**: `` de RSS 2.0 **exige un email**, así que sin él la única salida era `dc:creator`. Con `email` se puede emitir el elemento nativo.
**Hay que decir en voz alta lo que este campo no tiene**: ninguna de las cinco plataformas estudiadas ([`research/findings/json-ld-event-platforms.md`](../../research/findings/json-ld-event-platforms.md)) emite email en su JSON-LD. Lo esconden detrás de un formulario, **y hacen bien**. El productor de este campo es iCalendar, no la web de eventos, y por eso el campo llega con más reglas que cualquier otro de la spec.
**El precio es el spam, y es real.** Publicar una dirección en un fichero JSON abierto y rastreable es regalarla a los recolectores, y **lo publicado no se despublica**: sigue en las cachés de quien lo leyó. De ahí las reglas:
1. **Una dirección de rol, no el buzón de nadie.** `info@`, `hola@`, `eventos@`. Con `type: "person"` piénsalo dos veces: la dirección de una persona en un feed rastreable es un problema que sufre ella, no el proyecto.
2. **Un importador MUST NOT rellenar `email` desde una fuente que no esté publicada públicamente.** Un `.ics` compartido por enlace, un calendario de empresa o una exportación privada **no son publicación**: copiar de ahí a un feed abierto no es traducir un dato, es **cambiarle el nivel de exposición**. Lo que `source.license` ya dice sobre republicación aquí se dice aparte, porque el dato es personal.
3. **Un consumidor MUST NOT usar `email` para nada que no sea escribir sobre el evento.** Ni listas de correo, ni directorios de contactos, ni bases de datos comerciales. Es una regla que ningún schema puede comprobar, como la de [actualizar `status`](#status-un-evento-cancelado-sigue-publicado), y se cumple igual.
4. **Quien exporta a JSON-LD lo piensa dos veces.** `Organization.email` existe, y emitirlo mete la dirección en una página pública. Es legítimo —es la web de quien organiza—, pero **Google no lo usa** para el rich result de `Event`: el email entra en esta spec por iCal, no por SEO.
**No es [recomendado](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados)**, y por dos motivos que se suman. Uno, el de siempre: quien importa un `.ics` sin `ORGANIZER` no puede atender el aviso. Y dos, uno nuevo que solo aparece aquí: **un aviso por un email ausente es presionar a alguien para que publique una dirección que decidió no publicar**. El perfil de calidad existe para señalar lo que le falta al evento, no para empujar a nadie a exponer datos de contacto. Es el único campo de la spec que se queda fuera del perfil por una razón que no es técnica.
**Y no añade ninguna regla de herencia.** Es la pregunta que este campo provoca sola —«¿se hereda del feed?»— y la respuesta ya estaba escrita: `email` vive **dentro** de `organizers[]`, y `organizers` se hereda **por reemplazo, no por fusión**. Las consecuencias, que son exactamente las que se quieren:
| Caso | Qué pasa con el email |
| --- | --- |
| Feed comunitario, evento sin `organizers` | Hereda la lista entera del feed, email incluido. **Correcto**: es la misma comunidad, y el email *es* el mismo. Una línea en el feed, cero repetición. |
| Feed comunitario, evento **con** `organizers` | **No hereda nada.** La lista declarada sustituye la entera, así que un evento co-organizado nunca acaba con el email de quien no lo organiza. |
| Feed de **agregador** | La spec ya obliga a **omitir** `feed.organizers`. Sin lista no hay email que heredar. |
El riesgo que se teme —heredar un email ajeno— **solo existiría si el email se heredara por su cuenta**, con un `feed.organizerEmail` suelto o con fusión campo a campo. Ninguna de las dos cosas se hace, y la [tabla de herencia](#el-feed) sigue teniendo las mismas cuatro filas que antes.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | `organizer` (array), `@type` `Organization`/`Person` según `type`, `name`, `url`, `email` | Ninguna en el modelo. Emitir `email` es **opcional para quien exporta** (ver la regla 4): Google no lo lee para el `Event`. |
| **Atom** | `………`, repetible | Ninguna. Es el único destino que recibe los tres campos, y repetido. |
| **RSS 2.0** | `` si hay `email`; `` si no | El `` de RSS 2.0 **exige email**: sin él sigue haciendo falta `dc:creator`, que no lleva dirección. |
| **iCal** | `ORGANIZER;CN="…":mailto:…` si hay `email` | **Sin `email` la pérdida sigue siendo grave**: `ORGANIZER` es un `CAL-ADDRESS`, en la práctica un `mailto:`, así que no hay nada válido que emitir → degrádalo a `X-OTE-ORGANIZER` o a la `DESCRIPTION`. Y `ORGANIZER` es **único** aunque haya email: del segundo organizador en adelante no hay dónde ponerlos. |
**Un exportador no se inventa la dirección.** Sin `email` no hay `ORGANIZER`, y punto: deducir un `mailto:` del dominio de `url` (`hola@` + el dominio, o el `webmaster@` de siempre) es fabricar un dato de contacto que nadie ha publicado, y encima obligar a quien lo reciba a escribir a una dirección que puede no existir. Es la misma regla que rige toda la spec: **callarse y adivinar son afirmaciones distintas, y solo una es honesta.**
**Por qué no hay `id` ni `communityId`.** Se valoró un identificador que enlazase al organizador con un directorio de comunidades, del estilo `combuilders:mi-comunidad`. Se descarta en la v0.3 por tres razones: exige una **gobernanza de prefijos** (quién asigna `combuilders`, quién resuelve colisiones) que este proyecto no tiene y que acoplaría OTE a otro; contradice la regla de `id` de la propia spec (*una URI bajo un dominio que controlas*, que no necesita registro central porque el DNS ya garantiza la unicidad); y sobre todo **no hay todavía un consumidor real** — el directorio no existe como especificación. Meterlo ahora sería exactamente el diseño especulativo que la sección de Extensiones prohíbe.
No hace falta esperar a nadie para usarlo: es un campo de extensión con prefijo (ver abajo), funciona hoy, y si sobrevive al uso real se gradúa a núcleo con la forma que dicte ese uso.
### `tags`, `location.geo`, `updatedAt`: lo que la v0.2 añade
Los tres son **opcionales** y entraron por la misma razón: el importador de `.ics` los tenía delante en cada `VEVENT` y no había dónde ponerlos. Ninguno es una idea especulativa; los tres tienen ya un productor real. Detalle en el [CHANGELOG](../../CHANGELOG.md).
- **`tags`** — lista libre de temáticas (`["rust","wasm"]`). Mapea a `CATEGORIES` de iCal y a `keywords` de schema.org. **Libre a propósito**: quien organiza etiqueta como quiera. Un vocabulario controlado (para no ensuciar interfaces de filtro o suscripción) podría superponerse más adelante **sin** cerrar el campo. Ausente = desconocido, no «sin temática». Sí se rechaza el duplicado exacto (`["rust","rust"]`): no es libertad de vocabulario, es la misma etiqueta afirmada dos veces. `languages` tiene la misma regla, por el mismo motivo — ver [DECISIONS.md, D024](DECISIONS.md#d024--tags-and-languages-must-not-carry-exact-duplicate-entries). `languages`, además, rechaza la misma etiqueta BCP 47 repetida con distinta capitalización (`["es","ES"]`): `tags` es vocabulario libre sin ese precedente, pero `languages` comparte tipo con las claves de `translations`, que ya se comparan así — ver [DECISIONS.md, D028](DECISIONS.md#d028--languages-must-not-repeat-the-same-bcp-47-tag-under-different-case).
> **`CATEGORIES` no viaja por Google Calendar** (no lo emite ni lo lee). Un importador puede recuperar temáticas de una convención de *hashtags* (`#rust`) en la `description` — pero eso es **comportamiento del importador, no del schema**: aquí `tags` es siempre la lista estructurada.
- **`location.geo`** — punto WGS-84 `{ lat, lon }` en grados decimales. Mapea a `GEO` de iCal y a `Place.geo` de schema.org. Es **independiente de `venue`** (texto libre): un punto en el mapa, no un nombre. Va **dentro de `location`** —hermano de `venue`/`onlineUrl`— igual que schema.org anida `geo` dentro de `Place`; no cuelga de `venue`, porque `venue` es una cadena, no un objeto. `geo` no basta por sí solo para satisfacer `location`: sigue haciendo falta `venue` u `onlineUrl`.
- **`updatedAt`** — instante (con offset/Z) en que **los datos del evento** cambiaron por última vez. Es el equivalente de `LAST-MODIFIED` de iCal, **no** de `DTSTAMP`: `DTSTAMP` marca *cuándo se generó el fichero* y cambia en cada exportación aunque no haya cambiado nada, así que no sirve para «qué cambió». Su valor está en la **sincronización incremental**: un consumidor que lee el feed a diario filtra por `updatedAt > última_lectura` en vez de recomparar la colección entera. El `updatedAt` del feed dice «algo cambió»; el del evento dice **qué**. Ausente = desconocido, no «nunca cambió». **Ningún evento puede tener un `updatedAt` posterior al del propio feed** — el feed no puede contener una revisión que, según sus propios sellos, todavía no existía cuando se generó; el validador lo comprueba comparando los instantes reales, no el texto. Detalle en [DECISIONS.md, D015](DECISIONS.md#d015--no-events-updatedat-may-be-later-than-the-feeds-own-updatedat).
### `image`: el cartel, su texto alternativo, y por qué la lista admite dos formas
Nuevo en la v0.3. Opcional —aunque [recomendado](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados)— y **una lista** cuyas entradas apuntan con URLs `https` **al fichero de imagen**, nunca a una página que lo muestre:
```json
"image": [
{
"url": "https://rustmadrid.example/img/2026-06-16x9.png",
"alt": "Cartel sobre fondo morado: el cangrejo Ferris con casco de obra, y la fecha «26 de junio, 19:00» en grande"
},
"https://rustmadrid.example/img/2026-06-4x3.png",
"https://rustmadrid.example/img/2026-06-1x1.png"
]
```
**El orden es significativo**: la primera es la principal, y a menudo la única que un destino puede usar. Quien solo pueda mostrar una, muestra la primera.
**No es una galería.** Lo habitual es que las entradas sean **la misma imagen** en distintos recortes o resoluciones — que es exactamente lo que [Google pide](https://developers.google.com/search/docs/appearance/structured-data/event) (1:1, 4:3 y 16:9) y lo que ya emiten Meetup, Luma y Guild. Pero eso es lo habitual, **no una garantía**: nada impide publicar el cartel y una foto de la sede, y un consumidor no tiene forma de distinguir los dos casos. Por eso la regla que sí vale es la del orden, y ninguna interfaz debe renderizar la lista como carrusel de fotos.
Entra por la vía de siempre —**un campo que ya emite todo el mundo**—: de las cinco fuentes estudiadas ([`research/findings/json-ld-event-platforms.md`](../../research/findings/json-ld-event-platforms.md)), **las cinco** emiten `image`, y tres de ellas ya como array.
#### `image[].alt`: accesibilidad, y las tres decisiones que arrastra
Una entrada es **o una cadena o un objeto** `{ url, alt?, translations? }`. Las dos formas conviven en la misma lista a propósito, y cada parte de esa frase es una decisión:
**Por qué el `alt` va dentro de la entrada y no en un campo hermano del evento.** Un `imageAlt` al lado de `image` sería más simple de escribir y estaría mal: describiría la primera imagen y se aplicaría a las tres. Solo funciona si la lista es siempre el mismo cartel recortado, y acabamos de ver que eso **no se puede garantizar**. El texto alternativo describe *una* imagen concreta, así que viaja pegado a su URL.
**Por qué las dos formas conviven en vez de migrar la lista a objetos.** Porque un array de objetos rompería todos los documentos `0.2` y `0.3` ya publicados —la v0.3 se anuncia como retrocompatible con la v0.2, y lo sigue siendo— y porque los recortes extra **no necesitan `alt`**: describir tres veces la misma imagen es ruido para quien lo escribe y para quien lo escucha. La forma de cadena es la respuesta correcta para ellos. El precio es que quien consume normaliza en una línea (`typeof i === "string" ? { url: i } : i`), y es un precio bajo.
**Por qué el `alt` se traduce, y no se escribe en «inglés internacional».** Un `alt` lo lee un lector de pantalla con la voz y la pronunciación del idioma que lo rodea: meter inglés dentro de un documento en catalán produce audio destrozado, que es peor accesibilidad que la que venía a arreglar ([WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) existe justo por esto). Así que `alt` está en el `textLanguage` del documento, como `name` y `description`, y se traduce con el mapa `translations` **de la propia entrada** — igual que `offers[].name`, y por la misma razón: [un espejo posicional](#textlanguage-y-translations-en-qué-idioma-está-escrito-esto) (`translations.es.image[0]`) pegaría el texto a la imagen equivocada en cuanto alguien reordene la lista. Ver [`examples/event-online.json`](examples/event-online.json).
Sobre qué escribir: **describe lo que se ve**, no lo que ya dice el evento. Repetir el `name` hace que un lector de pantalla lo diga dos veces seguidas, y «imagen de…» sobra porque el cliente ya anuncia que es una imagen. No hay cadena vacía: el `alt=""` de HTML significa «decorativa», y una imagen decorativa no pinta nada en un feed — una imagen que no tiene nada que decir es una imagen que se deja fuera.
Y una advertencia por si se confunde con lo de abajo: esto **no es SEO**. Google no puntúa el `alt` de `Event.image`; se añade porque hay personas que no ven el cartel.
**Es un campo [recomendado](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados)**, no obligatorio. Sin él nada deja de validar y nada se rompe en los tres destinos — pero el evento se lista sin cara, y en una interfaz llena de tarjetas eso decide si alguien lo mira. Pesa más el segundo test del perfil: **el aviso es accionable**. Las cinco fuentes estudiadas ya emiten imagen, así que un feed sin `image` casi nunca es un evento sin cartel: es un cartel que no se ha mapeado. Un aviso que quien publica puede arreglar en un minuto es exactamente lo que el perfil existe para dar.
La excepción conocida es quien importa un `.ics`: **iCalendar casi nunca trae imagen** (`IMAGE` es de 2016 y apenas se emite), así que ahí el aviso no es accionable. Se asume, por la misma razón que `url` y `description` siguen siendo recomendados pese a faltar en casi todo `.ics` publicado: el perfil describe **qué le falta al evento**, no a quién culpar de que falte. Ver [`examples/event-from-ics.json`](examples/event-from-ics.json), que avisa exactamente de eso.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | `image`: URLs peladas para las entradas sin `alt`, y `ImageObject { url, caption }` para las que lo llevan | Ninguna en las URLs — el array de OTE **es** el que pide Google, y `Event.image` admite `ImageObject`, así que el rich result no se pierde por añadir `alt`. schema.org **no tiene una propiedad `alt`**: `caption` es la más cercana y es la que usa Google, así que el matiz «texto alternativo» y no «pie de foto» sí se pierde. |
| **iCal** | `IMAGE;VALUE=URI;DISPLAY=BADGE:` ([RFC 7986](https://www.rfc-editor.org/rfc/rfc7986)) | **De la segunda en adelante**, y **el `alt` entero**: `IMAGE` no tiene parámetro para texto alternativo. `IMAGE` admite varias, pero los clientes que la soportan enseñan una. Un cliente que ignore la propiedad —la mayoría— ve el evento completo igual. |
| **RSS 2.0** | `` con la primera, o `` + `` para el `alt` | `` **exige `type` y `length`**, que OTE no modela: quien exporte tiene que inferir el MIME por la extensión y hacer un `HEAD` para el tamaño, o usar `media:content`, que no exige ninguno de los dos **y además es el único de los dos que sabe llevar el `alt`**. |
| **Atom** | ``, repetible; el `alt` va en el `alt=` del `` dentro del `` | Ninguna en el número de imágenes; el `type` tiene el mismo problema que en RSS. |
Que el MIME no esté es la decisión **contraria** a la de [`organizers[].email`](#email-la-dirección-que-hace-válido-el-organizer-de-ical), y por el mismo criterio: un tipo MIME se **deriva** sin inventar nada —la extensión del fichero, o un `HEAD`—, así que el exportador lo resuelve solo y el schema no tiene que pedirlo. Un email no se deriva de ninguna parte, y por eso sí está.
### `textLanguage` y `translations`: en qué idioma está escrito esto
Nuevos en la v0.3, los dos opcionales. Son **dos campos porque son dos problemas**, y solo el segundo tiene que ver con eventos multilingües:
```json
"name": "Sessió setmanal de codificació — Rust Girona",
"description": "Cada setmana ens trobem en línia per picar Rust una estona.",
"languages": ["ca", "es"],
"textLanguage": "ca",
"translations": {
"es": {
"name": "Sesión semanal de programación — Rust Girona",
"description": "Cada semana nos juntamos en línea para picar Rust un rato."
}
}
```
**`languages` y `textLanguage` no son el mismo dato**, y este ejemplo es exactamente por qué: en la sesión **se habla** catalán y castellano, y el documento **está escrito** solo en catalán. Ninguno de los dos se deriva del otro, y confundirlos es el riesgo real de esta pareja: `languages` contesta «¿lo entenderé si voy?», `textLanguage` contesta «¿en qué idioma está este texto?». La descripción de `languages` ahora lo dice en el propio schema, porque es donde alguien lo va a leer. Que sea válido no significa que sea completo: el perfil recomendado avisa (sin invalidar) si algún idioma de `languages` no tiene ni `textLanguage` (propio o heredado del feed) ni una entrada en `translations` que lo cubra — quien solo lea ese idioma no encuentra ni una palabra que entender. Detalle en [DECISIONS.md, D019](DECISIONS.md#d019--recommended-profile-warns-when-a-spoken-languages-entry-has-no-available-text).
**`textLanguage` es una etiqueta, no una lista.** Un texto está escrito en un idioma. Sin él, un consumidor no puede poner `lang="ca"` en el HTML —lo que decide la separación de sílabas, la voz del lector de pantalla y el diccionario del corrector—, ni ordenar alfabéticamente bien, ni decidir si traducir automáticamente. Nada de eso se puede adivinar del texto sin adivinar.
**«Etiqueta BCP 47» se valida de verdad, no solo por su forma:** el núcleo (idioma, con script/región/variante opcionales) se comprueba subtag a subtag contra el registro real de IANA. Uso privado (`x-...`), etiquetas *grandfathered* (`i-klingon`) y extensiones quedan **deliberadamente fuera de alcance** — sin caso de uso real para «en qué idioma está este texto» — y códigos registrados que no nombran un idioma concreto (`und` Undetermined, `mis`, `mul`...) tampoco valen, mismo motivo que `ZZ` no vale como `country`. Detalle y alternativas descartadas en [DECISIONS.md, D007](DECISIONS.md#d007--languagetag-validates-the-core-of-bcp-47-against-the-real-iana-registry-not-all-of-it).
**Se hereda del feed, y ahí está lo barato.** Como `license` y `organizers`: el feed lo declara una vez y todo evento que no lo declare lo hereda. Para quien publica en un solo idioma —el 99%— el coste de este campo es **una línea en todo el fichero**. Ausente significa **desconocido**: ni el inglés, ni el idioma de la cabecera HTTP, ni el del feed si el feed tampoco lo dice.
**`translations` es un mapa, y el texto principal sigue siendo una cadena.** Es la decisión que sostiene todo lo demás:
- **`name` y `description` no cambian de forma.** Un consumidor de v0.2 lee un documento con `translations` y no se entera de que existe. La alternativa —mapas de idioma en el propio campo, `"name": {"ca": "…", "es": "…"}`, que es el `@container: @language` de JSON-LD— es técnicamente más limpia y **rompe `name` para todos los consumidores que existen hoy**, gravando al 99% monolingüe para servir al 1%. Descartada por eso.
- **Un mapa y no una lista, porque el idioma es la clave.** Una entrada por idioma, y **ninguna forma de publicar dos versiones en castellano** que se contradigan — ni siquiera escribiendo la etiqueta con mayúsculas distintas: `translations.en-US` y `translations.EN-us` son la misma etiqueta BCP 47 y el validador las trata como el mismo idioma, igual que ya hace al compararlas con `textLanguage`. Detalle en [DECISIONS.md, D023](DECISIONS.md#d023--a-translations-map-must-not-carry-two-keys-naming-the-same-language-in-different-case).
- **El texto que vive dentro de un objeto se traduce donde vive**, con un `translations` local a ese objeto: `offers[].translations`, `eligibility.translations`, `partOf.translations`. [Detalle abajo](#traducciones-locales-el-texto-que-vive-dentro-de-un-objeto).
- **Nunca se traduce al idioma en que ya está el documento.** Una entrada `ca` en un documento con `textLanguage: "ca"` es el mismo texto dos veces, y dos formas de afirmar lo mismo son dos formas de contradecirse — el argumento de [`isFree`](#offers-cuánto-cuesta-y-dónde-se-saca-la-entrada). **El validador lo comprueba de verdad** (no es solo una regla normativa que vigile quien publica): un *keyword* Ajv personalizado rechaza cualquier `translations` que repita `textLanguage`, comparando sin distinguir mayúsculas — `ca` y `CA` son el mismo idioma. Detalle en [DECISIONS.md, D010](DECISIONS.md#d010--a-translations-map-must-not-carry-a-key-equal-to-textlanguage).
- **Un mapa vacío es inválido**, igual que `location: {}`: decir nada ya se hace omitiendo el campo. Y las claves tienen que ser etiquetas BCP 47 — `"castellano"` no valida, que es justo el error que se comete a mano. **Y una entrada que solo lleva campos desconocidos tampoco vale como traducción**: sigue pudiendo llevar extensiones junto a `name`/`description` (o `title`/`description` en el feed), pero como único contenido no traduce nada que OTE reconozca — y silenciaría sin querer el aviso de idiomas cubiertos ([D019](DECISIONS.md#d019--recommended-profile-warns-when-a-spoken-languages-entry-has-no-available-text)). Detalle en [DECISIONS.md, D022](DECISIONS.md#d022--eventfeed-top-level-translations-must-carry-at-least-one-recognized-ote-field).
- **Traducir un campo que el texto principal no tiene sigue siendo válido** — puede ser una decisión editorial legítima, no una contradicción — pero el perfil recomendado avisa (sin invalidar) cuando eso ocurre en `offers[].name`, `partOf.name` o `eligibility.note`: quien ya escribió el texto en otro idioma probablemente quiere que también exista en el principal, para quien no lea `translations`. Detalle en [DECISIONS.md, D030](DECISIONS.md#d030--recommended-tier-warning-when-a-translation-exists-for-a-field-the-primary-text-omits).
**Cualquier `translations` del documento exige `textLanguage`.** Es la **única dependencia entre campos de toda la spec**, y el schema la comprueba con un `if`/`then` — **a cualquier profundidad**: una traducción dentro de una oferta también la activa, porque el idioma del texto principal es propiedad del documento entero, no de cada objeto. Sin ella, un mapa de traducciones es inservible: nadie puede saber cuál de las entradas duplica el texto principal, ni a qué está cayendo de vuelta si no encuentra su idioma. El orden de lectura de un consumidor es: **el idioma que pide → `translations` → el texto principal**, y ese último paso necesita saber en qué idioma está.
#### Traducciones locales: el texto que vive dentro de un objeto
`name` y `description` no son el único texto libre de un evento. Hay más dentro de objetos y de listas, y **se traduce donde vive**:
```json
"offers": [
{ "name": "Estudiantes", "price": 0, "translations": { "en": { "name": "Students" } } }
],
"eligibility": {
"type": "members-only",
"note": "Membres del Discord de Rust Girona",
"translations": { "es": { "note": "Miembros del Discord de Rust Girona" } }
},
"image": [
{
"url": "https://rustgirona.example/img/sessio-setmanal.png",
"alt": "Quadrícula de webcams i un editor amb codi Rust compartit",
"translations": { "es": { "alt": "Cuadrícula de webcams y un editor con código Rust compartido" } }
}
]
```
**Nunca un espejo posicional.** `translations.es.offers[0].name` es la forma que esta spec **rechaza**: una lista no tiene claves estables, así que basta con que alguien reordene las ofertas para que la traducción quede colgada de la tarifa equivocada — **y nada dejaría de validar**. Un mapa local no puede desalinearse: vive dentro del objeto que traduce.
**Qué se traduce y qué no**, porque el texto libre de un evento no es todo la misma clase de cadena:
| Clase | Ejemplos | Qué hace la spec |
| --- | --- | --- |
| **Prosa y rótulos** | `name`, `description`, `offers[].name`, `eligibility.note`, `partOf.name`, `image[].alt` | **Se traducen**, con `translations` — el del evento para los dos primeros, uno local para el resto. `image[].alt` es el caso con más consecuencia: se lee **en voz alta** con la pronunciación del idioma que lo rodea. |
| **Nombres propios** | `organizers[].name`, `location.venue` | **No se traducen nunca.** «PyAlmería» es «PyAlmería» en todos los idiomas, y traducir el nombre de una sede es inventarse un sitio. |
| **Etiquetas** | `tags` | **No se traducen en el dato**, y aquí la spec deja una arista: `tags` es texto libre, así que un evento etiquetado `["aprenentatge-automàtic"]` y otro `["machine-learning"]` no se encuentran entre sí. La recomendación práctica es **etiquetar en el idioma del ecosistema técnico**, que es de hecho lo que ya pasa (`rust`, `wasm`, `ai`), y dejar la presentación a la interfaz. Un vocabulario controlado encima lo resolvería del todo; sigue siendo [pregunta abierta](#otras). |
| **Valores cerrados** | `eligibility.type`, `status`, `attendanceMode`, `offers[].availability` | **No necesitan traducción**: un enum es **multilingüe gratis**. `members-only` se renderiza en el idioma de quien lee, y el dato no cambia. Es el mejor argumento a favor de los enums de toda la spec. |
| **Códigos** | `address.country`, `languages`, `textLanguage`, `offers[].currency` | Ya resueltos, y por esto mismo: `ES` en vez de «España» es [una decisión que la spec ya tomó](#locationaddress-la-dirección-que-se-valida-por-partes). |
| **Identificadores** | `id`, `partOf.id`, todas las `url` | **Jamás.** Un `id` con dos grafías son dos eventos, y una serie con dos `id` son dos series. |
**`offers[].name` merece una nota**, porque es el caso donde había alternativa: un `kind` con enum (`general`, `early-bird`, `student`) habría sido multilingüe gratis, como `eligibility.type`. **Se descarta**: quitaría a quien organiza el derecho a nombrar sus propias entradas, que es una libertad real y usada. Texto libre es la decisión; traducirlo es su precio, y por eso `offers[].translations` existe.
**Lo que sigue sin traducirse, y la recomendación en su lugar:** `location.address.locality` y `region`, donde **València/Valencia** o **Girona/Gerona** son dos grafías reales del mismo sitio y no hay tabla como la de países. No se modela: escribe **la grafía más reconocible para la audiencia mayoritaria del evento**, y deja que el resto lo resuelva `geo` — unas coordenadas no tienen idioma.
**En el feed, lo mismo con una diferencia importante.** `feed.textLanguage` describe el `title` y la `description` **del feed** y además es el valor por defecto de sus eventos; `feed.translations` traduce **el título del feed, nunca sus eventos**. Y **no se hereda**: el título de un feed no es el nombre de un evento. Un publicador completamente bilingüe tiene además la salida que ya usa cualquier web —**un feed por idioma** (`/feed.ca.json`, `/feed.es.json`, cada uno con su `textLanguage`)—, y sigue siendo la opción más simple cuando *todo* el contenido está duplicado.
**Cómo entra, y qué le falta.** El listón de esta spec es que exista productor real, y hay que decirlo con claridad: **`textLanguage` lo cumple** —iCalendar tiene `LANGUAGE` como parámetro nativo desde el [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545), RSS tiene `` y JSON-LD tiene `@language`: los tres destinos saben recibirlo— y **`translations` no**: ninguna de las cinco plataformas estudiadas publica texto multilingüe por evento, porque cada una sirve una página por idioma. Entra igualmente, y por una razón concreta: en catalán, euskera, galego y valenciano el evento bilingüe **es el caso normal**, y hoy la única salida es meter los dos idiomas dentro de la misma cadena, que es peor que no tener el campo. Es la deuda declarada de esta pareja: si en la práctica nadie lo emite, sobra, y sobrará en voz alta.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org / JSON-LD** | `textLanguage` → `@language` del contexto o del valor; `translations` → mapas de idioma (`{"@language":"es","@value":"…"}` por entrada) | **Ninguna.** Es el único destino que recibe los dos campos completos, con estructura. |
| **iCal** | `textLanguage` → el parámetro `LANGUAGE` de cada propiedad de texto (`SUMMARY;LANGUAGE=ca:…`), nativo en RFC 5545 | **`translations`.** `SUMMARY` no se repite: solo sobrevive el texto principal. El resto va a la `DESCRIPTION` («ES: Sesión semanal…») o a un `X-OTE-TRANSLATION`. |
| **RSS / Atom** | `textLanguage` → `` del canal (RSS) o `xml:lang` (Atom, que además lo admite por entrada) | **`translations` en RSS**, que solo tiene idioma a nivel de canal. Atom aguanta más porque `xml:lang` es por elemento. |
Que solo JSON-LD lo reciba entero es aceptable por la regla de siempre: quien ignore los dos campos **sigue viendo un evento correcto**, en un idioma concreto. Es pérdida de estructura, no de información.
### `eligibility`: quién puede entrar — y por qué no es un `tag`
Nuevo en la v0.3. Opcional, **un objeto**, y solo `type` es obligatorio:
```json
"eligibility": {
"type": "members-only",
"note": "Miembros del Discord de Rust Girona",
"url": "https://rustgirona.example/join"
}
```
Un evento sin condiciones lo dice con una línea, y **dice algo**:
```json
"eligibility": { "type": "open" }
```
**Es la tercera parte de «¿puedo ir?».** `attendanceMode` dice si hay que desplazarse y `location` dónde; los dos contestan si el evento **está a tu alcance**. Ninguno contesta si **te dejan entrar**. Son preguntas distintas y se contradicen sin problema: la sesión semanal de Rust Girona es online, gratis y abierta a cualquiera con conexión —y aun así el canal de voz está dentro de un Discord al que hay que pertenecer. Hasta ahora ese requisito solo vivía en la prosa de `description`, que es exactamente donde un consumidor no puede filtrarlo.
**Cuatro valores, y el enum es el punto.** Corto a propósito: un consumidor que tiene que tratar veinte puertas no trata ninguna, y la razón de que esto sea un enum y no texto libre es que **«¿puedo ir?» es una casilla de filtro**, no un párrafo.
| Valor | Qué afirma | Ejemplo real |
| --- | --- | --- |
| `open` | Puede asistir cualquiera. **Incluye** el evento con entrada de pago y el que se queda sin plazas: un precio y un aforo no son condiciones sobre **quién eres**. | Un meetup con registro abierto; una conferencia con entradas a la venta. |
| `members-only` | Hay que **pertenecer** a algo antes. | La sesión que ocurre dentro del Discord de la comunidad. |
| `approval-required` | Te apuntas y **quien organiza decide**. | El «request to approve» de Luma; un grupo de Meetup con pregunta de admisión; un taller que selecciona a quien asiste. |
| `restricted` | Hay condición y **ninguno de los otros la nombra**. Obliga a escribir `note`. | «Solo alumnado de la Universidad de Almería»; una cena de speakers y patrocinadores dentro de una conferencia. |
**`approval-required` no es aforo, y conviene decirlo porque se confunde solo.** Va de un **juicio sobre la persona**: alguien mira tu solicitud y decide. Un evento al que puede ir cualquiera **por orden de llegada** hasta que se acaban las plazas **no tiene puerta**: es `open`, y que las plazas se agoten se dice con [`offers[].availability: "sold-out"`](#offers-cuánto-cuesta-y-dónde-se-saca-la-entrada) —o con `capacity`, que sigue siendo extensión—. Es la distinción que sostiene todo el campo: `eligibility` describe **una condición sobre quién puede entrar**, no el **estado de la taquilla**. Si la respuesta a «¿puedo ir?» cambia sola con el paso del tiempo, no es `eligibility`.
**`restricted` es el escape, y exige `note`.** Es lo que mantiene el enum pequeño y honesto: sin un catch-all, toda condición que no encaje —«solo alumnado de la UAL», los eventos con requisito de identidad de una comunidad— acaba metida a martillazos en `members-only`, que es afirmar algo que nadie afirmó. Y `restricted` a secas no dice nada, así que el schema **rechaza el documento** si falta `note`: es la misma condicional que exige `currency` en cuanto `price` pasa de 0. Cumple el papel que `tentative` cumple en `status`: el valor que evita que quien importa tenga que mentir.
**Sin valor por defecto: ausente significa desconocido, nunca `open`.** Misma regla que `attendanceMode` y `offers`. Quien importa un `.ics` no tiene el dato en ninguna parte, y un valor por defecto convertiría cada evento importado en una afirmación —«abierto a cualquiera»— que nadie ha hecho. El corolario es que **`"type": "open"` sí aporta información**, al contrario que `"status": "scheduled"`: aquí callarse no significa lo mismo que decirlo. Es el único par de la spec donde esa diferencia se ve tan de cerca.
**Por qué no hay `invite-only`.** Estuvo en el borrador y **se cae por alcance, no por forma**. Esta spec existe para que alguien encuentre eventos y comunidades **en los que puede participar**; un evento al que solo se entra por invitación no es un evento que buscar, es un club privado, y darle un valor propio del enum sería declarar que describir clubes privados es parte del trabajo. Los casos reales que rozan el límite —una cena de speakers y patrocinadores, un encuentro de un programa de embajadores— **siguen siendo publicables**, y con más información que antes: `restricted` con su `note` obligatoria («Solo speakers y patrocinadores») dice **quién** puede entrar, mientras que `invite-only` solo decía «tú no». Un valor menos, y el que queda obliga a explicarse.
**Por qué no son `tags`.** Es como se hace hoy, y por eso el campo existe: en cuanto `["rust","members-only","principiantes"]` es una lista libre, el consumidor tiene que **adivinar** cuál de esas cadenas es una condición de acceso, y ninguna interfaz puede ofrecer una casilla «solo eventos a los que puedo entrar» sin un vocabulario que la lista libre no tiene. `tags` es **de qué va** el evento; se queda libre justo por eso, y su descripción ahora lo dice. El otro eje que hoy también se cuela en `tags` —**el público y el nivel** («principiantes», «estudiantes»)— sigue sin resolver: es una [pregunta abierta](#otras), no este campo. La puerta y la recomendación no son lo mismo.
**Por qué un objeto y no una cadena** (`"eligibility": "members-only"`). Porque *qué* comunidad es un dato que quien publica ya tiene hoy, y en `members-only` sin nombrarla el campo se queda a medias. Ensanchar cadena → objeto después es un cambio que rompe —el precio que se pagó al declararlo en [`image`](#image-el-cartel-y-por-qué-es-una-lista-de-cadenas)—, así que se paga ahora, que es gratis. Solo `type` es obligatorio: quien no tenga más, escribe una línea.
**Y no va dentro de `offers`.** Se valoró, porque el eje por tramo existe de verdad —la tarifa de estudiante pide carné, la de socio pide alta— y aun así se descarta: `offers` es **opcional y ausente en la mayoría de los eventos** (ninguno de los 24 del feed de referencia lo trae, y un exportador de `.ics` no tiene precio que emitir), así que la puerta desaparecería justo donde más eventos hay. Además «¿puedo ir?» es propiedad **del evento**, no del tramo de precio: si vive solo en `offers`, cada consumidor tiene que plegar N ofertas en una respuesta, y dos formas de afirmar lo mismo son dos formas de contradecirse — el argumento por el que [no hay `isFree`](#offers-cuánto-cuesta-y-dónde-se-saca-la-entrada). Si aparece un productor real con requisito por tramo, `offers[].eligibility` entrará **reutilizando este mismo enum**, y hasta entonces no se paga por un caso hipotético.
**Lo que `eligibility` no modela**: aforo y plazas restantes (eso es [taquilla](#offers-cuánto-cuesta-y-dónde-se-saca-la-entrada)), el estado de *tu* solicitud, códigos de acceso, listas de invitados, edad mínima como campo aparte, ni el código de conducta. Describe **la condición, no el trámite**.
**Cómo entra**, porque el listón de esta spec es que alguien lo emita de verdad: ninguna plataforma lo publica **estructurado** —schema.org no tiene término para la puerta—, pero todas lo tienen **como funcionalidad**: la aprobación previa de Luma, los eventos solo para miembros de un grupo de Meetup, los eventos privados de Eventbrite, el canal cerrado de Discord. Es el mismo caso que [`cfp`](#cfp-la-convocatoria-de-charlas--y-el-primer-campo-que-no-viaja-a-ninguna-parte): el dato existe y se publica en HTML, y quien lo quiere estructurado hoy tiene que adivinarlo. Su valor está **dentro del ecosistema OTE**, no en la traducción.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | Sin término propio. Lo más cercano es `audience` (`Audience.audienceType`), que es **texto libre y otra cosa** —a quién va dirigido, no quién tiene permiso—; `note` y `url` van dentro de `description`. | **La estructura, no el dato.** El texto llega; el filtro no. Y ojo con dos falsos amigos: `Offer.eligibleCustomerType` es B2B/B2C, y `isAccessibleForFree` es el precio, no la puerta. |
| **iCal** | Nada nativo → `X-OTE-ELIGIBILITY` y el texto en `DESCRIPTION` («Solo miembros del Discord de Rust Girona») | **La estructura.** Falso amigo a evitar: `CLASS:PRIVATE` de [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545) es la **visibilidad del dato en un calendario**, no el permiso de entrada al evento. Mapear ahí sería decir otra cosa. |
| **RSS / Atom** | Nada nativo: va dentro del texto del ítem | **La estructura.** Un anuncio que no dice que hace falta ser socio manda a alguien a una puerta cerrada. |
Que solo llegue como texto es aceptable por la regla de siempre: un cliente de calendario que ignora `eligibility` **sigue mostrando un evento correcto**. Es pérdida de estructura, no de información — y la información sobrevive porque `note` está pensada precisamente para que la lea una persona.
### `offers`: cuánto cuesta, y dónde se saca la entrada
Nuevo en la v0.3. Opcional, y **una lista**:
```json
"offers": [
{ "name": "Early bird", "price": 35, "currency": "EUR", "url": "https://…/entradas", "availability": "sold-out", "closesAt": "2026-06-30T23:59:59+02:00" },
{ "name": "General", "price": 45, "currency": "EUR", "url": "https://…/entradas", "availability": "in-stock" },
{ "name": "Estudiantes","price": 0, "url": "https://…/entradas#estudiantes" }
]
```
Un meetup gratuito es **una sola entrada**:
```json
"offers": [{ "price": 0, "url": "https://rustmadrid.example/meetups/2026-06#registro" }]
```
**Ausente significa desconocido, no gratis.** Es la misma regla que `attendanceMode`, y aquí duele igual: un consumidor que interprete «sin `offers` = gratis» convierte en gratuita cualquier conferencia de pago cuyo exportador no mapeó el precio. Decir «gratis» tiene una forma, y es `price: 0`.
**Es una lista porque el precio de un evento casi nunca es un número.** Early bird, general, estudiantes, empresa: son ofertas distintas, con fechas y disponibilidad distintas. Por eso **no hay rangos ni «desde 45 €»**: un precio que no se puede escribir con un solo número **son varias ofertas**, y escribirlo como texto rompe lo único por lo que merece la pena publicarlo como dato — que alguien pueda **filtrar y comparar**. `price` es un número, sin símbolo de moneda y sin separador de miles.
**`currency` es obligatoria en cuanto `price` pasa de 0**, y sobra cuando es 0. Lo gratis es gratis en cualquier moneda, y exigirla ahí es exactamente cómo Luma acaba publicando `"price": 0, "priceCurrency": "usd"` para una sesión semanal de Rust en Girona: una divisa inventada para un dato que no la necesita. Al revés, un `45` sin moneda no es un precio: es un número que cada consumidor leerá en la suya. Es la misma decisión que `country` en `address` — código ISO (4217 aquí, alfa-3 en mayúsculas), no nombre. El validador lo comprueba contra la lista ISO 4217 activa, no solo por su forma — mismo motivo y mismo patrón que `timezone`: ver [DECISIONS.md, D005](DECISIONS.md#d005--offerscurrency-must-be-a-real-iso-4217-code). Y al revés de esto: `currency` sin ningún `price` tampoco vale — es una moneda que no califica nada, huérfana en el `Offer.priceCurrency` que se mapea a schema.org. Si el precio todavía no se conoce, la forma de decirlo ya existe y es la misma de siempre: omitir el campo. Ver [DECISIONS.md, D021](DECISIONS.md#d021--offerscurrency-requires-offersprice-to-be-present).
**`availability` tiene dos valores, `in-stock` y `sold-out`, y no tiene valor por defecto.** Son los dos estados sobre los que quien asiste puede actuar. Ausente significa desconocido, y eso es deliberado: **un feed desactualizado que sigue afirmando `in-stock` es peor que uno que se calla**, porque manda a alguien a una página de entradas agotadas. Quien no mantenga el dato al día, que lo omita.
**Regla para quien consume, y vale para todos los enums de la spec: un valor que no conozcas se trata como desconocido, nunca como el valor tolerante.** Escrito como `availability !== "sold-out"` ⇒ disponible —que es como se escribe esto casi siempre— cualquier valor futuro se convierte en «a la venta», y eso manda a alguien a comprar lo que no se puede comprar. Es la misma regla que «ausente = desconocido», aplicada al futuro en vez de al vacío.
**`waitlistUrl`: agotado con cola no es lo mismo que agotado.** Cuando las plazas se acaban hay dos situaciones distintas —no hay nada que hacer, o puedes ponerte en la cola— y hasta ahora eran el mismo documento:
```json
{ "name": "Estudiantes", "price": 0, "availability": "sold-out",
"waitlistUrl": "https://devfest-levante.example/2026/lista-espera" }
```
**Y por qué no es un tercer valor de `availability`.** Fue la primera idea, y pierde en lo único que importa aquí: **cómo degrada**. Con `availability: "waitlist"`, un consumidor que no conozca el valor no tiene ninguna lectura segura, y el que lo parsee con `availability !== "sold-out"` —lo normal— acaba anunciando como disponible algo que no lo está. Con `sold-out` + `waitlistUrl`, **todo consumidor que existe hoy sigue leyendo «agotado», que es verdad**: no puedes comprar. El que conozca el campo, además, ofrece la cola. Callarse sobre la cola es una omisión; decir «a la venta» es una mentira, y esta spec elige la omisión siempre.
**`price` no contradice a `sold-out`, ni a la cola.** Los dos ejes son ortogonales por diseño: `price` describe **el trato**, `availability` describe **si puedes actuar sobre él ahora**. «45 €, agotado» ya era un documento normal en la v0.3; con cola significa lo mismo que significaba `price` antes de comprar — **lo que costará si entras**. Apuntarse a una cola no cuesta dinero, y nada en el modelo insinúa lo contrario. Por eso el caso que más lo necesita —el **evento gratuito con aforo limitado**, que es el pan de cada día de un meetup— se escribe `price: 0` + `sold-out` + `waitlistUrl`, y quien lo lea sin entender colas ve «gratis, agotado».
**El schema rechaza `in-stock` + `waitlistUrl`**, con un `if`/`then` como el de `currency`: una cola para algo que está a la venta no es una cola. Lo que **sí** permite es `waitlistUrl` sin `availability`: quien sabe que hay cola y no mantiene el estado de la taquilla al día no debería verse forzado a **afirmar** `sold-out` para poder mencionarla. Se prohíbe la combinación incoherente, nunca la incompleta.
**Lo que se descartó por el camino: `last-tickets`.** schema.org tiene el término (`LimitedAvailability`, y Google lo lee), y aun así se queda fuera por tres razones que se refuerzan: de las cinco fuentes estudiadas, **las tres que emiten `availability` emiten `InStock` y nada más**; el umbral no se puede definir —¿cinco plazas, el 10%, lo que decida el marketing?—, así que nadie podría comparar ni filtrar, que es el argumento por el que `price` es un número y no «desde 45 €»; y es **el estado más volátil posible**, incompatible con un fichero que se publica cada noche. Sobre todo, **no cambia la acción**: sigues pudiendo comprar. Cambia la urgencia, y la urgencia es aforo — o sea taquilla, que esta spec deja fuera.
**`opensAt` y `closesAt` son INSTANTES, con offset o `Z`** — a diferencia de `startDate`, que es reloj de pared. No es una incoherencia: una venta que abre es **el momento en que un botón empieza a funcionar**, no una hora en un cartel. [Detalle abajo, junto al mismo caso en el CFP](#fechas-límite-por-qué-llevan-offset-y-startdate-no).
**Lo que `offers` no modela**, y no por olvido: **aforo** (`maximumAttendeeCapacity`, que Guild sí emite), **plazas restantes**, **cuánta gente hay en la cola**, códigos de descuento, cuotas por equipo, y la **inscripción única** de un evento multi-parte. Todo eso es *ticketing*: estado que cambia solo, que caduca en minutos y que un fichero JSON publicado cada noche no puede sostener. `offers` describe **la entrada, no la taquilla**. Si necesitas aforo hoy, ponlo como extensión sin prefijo (ver [`examples/event-meetup.json`](examples/event-meetup.json)) y dilo en el issue.
**Por qué no hay `isFree`.** Estaba en el boceto anterior, y es redundante: `price: 0` ya lo dice. Dos formas de afirmar lo mismo son dos formas de contradecirse — `{"isFree": true, "price": 45}` es un documento que valida y no significa nada —, y obliga a todo consumidor a decidir cuál gana. Por lo mismo, `registrationUrl` se llama aquí `url`: el objeto ya se llama «oferta».
Entra por la vía de siempre: de las cinco fuentes estudiadas ([`research/findings/json-ld-event-platforms.md`](../../research/findings/json-ld-event-platforms.md)), **tres emiten `offers`** —Luma, Guild y el ejemplo canónico de Google—, con la forma `price` + `priceCurrency` + `availability` + `url` que aquí se copia casi tal cual. Y es un campo que Google **muestra** en el rich result de `Event`.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | `offers` (array de `Offer`): `price` → `price`, `currency` → `priceCurrency`, `url` → `url`, `availability` → `https://schema.org/InStock` \| `SoldOut`, `opensAt` → `validFrom`, `closesAt` → `validThrough`, `name` → `name` | **Solo `waitlistUrl`.** El resto es 1:1 con el término que Google lee hoy. schema.org **no tiene término** para lista de espera —`BackOrder` y `PreOrder` significan otra cosa—, así que se emite `SoldOut`, que no es falso, y la cola se degrada al texto. Un valor de enum habría perdido exactamente lo mismo. |
| **iCal** | Nada nativo | **Total.** [RFC 5545](https://www.rfc-editor.org/rfc/rfc5545) no modela precio ni entradas: no hay propiedad donde ponerlo. Un exportador puede llevarlo a la `DESCRIPTION` («Entrada general: 45 €») o a un `X-OTE-PRICE`, y quien no lo entienda ve el evento entero igual. |
| **RSS / Atom** | Nada nativo: va dentro del texto del ítem | **Toda la estructura.** Un canal de anuncios que no dice el precio en el cuerpo está ocultando lo primero que se pregunta. |
Que solo schema.org lo reciba estructurado es aceptable porque **la pérdida es inocua**: un cliente de calendario que ignora el precio sigue mostrando un evento correcto. Es la misma regla que `partOf` — un campo de identidad o de contexto que se ignora deja datos incompletos; uno de tiempo que se ignora deja datos falsos.
### `cfp`: la convocatoria de charlas — y el primer campo que no viaja a ninguna parte
Nuevo en la v0.3. Opcional, **un objeto**, y solo `url` es obligatoria:
```json
"cfp": {
"url": "https://devfest-levante.example/2026/cfp",
"opensAt": "2026-05-01T00:00:00+02:00",
"closesAt": "2026-07-15T23:59:59+02:00",
"coversTravel": true,
"coversAccommodation": true
}
```
**Es el único campo de la spec sin equivalente en ninguno de los tres destinos.** schema.org no tiene término para una convocatoria de propuestas, iCalendar tampoco, y RSS/Atom menos. Al exportar se degrada a texto, y punto. Así que hay que justificar por qué entra igualmente, porque el listón de esta spec es «lo emite alguien de verdad» y aquí ningún productor de JSON-LD lo emite.
Entra por **el otro lado del tubo**: el del consumidor. «¿Qué conferencias están aceptando propuestas ahora mismo?» es una de las preguntas que este proyecto existe para contestar, y hoy se contesta **scrapeando**: confs.tech, developers.events, CFP Land y demás listados mantienen a mano —o a base de raspar webs— exactamente estos dos datos, enlace y fecha límite. Que no haya un `Offer` de schema.org detrás no significa que no haya productores: significa que los productores lo publican **en HTML**, y que quien lo quiere estructurado tiene que adivinarlo. OTE no es solo un formato de exportación a otros tres formatos; es también el sitio donde puede vivir un dato que los otros tres no saben nombrar. `cfp` es el primer campo que ejerce eso, y conviene decirlo en voz alta: **su valor está dentro del ecosistema OTE, no en la traducción**.
**`closesAt` es [recomendado](#válido-no-es-lo-mismo-que-útil-los-campos-recomendados) en cuanto hay `cfp`.** Sin fecha límite, un consumidor ve un enlace y no puede saber si cerró en marzo — y la pregunta que el campo existe para responder («¿está abierto?») se queda sin responder. El aviso es accionable por definición: quien abrió la convocatoria sabe cuándo cierra. Es la otra recomendación condicional de la spec, junto a `endDate`.
**Un objeto, no una lista** — al contrario que `organizers` e `image`. La razón es la misma en los tres casos, aplicada a los hechos: `organizers` nace lista porque Luma **ya emite** varios; aquí ningún productor real publica dos convocatorias por evento, y los directorios de CFP que existen modelan exactamente **un enlace y una fecha**. Si aparecen de verdad los casos que se imaginan (charlas y talleres con plazos distintos), ensanchar objeto → lista es un cambio que rompe y llegará con su versión. No se paga hoy por un caso hipotético.
**`coversTravel` y `coversAccommodation` son booleanos sin valor por defecto**: ausente significa **desconocido**, nunca `false`. Están aquí, y «call for sponsors» o «call for volunteers» no, porque son **lo que se filtra antes de decidir si puedes permitirte enviar una propuesta**: para quien da charlas fuera de su ciudad, esa casilla decide si la convocatoria le concierne.
**Lo que `cfp` no modela**: tracks, formatos y duraciones, estado de la revisión, si es ciega, cuotas de diversidad o el resultado. Nada de eso lo tiene un directorio de eventos: lo tiene la plataforma de CFP, que ya es Sessionize o Pretalx, y a la que precisamente apunta `url`.
#### Fechas límite: por qué llevan offset, y `startDate` no
`cfp.opensAt`, `cfp.closesAt`, `offers[].opensAt` y `offers[].closesAt` son **instantes**: exigen offset (`+02:00`) o `Z`. Las fechas del evento, no. Parece una incoherencia y es justo lo contrario:
- **Un evento le pasa a la gente en un sitio.** «El 16 de octubre a las 9:00» es la hora del cartel, y quien esté ahí la lee tal cual. Por eso es reloj de pared, y por eso `timezone` la contextualiza.
- **Una fecha límite es un botón que deja de funcionar.** No la vive nadie en ninguna sede: la viven a la vez alguien en Madrid y alguien en Bogotá, y lo único que importa es el instante exacto.
Y hay un caso que zanja la discusión: **«anywhere on Earth»**. Un CFP que cierra AoE cierra a las 23:59 **en UTC-12**, que no es la zona del evento ni la de nadie que lo organice. Con reloj de pared + `timezone` del evento no se puede expresar; con offset se escribe `"2026-07-15T23:59:59-12:00"` y se acabó. Un `"23:59"` pelado es el bug clásico de las convocatorias — qué medianoche es literalmente toda la pregunta.
**El offset de cada instante es el que quien lo escribe elija — no se exige UTC.** Forzarlo simplificaría la validación, pero obligaría a convertir a mano una fecha que alguien ya piensa en su propia zona horaria, el mismo coste que ya se descartó para `startDate`. Lo que sí exige el validador es que `closesAt` no sea anterior a `opensAt` — comparando los instantes reales, no el texto, porque con offsets distintos el orden de las cadenas puede no coincidir con el orden real. Detalle en [DECISIONS.md, D009](DECISIONS.md#d009--offers-and-cfp-windows-opensatclosesat-must-not-be-inverted-and-instants-keep-their-own-offset-rather-than-being-forced-to-utc).
El precio es que Google, en su ejemplo canónico, emite `"validFrom": "2024-05-21T12:00"` **sin** offset. Un exportador de OTE emite el instante completo, que es un superconjunto: nada se pierde, y lo que llega es menos ambiguo que el ejemplo.
**Traducción a los tres formatos de destino, incluida la pérdida:**
| Destino | Mapeo | Pérdida |
| --- | --- | --- |
| **schema.org** | Ninguno | **Total.** No hay término. Un exportador puede mencionar la convocatoria en `description`; Google no la va a entender de ninguna forma. |
| **iCal** | Ninguno | **Total.** Ni `URL` (ya la ocupa la del evento) ni nada equivalente. Degrádalo a `DESCRIPTION` o a un `X-OTE-CFP-URL`. |
| **RSS / Atom** | Nada nativo: va dentro del texto del ítem | **Toda la estructura**, y aquí sí conviene compensarla: un feed de anuncios que no dice «CFP abierto hasta el 15 de julio» en el cuerpo está callándose la razón por la que mucha gente lo lee. |
## El feed
Obligatorio: `specVersion`, `title`, `updatedAt`, `events`.
**La `license` del feed es el valor por defecto de sus eventos**: un evento que no declare la suya hereda la del feed. Repetir `"license": "CC-BY-4.0"` en 200 eventos es ruido, no rigor. Un evento *dentro de un feed* tampoco repite `specVersion`: hereda la del feed. Un evento **suelto** (fuera de un feed) sí debe declarar ambas — no tiene de quién heredarlas.
**`license` es la única de estas herencias que puede quedar sin valor por defecto — y solo si nada se rompe por ello.** Un feed agregador cuyos eventos tienen licencias distintas puede **omitir** `feed.license`, igual que ya puede omitir `organizers`/`textLanguage` — pero con una condición que esas dos no llevan: si el feed no declara `license`, **cada evento debe declarar la suya propia**. Ningún documento OTE válido, suelto o dentro de un feed, puede terminar con una licencia desconocida — desconocer bajo qué términos se redistribuyen unos datos es un riesgo legal real, no solo una atribución poco clara, y por eso esta garantía es más estricta que la de `organizers`/`textLanguage`. Detalle en [DECISIONS.md, D029](DECISIONS.md#d029--feedlicense-may-be-omitted-only-if-every-event-then-declares-its-own).
**`organizers` se hereda igual, con una diferencia**: la lista del evento **reemplaza** la del feed, no se suma a ella (el porqué, arriba). Y un feed de **agregador** debe **omitir** `organizers`: no organiza lo que publica, y ponerlo ahí atribuiría mal cada evento del feed.
**`textLanguage` también se hereda** —una línea en el feed y ningún evento la repite— y **`translations` no se hereda nunca**: el `title` de un feed no es el `name` de un evento, así que `feed.translations` traduce el feed y cada evento lleva las suyas. [Detalle arriba](#textlanguage-y-translations-en-qué-idioma-está-escrito-esto). Y, igual que `organizers`, un feed de **agregador** cuyos eventos no comparten idioma debe **omitir** `feed.textLanguage`: heredarlo atribuiría a todo evento un idioma que puede no ser el suyo. El perfil recomendado avisa (sin invalidar) si `textLanguage` está presente y `organizers` no —la misma señal que ya usa el feed para saber que es de agregador—, precisamente para detectar este caso. Detalle en [DECISIONS.md, D016](DECISIONS.md#d016--feedtextlanguage-inheritance-is-enforced-against-the-effective-language-computed-at-the-feed-root).
**Resumen de qué se hereda**, porque son cuatro campos con tres comportamientos distintos:
| Campo del feed | Cómo llega al evento |
| --- | --- |
| `specVersion`, `license` | **Valor por defecto.** El evento que lo declara gana; suelto, es obligatorio declararlo. |
| `textLanguage` | **Valor por defecto.** Igual que `license`. |
| `organizers` | **Valor por defecto por REEMPLAZO**: la lista del evento sustituye la entera, no se fusiona. |
| `translations` | **No se hereda.** Traduce el texto del feed, y nada más. |
Por eso el schema del evento tiene dos capas: `$defs/event` (lo común) y el documento de nivel superior, que añade `specVersion` y `license` como obligatorios. El feed referencia `$defs/event`.
El feed es un **formato de intercambio, no una API**: sin paginación, sin filtrado, sin autenticación, sin federación.
## Extensiones
Los schemas **no prohíben campos adicionales**. Si tu comunidad necesita `sponsors` o `capacity` hoy, ponlos: tu documento sigue siendo válido. Es la vía por la que la spec debe crecer — **campos que alguien ya usa de verdad**, no campos que imaginamos que hará falta usar. Así entró `tags` en la v0.2, y así entraron `organizers`, `image`, `offers` y `cfp` en la v0.3.
Cuando un campo se estandarice, se le dará un significado normativo. Hasta entonces, un consumidor puede ignorarlos sin miedo.
### Dos tipos de extensión, y por qué distinguirlos
Bajo «campo adicional» conviven dos cosas muy distintas, y confundirlas se paga más adelante:
| | Qué es | Cómo se escribe | Ejemplo |
| --- | --- | --- | --- |
| **Candidato a núcleo** | Un campo genérico que **aspira a ser de OTE**. Lo usas hoy porque te hace falta; si a más gente le hace falta, se estandariza. | **Sin prefijo** | `capacity`, `sponsors`, `speakers` |
| **Vocabulario externo** | Un campo cuyo significado **lo define otro proyecto** y que nunca será de OTE, porque no le pertenece. | **Con prefijo `proyecto:campo`** | `combuilders:communityId` |
**Compromiso de la spec: OTE no acuñará jamás un nombre de campo que contenga `:`.** Es una reserva de espacio de nombres, y es lo que hace segura la segunda fila: un campo con prefijo **no puede colisionar** con un campo del núcleo, hoy ni en la v1.0. Un campo sin prefijo sí puede — y el día que OTE estandarice ese nombre, tu significado local desaparece bajo el normativo. Elige en consecuencia: sin prefijo estás proponiendo, con prefijo estás integrando.
Un consumidor de OTE ignora ambos tipos sin miedo. La diferencia no la nota él: la nota quien mantiene el dato dentro de dos versiones.
Esto es lo que permite que OTE **conecte** con otras especificaciones sin **acoplarse** a ellas. Un directorio de comunidades puede definir su propio identificador y publicarlo dentro de un documento OTE válido, hoy, sin pedir permiso ni esperar a una versión:
```json
"organizers": [
{
"name": "GDG Madrid",
"url": "https://gdgmadrid.example",
"combuilders:communityId": "gdg-madrid"
}
]
```
OTE no sabe qué significa `combuilders:communityId` y no le hace falta saberlo. El prefijo garantiza que las dos especificaciones puedan evolucionar por separado sin pisarse. Ver [`examples/event-co-organized.json`](examples/event-co-organized.json).
## Lo que la v0.3 no resuelve
Deduplicación entre fuentes, sincronización, publicación automática en plataformas, modelado de ponentes/agenda/patrocinadores, y **la taquilla**: aforo, plazas restantes, códigos de descuento e inscripción única de un evento multi-parte. `offers` describe **la entrada**, no el estado de la venta; `cfp` describe **la convocatoria**, no la revisión de propuestas.
Tampoco resuelve **las grafías de una localidad**: `location.address.locality` y `region` se escriben una sola vez, en la grafía más reconocible para la audiencia del evento — «València» o «Valencia», no las dos. Lo demás sí se traduce, y [donde vive](#traducciones-locales-el-texto-que-vive-dentro-de-un-objeto).
El objetivo es describir **el evento**, no el registro en una base de datos.
## Preguntas abiertas
### Descubrimiento: cómo se encuentra un feed desde una web
Ver [#6](https://github.com/OpenTechEvents/opentechevents-spec/issues/6). Los tres mecanismos **no son excluyentes**, y probablemente hagan falta los tres:
| Mecanismo | Para quién | Estado |
| --- | --- | --- |
| **``** en el ``, símil RSS | **Todo el mundo.** Es el único que funciona para quien publica en una ruta cuyo dominio no controla: GitHub Pages de proyecto (`usuario.github.io/repo`), una página dentro de un dominio corporativo, un CMS ajeno. | Propuesto como **mecanismo principal**. Falta decidir el MIME: `application/ote+json` propio vs. reutilizar `application/feed+json`. |
| **`/.well-known/ote-feed`** | Quien **sí controla el apex** de su dominio. Permite descubrir sin parsear HTML — barato para un crawler. | Propuesto como **complemento**. Ver abajo. |
| **JSON-LD `schema.org/Event`** embebido en la página | Reaprovecha lo que ya detectan Google y agregadores como dev.events. | Es una **fuente para importadores** (ver la [extensión de navegador](../../ecosystem/browser-extension.md)), no un feed: describe *un* evento, no una colección. |
> 📌 **Dato relevante sobre `/.well-known/`**: el [registro de IANA](https://www.iana.org/assignments/well-known-uris/well-known-uris.xhtml) **no tiene ninguna entrada para feeds** — ni RSS, ni Atom, ni JSON Feed. Su procedimiento de registro es *«Specification Required»*, y **OTE tiene una especificación**, así que `ote-feed` podría **registrarse formalmente** (aunque sea con estado provisional) en vez de okupar una ruta. Sería, de hecho, el primer well-known de feeds del registro.
### Serialización: ¿solo un fichero, o también metadatos embebidos?
Hoy la spec asume **un fichero JSON en una URL**. La alternativa —o el complemento— es permitir el feed **embebido en la propia página**, al estilo del JSON-LD de schema.org:
```html
```
- **A favor**: quien usa un CMS o un generador de sitios puede pegar un bloque en su plantilla, pero a menudo **no puede publicar un fichero suelto** ni tocar `/.well-known/`. Baja la barrera de entrada justo para quien menos herramientas tiene.
- **En contra**: obliga a los consumidores a parsear HTML, acopla el feed a una página concreta, y complica servir el mismo dato como `.ics` o RSS.
Pendiente de decidir. Si se acepta, sería una **serialización equivalente** del mismo documento, no un formato distinto — y habría que cambiar la promesa de la web («es un archivo que publicas»).
### Otras
- **Público y nivel.** [`eligibility`](#eligibility-quién-puede-entrar--y-por-qué-no-es-un-tag) resuelve la **puerta** («¿me dejan entrar?») y saca de `tags` ese eje. Queda el otro que hoy también se cuela ahí: «¿es para mí?» — «principiantes», «estudiantes», «senior». Es **recomendación, no permiso**, y por eso no cabe en `eligibility`. Un `level` con enum (`beginner` / `intermediate` / `advanced`) sería filtrable y lo etiquetan conferencias reales; un `audience` de texto libre, copiado de schema.org, solo movería el problema de sitio. No entra hasta que haya productor real: mientras tanto, `tags` sigue aceptándolo, con lo que eso cuesta.
- **`eligibility` por tramo de entrada.** El eje existe —la tarifa de estudiante pide carné— y el hueco está reservado: `offers[].eligibility`, reutilizando el mismo enum, en cuanto alguien lo emita de verdad.
- **`id` de un evento importado de un `.ics` sin URL.** Hoy los ejemplos usan `#`. Funciona y es estable, pero ata el `id` al calendario de origen: si la comunidad se muda, el `id` que acuñó el importador ya no está bajo un dominio que ella controle.
- **Serialización.** El schema es JSON. YAML es cómodo para escribir a mano (los issues usan YAML) y se mapea 1:1. ¿Se declaran ambos normativos?
- **`license` obligatoria en el evento suelto**: ¿es una barrera de entrada demasiado alta para quien solo quiere publicar su meetup?
```
## reference-v0.3.md
Source: spec/v0.3/reference.en.md — Field reference table generated from the schemas: type, required or recommended, allowed values, examples.
```markdown
# Field reference — OTE Spec 0.3.0
> 🤖 Generated from the schemas — do not edit by hand. Run `npm run build-reference`.
>
> The rules a validator cannot check (why `id` must never change, why a cancelled event stays published) are in [README.md](README.md).
**Level** — `required`: the validator rejects the document without it. `recommended`: valid without it, but a checker warns — these are the fields that decide whether the event can be found, filtered and subscribed to. They are read from [`event.recommended.schema.json`](event.recommended.schema.json) and [`feed.recommended.schema.json`](feed.recommended.schema.json).
`required within X` means required **inside an object that is itself optional**: a minimal document needs neither, but a document that has an `X` must give the field. Omitting the whole object stays valid.
**Default** — the value a consumer assumes when the field is absent. Some are not literals: inside a feed, an event that omits `license`, `organizers`, `textLanguage` or `specVersion` takes the feed's, which is what makes a feed cheap to publish — said once, never repeated per event. A blank cell is a statement, not an omission: the field has **no default**, and absent means *unknown* — never the reassuring value. An event with no `attendanceMode` is not in-person, an offer with no `availability` is not on sale.
## `event` — OTE Event
A single tech community event. See https://opentechevents.org for the normative prose.
| Field | Type | Level | Default | Description | Examples |
| --- | --- | :---: | :---: | --- | --- |
| `specVersion` | const: "0.3.0" | **required** | the feed's `specVersion` | Version of OTE Spec this document adheres to. | `"0.3.0"` |
| `id` | string (uri) | **required** | | Stable, globally unique identifier: an HTTP(S) URL under a domain the publisher controls — not necessarily one they own; a canonical page on a platform they use (Meetup, GitHub Pages, LinkedIn) works exactly as well, since what matters is that the URL is stable and nobody else can end up with that same one. Minted once, never rewritten — this is what lets consumers update an event instead of duplicating it. | `"https://pyalmeria.example/eventos/2026-06-async"` `"https://calendar.example/ics/rust-madrid#a1b2c3d4-uid"` `"https://www.meetup.com/pyalmeria/events/123456789/"` |
| `url` | string (uri) | _recommended_ | | Canonical URL where the event is described today. May change over time; id may not. | `"https://pyalmeria.example/eventos/2026-06-async"` |
| `name` | string | **required** | | Display name of the event. | `"PyAlmería — Introducción a async/await"` |
| `description` | string | _recommended_ | | Short description. Plain text or Markdown. | `"Charla introductoria a la programación asíncrona en Python, con ejemplos en vivo."` |
| `image` | (string \| object)[] | _recommended_ | | Promotional images of the event: poster, cover, card. A list in preference order — the FIRST is the primary one, and often the only one a destination can use. The rest may be other crops or resolutions of that same image (Google asks for 1:1, 4:3 and 16:9) or different images altogether; a consumer that can show only one shows the first, and none may assume the list renders as a photo gallery. An entry is either a bare https URL to the image file itself — never to a page showing it — or an object that adds alt text. | `["https://rustmadrid.example/img/2026-06-16x9.png"]` `[{"url":"https://rustmadrid.example/img/2026-06-16x9.png","alt":"Cartel: Ferris sobre fondo morado, «Rust Madrid · 16 junio · Impact Hub»"},"https://rustmadrid.example/img/2026-06-1x1.png","https://rustmadrid.example/img/2026-06-4x3.png"]` |
| `image[].url` | string (uri) | **required** within `image[]` | | Absolute https URL of the image file itself, never of a page showing it. The same value the bare-string form carries. | `"https://rustmadrid.example/img/2026-06-16x9.png"` |
| `image[].alt` | string | optional | | What the image SHOWS, for whoever cannot see it: screen readers, text-only clients, and any render where the image fails to load. Describe the picture, not the event — the name and description are already being read out next to it, so repeating them makes a screen reader say the same thing twice. Skip "image of" or "poster showing": the client already announces it is an image. Written in the document's textLanguage, like every other free text here, and translated by this entry's own translations map: alt is read aloud with the pronunciation of the surrounding language, so an English alt inside a Spanish document is worse for accessibility than the problem it was meant to solve. Purely decorative images have no place in a feed and no empty string here — an image with nothing to say is an image left out. | `"Cartel: Ferris sobre fondo morado, «Rust Madrid · 16 junio · Impact Hub»"` `"Sala diáfana con unas 60 sillas y una pantalla al fondo"` |
| `image[].translations` | object | optional | | This image's alt text in other languages. Local to the image it describes — never a positional mirror of the image list. Requires the document's textLanguage, like every other translations map. | `{"en":{"alt":"Poster: Ferris on a purple background, “Rust Madrid · June 16 · Impact Hub”"}}` |
| `image[].translations.*.alt` | string | **required** within `image[].translations.*` | | What the image shows, in this language. | `"Poster: Ferris on a purple background, “Rust Madrid · June 16 · Impact Hub”"` |
| `organizers` | object[] | _recommended_ | the feed's `organizers` | Who runs the event — not where the data came from (that is source). A list: co-organised events are the norm, not the exception. Declaring it REPLACES the inherited list, it does not add to it. | `[{"name":"PyAlmería","url":"https://pyalmeria.example"}]` `[{"name":"GDG Madrid","url":"https://gdgmadrid.example"},{"type":"person","name":"Ada Lovelace","url":"https://ada.example"}]` |
| `organizers[].name` | string | **required** within `organizers[]` | | Display name of the organiser. | `"PyAlmería"` `"Ada Lovelace"` |
| `organizers[].url` | string (uri) | optional | | Where this organiser lives on the web — their own site, or their profile on the platform they publish from. | `"https://pyalmeria.example"` `"https://www.meetup.com/pyalmeria/"` |
| `organizers[].email` | string (email) | optional | | Address for enquiries about the event. A ROLE address (info@, hola@) rather than someone's personal mailbox: a feed is open and crawlable, and what goes in it cannot be unpublished. Optional and deliberately NOT recommended. It exists because without it there is no valid iCal ORGANIZER to emit (a CAL-ADDRESS is in practice a mailto:) and no RSS 2.0 , which requires an email. Written bare, without the mailto: prefix — the exporter adds it. Never populate it from a source that is not itself publicly published. | `"hola@pyalmeria.example"` `"info@gdgmadrid.example"` |
| `organizers[].type` | enum: organization \| person | optional | `"organization"` | Organisation or person. A translator has to pick a schema.org @type either way, and Organization is the tolerant choice. | `"organization"` `"person"` |
| `startDate` | string | **required** | | Wall-clock start: a date (2026-10-15) for all-day events, or a local date-time (2026-10-15T09:00), never with seconds. Never carries a UTC offset — timezone does that. Which of the two forms you pick is not this field's decision alone: it has to match endDate's, and endDate (if present) must not be earlier — see the document's constraints. | `"2026-06-11T18:30"` `"2026-10-15"` |
| `endDate` | string | _recommended_ | | Wall-clock end, in the SAME form as startDate (both dates, or both date-times) and never earlier than it. If absent, the event is assumed to end on the day it starts. For an all-day event (date form, no time), endDate is INCLUSIVE — it names the last day the event runs, not the day after. Converting to iCalendar needs +1 day for DTEND;VALUE=DATE (RFC 5545 defines it as the non-inclusive end); importing needs -1 day back. Both rules belong to the document, not to this field — see the document's constraints. | `"2026-06-11T20:00"` `"2026-10-16"` |
| `timezone` | enum: Africa/Abidjan \| Africa/Accra \| Africa/Addis_Ababa \| Africa/Algiers \| Africa/Asmara \| Africa/Asmera \| Africa/Bamako \| Africa/Bangui \| Africa/Banjul \| Africa/Bissau \| Africa/Blantyre \| Africa/Brazzaville \| Africa/Bujumbura \| Africa/Cairo \| Africa/Casablanca \| Africa/Ceuta \| Africa/Conakry \| Africa/Dakar \| Africa/Dar_es_Salaam \| Africa/Djibouti \| Africa/Douala \| Africa/El_Aaiun \| Africa/Freetown \| Africa/Gaborone \| Africa/Harare \| Africa/Johannesburg \| Africa/Juba \| Africa/Kampala \| Africa/Khartoum \| Africa/Kigali \| Africa/Kinshasa \| Africa/Lagos \| Africa/Libreville \| Africa/Lome \| Africa/Luanda \| Africa/Lubumbashi \| Africa/Lusaka \| Africa/Malabo \| Africa/Maputo \| Africa/Maseru \| Africa/Mbabane \| Africa/Mogadishu \| Africa/Monrovia \| Africa/Nairobi \| Africa/Ndjamena \| Africa/Niamey \| Africa/Nouakchott \| Africa/Ouagadougou \| Africa/Porto-Novo \| Africa/Sao_Tome \| Africa/Timbuktu \| Africa/Tripoli \| Africa/Tunis \| Africa/Windhoek \| America/Adak \| America/Anchorage \| America/Anguilla \| America/Antigua \| America/Araguaina \| America/Argentina/Buenos_Aires \| America/Argentina/Catamarca \| America/Argentina/ComodRivadavia \| America/Argentina/Cordoba \| America/Argentina/Jujuy \| America/Argentina/La_Rioja \| America/Argentina/Mendoza \| America/Argentina/Rio_Gallegos \| America/Argentina/Salta \| America/Argentina/San_Juan \| America/Argentina/San_Luis \| America/Argentina/Tucuman \| America/Argentina/Ushuaia \| America/Aruba \| America/Asuncion \| America/Atikokan \| America/Atka \| America/Bahia \| America/Bahia_Banderas \| America/Barbados \| America/Belem \| America/Belize \| America/Blanc-Sablon \| America/Boa_Vista \| America/Bogota \| America/Boise \| America/Buenos_Aires \| America/Cambridge_Bay \| America/Campo_Grande \| America/Cancun \| America/Caracas \| America/Catamarca \| America/Cayenne \| America/Cayman \| America/Chicago \| America/Chihuahua \| America/Ciudad_Juarez \| America/Coral_Harbour \| America/Cordoba \| America/Costa_Rica \| America/Coyhaique \| America/Creston \| America/Cuiaba \| America/Curacao \| America/Danmarkshavn \| America/Dawson \| America/Dawson_Creek \| America/Denver \| America/Detroit \| America/Dominica \| America/Edmonton \| America/Eirunepe \| America/El_Salvador \| America/Ensenada \| America/Fort_Nelson \| America/Fort_Wayne \| America/Fortaleza \| America/Glace_Bay \| America/Godthab \| America/Goose_Bay \| America/Grand_Turk \| America/Grenada \| America/Guadeloupe \| America/Guatemala \| America/Guayaquil \| America/Guyana \| America/Halifax \| America/Havana \| America/Hermosillo \| America/Indiana/Indianapolis \| America/Indiana/Knox \| America/Indiana/Marengo \| America/Indiana/Petersburg \| America/Indiana/Tell_City \| America/Indiana/Vevay \| America/Indiana/Vincennes \| America/Indiana/Winamac \| America/Indianapolis \| America/Inuvik \| America/Iqaluit \| America/Jamaica \| America/Jujuy \| America/Juneau \| America/Kentucky/Louisville \| America/Kentucky/Monticello \| America/Knox_IN \| America/Kralendijk \| America/La_Paz \| America/Lima \| America/Los_Angeles \| America/Louisville \| America/Lower_Princes \| America/Maceio \| America/Managua \| America/Manaus \| America/Marigot \| America/Martinique \| America/Matamoros \| America/Mazatlan \| America/Mendoza \| America/Menominee \| America/Merida \| America/Metlakatla \| America/Mexico_City \| America/Miquelon \| America/Moncton \| America/Monterrey \| America/Montevideo \| America/Montreal \| America/Montserrat \| America/Nassau \| America/New_York \| America/Nipigon \| America/Nome \| America/Noronha \| America/North_Dakota/Beulah \| America/North_Dakota/Center \| America/North_Dakota/New_Salem \| America/Nuuk \| America/Ojinaga \| America/Panama \| America/Pangnirtung \| America/Paramaribo \| America/Phoenix \| America/Port_of_Spain \| America/Port-au-Prince \| America/Porto_Acre \| America/Porto_Velho \| America/Puerto_Rico \| America/Punta_Arenas \| America/Rainy_River \| America/Rankin_Inlet \| America/Recife \| America/Regina \| America/Resolute \| America/Rio_Branco \| America/Rosario \| America/Santa_Isabel \| America/Santarem \| America/Santiago \| America/Santo_Domingo \| America/Sao_Paulo \| America/Scoresbysund \| America/Shiprock \| America/Sitka \| America/St_Barthelemy \| America/St_Johns \| America/St_Kitts \| America/St_Lucia \| America/St_Thomas \| America/St_Vincent \| America/Swift_Current \| America/Tegucigalpa \| America/Thule \| America/Thunder_Bay \| America/Tijuana \| America/Toronto \| America/Tortola \| America/Vancouver \| America/Virgin \| America/Whitehorse \| America/Winnipeg \| America/Yakutat \| America/Yellowknife \| Antarctica/Casey \| Antarctica/Davis \| Antarctica/DumontDUrville \| Antarctica/Macquarie \| Antarctica/Mawson \| Antarctica/McMurdo \| Antarctica/Palmer \| Antarctica/Rothera \| Antarctica/South_Pole \| Antarctica/Syowa \| Antarctica/Troll \| Antarctica/Vostok \| Arctic/Longyearbyen \| Asia/Aden \| Asia/Almaty \| Asia/Amman \| Asia/Anadyr \| Asia/Aqtau \| Asia/Aqtobe \| Asia/Ashgabat \| Asia/Ashkhabad \| Asia/Atyrau \| Asia/Baghdad \| Asia/Bahrain \| Asia/Baku \| Asia/Bangkok \| Asia/Barnaul \| Asia/Beirut \| Asia/Bishkek \| Asia/Brunei \| Asia/Calcutta \| Asia/Chita \| Asia/Choibalsan \| Asia/Chongqing \| Asia/Chungking \| Asia/Colombo \| Asia/Dacca \| Asia/Damascus \| Asia/Dhaka \| Asia/Dili \| Asia/Dubai \| Asia/Dushanbe \| Asia/Famagusta \| Asia/Gaza \| Asia/Harbin \| Asia/Hebron \| Asia/Ho_Chi_Minh \| Asia/Hong_Kong \| Asia/Hovd \| Asia/Irkutsk \| Asia/Istanbul \| Asia/Jakarta \| Asia/Jayapura \| Asia/Jerusalem \| Asia/Kabul \| Asia/Kamchatka \| Asia/Karachi \| Asia/Kashgar \| Asia/Kathmandu \| Asia/Katmandu \| Asia/Khandyga \| Asia/Kolkata \| Asia/Krasnoyarsk \| Asia/Kuala_Lumpur \| Asia/Kuching \| Asia/Kuwait \| Asia/Macao \| Asia/Macau \| Asia/Magadan \| Asia/Makassar \| Asia/Manila \| Asia/Muscat \| Asia/Nicosia \| Asia/Novokuznetsk \| Asia/Novosibirsk \| Asia/Omsk \| Asia/Oral \| Asia/Phnom_Penh \| Asia/Pontianak \| Asia/Pyongyang \| Asia/Qatar \| Asia/Qostanay \| Asia/Qyzylorda \| Asia/Rangoon \| Asia/Riyadh \| Asia/Saigon \| Asia/Sakhalin \| Asia/Samarkand \| Asia/Seoul \| Asia/Shanghai \| Asia/Singapore \| Asia/Srednekolymsk \| Asia/Taipei \| Asia/Tashkent \| Asia/Tbilisi \| Asia/Tehran \| Asia/Tel_Aviv \| Asia/Thimbu \| Asia/Thimphu \| Asia/Tokyo \| Asia/Tomsk \| Asia/Ujung_Pandang \| Asia/Ulaanbaatar \| Asia/Ulan_Bator \| Asia/Urumqi \| Asia/Ust-Nera \| Asia/Vientiane \| Asia/Vladivostok \| Asia/Yakutsk \| Asia/Yangon \| Asia/Yekaterinburg \| Asia/Yerevan \| Atlantic/Azores \| Atlantic/Bermuda \| Atlantic/Canary \| Atlantic/Cape_Verde \| Atlantic/Faeroe \| Atlantic/Faroe \| Atlantic/Jan_Mayen \| Atlantic/Madeira \| Atlantic/Reykjavik \| Atlantic/South_Georgia \| Atlantic/St_Helena \| Atlantic/Stanley \| Australia/ACT \| Australia/Adelaide \| Australia/Brisbane \| Australia/Broken_Hill \| Australia/Canberra \| Australia/Currie \| Australia/Darwin \| Australia/Eucla \| Australia/Hobart \| Australia/LHI \| Australia/Lindeman \| Australia/Lord_Howe \| Australia/Melbourne \| Australia/North \| Australia/NSW \| Australia/Perth \| Australia/Queensland \| Australia/South \| Australia/Sydney \| Australia/Tasmania \| Australia/Victoria \| Australia/West \| Australia/Yancowinna \| Brazil/Acre \| Brazil/DeNoronha \| Brazil/East \| Brazil/West \| Canada/Atlantic \| Canada/Central \| Canada/Eastern \| Canada/Mountain \| Canada/Newfoundland \| Canada/Pacific \| Canada/Saskatchewan \| Canada/Yukon \| CET \| Chile/Continental \| Chile/EasterIsland \| CST6CDT \| Cuba \| EET \| Egypt \| Eire \| EST \| EST5EDT \| Etc/GMT \| Etc/GMT-0 \| Etc/GMT-1 \| Etc/GMT-10 \| Etc/GMT-11 \| Etc/GMT-12 \| Etc/GMT-13 \| Etc/GMT-14 \| Etc/GMT-2 \| Etc/GMT-3 \| Etc/GMT-4 \| Etc/GMT-5 \| Etc/GMT-6 \| Etc/GMT-7 \| Etc/GMT-8 \| Etc/GMT-9 \| Etc/GMT+0 \| Etc/GMT+1 \| Etc/GMT+10 \| Etc/GMT+11 \| Etc/GMT+12 \| Etc/GMT+2 \| Etc/GMT+3 \| Etc/GMT+4 \| Etc/GMT+5 \| Etc/GMT+6 \| Etc/GMT+7 \| Etc/GMT+8 \| Etc/GMT+9 \| Etc/GMT0 \| Etc/Greenwich \| Etc/UCT \| Etc/Universal \| Etc/UTC \| Etc/Zulu \| Europe/Amsterdam \| Europe/Andorra \| Europe/Astrakhan \| Europe/Athens \| Europe/Belfast \| Europe/Belgrade \| Europe/Berlin \| Europe/Bratislava \| Europe/Brussels \| Europe/Bucharest \| Europe/Budapest \| Europe/Busingen \| Europe/Chisinau \| Europe/Copenhagen \| Europe/Dublin \| Europe/Gibraltar \| Europe/Guernsey \| Europe/Helsinki \| Europe/Isle_of_Man \| Europe/Istanbul \| Europe/Jersey \| Europe/Kaliningrad \| Europe/Kiev \| Europe/Kirov \| Europe/Kyiv \| Europe/Lisbon \| Europe/Ljubljana \| Europe/London \| Europe/Luxembourg \| Europe/Madrid \| Europe/Malta \| Europe/Mariehamn \| Europe/Minsk \| Europe/Monaco \| Europe/Moscow \| Europe/Nicosia \| Europe/Oslo \| Europe/Paris \| Europe/Podgorica \| Europe/Prague \| Europe/Riga \| Europe/Rome \| Europe/Samara \| Europe/San_Marino \| Europe/Sarajevo \| Europe/Saratov \| Europe/Simferopol \| Europe/Skopje \| Europe/Sofia \| Europe/Stockholm \| Europe/Tallinn \| Europe/Tirane \| Europe/Tiraspol \| Europe/Ulyanovsk \| Europe/Uzhgorod \| Europe/Vaduz \| Europe/Vatican \| Europe/Vienna \| Europe/Vilnius \| Europe/Volgograd \| Europe/Warsaw \| Europe/Zagreb \| Europe/Zaporozhye \| Europe/Zurich \| GB \| GB-Eire \| GMT \| GMT-0 \| GMT+0 \| GMT0 \| Greenwich \| Hongkong \| HST \| Iceland \| Indian/Antananarivo \| Indian/Chagos \| Indian/Christmas \| Indian/Cocos \| Indian/Comoro \| Indian/Kerguelen \| Indian/Mahe \| Indian/Maldives \| Indian/Mauritius \| Indian/Mayotte \| Indian/Reunion \| Iran \| Israel \| Jamaica \| Japan \| Kwajalein \| Libya \| MET \| Mexico/BajaNorte \| Mexico/BajaSur \| Mexico/General \| MST \| MST7MDT \| Navajo \| NZ \| NZ-CHAT \| Pacific/Apia \| Pacific/Auckland \| Pacific/Bougainville \| Pacific/Chatham \| Pacific/Chuuk \| Pacific/Easter \| Pacific/Efate \| Pacific/Enderbury \| Pacific/Fakaofo \| Pacific/Fiji \| Pacific/Funafuti \| Pacific/Galapagos \| Pacific/Gambier \| Pacific/Guadalcanal \| Pacific/Guam \| Pacific/Honolulu \| Pacific/Johnston \| Pacific/Kanton \| Pacific/Kiritimati \| Pacific/Kosrae \| Pacific/Kwajalein \| Pacific/Majuro \| Pacific/Marquesas \| Pacific/Midway \| Pacific/Nauru \| Pacific/Niue \| Pacific/Norfolk \| Pacific/Noumea \| Pacific/Pago_Pago \| Pacific/Palau \| Pacific/Pitcairn \| Pacific/Pohnpei \| Pacific/Ponape \| Pacific/Port_Moresby \| Pacific/Rarotonga \| Pacific/Saipan \| Pacific/Samoa \| Pacific/Tahiti \| Pacific/Tarawa \| Pacific/Tongatapu \| Pacific/Truk \| Pacific/Wake \| Pacific/Wallis \| Pacific/Yap \| Poland \| Portugal \| PRC \| PST8PDT \| ROC \| ROK \| Singapore \| Turkey \| UCT \| Universal \| US/Alaska \| US/Aleutian \| US/Arizona \| US/Central \| US/East-Indiana \| US/Eastern \| US/Hawaii \| US/Indiana-Starke \| US/Michigan \| US/Mountain \| US/Pacific \| US/Samoa \| UTC \| W-SU \| WET \| Zulu | **required** | | A real IANA timezone identifier (e.g. Europe/Madrid) — canonical name or historical alias, never an invented or malformed one. Turns a wall-clock startDate into an unambiguous instant. For all-day events it contextualises the date — it does not shift it. On the two nights a year local time is ambiguous (a repeated hour) or impossible (a skipped hour) because of a DST transition, resolve exactly as RFC 5545 §3.3.5 does: a repeated local time means its FIRST occurrence; a skipped local time is read using the UTC offset that was in effect BEFORE the transition. | `"Europe/Madrid"` `"America/Bogota"` `"UTC"` |
| `attendanceMode` | enum: in-person \| online \| hybrid | _recommended_ | | What the organiser says this event is. Absent never means in-person. | `"in-person"` `"online"` `"hybrid"` |
| `location` | object | _recommended_ | | What is KNOWN about where the event happens. Not the same question as attendanceMode, which states the organiser's intent. | `{"venue":"El Cable, Almería"}` `{"onlineUrl":"https://meet.example/pyalmeria"}` `{"venue":"Campus Madrid, Calle de Moreno Nieto 2, Madrid","address":{"street":"Calle de Moreno Nieto 2","locality":"Madrid","postalCode":"28005","country":"ES"},"onlineUrl":"https://meet.example/rust-madrid"}` |
| `location.venue` | string | _recommended_ | | Human-readable physical location, in one line of free text: the name of the place plus as much address as it takes to get there — how much is your call. Its presence means the event has a physical venue. It is not made redundant by address, because joining address's parts back up never gives you the name: a PostalAddress has no field for "El Cable" or "Campus Madrid", and the name is what people navigate by. In schema.org it is Place.name, a sibling of Place.address. | `"El Cable, Almería"` `"Campus Madrid, Calle de Moreno Nieto 2, Madrid"` |
| `location.address` | object | optional | | Postal address of the physical venue, in parts. COMPLEMENTS venue, never replaces it: venue is the one string every format can print, address is what a translator needs to emit a schema.org PostalAddress — whose subfields Google validates one by one for the Event rich result. Every part is optional; leave out what you do not know. An absent key means unknown; "" or null publish 'unknown' as if it were data, which is the one thing worse than saying nothing. | `{"street":"Calle de Moreno Nieto 2","locality":"Madrid","postalCode":"28005","country":"ES"}` `{"locality":"Almería","country":"ES"}` |
| `location.address.street` | string | optional | | Street and number, as written locally. May carry a floor or a unit; it is one line of text, not a sub-object. | `"Calle de Moreno Nieto 2"` `"100 West Snickerpark Dr"` |
| `location.address.locality` | string | optional | | City, town or village. | `"Madrid"` `"Almería"` |
| `location.address.region` | string | optional | | Province, state or autonomous community — whatever level sits between locality and country in that country. Free text or an ISO 3166-2 code; both travel to schema.org addressRegion unchanged. | `"Comunidad de Madrid"` `"PA"` |
| `location.address.postalCode` | string | optional | | Postal code, as the local post office writes it. A string, never a number: leading zeros are part of it. | `"28005"` `"19019"` |
| `location.address.country` | enum: AD \| AE \| AF \| AG \| AI \| AL \| AM \| AO \| AQ \| AR \| AS \| AT \| AU \| AW \| AX \| AZ \| BA \| BB \| BD \| BE \| BF \| BG \| BH \| BI \| BJ \| BL \| BM \| BN \| BO \| BQ \| BR \| BS \| BT \| BV \| BW \| BY \| BZ \| CA \| CC \| CD \| CF \| CG \| CH \| CI \| CK \| CL \| CM \| CN \| CO \| CR \| CU \| CV \| CW \| CX \| CY \| CZ \| DE \| DJ \| DK \| DM \| DO \| DZ \| EC \| EE \| EG \| EH \| ER \| ES \| ET \| FI \| FJ \| FK \| FM \| FO \| FR \| GA \| GB \| GD \| GE \| GF \| GG \| GH \| GI \| GL \| GM \| GN \| GP \| GQ \| GR \| GS \| GT \| GU \| GW \| GY \| HK \| HM \| HN \| HR \| HT \| HU \| ID \| IE \| IL \| IM \| IN \| IO \| IQ \| IR \| IS \| IT \| JE \| JM \| JO \| JP \| KE \| KG \| KH \| KI \| KM \| KN \| KP \| KR \| KW \| KY \| KZ \| LA \| LB \| LC \| LI \| LK \| LR \| LS \| LT \| LU \| LV \| LY \| MA \| MC \| MD \| ME \| MF \| MG \| MH \| MK \| ML \| MM \| MN \| MO \| MP \| MQ \| MR \| MS \| MT \| MU \| MV \| MW \| MX \| MY \| MZ \| NA \| NC \| NE \| NF \| NG \| NI \| NL \| NO \| NP \| NR \| NU \| NZ \| OM \| PA \| PE \| PF \| PG \| PH \| PK \| PL \| PM \| PN \| PR \| PS \| PT \| PW \| PY \| QA \| RE \| RO \| RS \| RU \| RW \| SA \| SB \| SC \| SD \| SE \| SG \| SH \| SI \| SJ \| SK \| SL \| SM \| SN \| SO \| SR \| SS \| ST \| SV \| SX \| SY \| SZ \| TC \| TD \| TF \| TG \| TH \| TJ \| TK \| TL \| TM \| TN \| TO \| TR \| TT \| TV \| TW \| TZ \| UA \| UG \| UM \| US \| UY \| UZ \| VA \| VC \| VE \| VG \| VI \| VN \| VU \| WF \| WS \| YE \| YT \| ZA \| ZM \| ZW | optional | | A real, currently-assigned ISO 3166-1 alpha-2 code (ES, US, MX), never an invented, reserved or former one. A code and not a country name, because the name has one spelling per language: "España", "Spain" and "Espagne" are the same country, and a consumer grouping events by country would see three. Turning a name into a code is a table lookup, not an invention — which is why the spec asks for it here and nowhere else. Common mistake: the UK is GB, not UK — "UK" is not an ISO 3166-1 code. | `"ES"` `"US"` |
| `location.onlineUrl` | string (uri) | _recommended_ | | URL to attend online. Its presence means the event has online access. | `"https://meet.example/pyalmeria"` |
| `location.geo` | object | optional | | Coordinates of the physical venue (WGS-84 decimal degrees). Independent of venue, which is free text — a point, not a name. Maps to iCal GEO and schema.org Place.geo (GeoCoordinates). | — |
| `location.geo.lat` | number | **required** within `location.geo` | | Latitude in decimal degrees. | `40.4168` |
| `location.geo.lon` | number | **required** within `location.geo` | | Longitude in decimal degrees. | `-3.7038` |
| `eligibility` | object | optional | | Who may attend, when the answer is not "anyone". The third part of "can I go?", after attendanceMode and location: those two answer whether the event is reachable, this one whether you are allowed in. Absent never means open — an importer reading a .ics cannot know, and staying quiet is not the same claim as saying the door is open. | `{"type":"open"}` `{"type":"members-only","note":"Miembros del Discord de Rust Girona","url":"https://rustgirona.example/join"}` `{"type":"restricted","note":"Solo alumnado de la Universidad de Almería"}` |
| `eligibility.type` | enum: open \| members-only \| approval-required \| restricted | **required** within `eligibility` | | The kind of door. open: anyone may attend — including an event that sells tickets or runs out of seats, because a price and a capacity are not conditions on WHO you are. members-only: you have to belong to something first. approval-required: you sign up and the organiser DECIDES — Luma's request-to-approve, a Meetup group with an admission question, a workshop that picks a cohort. It is about a judgement on the person, never about capacity: first-come-first-served with limited seats is open, and the seats running out is offers[].availability. restricted: there IS a condition and none of the other values names it — say which in `note`, which is why the schema demands it there. Four values, kept small on purpose: a consumer that has to handle twenty doors handles none. invite-only is deliberately NOT one of them, see the spec prose. | `"open"` `"members-only"` `"approval-required"` |
| `eligibility.note` | string | _recommended_ | | The condition in words, for a person to read: which community, which university, which company. REQUIRED when type is restricted, because "restricted" on its own tells nobody anything; worth writing whenever the enum value alone leaves a question. This is the part that survives export to every format, inside the text. | `"Miembros del Discord de Rust Girona"` `"Solo alumnado de la Universidad de Almería"` |
| `eligibility.url` | string (uri) | optional | | Where the condition is explained or met: the page to join the community, request an invitation, apply. Distinct from offers[].url, which is where a seat or money changes hands — here nothing is bought, a door is opened. | `"https://rustgirona.example/join"` |
| `eligibility.translations` | object | optional | | The note in other languages. type needs none: an enum carries no language, and a consumer renders it in the reader's. | `{"es":{"note":"Miembros del Discord de Rust Girona"}}` |
| `eligibility.translations.*.note` | string | **required** within `eligibility.translations.*` | | The condition, in this language. | `"Miembros del Discord de Rust Girona"` |
| `tags` | string[] | _recommended_ | | Free-form topic tags — what the event is ABOUT. Maps to iCal CATEGORIES and schema.org keywords. Not who may attend: that question has its own field, eligibility, because a tag like "members-only" is invisible to a consumer that does not already know to look for it. A controlled vocabulary may layer on top later; the field itself stays free. | `["rust","wasm"]` `["python","async"]` |
| `languages` | string[] | _recommended_ | | Languages SPOKEN at the event, as BCP 47 tags, e.g. ["es","en"]. Not the language this document is written in — that is textLanguage, and the two disagree all the time: a bilingual session described in Catalan only. | `["es"]` `["es","en"]` |
| `textLanguage` | string (ote-language-tag) | optional | the feed's `textLanguage` | Language THIS DOCUMENT's free text is written in — name, description, and any other prose in it. One BCP 47 tag, not a list: a text is written in one language. A different question from languages, which says what is spoken at the event. Absent defaults to the enclosing feed's textLanguage; in a standalone document, with no feed to inherit from, absent means unknown, never English. | `"es"` `"ca"` `"en"` |
| `offers` | object[] | optional | | What it costs to attend, and where to register. A list: tiered pricing (early bird, student, patron) is one entry each, and a free event is a single entry with price 0. Absent means UNKNOWN, never free — saying free is what price 0 is for. | `[{"price":0,"url":"https://rustmadrid.example/meetups/2026-06#registro"}]` `[{"name":"Early bird","price":35,"currency":"EUR","url":"https://devfest-levante.example/2026/entradas","availability":"sold-out","closesAt":"2026-07-31T23:59:59+02:00"},{"name":"General","price":45,"currency":"EUR","url":"https://devfest-levante.example/2026/entradas","availability":"in-stock"}]` |
| `offers[].name` | string | _recommended_ | | What this ticket is called ("General admission", "Estudiantes"). Worth writing when there is more than one offer, noise when there is only one. | `"General admission"` `"Estudiantes"` |
| `offers[].price` | number | optional | | Amount per attendee, in `currency`. 0 means free — and it is the ONLY way to say free: an absent offers list means the price is unknown. A number, never text: no currency symbol, no thousands separator, no range and no "desde", because the whole reason to publish a price as data is that someone can filter and compare on it. A price that cannot be written as one number is several offers. | `0` `45` `12.5` |
| `offers[].currency` | enum: AED \| AFN \| ALL \| AMD \| AOA \| ARS \| AUD \| AWG \| AZN \| BAM \| BBD \| BDT \| BHD \| BIF \| BMD \| BND \| BOB \| BOV \| BRL \| BSD \| BTN \| BWP \| BYN \| BZD \| CAD \| CDF \| CHE \| CHF \| CHW \| CLF \| CLP \| CNY \| COP \| COU \| CRC \| CUP \| CVE \| CZK \| DJF \| DKK \| DOP \| DZD \| EGP \| ERN \| ETB \| EUR \| FJD \| FKP \| GBP \| GEL \| GHS \| GIP \| GMD \| GNF \| GTQ \| GYD \| HKD \| HNL \| HTG \| HUF \| IDR \| ILS \| INR \| IQD \| IRR \| ISK \| JMD \| JOD \| JPY \| KES \| KGS \| KHR \| KMF \| KPW \| KRW \| KWD \| KYD \| KZT \| LAK \| LBP \| LKR \| LRD \| LSL \| LYD \| MAD \| MDL \| MGA \| MKD \| MMK \| MNT \| MOP \| MRU \| MUR \| MVR \| MWK \| MXN \| MXV \| MYR \| MZN \| NAD \| NGN \| NIO \| NOK \| NPR \| NZD \| OMR \| PAB \| PEN \| PGK \| PHP \| PKR \| PLN \| PYG \| QAR \| RON \| RSD \| RUB \| RWF \| SAR \| SBD \| SCR \| SDG \| SEK \| SGD \| SHP \| SLE \| SOS \| SRD \| SSP \| STN \| SVC \| SYP \| SZL \| THB \| TJS \| TMT \| TND \| TOP \| TRY \| TTD \| TWD \| TZS \| UAH \| UGX \| USD \| USN \| UYI \| UYU \| UYW \| UZS \| VED \| VES \| VND \| VUV \| WST \| XAD \| XAF \| XAG \| XAU \| XBA \| XBB \| XBC \| XBD \| XCD \| XCG \| XDR \| XOF \| XPD \| XPF \| XPT \| XSU \| XTS \| XUA \| XXX \| YER \| ZAR \| ZMW \| ZWG | optional | | A real ISO 4217 alpha-3 code (EUR, USD, MXN), never an invented one. Only meaningful alongside `price` — it names what price is denominated in, and nothing else — so it requires `price` to be present at all, whatever its value. Required whenever `price` is above 0, and pointless at 0: free is free in every currency, and emitting one there is how Luma ends up publishing a currency for a meetup that costs nothing. | `"EUR"` `"USD"` |
| `offers[].url` | string (uri) | optional | | Where to buy the ticket or register for this particular offer. Distinct from the event's own url: that page describes the event, this one is where money or a seat changes hands. Omit it when registration happens on the event page itself. | `"https://devfest-levante.example/2026/entradas"` |
| `offers[].availability` | enum: in-stock \| sold-out | optional | | Whether this offer can still be taken. Absent never means available — a stale feed that keeps claiming in-stock is worse than one that says nothing. Only the two states an attendee can act on are modelled. | `"in-stock"` `"sold-out"` |
| `offers[].waitlistUrl` | string (uri) | optional | | Where to join the queue for this offer once it is gone. It exists so "gone, nothing to do" and "gone, but you can queue" stop being the same document — the third thing an attendee can act on, and the reason it is a URL and not a third availability value: every consumer that already exists keeps reading sold-out, which is TRUE, instead of meeting an enum value it cannot interpret. Distinct from url, where the ticket is bought: nothing is bought in a queue. | `"https://devfest-levante.example/2026/lista-espera"` |
| `offers[].opensAt` | string (date-time) | optional | | When this offer goes on sale. An INSTANT, with offset or Z — unlike the event's own dates, which are wall clock: a sale opening is a moment a button starts working, not an hour on a poster. | `"2026-05-01T10:00:00+02:00"` |
| `offers[].closesAt` | string (date-time) | optional | | When this offer stops being available. An INSTANT, with offset or Z, for the same reason as opensAt — and because "23:59" without an offset is the classic deadline bug. | `"2026-07-31T23:59:59+02:00"` |
| `offers[].translations` | object | optional | | This offer's name in other languages. Local to the offer it belongs to — never a positional mirror of the offers list. Requires the document's textLanguage, like every other translations map. | `{"en":{"name":"Students"}}` |
| `offers[].translations.*.name` | string | **required** within `offers[].translations.*` | | This ticket's name in this language. | `"Students"` `"Estudiantes"` |
| `cfp` | object | optional | | The event's open call for proposals — talks, workshops, papers. The one field of the spec with no equivalent in ANY of the three destination formats: it exists because 'which conferences are accepting proposals right now' is a question only the publisher can answer, and today it is answered by scraping. | `{"url":"https://devfest-levante.example/2026/cfp","closesAt":"2026-07-15T23:59:59+02:00"}` `{"url":"https://devfest-levante.example/2026/cfp","opensAt":"2026-05-01T00:00:00+02:00","closesAt":"2026-07-15T23:59:59+02:00","coversTravel":true,"coversAccommodation":true}` |
| `cfp.url` | string (uri) | **required** within `cfp` | | Where proposals are submitted — the form, or the page describing the call. Required: a CFP nobody can find is not a call, and this is the one piece of it that survives export to every format, inside the text. | `"https://devfest-levante.example/2026/cfp"` |
| `cfp.opensAt` | string (date-time) | optional | | When the call starts accepting proposals. An INSTANT, with offset or Z. Absent means it is already open — a call that has not opened yet is announced, not published. | `"2026-05-01T00:00:00+02:00"` |
| `cfp.closesAt` | string (date-time) | _recommended_ | | Deadline for proposals. An INSTANT, with offset or Z, never a bare "23:59": which midnight it is is the whole question, and "anywhere on Earth" is a real answer (-12:00) that a wall-clock field could not express. Absent means unknown, not open forever — and it is what a consumer needs to answer "which CFPs are open right now". | `"2026-07-15T23:59:59+02:00"` `"2026-07-15T23:59:59-12:00"` |
| `cfp.coversTravel` | boolean | optional | | Whether the event covers a selected speaker's travel. Absent never means false. It is here and "call for sponsors" is not because this is what a speaker filters on before deciding whether they can afford to submit. | `true` |
| `cfp.coversAccommodation` | boolean | optional | | Whether the event covers a selected speaker's accommodation. Same rule as coversTravel: absent means unknown. | `true` |
| `status` | enum: scheduled \| tentative \| cancelled \| postponed \| rescheduled \| moved-online | optional | `"scheduled"` | What happened to the event, not to the data. An event that is cancelled, postponed or moved online MUST stay published: removing it leaves a dead event in subscribers' calendars. tentative means announced but not confirmed (iCal STATUS:TENTATIVE) — it exists so an importer never has to upgrade an unconfirmed event to scheduled. | `"scheduled"` `"cancelled"` `"moved-online"` |
| `partOf` | object | optional | | The series or multi-part event this document is one occurrence of. A REFERENCE, never a recurrence rule: OTE does not generate dates: whoever publishes expands the recurrence into one document per occurrence, each with its own id, dates and status. A consumer that ignores this field still sees complete, correct events. | `{"id":"https://rustmadrid.example/meetups","name":"Rust Madrid — meetup mensual","url":"https://rustmadrid.example/meetups"}` `{"type":"multipart","id":"https://pyalmeria.example/study-jams/2026-testing","name":"Study Jam de testing en Python (3 sesiones)"}` |
| `partOf.id` | string (uri) | **required** within `partOf` | | Stable identifier of the series or multi-part event. Same rules as the event's id: an HTTP(S) URL under a domain the publisher controls — not necessarily one they own, a platform page (Meetup, GitHub Pages, LinkedIn) works too — minted once. It does NOT have to resolve to an OTE document — it is what lets a consumer group occurrences. | `"https://rustmadrid.example/meetups"` `"https://pyalmeria.example/study-jams/2026-testing"` |
| `partOf.name` | string | _recommended_ | | Display name of the series or multi-part event, so a consumer can group occurrences without resolving the id. | `"Rust Madrid — meetup mensual"` `"Study Jam de testing en Python (3 sesiones)"` |
| `partOf.url` | string (uri) | optional | | Page describing the series or the multi-part event as a whole. | `"https://rustmadrid.example/meetups"` |
| `partOf.type` | enum: series \| multipart | optional | `"series"` | series: independent occurrences that share an identity (a monthly meetup). multipart: parts of ONE event held on non-consecutive dates (a three-session study jam on non-consecutive Saturdays, one registration). series is the tolerant choice, and the choice changes the translation: a series becomes schema.org EventSeries, a multi-part event becomes an Event whose parts are its subEvent. | `"series"` `"multipart"` |
| `partOf.translations` | object | optional | | The series' display name in other languages. Its id stays untranslated — an identifier with two spellings is two series. | `{"es":{"name":"Sesión semanal de programación"}}` |
| `partOf.translations.*.name` | string | **required** within `partOf.translations.*` | | The series' or multi-part event's name in this language. | `"Sesión semanal de programación"` |
| `license` | string | **required** | the feed's `license` | License of THIS DATA, not of the event. SPDX identifier (CC0-1.0, CC-BY-4.0…, full list at https://spdx.org/licenses/) or a URL. | `"CC-BY-4.0"` `"CC0-1.0"` |
| `source` | object | optional | | Provenance. Required when the event was imported or aggregated from elsewhere; omitted when the organiser describes their own event — they are the source. | `{"name":"Rust Madrid","url":"https://calendar.example/ics/rust-madrid","license":"CC-BY-4.0","retrievedAt":"2026-06-01T05:00:00Z"}` |
| `source.name` | string | optional | | Name of the origin (e.g. "Rust Madrid", "Meetup"), as a person would read it. Write it whenever the origin has a name of its own: a consumer showing where the data came from can derive a label from `url` (its host), but a derived label is a guess. | `"Rust Madrid"` `"Meetup"` |
| `source.url` | string (uri) | optional | | Link to the original record, so the data can be verified and corrected upstream. | `"https://calendar.example/ics/rust-madrid"` |
| `source.license` | string | optional | | License under which the ORIGIN publishes the data. Constrains what may be republished: declaring a license does not grant rights the origin never gave. | `"CC-BY-4.0"` |
| `source.retrievedAt` | string (date-time) | optional | | When the data was fetched. | `"2026-06-01T05:00:00Z"` |
| `updatedAt` | string (date-time) | _recommended_ | | Instant the event's DATA last changed — equivalent to iCal LAST-MODIFIED, not DTSTAMP (which marks generation and changes on every export). Lets a consumer sync incrementally: fetch only what changed since its last read. Absent means unknown, not 'never changed'. | `"2026-06-10T18:00:00Z"` |
| `translations` | object | optional | | The same event's free text in other languages, keyed by BCP 47 tag. The document keeps ONE primary text in its own fields — declared by textLanguage — and this carries the versions of it. Additive on purpose: name and description stay strings, so every existing consumer keeps working and a monolingual publisher writes nothing at all. Never a translation of the language the document is already in. Requires textLanguage — and so does any other translations map in the document, at any depth; see the document's constraints. | `{"es":{"name":"Sesión semanal de programación — Rust Girona","description":"Cada semana nos juntamos en línea para picar Rust un rato."}}` |
| `translations.*.name` | string | optional | | The event's name in this language. | `"Sesión semanal de programación — Rust Girona"` |
| `translations.*.description` | string | optional | | The event's description in this language. Plain text or Markdown, like the field it translates. | `"Cada semana nos juntamos en línea para picar Rust un rato."` |
### Constraints
Rules the schema enforces on whole objects, which no single field's level can express. Generated from the schemas too — a validator rejects a document that breaks them.
- **the document** — endDate must not be earlier than startDate.
- **the document** — a translations map must not repeat textLanguage's own language, or any of its own keys, twice.
- **the document** — partOf.id must not equal the event's own id.
- **the document** — startDate and endDate must be of the same form: two all-day dates, or two local date-times.
- **the document** — A map of translations is unusable without knowing which language the primary text is in: a consumer cannot tell which entry duplicates it, nor what it is falling back to. So ANY translations map in the document — the event's own, or one inside image, offers, eligibility or partOf — requires textLanguage. It is the one place where a field of this spec depends on another being present, and it holds at every depth because the primary language is a property of the whole document, not of each object. A standalone document has no feed to inherit textLanguage from, so it must always carry its own; inside a feed, the same requirement is enforced against the effective (possibly inherited) language — see feed.schema.json.
- **`image[]`** — With `translations`, `alt` is required.
- **`location`** — Requires at least one of: `venue`, `onlineUrl`.
- **`location.address`** — Must carry at least one property.
- **`eligibility`** — When `type` is `"restricted"`, `note` is required. restricted means "there is a door the enum cannot name". Without a note it names nothing, and a consumer can only show the word itself — which is how a field meant to answer "can I go?" ends up asking it.
- **`languages`** — languages must not repeat the same language tag under different case.
- **`offers[]`** — Requires at least one of: `price`, `url`. An offer must carry a price or a link — ideally both. One with neither says nothing that omitting the whole list does not already say, the same rule location follows with venue and onlineUrl.
- **`offers[]`** — With `currency`, `price` is required.
- **`offers[]`** — closesAt must not be earlier than opensAt.
- **`offers[]`** — With `waitlistUrl`, `availability` must not be `"in-stock"`. A waitlist for something you can still buy is not a waitlist: the queue only makes sense once the offer is gone. Availability may still be ABSENT — a publisher who knows there is a queue and does not track the ticket state should not be forced to assert sold-out to mention it. Only the incoherent combination is rejected, never the incomplete one.
- **`offers[]`** — When `price` is above 0, `currency` is required. A non-zero amount without a currency is not a price: 45 is a different thing in EUR, USD and MXN, and a consumer that has to guess will guess its own.
- **`cfp`** — closesAt must not be earlier than opensAt.
- **`source`** — Requires at least one of: `name`, `url`. Provenance has to point somewhere: a name a person can read, a URL a machine can follow, ideally both. Either alone is enough, and demanding the name would be worse than accepting the URL — an importer of an `.ics` always knows the address it fetched and often has no publisher name to read (iCalendar's X-WR-CALNAME is optional), so the requirement would be met by inventing one. A fabricated origin is worse than an origin given only as a link. Same rule as offers with price and url, and location with venue and onlineUrl.
- **`translations.*`** — Requires at least one of: `name`, `description`. A translation entry is only a translation if it translates something OTE recognizes. Extension fields may still ride alongside name/description — this only forbids an entry whose entire content is unrecognized, which minProperties alone cannot catch.
- **`translations.*`** — Must carry at least one property.
## `feed` — OTE Feed
A collection of OTE events published at a stable URL. An exchange format, not an API.
| Field | Type | Level | Default | Description | Examples |
| --- | --- | :---: | :---: | --- | --- |
| `specVersion` | const: "0.3.0" | **required** | → every event that omits it | Version of OTE Spec this feed adheres to. Applies to every event in it. | `"0.3.0"` |
| `title` | string | **required** | | Human-readable name of the feed. | `"Eventos de PyAlmería"` |
| `description` | string | _recommended_ | | Short description of the feed. | `"Meetups mensuales de Python en Almería."` |
| `url` | string (uri) | _recommended_ | | Canonical URL of the community, directory or organisation publishing the feed. | `"https://pyalmeria.example"` |
| `textLanguage` | string (ote-language-tag) | optional | → every event that omits it | Language this feed's own free text is written in — title and description — and the default every event inherits when it omits its own. What makes it cheap: a monolingual publisher declares it once for the whole file and no event repeats it. Not the same as organizers: an aggregator whose events don't share one language must leave this out, exactly as it must leave out organizers, so each event states its own instead of inheriting the wrong one. Absent means unknown, never English and never the language of the HTTP response. | `"es"` `"ca"` |
| `organizers` | object[] | optional | → every event that omits it | Who runs the events in this feed. Not the same as title/url, which name whoever publishes the feed: an aggregator publishes events it does not organise, and must leave this out so each event states its own. | `[{"name":"PyAlmería","url":"https://pyalmeria.example"}]` |
| `license` | string | optional | → every event that omits it | License for the feed's contents, and the default every event inherits when it omits its own. Optional only for an aggregator whose events carry different licenses: if this is absent, every event in the feed must declare its own license — no valid OTE document, standalone or inside any feed, may resolve to an unknown license. SPDX identifier (full list at https://spdx.org/licenses/) or URL. | `"CC-BY-4.0"` `"CC0-1.0"` |
| `licenseUrl` | string (uri) | optional | | URL of the full license text. | `"https://creativecommons.org/licenses/by/4.0/"` |
| `updatedAt` | string (date-time) | **required** | | When this feed was generated. Never earlier than any event's own updatedAt — a feed cannot contain a revision that, by its own timestamps, didn't exist yet when it was generated. | `"2026-07-06T10:00:00Z"` |
| `translations` | object | optional | | This feed's own title and description in other languages. Its EVENTS are not translated here: each one carries its own translations, and unlike license or textLanguage this field is never inherited — a feed's title is not an event's name. Requires textLanguage, like an event's translations do — see the document's constraints. | `{"en":{"title":"Rust Girona events","description":"Weekly online Rust coding sessions, in Catalan and Spanish."}}` |
| `translations.*.title` | string | optional | | The feed's title in this language. | `"Rust Girona events"` |
| `translations.*.description` | string | optional | | The feed's description in this language. | `"Weekly online Rust coding sessions, in Catalan and Spanish."` |
| `events` | object[] | **required** | | Events in this feed. Each one inherits the feed's specVersion and license unless it declares its own. No two may share an id — see the document's constraints. | — |
### Constraints
Rules the schema enforces on whole objects, which no single field's level can express. Generated from the schemas too — a validator rejects a document that breaks them.
- **the document** — a translations map must not repeat textLanguage's own language, or any of its own keys, twice.
- **the document** — events must not repeat the same id.
- **the document** — no event may be updated after the feed itself was generated.
- **the document** — every event's translations must have an effective textLanguage (own or inherited from the feed) and must not repeat it.
- **the document** — With `translations`, `textLanguage` is required. Same rule as an event: a map of translations is unusable without knowing which language the primary text is in. A feed that translates its title must say what language that title is in.
- **the document** — license is the one default this spec never lets go missing by omission: an aggregator MAY leave feed.license out precisely because its events carry different licenses, but only if every event then declares its own — the same disciplined-omission pattern organizers/textLanguage already use for an aggregator, applied to a guarantee that is deliberately stricter than attribution or language, because redistributing data under an unknown license is a real legal risk, not just an editorial gap. CHANGES.log #P032 / DECISIONS.md D029.
- **`translations.*`** — Requires at least one of: `title`, `description`. Same rule as an event's own translation: extension fields may still ride alongside title/description, but an entry whose entire content is unrecognized is not a translation.
- **`translations.*`** — Must carry at least one property.
```
==============================================================================
SECTION: Data model
==============================================================================
## data-model.md
Source: spec/data-model.md — Why the fields are the fields: what each one models and what it deliberately does not.
```markdown
# Modelo de datos — OTE Spec (borrador)
> ⛔ **Superado por [OTE Spec v0.1](v0.1/README.md).** Este documento es el boceto previo, no normativo. Se conserva por las ideas que aún no han entrado en la spec.
> ⚠️ **Borrador inicial generado por IA, sin revisión humana todavía.**
> Este modelo es un **boceto propuesto para ilustrar la idea** y abrir el debate. No está acordado ni validado. Todo (nombres de campos, obligatoriedad, estructura, tipos) es provisional y cambiará.
>
> 🗣️ **El núcleo de la v0.1 se está discutiendo en el [issue #5](https://github.com/OpenTechEvents/opentechevents-spec/issues/5) — ese hilo manda sobre este documento.**
> Este fichero **no** refleja todavía lo que allí se debate. Puntos abiertos que lo afectan directamente:
>
> - **`id`, `license` y `source` en el núcleo**: los exige cualquier proceso de ingesta (sin `id` estable, cada relectura duplica; sin `license`, el dato no es republicable como open data). El issue los declaraba fuera de la v0.1.
> - **`attendanceMode` opcional y sin valor por defecto** (ausente = *desconocido*, nunca `in-person`).
> - **Fecha y hora en un solo campo ISO 8601** (`startDate`/`endDate`) + `timezone` IANA, en vez de campos de fecha y hora separados.
> - **Convención de nombres**: `camelCase` (este documento) vs. `snake_case` (propuesta del issue).
>
> No edites este documento para alinearlo: llévalo al issue. Ver también el [diseño del agregador](../ecosystem/aggregator.md#lo-que-esto-le-exige-a-la-spec), que es lo que puso estos puntos sobre la mesa.
## Principios de diseño
Derivados de la investigación ([analysis.md](../research/findings/analysis.md), [standards.md](../research/findings/standards.md)):
1. **Núcleo pequeño + módulos opcionales.** Pocos campos obligatorios (como joind.in); el resto enriquece. Una comunidad pequeña debe poder describir un evento con lo mínimo.
2. **Alineado con schema.org/`Event`.** Es el modelo más completo y con difusión automática; OTE se serializa a JSON-LD casi 1:1.
3. **Convertible a iCalendar.** Núcleo mapeable a `VEVENT`.
4. **Online/presencial/híbrido de primera clase.** `attendanceMode` explícito, no inferido de la ubicación.
5. **CFP como módulo propio.** Ningún estándar generalista lo modela.
6. **Fechas ISO 8601 + zona IANA.** Base convertible a todas las formas de iCal y schema.org.
7. **Serializable en JSON y YAML** _(provisional, en debate)_. El borrador asume JSON/YAML (mismo modelo, dos sintaxis) por ser lo más extendido en la investigación, pero la **elección del formato sigue abierta** — ver [Preguntas abiertas](#preguntas-abiertas).
8. **Separar evento de comunidad (sin acoplar a un registro concreto).** El evento describe el **encuentro**, no al organizador. La comunidad se **referencia** mediante un identificador **global y descentralizado** (su URL canónica), de forma opcional. Ver siguiente sección.
## Separación de responsabilidades: evento vs. comunidad
OTE Spec describe **eventos/encuentros** (algo que ocurre en una fecha). **No** modela al organizador (tipo de comunidad, audiencia, redes sociales de la comunidad…): eso es responsabilidad de un **registro de comunidades**, que es otra capa.
| Concepto | Qué modela | Identificador |
| --- | --- | --- |
| **Evento** (un encuentro concreto, con fecha) | OTE Spec (esto) | `event.id` |
| **Comunidad** (organizador: meetup, conferencia, hacklab…) | Un registro de comunidades (capa aparte) | URI global (su URL canónica) |
**Identidad descentralizada, sin registro central obligatorio.** Igual que el evento, la comunidad se identifica con un **URI global**. La opción por defecto es **su URL canónica**: el dominio ya garantiza unicidad vía DNS, sin necesidad de un registro central (mismo enfoque que RSS o schema.org, que usan URLs como identidad). Esa identidad puede "vivir en cualquier sitio".
**Registros como dato opcional, no como dependencia.** Quien quiera puede, *además*, apuntar a uno o varios registros que describan esa comunidad. El [directorio de Community Builders](https://github.com/ComBuildersES/communities-directory) es **una implementación de referencia / un registro compatible**, no un requisito de la spec. OTE no depende de él.
En consecuencia, el evento OTE **no duplica** datos del organizador; como mucho **cachea** su `name` para mostrarlo. Solo lleva datos **propios del encuentro** (fecha, lugar de esa edición, CFP, ponentes, registro).
> Nota de alineación: el directorio de ComBuilders usa `eventFormat` con valores `in-person`/`online`/`hybrid` y `langs`; OTE reutiliza ese vocabulario (`attendanceMode`, `languages`) para facilitar la interoperabilidad con ese y otros registros, sin acoplarse a ninguno.
## Entidad núcleo: `Event`
| Campo | Tipo | Oblig. | Descripción | Mapeo |
| --- | --- | :---: | --- | --- |
| `specVersion` | string (SemVer) | ✅ | Versión de OTE Spec a la que se adhiere el documento (p. ej. `0.1.0`). | — |
| `id` | string (URI) | ✅ | Identificador estable y único del evento. URI global (URL canónica del evento o `slug` + dominio). | iCal `UID`, JSON Feed `id` |
| `name` | string | ✅ | Título del evento. | schema `name`, iCal `SUMMARY` |
| `startDate` | string (ISO 8601) | ✅ | Inicio. Fecha u fecha-hora. | schema `startDate`, iCal `DTSTART` |
| `timezone` | string (IANA, p. ej. `Europe/Madrid`) | ✅¹ | Zona horaria. ¹Obligatoria salvo eventos de día completo. | iCal `TZID`/`VTIMEZONE` |
| `attendanceMode` | enum: `in-person` \| `online` \| `hybrid` | ✅ | Formato de asistencia. | schema `eventAttendanceMode` |
| `endDate` | string (ISO 8601) | — | Fin. Si falta, se asume evento puntual o se usa `duration`. | schema `endDate`, iCal `DTEND` |
| `duration` | string (ISO 8601 duration, p. ej. `PT2H`) | — | Alternativa a `endDate`. | iCal `DURATION` |
| `summary` | string | — | Resumen corto (una línea), para listados. | iCal `SUMMARY` corto |
| `description` | string (Markdown) | — | Descripción completa. | schema `description`, iCal `DESCRIPTION` |
| `url` | string (URL) | — | Página canónica del evento. | schema `url`, iCal `URL` |
| `status` | enum: `scheduled` \| `cancelled` \| `postponed` \| `rescheduled` | — | Estado. Por defecto `scheduled`. | schema `eventStatus`, iCal `STATUS` |
| `languages` | string[] (BCP 47, p. ej. `["es","en"]`) | — | Idiomas del evento. | — |
| `image` | string (URL) | — | Imagen de portada / logo. | schema `image` |
| `location` | `Location` | —² | Ubicación. ²Recomendada según `attendanceMode`. | schema `location` |
| `tags` | string[] | — | Temáticas (p. ej. `["ai","cloud"]`). Comparte taxonomía con el directorio. | schema `keywords`, iCal `CATEGORIES` |
| `community` | `CommunityRef` \| `CommunityRef[]` | —³ | **Referencia** a la comunidad organizadora por URI global. ³Opcional pero recomendada. No se duplican sus datos. | schema `organizer` (resuelto) |
| `license` | string (SPDX id o URL) | — | Licencia de **estos datos**: cómo pueden reutilizarse. P. ej. `CC0-1.0`, `CC-BY-4.0` o una URL. | schema `license` |
| `source` | `Source` \| `Source[]` | — | **Procedencia/atribución**: de dónde provienen los datos (si se importaron o agregaron de otra fuente). | schema `isBasedOn` |
| `createdAt` / `updatedAt` | string (ISO 8601) | — | Metadatos de la ficha. | iCal `DTSTAMP` / `LAST-MODIFIED` |
> **Un documento = un evento concreto** (una fecha). La spec **no** modela recurrencia: un meetup que se repite produce **varios** documentos de evento, uno por ocurrencia, todos referenciando la misma `community`. La cadencia (mensual, anual…) es propiedad de la *serie*, no del evento → ver [Conceptos diferidos](#conceptos-diferidos).
### Sub-objeto `Location`
Soporta presencial, online o ambos a la vez (híbrido).
| Campo | Tipo | Descripción |
| --- | --- | --- |
| `venue` | `Venue` | Lugar físico (solo si presencial/híbrido). |
| `online` | `OnlineLocation` | Acceso online (solo si online/híbrido). |
```
Venue: { name, address, city, region, postalCode, country (ISO 3166-1 alpha-2), geo: { lat, lon } }
OnlineLocation: { url, platform } # platform opcional: "zoom", "youtube", "twitch"…
```
Mapeo: `Venue` → schema `Place`/`PostalAddress`/`geo`, iCal `LOCATION`+`GEO`. `OnlineLocation` → schema `VirtualLocation`.
### Sub-objeto `CommunityRef`
Referencia (no copia) a la comunidad organizadora, **agnóstica de registro**.
| Campo | Tipo | Oblig.² | Descripción |
| --- | --- | :---: | --- |
| `uri` | string (URI) | ✅ | Identidad global de la comunidad. Por defecto, su **URL canónica** (única vía DNS, sin registro central). |
| `name` | string | — | Nombre, cacheado para mostrar sin resolver el `uri`. |
| `registries` | `Registry[]` | — | Registros opcionales que describen esta comunidad (0..N). No obligatorio. |
```
Registry: { name: "community-builders", url: "https://github.com/ComBuildersES/communities-directory", localId: "42" }
```
`registries` permite enlazar la comunidad con **cualquier** directorio compatible (ComBuilders u otros) sin acoplar la spec a ninguno. `localId` es el id que use ese registro concreto.
> Un mismo evento puede co-organizarse entre varias comunidades → `community` se admite también como **lista** de `CommunityRef`.
### Sub-objeto `Source`
Atribución de la procedencia cuando los datos se **importan o agregan** de otra fuente (Meetup, un directorio, otro feed OTE…). Permite dar crédito y respetar la licencia de origen.
| Campo | Tipo | Oblig.² | Descripción |
| --- | --- | :---: | --- |
| `name` | string | ✅ | Nombre de la fuente (p. ej. "Meetup", "confs.tech"). |
| `url` | string (URL) | — | Enlace a la ficha original (verificable). |
| `license` | string (SPDX id o URL) | — | Licencia bajo la que esa fuente publica el dato. |
| `retrievedAt` | string (ISO 8601) | — | Cuándo se obtuvo. |
> **Nota legal** 🔲: poder declarar `source`/`license` no exime de respetar los términos de la fuente original. Qué licencias permiten ingerir y re-publicar está **pendiente de investigar** por fuente (ver [research](../research/README.md)).
## Módulos opcionales
Bloques que se añaden a `Event` solo si aplican. Mantienen el núcleo ligero.
### `cfp` — Call for Papers/Speakers
> No existe en ningún estándar generalista; al exportar a iCal/schema se degrada a texto/URL.
| Campo | Tipo | Oblig.² | Descripción |
| --- | --- | :---: | --- |
| `url` | string (URL) | ✅ | Formulario/página de la convocatoria. |
| `opensAt` | string (ISO 8601) | — | Apertura. |
| `closesAt` | string (ISO 8601) | — | Cierre. |
| `timezone` | string (IANA) | — | Zona horaria del CFP. |
| `coversTravel` | boolean | — | Cubre gastos de viaje. |
| `coversAccommodation` | boolean | — | Cubre alojamiento. |
²Obligatorio _dentro del módulo_ si el módulo está presente.
### `speakers` — Ponentes
Lista de `Speaker` (mapea a schema `performer`):
```
{ name, bio, photo (URL), website (URL), socials: [Social], talkTitle }
```
### `offers` — Registro y precio
| Campo | Tipo | Descripción |
| --- | --- | --- |
| `isFree` | boolean | Gratuito o de pago. |
| `price` | number | Importe (si de pago). |
| `currency` | string (ISO 4217) | Moneda. |
| `registrationUrl` | string (URL) | Enlace de registro/entradas. |
| `capacity` | integer | Aforo máximo. |
Mapeo: schema `offers` (`Offer`).
### `promotion` — Difusión específica del evento
> Las **redes de la comunidad** organizadora **no** van aquí: viven en el directorio (`urls`). Este módulo es solo para datos de difusión **propios del encuentro**.
| Campo | Tipo | Descripción |
| --- | --- | --- |
| `hashtag` | string | Hashtag del evento (p. ej. `#RustMad2026`). |
| `eventUrls` | object | Enlaces propios del evento que no sean los de la comunidad (página de la edición, álbum de fotos, grabación…). |
### `governance` — Buenas prácticas
| Campo | Tipo | Descripción |
| --- | --- | --- |
| `codeOfConductUrl` | string (URL) | Código de conducta. |
| `accessibility` | object | `{ captions: bool, signLanguage: bool, notes: string }`. |
| `privacyPolicyUrl` | string (URL) | Política de privacidad / derechos de imagen. |
### `sponsors` — Patrocinadores
Lista de `{ name, url, logo, tier }`.
## Ejemplo (YAML)
```yaml
specVersion: "0.1.0"
id: "https://rustmadrid.example/2026-05"
name: "Rust Madrid — Mayo 2026"
summary: "Charlas mensuales sobre Rust en Madrid"
startDate: "2026-05-29T19:00:00"
endDate: "2026-05-29T21:00:00"
timezone: "Europe/Madrid"
attendanceMode: hybrid
status: scheduled
url: "https://rustmadrid.example/2026-05"
languages: ["es"]
image: "https://rustmadrid.example/cover.png"
location:
venue:
name: "Campus Madrid"
address: "Calle de Moreno Nieto 2"
city: "Madrid"
country: "ES"
geo: { lat: 40.4081, lon: -3.7188 }
online:
url: "https://youtube.example/live/xyz"
platform: "youtube"
tags: ["rust", "systems"]
community: # referencia por URI global; no se duplican sus datos
uri: "https://rustmadrid.example"
name: "Rust Madrid"
registries: # opcional: registros compatibles que la describen
- { name: "community-builders", url: "https://github.com/ComBuildersES/communities-directory", localId: "42" }
offers:
isFree: true
registrationUrl: "https://rustmadrid.example/2026-05/register"
capacity: 80
promotion:
hashtag: "#RustMad202605"
governance:
codeOfConductUrl: "https://rustmadrid.example/coc"
accessibility: { captions: true, signLanguage: false }
```
## Resumen de mapeo a estándares
| OTE | schema.org/Event | iCalendar VEVENT | RSS / JSON Feed |
| --- | --- | --- | --- |
| `id` | `@id` | `UID` | `guid` / `id` |
| `name` | `name` | `SUMMARY` | `title` |
| `description` | `description` | `DESCRIPTION` | `description` / `content_html` |
| `startDate`/`endDate` | `startDate`/`endDate` | `DTSTART`/`DTEND` | — (en cuerpo) |
| `timezone` | (en fecha ISO) | `TZID`+`VTIMEZONE` | — |
| `attendanceMode` | `eventAttendanceMode` | (sin equiv.) | — |
| `location.venue` | `Place`/`PostalAddress` | `LOCATION`+`GEO` | — |
| `location.online` | `VirtualLocation` | `URL` | — |
| `tags` | `keywords` | `CATEGORIES` | `category` / `tags` |
| `cfp` | (extensión) | (texto) | (cuerpo) |
| `community` | `organizer` (resuelto desde el directorio) | `ORGANIZER` | — |
| `speakers` | `performer` | (sin equiv.) | — |
| `offers` | `offers` | (sin equiv.) | — |
| `promotion.hashtag` | (sin equiv.) | (sin equiv.) | — |
| `license` | `license` | (sin equiv.) | — |
| `source` | `isBasedOn` | (sin equiv.) | — |
> **Difusión**: RSS y JSON Feed no modelan eventos → un evento por `item`, con enlace a la ficha y el evento estructurado en una extensión (namespace en RSS, campo `_ote` en JSON Feed).
## Conceptos diferidos
Cosas deliberadamente **fuera** de la v0.1 para no complicar el núcleo; se evaluarán como entidades/módulos aparte:
- **Serie de eventos (`EventSeries`)**: la cadencia de un meetup mensual o las ediciones anuales de una conferencia. Cada ocurrencia sigue siendo un `Event` independiente; la serie los agruparía y podría exportarse a un `VEVENT` con `RRULE`. Mantiene la identidad "un documento = un evento".
- **Agenda multi-sesión** (tracks, horarios por charla dentro de un evento).
## Versionado
La especificación se versiona con **SemVer** (`MAJOR.MINOR.PATCH`):
- **MAJOR** — cambios incompatibles (renombrar/eliminar campos, cambiar obligatoriedad o tipos).
- **MINOR** — añadidos retrocompatibles (nuevos campos/módulos opcionales).
- **PATCH** — correcciones sin impacto en el modelo (redacción, ejemplos, aclaraciones).
Cada documento de evento declara la versión a la que se adhiere en `specVersion`, lo que permite a las herramientas validar y migrar. Mientras la spec esté en `0.x` se considera **inestable**: puede haber cambios incompatibles entre versiones menores. La primera versión estable será `1.0.0`.
> Los **estándares destino** (iCal, schema.org…) tienen su propio versionado; el mapeo documenta contra qué versión se valida.
## Preguntas abiertas
- **Formato de serialización** _(decisión de fondo, abierta)_: ¿JSON, YAML, XML, JSON-LD u otro? Consideraciones de la [investigación](../research/findings/analysis.md): la mayoría de proyectos usan **JSON/YAML**; **XML** aparece casi solo a través de RSS; **JSON-LD** facilita la detección automática (schema.org/dev.events). Sub-preguntas: ¿un **formato canónico** único (p. ej. JSON) con el resto como representaciones derivadas, o varios igual de válidos? ¿El canónico es JSON "a secas" o **JSON-LD** directamente? El borrador usa JSON/YAML de forma provisional, sin cerrar el debate.
- **Identidad de la comunidad**: ¿imponer que `community.uri` sea una URL resoluble, o admitir otros esquemas URI (DID, etc.)? ¿Cómo se "resuelven" los datos de la comunidad desde el `uri` (convención de descubrimiento)?
- **Identidad del evento** (sí es de la spec; el *dónde* se almacena, no): ¿`event.id` siempre URI resoluble o se admite slug? ¿Cómo garantizar unicidad **independiente de la ubicación** de los datos?
- ¿Normalizar `tags` con un vocabulario controlado (reutilizando taxonomías existentes) o dejar libre?
- **Licencia por defecto**: ¿qué licencia recomendar a las comunidades adheridas para sus eventos (p. ej. `CC0-1.0` / `CC-BY-4.0`)? ¿Debe `license` ser **obligatorio** en el evento, o se asume una por defecto si falta?
- **Atribución**: ¿`source` debe ser obligatorio cuando el dato se importa/agrega de otra fuente? ¿Cómo se propaga la atribución en cadena (fuente → feed agregado → re-publicación)?
- **Serie de eventos**: ¿hace falta una entidad `EventSeries` que agrupe ocurrencias (meetup mensual, ediciones anuales) y, opcionalmente, las enlace? ¿Cómo se referencian entre sí evento y serie? (ver [Conceptos diferidos](#conceptos-diferidos)).
- Validación: ¿JSON Schema oficial, versionado junto a la spec?
```
## feed.md
Source: spec/feed.md — How a feed collects events, how it is discovered from a site, and how it is kept fresh.
```markdown
# Feed de eventos — OTE Spec (borrador)
> ⛔ **Superado por [OTE Spec v0.1](v0.1/README.md).** Este documento es el boceto previo, no normativo. Se conserva por las ideas que aún no han entrado en la spec.
> ⚠️ **Borrador inicial generado por IA, sin revisión humana todavía.**
> Boceto para ilustrar la idea y abrir debate. Nombres, campos y decisiones son provisionales.
Los documentos de [data-model.md](data-model.md) describen **eventos individuales**. Un **feed** es una **colección** de eventos publicada en una URL estable, a la que la gente puede **suscribirse** y que las herramientas pueden ingerir.
## Alcance: qué es y qué no es de la spec
| Es de la spec | Es del ecosistema (herramientas) |
| --- | --- |
| Formato del feed (colección de eventos + metadatos). | Motor de **suscripción** y entrega de **notificaciones**. |
| Mapeo a estándares de feed (RSS, JSON Feed, iCal). | UI para que el usuario fije condiciones/alertas. |
| Qué campos son **filtrables** y la convención de filtrado por URL. | Lógica de filtrado en cliente, dedupe entre feeds, etc. |
Objetivo de diseño: que un feed OTE sea **suscribible con herramientas que la gente ya usa** (lector RSS, app de calendario) sin adoptar nada nuevo.
## Objeto `Feed`
```
{
"specVersion": "0.1.0",
"kind": "feed",
"title": "Eventos de IA en España",
"feedUrl": "https://eventos.example/feeds/ia-es.json", // identidad/self
"homePageUrl": "https://eventos.example/ia-es",
"description": "Meetups y conferencias de IA en España.",
"updatedAt": "2026-05-29T08:00:00Z",
"scope": { ... }, // opcional: a qué está pre-filtrado este feed
"next": "https://eventos.example/feeds/ia-es.json?page=2", // opcional, paginación
"events": [ /* Event, Event, … */ ]
}
```
| Campo | Tipo | Oblig. | Descripción |
| --- | --- | :---: | --- |
| `specVersion` | string (SemVer) | ✅ | Versión de OTE Spec. |
| `kind` | `"feed"` | ✅ | Discrimina feed de evento suelto. |
| `title` | string | ✅ | Nombre del feed. |
| `feedUrl` | string (URL) | ✅ | URL canónica del propio feed (identidad). |
| `events` | `Event[]` | ✅ | Eventos contenidos (formato de [data-model.md](data-model.md)). |
| `homePageUrl` | string (URL) | — | Página HTML asociada. |
| `description` | string | — | De qué va el feed. |
| `updatedAt` | string (ISO 8601) | — | Última actualización. |
| `scope` | `Scope` | — | Filtros ya aplicados (ver abajo). |
| `next` | string (URL) | — | Siguiente página (paginación). |
| `license` | string (SPDX id o URL) | — | Licencia **por defecto** de los eventos del feed (cada `Event` puede sobrescribirla con su propio `license`). |
| `source` | `Source` \| `Source[]` | — | Procedencia/atribución del feed si agrega otras fuentes. |
> Atribución y licencia: un feed que **agrega** varias fuentes debería declarar `source`/`license` a nivel de feed y/o por evento, para respetar y propagar la procedencia. Ver [data-model.md](data-model.md#sub-objeto-source).
`events` puede contener eventos **completos** o una forma reducida con enlace (`id`/`url`) a la ficha completa; pendiente de decidir (ver preguntas abiertas).
## Filtrado
### 1. Feeds pre-filtrados (curados)
Un publicador ofrece **varias URLs**, cada una ya acotada: por `tag`, ciudad, comunidad, modalidad… Cada feed declara su acotación en `scope`, de forma informativa:
```
scope: {
tags: ["ai"],
country: "ES",
attendanceMode: "online", // in-person | online | hybrid
from: "2026-05-01",
to: "2026-12-31"
}
```
Es la opción más interoperable: cada `scope` = una URL suscribible en cualquier lector estándar, sin lógica adicional.
### 2. Convención de filtrado por query params (endpoints dinámicos)
Para feeds servidos dinámicamente, **recomendación** de parámetros (no obligatoria; el servidor decide qué soporta):
| Param | Ejemplo | Filtra por |
| --- | --- | --- |
| `tag` | `?tag=ai&tag=cloud` | etiquetas (OR) |
| `attendanceMode` | `?attendanceMode=online` | modalidad |
| `country` / `city` | `?country=ES` | ubicación |
| `community` | `?community=https://rustmadrid.example` | comunidad (por URI) |
| `from` / `to` | `?from=2026-06-01&to=2026-06-30` | rango de fechas |
| `hasCfp` | `?hasCfp=true` | solo con CFP abierto |
El resultado es un `Feed` cuyo `scope` refleja los filtros aplicados.
### 3. Notificaciones y condiciones del usuario → herramientas
Suscribirse, evaluar condiciones ("avísame de eventos online de Rust a <50 km") y **notificar** es responsabilidad de las herramientas del ecosistema, no de la spec. La spec solo garantiza que los **campos** necesarios para filtrar (tags, modalidad, ubicación, fechas, CFP) están normalizados.
## Mapeo a estándares de feed
Para máxima compatibilidad, un `Feed` OTE debería poder exportarse a:
| Estándar | Cómo | Suscripción con |
| --- | --- | --- |
| **JSON Feed 1.1** | `Feed`→feed, cada `Event`→`item` (evento estructurado en `_ote`). | Lectores JSON Feed. |
| **RSS 2.0** | `channel` + un `item` por evento; datos estructurados en un namespace. | Cualquier lector RSS. |
| **iCalendar** | `VCALENDAR` con un `VEVENT` por evento. | Google/Apple/Outlook Calendar (suscripción a `.ics`). |
> El mapeo de cada `Event` es el de [data-model.md](data-model.md#resumen-de-mapeo-a-estándares). RSS/JSON Feed sirven como **anuncio** (título + enlace a la ficha + evento estructurado); iCal permite **suscribir el calendario** directamente.
## Preguntas abiertas
- ¿Los `events` del feed van **completos** o en forma reducida (resumen + enlace a la ficha)? ¿Mixto según tamaño?
- ¿`scope` es solo informativo o las herramientas deben **validar** que el contenido lo respeta?
- ¿Estandarizar paginación (`next`) o delegar en el transporte (HTTP `Link`)?
- ¿Feeds de **CFP** como tipo aparte, o un feed normal con `?hasCfp=true`?
- ¿Soporte de "tiempo real" (WebSub/hubs, como JSON Feed) o solo polling?
- **¿Cómo se descubre un feed OTE desde una web?** RSS resuelve esto con *autodiscovery*. Opciones a evaluar (no excluyentes):
- **HTML ``** en el ``, símil RSS: ``. Falta decidir el `type` (MIME propio vs. `application/feed+json`).
- **`.well-known/`** a nivel de dominio (p.ej. `/.well-known/ote-feed`) para descubrir sin parsear HTML.
- **JSON-LD / `schema.org` `Event`** embebido en la página, reaprovechando lo que ya detectan crawlers (dev.events, Google). Ver [research/findings/analysis.md](../research/findings/analysis.md).
```
## spec-versions.md
Source: spec/README.md — Which spec versions exist and what stability each one carries.
```markdown
# Especificación — OpenTechEvents (OTE Spec)
> ✅ **La especificación vigente es [`v0.3/`](v0.3/)** — schemas ejecutables, prosa normativa y ejemplos validados en CI. La [`v0.1/`](v0.1/) y la [`v0.2/`](v0.2/) quedan congeladas. Qué cambió: [CHANGELOG](../CHANGELOG.md).
>
> ⚠️ **Los documentos de esta carpeta (`data-model.md`, `feed.md`, `examples/`) son el boceto ANTERIOR**, generado por IA para abrir el debate. Se conservan por su valor histórico y por las ideas que aún no han entrado en la spec (speakers, promotion, governance…). **No son normativos y no deben implementarse.**
> ⚠️ **Borrador inicial generado por IA, sin revisión humana todavía.**
> Este contenido es un **boceto propuesto** cuyo único objetivo es **ilustrar la idea** y servir de punto de partida para la discusión. No es una especificación acordada ni estable: nombres, campos y decisiones cambiarán tras la revisión de la comunidad.
Nombre y versión provisionales. El diseño parte de la investigación en [../research/](../research/), en especial de [../research/findings/analysis.md](../research/findings/analysis.md) y [../research/findings/standards.md](../research/findings/standards.md).
## Contenido
- [data-model.md](data-model.md) — modelo de datos propuesto: entidad núcleo, módulos opcionales y mapeo a estándares existentes.
- [feed.md](feed.md) — formato de **feed** (colección de eventos suscribible), filtrado y mapeo a RSS / JSON Feed / iCal.
- [examples/](examples/) — ejemplos JSON ilustrativos (datos semi-inventados): evento mínimo, sesión de un meetup, conferencia con CFP y un feed.
```
==============================================================================
SECTION: Schemas
==============================================================================
## event.schema.json
Source: spec/v0.3/event.schema.json — JSON Schema for a single event — the normative source for validity.
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://opentechevents.org/schema/v0.3/event.schema.json",
"title": "OTE Event",
"description": "A single tech community event. See https://opentechevents.org for the normative prose.",
"allOf": [
{
"$ref": "#/$defs/event"
},
{
"type": "object",
"description": "A standalone event document must carry its own specVersion and license. Inside a feed, both are inherited from the feed.",
"required": [
"specVersion",
"license"
]
},
{
"description": "A map of translations is unusable without knowing which language the primary text is in: a consumer cannot tell which entry duplicates it, nor what it is falling back to. So ANY translations map in the document — the event's own, or one inside image, offers, eligibility or partOf — requires textLanguage. It is the one place where a field of this spec depends on another being present, and it holds at every depth because the primary language is a property of the whole document, not of each object. A standalone document has no feed to inherit textLanguage from, so it must always carry its own; inside a feed, the same requirement is enforced against the effective (possibly inherited) language — see feed.schema.json.",
"if": {
"type": "object",
"anyOf": [
{
"required": [
"translations"
]
},
{
"required": [
"eligibility"
],
"properties": {
"eligibility": {
"required": [
"translations"
],
"type": "object"
}
}
},
{
"required": [
"partOf"
],
"properties": {
"partOf": {
"required": [
"translations"
],
"type": "object"
}
}
},
{
"required": [
"offers"
],
"properties": {
"offers": {
"contains": {
"required": [
"translations"
],
"type": "object"
},
"type": "array"
}
}
},
{
"required": [
"image"
],
"properties": {
"image": {
"contains": {
"required": [
"translations"
],
"type": "object"
},
"type": "array"
}
}
}
]
},
"then": {
"type": "object",
"required": [
"textLanguage"
]
}
}
],
"$defs": {
"event": {
"type": "object",
"required": [
"id",
"name",
"startDate",
"timezone"
],
"properties": {
"specVersion": {
"description": "Version of OTE Spec this document adheres to.",
"x-inheritsFrom": "feed.specVersion",
"const": "0.3.0",
"examples": [
"0.3.0"
]
},
"id": {
"description": "Stable, globally unique identifier: an HTTP(S) URL under a domain the publisher controls — not necessarily one they own; a canonical page on a platform they use (Meetup, GitHub Pages, LinkedIn) works exactly as well, since what matters is that the URL is stable and nobody else can end up with that same one. Minted once, never rewritten — this is what lets consumers update an event instead of duplicating it.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://pyalmeria.example/eventos/2026-06-async",
"https://calendar.example/ics/rust-madrid#a1b2c3d4-uid",
"https://www.meetup.com/pyalmeria/events/123456789/"
]
},
"url": {
"description": "Canonical URL where the event is described today. May change over time; id may not.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://pyalmeria.example/eventos/2026-06-async"
]
},
"name": {
"description": "Display name of the event.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"PyAlmería — Introducción a async/await"
]
},
"description": {
"description": "Short description. Plain text or Markdown.",
"type": "string",
"examples": [
"Charla introductoria a la programación asíncrona en Python, con ejemplos en vivo."
]
},
"image": {
"description": "Promotional images of the event: poster, cover, card. A list in preference order — the FIRST is the primary one, and often the only one a destination can use. The rest may be other crops or resolutions of that same image (Google asks for 1:1, 4:3 and 16:9) or different images altogether; a consumer that can show only one shows the first, and none may assume the list renders as a photo gallery. An entry is either a bare https URL to the image file itself — never to a page showing it — or an object that adds alt text.",
"$ref": "#/$defs/images",
"examples": [
[
"https://rustmadrid.example/img/2026-06-16x9.png"
],
[
{
"url": "https://rustmadrid.example/img/2026-06-16x9.png",
"alt": "Cartel: Ferris sobre fondo morado, «Rust Madrid · 16 junio · Impact Hub»"
},
"https://rustmadrid.example/img/2026-06-1x1.png",
"https://rustmadrid.example/img/2026-06-4x3.png"
]
]
},
"organizers": {
"description": "Who runs the event — not where the data came from (that is source). A list: co-organised events are the norm, not the exception. Declaring it REPLACES the inherited list, it does not add to it.",
"x-inheritsFrom": "feed.organizers",
"$ref": "#/$defs/organizers",
"examples": [
[
{
"name": "PyAlmería",
"url": "https://pyalmeria.example"
}
],
[
{
"name": "GDG Madrid",
"url": "https://gdgmadrid.example"
},
{
"type": "person",
"name": "Ada Lovelace",
"url": "https://ada.example"
}
]
]
},
"startDate": {
"description": "Wall-clock start: a date (2026-10-15) for all-day events, or a local date-time (2026-10-15T09:00), never with seconds. Never carries a UTC offset — timezone does that. Which of the two forms you pick is not this field's decision alone: it has to match endDate's, and endDate (if present) must not be earlier — see the document's constraints.",
"$ref": "#/$defs/wallClock",
"examples": [
"2026-06-11T18:30",
"2026-10-15"
]
},
"endDate": {
"description": "Wall-clock end, in the SAME form as startDate (both dates, or both date-times) and never earlier than it. If absent, the event is assumed to end on the day it starts. For an all-day event (date form, no time), endDate is INCLUSIVE — it names the last day the event runs, not the day after. Converting to iCalendar needs +1 day for DTEND;VALUE=DATE (RFC 5545 defines it as the non-inclusive end); importing needs -1 day back. Both rules belong to the document, not to this field — see the document's constraints.",
"$ref": "#/$defs/wallClock",
"examples": [
"2026-06-11T20:00",
"2026-10-16"
]
},
"timezone": {
"description": "A real IANA timezone identifier (e.g. Europe/Madrid) — canonical name or historical alias, never an invented or malformed one. Turns a wall-clock startDate into an unambiguous instant. For all-day events it contextualises the date — it does not shift it. On the two nights a year local time is ambiguous (a repeated hour) or impossible (a skipped hour) because of a DST transition, resolve exactly as RFC 5545 §3.3.5 does: a repeated local time means its FIRST occurrence; a skipped local time is read using the UTC offset that was in effect BEFORE the transition.",
"type": "string",
"enum": [
"Africa/Abidjan",
"Africa/Accra",
"Africa/Addis_Ababa",
"Africa/Algiers",
"Africa/Asmara",
"Africa/Asmera",
"Africa/Bamako",
"Africa/Bangui",
"Africa/Banjul",
"Africa/Bissau",
"Africa/Blantyre",
"Africa/Brazzaville",
"Africa/Bujumbura",
"Africa/Cairo",
"Africa/Casablanca",
"Africa/Ceuta",
"Africa/Conakry",
"Africa/Dakar",
"Africa/Dar_es_Salaam",
"Africa/Djibouti",
"Africa/Douala",
"Africa/El_Aaiun",
"Africa/Freetown",
"Africa/Gaborone",
"Africa/Harare",
"Africa/Johannesburg",
"Africa/Juba",
"Africa/Kampala",
"Africa/Khartoum",
"Africa/Kigali",
"Africa/Kinshasa",
"Africa/Lagos",
"Africa/Libreville",
"Africa/Lome",
"Africa/Luanda",
"Africa/Lubumbashi",
"Africa/Lusaka",
"Africa/Malabo",
"Africa/Maputo",
"Africa/Maseru",
"Africa/Mbabane",
"Africa/Mogadishu",
"Africa/Monrovia",
"Africa/Nairobi",
"Africa/Ndjamena",
"Africa/Niamey",
"Africa/Nouakchott",
"Africa/Ouagadougou",
"Africa/Porto-Novo",
"Africa/Sao_Tome",
"Africa/Timbuktu",
"Africa/Tripoli",
"Africa/Tunis",
"Africa/Windhoek",
"America/Adak",
"America/Anchorage",
"America/Anguilla",
"America/Antigua",
"America/Araguaina",
"America/Argentina/Buenos_Aires",
"America/Argentina/Catamarca",
"America/Argentina/ComodRivadavia",
"America/Argentina/Cordoba",
"America/Argentina/Jujuy",
"America/Argentina/La_Rioja",
"America/Argentina/Mendoza",
"America/Argentina/Rio_Gallegos",
"America/Argentina/Salta",
"America/Argentina/San_Juan",
"America/Argentina/San_Luis",
"America/Argentina/Tucuman",
"America/Argentina/Ushuaia",
"America/Aruba",
"America/Asuncion",
"America/Atikokan",
"America/Atka",
"America/Bahia",
"America/Bahia_Banderas",
"America/Barbados",
"America/Belem",
"America/Belize",
"America/Blanc-Sablon",
"America/Boa_Vista",
"America/Bogota",
"America/Boise",
"America/Buenos_Aires",
"America/Cambridge_Bay",
"America/Campo_Grande",
"America/Cancun",
"America/Caracas",
"America/Catamarca",
"America/Cayenne",
"America/Cayman",
"America/Chicago",
"America/Chihuahua",
"America/Ciudad_Juarez",
"America/Coral_Harbour",
"America/Cordoba",
"America/Costa_Rica",
"America/Coyhaique",
"America/Creston",
"America/Cuiaba",
"America/Curacao",
"America/Danmarkshavn",
"America/Dawson",
"America/Dawson_Creek",
"America/Denver",
"America/Detroit",
"America/Dominica",
"America/Edmonton",
"America/Eirunepe",
"America/El_Salvador",
"America/Ensenada",
"America/Fort_Nelson",
"America/Fort_Wayne",
"America/Fortaleza",
"America/Glace_Bay",
"America/Godthab",
"America/Goose_Bay",
"America/Grand_Turk",
"America/Grenada",
"America/Guadeloupe",
"America/Guatemala",
"America/Guayaquil",
"America/Guyana",
"America/Halifax",
"America/Havana",
"America/Hermosillo",
"America/Indiana/Indianapolis",
"America/Indiana/Knox",
"America/Indiana/Marengo",
"America/Indiana/Petersburg",
"America/Indiana/Tell_City",
"America/Indiana/Vevay",
"America/Indiana/Vincennes",
"America/Indiana/Winamac",
"America/Indianapolis",
"America/Inuvik",
"America/Iqaluit",
"America/Jamaica",
"America/Jujuy",
"America/Juneau",
"America/Kentucky/Louisville",
"America/Kentucky/Monticello",
"America/Knox_IN",
"America/Kralendijk",
"America/La_Paz",
"America/Lima",
"America/Los_Angeles",
"America/Louisville",
"America/Lower_Princes",
"America/Maceio",
"America/Managua",
"America/Manaus",
"America/Marigot",
"America/Martinique",
"America/Matamoros",
"America/Mazatlan",
"America/Mendoza",
"America/Menominee",
"America/Merida",
"America/Metlakatla",
"America/Mexico_City",
"America/Miquelon",
"America/Moncton",
"America/Monterrey",
"America/Montevideo",
"America/Montreal",
"America/Montserrat",
"America/Nassau",
"America/New_York",
"America/Nipigon",
"America/Nome",
"America/Noronha",
"America/North_Dakota/Beulah",
"America/North_Dakota/Center",
"America/North_Dakota/New_Salem",
"America/Nuuk",
"America/Ojinaga",
"America/Panama",
"America/Pangnirtung",
"America/Paramaribo",
"America/Phoenix",
"America/Port_of_Spain",
"America/Port-au-Prince",
"America/Porto_Acre",
"America/Porto_Velho",
"America/Puerto_Rico",
"America/Punta_Arenas",
"America/Rainy_River",
"America/Rankin_Inlet",
"America/Recife",
"America/Regina",
"America/Resolute",
"America/Rio_Branco",
"America/Rosario",
"America/Santa_Isabel",
"America/Santarem",
"America/Santiago",
"America/Santo_Domingo",
"America/Sao_Paulo",
"America/Scoresbysund",
"America/Shiprock",
"America/Sitka",
"America/St_Barthelemy",
"America/St_Johns",
"America/St_Kitts",
"America/St_Lucia",
"America/St_Thomas",
"America/St_Vincent",
"America/Swift_Current",
"America/Tegucigalpa",
"America/Thule",
"America/Thunder_Bay",
"America/Tijuana",
"America/Toronto",
"America/Tortola",
"America/Vancouver",
"America/Virgin",
"America/Whitehorse",
"America/Winnipeg",
"America/Yakutat",
"America/Yellowknife",
"Antarctica/Casey",
"Antarctica/Davis",
"Antarctica/DumontDUrville",
"Antarctica/Macquarie",
"Antarctica/Mawson",
"Antarctica/McMurdo",
"Antarctica/Palmer",
"Antarctica/Rothera",
"Antarctica/South_Pole",
"Antarctica/Syowa",
"Antarctica/Troll",
"Antarctica/Vostok",
"Arctic/Longyearbyen",
"Asia/Aden",
"Asia/Almaty",
"Asia/Amman",
"Asia/Anadyr",
"Asia/Aqtau",
"Asia/Aqtobe",
"Asia/Ashgabat",
"Asia/Ashkhabad",
"Asia/Atyrau",
"Asia/Baghdad",
"Asia/Bahrain",
"Asia/Baku",
"Asia/Bangkok",
"Asia/Barnaul",
"Asia/Beirut",
"Asia/Bishkek",
"Asia/Brunei",
"Asia/Calcutta",
"Asia/Chita",
"Asia/Choibalsan",
"Asia/Chongqing",
"Asia/Chungking",
"Asia/Colombo",
"Asia/Dacca",
"Asia/Damascus",
"Asia/Dhaka",
"Asia/Dili",
"Asia/Dubai",
"Asia/Dushanbe",
"Asia/Famagusta",
"Asia/Gaza",
"Asia/Harbin",
"Asia/Hebron",
"Asia/Ho_Chi_Minh",
"Asia/Hong_Kong",
"Asia/Hovd",
"Asia/Irkutsk",
"Asia/Istanbul",
"Asia/Jakarta",
"Asia/Jayapura",
"Asia/Jerusalem",
"Asia/Kabul",
"Asia/Kamchatka",
"Asia/Karachi",
"Asia/Kashgar",
"Asia/Kathmandu",
"Asia/Katmandu",
"Asia/Khandyga",
"Asia/Kolkata",
"Asia/Krasnoyarsk",
"Asia/Kuala_Lumpur",
"Asia/Kuching",
"Asia/Kuwait",
"Asia/Macao",
"Asia/Macau",
"Asia/Magadan",
"Asia/Makassar",
"Asia/Manila",
"Asia/Muscat",
"Asia/Nicosia",
"Asia/Novokuznetsk",
"Asia/Novosibirsk",
"Asia/Omsk",
"Asia/Oral",
"Asia/Phnom_Penh",
"Asia/Pontianak",
"Asia/Pyongyang",
"Asia/Qatar",
"Asia/Qostanay",
"Asia/Qyzylorda",
"Asia/Rangoon",
"Asia/Riyadh",
"Asia/Saigon",
"Asia/Sakhalin",
"Asia/Samarkand",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Srednekolymsk",
"Asia/Taipei",
"Asia/Tashkent",
"Asia/Tbilisi",
"Asia/Tehran",
"Asia/Tel_Aviv",
"Asia/Thimbu",
"Asia/Thimphu",
"Asia/Tokyo",
"Asia/Tomsk",
"Asia/Ujung_Pandang",
"Asia/Ulaanbaatar",
"Asia/Ulan_Bator",
"Asia/Urumqi",
"Asia/Ust-Nera",
"Asia/Vientiane",
"Asia/Vladivostok",
"Asia/Yakutsk",
"Asia/Yangon",
"Asia/Yekaterinburg",
"Asia/Yerevan",
"Atlantic/Azores",
"Atlantic/Bermuda",
"Atlantic/Canary",
"Atlantic/Cape_Verde",
"Atlantic/Faeroe",
"Atlantic/Faroe",
"Atlantic/Jan_Mayen",
"Atlantic/Madeira",
"Atlantic/Reykjavik",
"Atlantic/South_Georgia",
"Atlantic/St_Helena",
"Atlantic/Stanley",
"Australia/ACT",
"Australia/Adelaide",
"Australia/Brisbane",
"Australia/Broken_Hill",
"Australia/Canberra",
"Australia/Currie",
"Australia/Darwin",
"Australia/Eucla",
"Australia/Hobart",
"Australia/LHI",
"Australia/Lindeman",
"Australia/Lord_Howe",
"Australia/Melbourne",
"Australia/North",
"Australia/NSW",
"Australia/Perth",
"Australia/Queensland",
"Australia/South",
"Australia/Sydney",
"Australia/Tasmania",
"Australia/Victoria",
"Australia/West",
"Australia/Yancowinna",
"Brazil/Acre",
"Brazil/DeNoronha",
"Brazil/East",
"Brazil/West",
"Canada/Atlantic",
"Canada/Central",
"Canada/Eastern",
"Canada/Mountain",
"Canada/Newfoundland",
"Canada/Pacific",
"Canada/Saskatchewan",
"Canada/Yukon",
"CET",
"Chile/Continental",
"Chile/EasterIsland",
"CST6CDT",
"Cuba",
"EET",
"Egypt",
"Eire",
"EST",
"EST5EDT",
"Etc/GMT",
"Etc/GMT-0",
"Etc/GMT-1",
"Etc/GMT-10",
"Etc/GMT-11",
"Etc/GMT-12",
"Etc/GMT-13",
"Etc/GMT-14",
"Etc/GMT-2",
"Etc/GMT-3",
"Etc/GMT-4",
"Etc/GMT-5",
"Etc/GMT-6",
"Etc/GMT-7",
"Etc/GMT-8",
"Etc/GMT-9",
"Etc/GMT+0",
"Etc/GMT+1",
"Etc/GMT+10",
"Etc/GMT+11",
"Etc/GMT+12",
"Etc/GMT+2",
"Etc/GMT+3",
"Etc/GMT+4",
"Etc/GMT+5",
"Etc/GMT+6",
"Etc/GMT+7",
"Etc/GMT+8",
"Etc/GMT+9",
"Etc/GMT0",
"Etc/Greenwich",
"Etc/UCT",
"Etc/Universal",
"Etc/UTC",
"Etc/Zulu",
"Europe/Amsterdam",
"Europe/Andorra",
"Europe/Astrakhan",
"Europe/Athens",
"Europe/Belfast",
"Europe/Belgrade",
"Europe/Berlin",
"Europe/Bratislava",
"Europe/Brussels",
"Europe/Bucharest",
"Europe/Budapest",
"Europe/Busingen",
"Europe/Chisinau",
"Europe/Copenhagen",
"Europe/Dublin",
"Europe/Gibraltar",
"Europe/Guernsey",
"Europe/Helsinki",
"Europe/Isle_of_Man",
"Europe/Istanbul",
"Europe/Jersey",
"Europe/Kaliningrad",
"Europe/Kiev",
"Europe/Kirov",
"Europe/Kyiv",
"Europe/Lisbon",
"Europe/Ljubljana",
"Europe/London",
"Europe/Luxembourg",
"Europe/Madrid",
"Europe/Malta",
"Europe/Mariehamn",
"Europe/Minsk",
"Europe/Monaco",
"Europe/Moscow",
"Europe/Nicosia",
"Europe/Oslo",
"Europe/Paris",
"Europe/Podgorica",
"Europe/Prague",
"Europe/Riga",
"Europe/Rome",
"Europe/Samara",
"Europe/San_Marino",
"Europe/Sarajevo",
"Europe/Saratov",
"Europe/Simferopol",
"Europe/Skopje",
"Europe/Sofia",
"Europe/Stockholm",
"Europe/Tallinn",
"Europe/Tirane",
"Europe/Tiraspol",
"Europe/Ulyanovsk",
"Europe/Uzhgorod",
"Europe/Vaduz",
"Europe/Vatican",
"Europe/Vienna",
"Europe/Vilnius",
"Europe/Volgograd",
"Europe/Warsaw",
"Europe/Zagreb",
"Europe/Zaporozhye",
"Europe/Zurich",
"GB",
"GB-Eire",
"GMT",
"GMT-0",
"GMT+0",
"GMT0",
"Greenwich",
"Hongkong",
"HST",
"Iceland",
"Indian/Antananarivo",
"Indian/Chagos",
"Indian/Christmas",
"Indian/Cocos",
"Indian/Comoro",
"Indian/Kerguelen",
"Indian/Mahe",
"Indian/Maldives",
"Indian/Mauritius",
"Indian/Mayotte",
"Indian/Reunion",
"Iran",
"Israel",
"Jamaica",
"Japan",
"Kwajalein",
"Libya",
"MET",
"Mexico/BajaNorte",
"Mexico/BajaSur",
"Mexico/General",
"MST",
"MST7MDT",
"Navajo",
"NZ",
"NZ-CHAT",
"Pacific/Apia",
"Pacific/Auckland",
"Pacific/Bougainville",
"Pacific/Chatham",
"Pacific/Chuuk",
"Pacific/Easter",
"Pacific/Efate",
"Pacific/Enderbury",
"Pacific/Fakaofo",
"Pacific/Fiji",
"Pacific/Funafuti",
"Pacific/Galapagos",
"Pacific/Gambier",
"Pacific/Guadalcanal",
"Pacific/Guam",
"Pacific/Honolulu",
"Pacific/Johnston",
"Pacific/Kanton",
"Pacific/Kiritimati",
"Pacific/Kosrae",
"Pacific/Kwajalein",
"Pacific/Majuro",
"Pacific/Marquesas",
"Pacific/Midway",
"Pacific/Nauru",
"Pacific/Niue",
"Pacific/Norfolk",
"Pacific/Noumea",
"Pacific/Pago_Pago",
"Pacific/Palau",
"Pacific/Pitcairn",
"Pacific/Pohnpei",
"Pacific/Ponape",
"Pacific/Port_Moresby",
"Pacific/Rarotonga",
"Pacific/Saipan",
"Pacific/Samoa",
"Pacific/Tahiti",
"Pacific/Tarawa",
"Pacific/Tongatapu",
"Pacific/Truk",
"Pacific/Wake",
"Pacific/Wallis",
"Pacific/Yap",
"Poland",
"Portugal",
"PRC",
"PST8PDT",
"ROC",
"ROK",
"Singapore",
"Turkey",
"UCT",
"Universal",
"US/Alaska",
"US/Aleutian",
"US/Arizona",
"US/Central",
"US/East-Indiana",
"US/Eastern",
"US/Hawaii",
"US/Indiana-Starke",
"US/Michigan",
"US/Mountain",
"US/Pacific",
"US/Samoa",
"UTC",
"W-SU",
"WET",
"Zulu"
],
"examples": [
"Europe/Madrid",
"America/Bogota",
"UTC"
],
"$comment": "Generated by scripts/update-timezones.mjs from IANA tzdata 2026c (https://www.iana.org/time-zones). Every Zone and Link (alias), so a historical rename (e.g. Europe/Kiev → Europe/Kyiv, 2022) never invalidates a document that used the old name."
},
"attendanceMode": {
"description": "What the organiser says this event is. Absent never means in-person.",
"enum": [
"in-person",
"online",
"hybrid"
],
"examples": [
"in-person",
"online",
"hybrid"
]
},
"location": {
"$ref": "#/$defs/location",
"examples": [
{
"venue": "El Cable, Almería"
},
{
"onlineUrl": "https://meet.example/pyalmeria"
},
{
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"postalCode": "28005",
"country": "ES"
},
"onlineUrl": "https://meet.example/rust-madrid"
}
]
},
"eligibility": {
"description": "Who may attend, when the answer is not \"anyone\". The third part of \"can I go?\", after attendanceMode and location: those two answer whether the event is reachable, this one whether you are allowed in. Absent never means open — an importer reading a .ics cannot know, and staying quiet is not the same claim as saying the door is open.",
"$ref": "#/$defs/eligibility",
"examples": [
{
"type": "open"
},
{
"type": "members-only",
"note": "Miembros del Discord de Rust Girona",
"url": "https://rustgirona.example/join"
},
{
"type": "restricted",
"note": "Solo alumnado de la Universidad de Almería"
}
]
},
"tags": {
"description": "Free-form topic tags — what the event is ABOUT. Maps to iCal CATEGORIES and schema.org keywords. Not who may attend: that question has its own field, eligibility, because a tag like \"members-only\" is invisible to a consumer that does not already know to look for it. A controlled vocabulary may layer on top later; the field itself stays free.",
"type": "array",
"items": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
},
"minItems": 1,
"uniqueItems": true,
"examples": [
[
"rust",
"wasm"
],
[
"python",
"async"
]
]
},
"languages": {
"description": "Languages SPOKEN at the event, as BCP 47 tags, e.g. [\"es\",\"en\"]. Not the language this document is written in — that is textLanguage, and the two disagree all the time: a bilingual session described in Catalan only.",
"type": "array",
"items": {
"$ref": "#/$defs/languageTag"
},
"minItems": 1,
"uniqueItems": true,
"distinctLanguageTags": true,
"examples": [
[
"es"
],
[
"es",
"en"
]
]
},
"textLanguage": {
"description": "Language THIS DOCUMENT's free text is written in — name, description, and any other prose in it. One BCP 47 tag, not a list: a text is written in one language. A different question from languages, which says what is spoken at the event. Absent defaults to the enclosing feed's textLanguage; in a standalone document, with no feed to inherit from, absent means unknown, never English.",
"x-inheritsFrom": "feed.textLanguage",
"$ref": "#/$defs/languageTag",
"examples": [
"es",
"ca",
"en"
]
},
"offers": {
"description": "What it costs to attend, and where to register. A list: tiered pricing (early bird, student, patron) is one entry each, and a free event is a single entry with price 0. Absent means UNKNOWN, never free — saying free is what price 0 is for.",
"$ref": "#/$defs/offers",
"examples": [
[
{
"price": 0,
"url": "https://rustmadrid.example/meetups/2026-06#registro"
}
],
[
{
"name": "Early bird",
"price": 35,
"currency": "EUR",
"url": "https://devfest-levante.example/2026/entradas",
"availability": "sold-out",
"closesAt": "2026-07-31T23:59:59+02:00"
},
{
"name": "General",
"price": 45,
"currency": "EUR",
"url": "https://devfest-levante.example/2026/entradas",
"availability": "in-stock"
}
]
]
},
"cfp": {
"description": "The event's open call for proposals — talks, workshops, papers. The one field of the spec with no equivalent in ANY of the three destination formats: it exists because 'which conferences are accepting proposals right now' is a question only the publisher can answer, and today it is answered by scraping.",
"$ref": "#/$defs/cfp",
"examples": [
{
"url": "https://devfest-levante.example/2026/cfp",
"closesAt": "2026-07-15T23:59:59+02:00"
},
{
"url": "https://devfest-levante.example/2026/cfp",
"opensAt": "2026-05-01T00:00:00+02:00",
"closesAt": "2026-07-15T23:59:59+02:00",
"coversTravel": true,
"coversAccommodation": true
}
]
},
"status": {
"description": "What happened to the event, not to the data. An event that is cancelled, postponed or moved online MUST stay published: removing it leaves a dead event in subscribers' calendars. tentative means announced but not confirmed (iCal STATUS:TENTATIVE) — it exists so an importer never has to upgrade an unconfirmed event to scheduled.",
"enum": [
"scheduled",
"tentative",
"cancelled",
"postponed",
"rescheduled",
"moved-online"
],
"default": "scheduled",
"examples": [
"scheduled",
"cancelled",
"moved-online"
]
},
"partOf": {
"description": "The series or multi-part event this document is one occurrence of. A REFERENCE, never a recurrence rule: OTE does not generate dates: whoever publishes expands the recurrence into one document per occurrence, each with its own id, dates and status. A consumer that ignores this field still sees complete, correct events.",
"$ref": "#/$defs/partOf",
"examples": [
{
"id": "https://rustmadrid.example/meetups",
"name": "Rust Madrid — meetup mensual",
"url": "https://rustmadrid.example/meetups"
},
{
"type": "multipart",
"id": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python (3 sesiones)"
}
]
},
"license": {
"description": "License of THIS DATA, not of the event. SPDX identifier (CC0-1.0, CC-BY-4.0…, full list at https://spdx.org/licenses/) or a URL.",
"x-inheritsFrom": "feed.license",
"$ref": "#/$defs/license",
"examples": [
"CC-BY-4.0",
"CC0-1.0"
]
},
"source": {
"description": "Provenance. Required when the event was imported or aggregated from elsewhere; omitted when the organiser describes their own event — they are the source.",
"$ref": "#/$defs/source",
"examples": [
{
"name": "Rust Madrid",
"url": "https://calendar.example/ics/rust-madrid",
"license": "CC-BY-4.0",
"retrievedAt": "2026-06-01T05:00:00Z"
}
]
},
"updatedAt": {
"description": "Instant the event's DATA last changed — equivalent to iCal LAST-MODIFIED, not DTSTAMP (which marks generation and changes on every export). Lets a consumer sync incrementally: fetch only what changed since its last read. Absent means unknown, not 'never changed'.",
"$ref": "#/$defs/instant",
"examples": [
"2026-06-10T18:00:00Z"
]
},
"translations": {
"description": "The same event's free text in other languages, keyed by BCP 47 tag. The document keeps ONE primary text in its own fields — declared by textLanguage — and this carries the versions of it. Additive on purpose: name and description stay strings, so every existing consumer keeps working and a monolingual publisher writes nothing at all. Never a translation of the language the document is already in. Requires textLanguage — and so does any other translations map in the document, at any depth; see the document's constraints.",
"$ref": "#/$defs/translations",
"examples": [
{
"es": {
"name": "Sesión semanal de programación — Rust Girona",
"description": "Cada semana nos juntamos en línea para picar Rust un rato."
}
}
]
}
},
"orderedDates": true,
"distinctTranslationLanguages": true,
"distinctPartOfId": true,
"allOf": [
{
"description": "startDate and endDate must be of the same form: two all-day dates, or two local date-times.",
"oneOf": [
{
"properties": {
"startDate": {
"$ref": "#/$defs/date"
},
"endDate": {
"$ref": "#/$defs/date"
}
},
"type": "object"
},
{
"properties": {
"startDate": {
"$ref": "#/$defs/dateTime"
},
"endDate": {
"$ref": "#/$defs/dateTime"
}
},
"type": "object"
}
]
}
]
},
"date": {
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}$",
"format": "date"
},
"dateTime": {
"description": "A calendar-valid wall-clock date-time, deliberately WITHOUT an offset — that is what timezone is for — and WITHOUT seconds: this is the hour on a poster, never a technical instant. No standard RFC 3339 format covers this shape, so validating it fully requires registering the `ote-local-date-time` format shipped as `customFormats` in the npm package.",
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}$",
"format": "ote-local-date-time"
},
"wallClock": {
"type": "string",
"anyOf": [
{
"$ref": "#/$defs/date"
},
{
"$ref": "#/$defs/dateTime"
}
]
},
"instant": {
"description": "An absolute point in time, WITH offset or Z. Used for metadata (when data was fetched) and for deadlines (when a sale or a call closes) — never for when an event happens, which is wall clock plus timezone.",
"type": "string",
"pattern": "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}(\\.\\d+)?(Z|[+-]\\d{2}:\\d{2})$",
"format": "date-time"
},
"currency": {
"type": "string",
"enum": [
"AED",
"AFN",
"ALL",
"AMD",
"AOA",
"ARS",
"AUD",
"AWG",
"AZN",
"BAM",
"BBD",
"BDT",
"BHD",
"BIF",
"BMD",
"BND",
"BOB",
"BOV",
"BRL",
"BSD",
"BTN",
"BWP",
"BYN",
"BZD",
"CAD",
"CDF",
"CHE",
"CHF",
"CHW",
"CLF",
"CLP",
"CNY",
"COP",
"COU",
"CRC",
"CUP",
"CVE",
"CZK",
"DJF",
"DKK",
"DOP",
"DZD",
"EGP",
"ERN",
"ETB",
"EUR",
"FJD",
"FKP",
"GBP",
"GEL",
"GHS",
"GIP",
"GMD",
"GNF",
"GTQ",
"GYD",
"HKD",
"HNL",
"HTG",
"HUF",
"IDR",
"ILS",
"INR",
"IQD",
"IRR",
"ISK",
"JMD",
"JOD",
"JPY",
"KES",
"KGS",
"KHR",
"KMF",
"KPW",
"KRW",
"KWD",
"KYD",
"KZT",
"LAK",
"LBP",
"LKR",
"LRD",
"LSL",
"LYD",
"MAD",
"MDL",
"MGA",
"MKD",
"MMK",
"MNT",
"MOP",
"MRU",
"MUR",
"MVR",
"MWK",
"MXN",
"MXV",
"MYR",
"MZN",
"NAD",
"NGN",
"NIO",
"NOK",
"NPR",
"NZD",
"OMR",
"PAB",
"PEN",
"PGK",
"PHP",
"PKR",
"PLN",
"PYG",
"QAR",
"RON",
"RSD",
"RUB",
"RWF",
"SAR",
"SBD",
"SCR",
"SDG",
"SEK",
"SGD",
"SHP",
"SLE",
"SOS",
"SRD",
"SSP",
"STN",
"SVC",
"SYP",
"SZL",
"THB",
"TJS",
"TMT",
"TND",
"TOP",
"TRY",
"TTD",
"TWD",
"TZS",
"UAH",
"UGX",
"USD",
"USN",
"UYI",
"UYU",
"UYW",
"UZS",
"VED",
"VES",
"VND",
"VUV",
"WST",
"XAD",
"XAF",
"XAG",
"XAU",
"XBA",
"XBB",
"XBC",
"XBD",
"XCD",
"XCG",
"XDR",
"XOF",
"XPD",
"XPF",
"XPT",
"XSU",
"XTS",
"XUA",
"XXX",
"YER",
"ZAR",
"ZMW",
"ZWG"
],
"$comment": "Generated by scripts/update-currencies.mjs from the official ISO 4217 active-currency list published by SIX Group (list-one.xml, published 2026-01-01) — not from Intl.supportedValuesOf('currency'), which lags the real registry. See CHANGES.log #P004."
},
"country": {
"type": "string",
"enum": [
"AD",
"AE",
"AF",
"AG",
"AI",
"AL",
"AM",
"AO",
"AQ",
"AR",
"AS",
"AT",
"AU",
"AW",
"AX",
"AZ",
"BA",
"BB",
"BD",
"BE",
"BF",
"BG",
"BH",
"BI",
"BJ",
"BL",
"BM",
"BN",
"BO",
"BQ",
"BR",
"BS",
"BT",
"BV",
"BW",
"BY",
"BZ",
"CA",
"CC",
"CD",
"CF",
"CG",
"CH",
"CI",
"CK",
"CL",
"CM",
"CN",
"CO",
"CR",
"CU",
"CV",
"CW",
"CX",
"CY",
"CZ",
"DE",
"DJ",
"DK",
"DM",
"DO",
"DZ",
"EC",
"EE",
"EG",
"EH",
"ER",
"ES",
"ET",
"FI",
"FJ",
"FK",
"FM",
"FO",
"FR",
"GA",
"GB",
"GD",
"GE",
"GF",
"GG",
"GH",
"GI",
"GL",
"GM",
"GN",
"GP",
"GQ",
"GR",
"GS",
"GT",
"GU",
"GW",
"GY",
"HK",
"HM",
"HN",
"HR",
"HT",
"HU",
"ID",
"IE",
"IL",
"IM",
"IN",
"IO",
"IQ",
"IR",
"IS",
"IT",
"JE",
"JM",
"JO",
"JP",
"KE",
"KG",
"KH",
"KI",
"KM",
"KN",
"KP",
"KR",
"KW",
"KY",
"KZ",
"LA",
"LB",
"LC",
"LI",
"LK",
"LR",
"LS",
"LT",
"LU",
"LV",
"LY",
"MA",
"MC",
"MD",
"ME",
"MF",
"MG",
"MH",
"MK",
"ML",
"MM",
"MN",
"MO",
"MP",
"MQ",
"MR",
"MS",
"MT",
"MU",
"MV",
"MW",
"MX",
"MY",
"MZ",
"NA",
"NC",
"NE",
"NF",
"NG",
"NI",
"NL",
"NO",
"NP",
"NR",
"NU",
"NZ",
"OM",
"PA",
"PE",
"PF",
"PG",
"PH",
"PK",
"PL",
"PM",
"PN",
"PR",
"PS",
"PT",
"PW",
"PY",
"QA",
"RE",
"RO",
"RS",
"RU",
"RW",
"SA",
"SB",
"SC",
"SD",
"SE",
"SG",
"SH",
"SI",
"SJ",
"SK",
"SL",
"SM",
"SN",
"SO",
"SR",
"SS",
"ST",
"SV",
"SX",
"SY",
"SZ",
"TC",
"TD",
"TF",
"TG",
"TH",
"TJ",
"TK",
"TL",
"TM",
"TN",
"TO",
"TR",
"TT",
"TV",
"TW",
"TZ",
"UA",
"UG",
"UM",
"US",
"UY",
"UZ",
"VA",
"VC",
"VE",
"VG",
"VI",
"VN",
"VU",
"WF",
"WS",
"YE",
"YT",
"ZA",
"ZM",
"ZW"
],
"$comment": "Generated by scripts/update-countries.mjs from the officially assigned ISO 3166-1 alpha-2 codes. Fetched from the Debian iso-codes project (https://salsa.debian.org/iso-codes-team/iso-codes/-/raw/main/data/iso_3166-1.json) and verified to match a human-retrieved snapshot of the ISO Online Browsing Platform (retrieved 2026-08-03 by hhkaos) before being trusted — iso.org itself returns 403 to automated requests. See CHANGES.log #P005 / DECISIONS.md D006."
},
"license": {
"description": "A real SPDX License List identifier, or a URL to the license text. Never an invented identifier: the whole point of asking for SPDX here instead of prose is that a consumer can compare it against an allowlist.",
"type": "string",
"anyOf": [
{
"enum": [
"0BSD",
"3D-Slicer-1.0",
"AAL",
"Abstyles",
"AdaCore-doc",
"Adobe-2006",
"Adobe-Display-PostScript",
"Adobe-Glyph",
"Adobe-Utopia",
"ADSL",
"Advanced-Cryptics-Dictionary",
"AFL-1.1",
"AFL-1.2",
"AFL-2.0",
"AFL-2.1",
"AFL-3.0",
"Afmparse",
"AGPL-1.0",
"AGPL-1.0-only",
"AGPL-1.0-or-later",
"AGPL-3.0",
"AGPL-3.0-only",
"AGPL-3.0-or-later",
"Aladdin",
"ALGLIB-Documentation",
"AMD-newlib",
"AMDPLPA",
"AML",
"AML-glslang",
"AMPAS",
"ANTLR-PD",
"ANTLR-PD-fallback",
"any-OSI",
"any-OSI-perl-modules",
"Apache-1.0",
"Apache-1.1",
"Apache-2.0",
"APAFML",
"APL-1.0",
"App-s2p",
"APSL-1.0",
"APSL-1.1",
"APSL-1.2",
"APSL-2.0",
"Arphic-1999",
"Artistic-1.0",
"Artistic-1.0-cl8",
"Artistic-1.0-Perl",
"Artistic-2.0",
"Artistic-dist",
"Aspell-RU",
"ASWF-Digital-Assets-1.0",
"ASWF-Digital-Assets-1.1",
"Baekmuk",
"Bahyph",
"Barr",
"bcrypt-Solar-Designer",
"Beerware",
"Bitstream-Charter",
"Bitstream-Vera",
"BitTorrent-1.0",
"BitTorrent-1.1",
"blessing",
"BlueOak-1.0.0",
"Boehm-GC",
"Boehm-GC-without-fee",
"BOLA-1.1",
"Borceux",
"Brian-Gladman-2-Clause",
"Brian-Gladman-3-Clause",
"BSD-1-Clause",
"BSD-2-Clause",
"BSD-2-Clause-Darwin",
"BSD-2-Clause-first-lines",
"BSD-2-Clause-FreeBSD",
"BSD-2-Clause-NetBSD",
"BSD-2-Clause-Patent",
"BSD-2-Clause-pkgconf-disclaimer",
"BSD-2-Clause-Views",
"BSD-3-Clause",
"BSD-3-Clause-acpica",
"BSD-3-Clause-Attribution",
"BSD-3-Clause-Clear",
"BSD-3-Clause-flex",
"BSD-3-Clause-HP",
"BSD-3-Clause-LBNL",
"BSD-3-Clause-Modification",
"BSD-3-Clause-No-Military-License",
"BSD-3-Clause-No-Nuclear-License",
"BSD-3-Clause-No-Nuclear-License-2014",
"BSD-3-Clause-No-Nuclear-Warranty",
"BSD-3-Clause-Open-MPI",
"BSD-3-Clause-Sun",
"BSD-3-Clause-Tso",
"BSD-4-Clause",
"BSD-4-Clause-Shortened",
"BSD-4-Clause-UC",
"BSD-4.3RENO",
"BSD-4.3TAHOE",
"BSD-Advertising-Acknowledgement",
"BSD-Attribution-HPND-disclaimer",
"BSD-Inferno-Nettverk",
"BSD-Mark-Modifications",
"BSD-Protection",
"BSD-Source-beginning-file",
"BSD-Source-Code",
"BSD-Systemics",
"BSD-Systemics-W3Works",
"BSL-1.0",
"Buddy",
"BUSL-1.1",
"bzip2-1.0.5",
"bzip2-1.0.6",
"C-UDA-1.0",
"CAL-1.0",
"CAL-1.0-Combined-Work-Exception",
"Caldera",
"Caldera-no-preamble",
"CAPEC-tou",
"Catharon",
"CATOSL-1.1",
"CC-BY-1.0",
"CC-BY-2.0",
"CC-BY-2.5",
"CC-BY-2.5-AU",
"CC-BY-3.0",
"CC-BY-3.0-AT",
"CC-BY-3.0-AU",
"CC-BY-3.0-DE",
"CC-BY-3.0-IGO",
"CC-BY-3.0-NL",
"CC-BY-3.0-US",
"CC-BY-4.0",
"CC-BY-NC-1.0",
"CC-BY-NC-2.0",
"CC-BY-NC-2.5",
"CC-BY-NC-3.0",
"CC-BY-NC-3.0-DE",
"CC-BY-NC-4.0",
"CC-BY-NC-ND-1.0",
"CC-BY-NC-ND-2.0",
"CC-BY-NC-ND-2.5",
"CC-BY-NC-ND-3.0",
"CC-BY-NC-ND-3.0-DE",
"CC-BY-NC-ND-3.0-IGO",
"CC-BY-NC-ND-4.0",
"CC-BY-NC-SA-1.0",
"CC-BY-NC-SA-2.0",
"CC-BY-NC-SA-2.0-DE",
"CC-BY-NC-SA-2.0-FR",
"CC-BY-NC-SA-2.0-UK",
"CC-BY-NC-SA-2.5",
"CC-BY-NC-SA-3.0",
"CC-BY-NC-SA-3.0-DE",
"CC-BY-NC-SA-3.0-IGO",
"CC-BY-NC-SA-4.0",
"CC-BY-ND-1.0",
"CC-BY-ND-2.0",
"CC-BY-ND-2.5",
"CC-BY-ND-3.0",
"CC-BY-ND-3.0-DE",
"CC-BY-ND-4.0",
"CC-BY-SA-1.0",
"CC-BY-SA-2.0",
"CC-BY-SA-2.0-UK",
"CC-BY-SA-2.1-JP",
"CC-BY-SA-2.5",
"CC-BY-SA-3.0",
"CC-BY-SA-3.0-AT",
"CC-BY-SA-3.0-DE",
"CC-BY-SA-3.0-IGO",
"CC-BY-SA-4.0",
"CC-PDDC",
"CC-PDM-1.0",
"CC-SA-1.0",
"CC0-1.0",
"CDDL-1.0",
"CDDL-1.1",
"CDL-1.0",
"CDLA-Permissive-1.0",
"CDLA-Permissive-2.0",
"CDLA-Sharing-1.0",
"CECILL-1.0",
"CECILL-1.1",
"CECILL-2.0",
"CECILL-2.1",
"CECILL-B",
"CECILL-C",
"CERN-OHL-1.1",
"CERN-OHL-1.2",
"CERN-OHL-P-2.0",
"CERN-OHL-S-2.0",
"CERN-OHL-W-2.0",
"CFITSIO",
"check-cvs",
"checkmk",
"ClArtistic",
"Clips",
"CMU-Mach",
"CMU-Mach-nodoc",
"CNRI-Jython",
"CNRI-Python",
"CNRI-Python-GPL-Compatible",
"COIL-1.0",
"Community-Spec-1.0",
"Condor-1.1",
"copyleft-next-0.3.0",
"copyleft-next-0.3.1",
"Cornell-Lossless-JPEG",
"CPAL-1.0",
"CPL-1.0",
"CPOL-1.02",
"Cronyx",
"Crossword",
"CryptoSwift",
"CrystalStacker",
"CUA-OPL-1.0",
"Cube",
"curl",
"cve-tou",
"D-FSL-1.0",
"DEC-3-Clause",
"diffmark",
"DL-DE-BY-2.0",
"DL-DE-ZERO-2.0",
"DOC",
"DocBook-DTD",
"DocBook-Schema",
"DocBook-Stylesheet",
"DocBook-XML",
"Dotseqn",
"DRL-1.0",
"DRL-1.1",
"DSDP",
"dtoa",
"dvipdfm",
"ECL-1.0",
"ECL-2.0",
"eCos-2.0",
"EFL-1.0",
"EFL-2.0",
"eGenix",
"Elastic-2.0",
"Entessa",
"EPICS",
"EPL-1.0",
"EPL-2.0",
"ErlPL-1.1",
"ESA-PL-permissive-2.4",
"ESA-PL-strong-copyleft-2.4",
"ESA-PL-weak-copyleft-2.4",
"etalab-2.0",
"EUDatagrid",
"EUPL-1.0",
"EUPL-1.1",
"EUPL-1.2",
"Eurosym",
"Fair",
"FBM",
"FDK-AAC",
"Ferguson-Twofish",
"Frameworx-1.0",
"FreeBSD-DOC",
"FreeImage",
"FSFAP",
"FSFAP-no-warranty-disclaimer",
"FSFUL",
"FSFULLR",
"FSFULLRSD",
"FSFULLRWD",
"FSL-1.1-ALv2",
"FSL-1.1-MIT",
"FTL",
"Furuseth",
"fwlw",
"Game-Programming-Gems",
"GCR-docs",
"GD",
"generic-xts",
"GFDL-1.1",
"GFDL-1.1-invariants-only",
"GFDL-1.1-invariants-or-later",
"GFDL-1.1-no-invariants-only",
"GFDL-1.1-no-invariants-or-later",
"GFDL-1.1-only",
"GFDL-1.1-or-later",
"GFDL-1.2",
"GFDL-1.2-invariants-only",
"GFDL-1.2-invariants-or-later",
"GFDL-1.2-no-invariants-only",
"GFDL-1.2-no-invariants-or-later",
"GFDL-1.2-only",
"GFDL-1.2-or-later",
"GFDL-1.3",
"GFDL-1.3-invariants-only",
"GFDL-1.3-invariants-or-later",
"GFDL-1.3-no-invariants-only",
"GFDL-1.3-no-invariants-or-later",
"GFDL-1.3-only",
"GFDL-1.3-or-later",
"Giftware",
"GL2PS",
"Glide",
"Glulxe",
"GLWTPL",
"gnuplot",
"GPL-1.0",
"GPL-1.0-only",
"GPL-1.0-or-later",
"GPL-1.0+",
"GPL-2.0",
"GPL-2.0-only",
"GPL-2.0-or-later",
"GPL-2.0-with-autoconf-exception",
"GPL-2.0-with-bison-exception",
"GPL-2.0-with-classpath-exception",
"GPL-2.0-with-font-exception",
"GPL-2.0-with-GCC-exception",
"GPL-2.0+",
"GPL-3.0",
"GPL-3.0-only",
"GPL-3.0-or-later",
"GPL-3.0-with-autoconf-exception",
"GPL-3.0-with-GCC-exception",
"GPL-3.0+",
"Graphics-Gems",
"gSOAP-1.3b",
"gtkbook",
"Gutmann",
"HaskellReport",
"HDF5",
"hdparm",
"HIDAPI",
"Hippocratic-2.1",
"HP-1986",
"HP-1989",
"HPND",
"HPND-DEC",
"HPND-doc",
"HPND-doc-sell",
"HPND-export-US",
"HPND-export-US-acknowledgement",
"HPND-export-US-modify",
"HPND-export2-US",
"HPND-Fenneberg-Livingston",
"HPND-INRIA-IMAG",
"HPND-Intel",
"HPND-Kevlin-Henney",
"HPND-Markus-Kuhn",
"HPND-merchantability-variant",
"HPND-MIT-disclaimer",
"HPND-Netrek",
"HPND-Pbmplus",
"HPND-sell-MIT-disclaimer-xserver",
"HPND-sell-regexpr",
"HPND-sell-variant",
"HPND-sell-variant-critical-systems",
"HPND-sell-variant-MIT-disclaimer",
"HPND-sell-variant-MIT-disclaimer-rev",
"HPND-SMC",
"HPND-UC",
"HPND-UC-export-US",
"HTMLTIDY",
"hyphen-bulgarian",
"IBM-pibs",
"ICU",
"IEC-Code-Components-EULA",
"IJG",
"IJG-short",
"ImageMagick",
"iMatix",
"Imlib2",
"Info-ZIP",
"Inner-Net-2.0",
"InnoSetup",
"Intel",
"Intel-ACPI",
"Interbase-1.0",
"IPA",
"IPL-1.0",
"ISC",
"ISC-Veillard",
"ISO-permission",
"Jam",
"JasPer-2.0",
"jove",
"JPL-image",
"JPNIC",
"JSON",
"Kastrup",
"Kazlib",
"Knuth-CTAN",
"LAL-1.2",
"LAL-1.3",
"Latex2e",
"Latex2e-translated-notice",
"Leptonica",
"LGPL-2.0",
"LGPL-2.0-only",
"LGPL-2.0-or-later",
"LGPL-2.0+",
"LGPL-2.1",
"LGPL-2.1-only",
"LGPL-2.1-or-later",
"LGPL-2.1+",
"LGPL-3.0",
"LGPL-3.0-only",
"LGPL-3.0-or-later",
"LGPL-3.0+",
"LGPLLR",
"Libpng",
"libpng-1.6.35",
"libpng-2.0",
"libselinux-1.0",
"libtiff",
"libutil-David-Nugent",
"LiLiQ-P-1.1",
"LiLiQ-R-1.1",
"LiLiQ-Rplus-1.1",
"Linux-man-pages-1-para",
"Linux-man-pages-copyleft",
"Linux-man-pages-copyleft-2-para",
"Linux-man-pages-copyleft-var",
"Linux-OpenIB",
"LOOP",
"LPD-document",
"LPL-1.0",
"LPL-1.02",
"LPPL-1.0",
"LPPL-1.1",
"LPPL-1.2",
"LPPL-1.3a",
"LPPL-1.3c",
"lsof",
"Lucida-Bitmap-Fonts",
"LZMA-SDK-9.11-to-9.20",
"LZMA-SDK-9.22",
"Mackerras-3-Clause",
"Mackerras-3-Clause-acknowledgment",
"magaz",
"mailprio",
"MakeIndex",
"man2html",
"Martin-Birgmeier",
"McPhee-slideshow",
"metamail",
"Minpack",
"MIPS",
"MirOS",
"MIT",
"MIT-0",
"MIT-advertising",
"MIT-Click",
"MIT-CMU",
"MIT-enna",
"MIT-feh",
"MIT-Festival",
"MIT-Khronos-old",
"MIT-Modern-Variant",
"MIT-open-group",
"MIT-STK",
"MIT-testregex",
"MIT-Wu",
"MITNFA",
"MMIXware",
"MMPL-1.0.1",
"Motosoto",
"MPEG-SSG",
"mpi-permissive",
"mpich2",
"MPL-1.0",
"MPL-1.1",
"MPL-2.0",
"MPL-2.0-no-copyleft-exception",
"mplus",
"MS-LPL",
"MS-PL",
"MS-RL",
"MTLL",
"MulanPSL-1.0",
"MulanPSL-2.0",
"Multics",
"Mup",
"NAIST-2003",
"NASA-1.3",
"Naumen",
"NBPL-1.0",
"NCBI-PD",
"NCGL-UK-2.0",
"NCL",
"NCSA",
"Net-SNMP",
"NetCDF",
"Newsletr",
"NGPL",
"ngrep",
"NICTA-1.0",
"NIST-PD",
"NIST-PD-fallback",
"NIST-PD-TNT",
"NIST-Software",
"NLOD-1.0",
"NLOD-2.0",
"NLPL",
"Nokia",
"NOSL",
"Noweb",
"NPL-1.0",
"NPL-1.1",
"NPOSL-3.0",
"NRL",
"NTIA-PD",
"NTP",
"NTP-0",
"Nunit",
"O-UDA-1.0",
"OAR",
"OCCT-PL",
"OCLC-2.0",
"ODbL-1.0",
"ODC-By-1.0",
"OFFIS",
"OFL-1.0",
"OFL-1.0-no-RFN",
"OFL-1.0-RFN",
"OFL-1.1",
"OFL-1.1-no-RFN",
"OFL-1.1-RFN",
"OGC-1.0",
"OGDL-Taiwan-1.0",
"OGL-Canada-2.0",
"OGL-UK-1.0",
"OGL-UK-2.0",
"OGL-UK-3.0",
"OGTSL",
"OLDAP-1.1",
"OLDAP-1.2",
"OLDAP-1.3",
"OLDAP-1.4",
"OLDAP-2.0",
"OLDAP-2.0.1",
"OLDAP-2.1",
"OLDAP-2.2",
"OLDAP-2.2.1",
"OLDAP-2.2.2",
"OLDAP-2.3",
"OLDAP-2.4",
"OLDAP-2.5",
"OLDAP-2.6",
"OLDAP-2.7",
"OLDAP-2.8",
"OLFL-1.3",
"OML",
"OpenMDW-1.0",
"OpenPBS-2.3",
"OpenSSL",
"OpenSSL-standalone",
"OpenVision",
"OPL-1.0",
"OPL-UK-3.0",
"OPUBL-1.0",
"OSC-1.0",
"OSET-PL-2.1",
"OSL-1.0",
"OSL-1.1",
"OSL-2.0",
"OSL-2.1",
"OSL-3.0",
"OSSP",
"PADL",
"ParaType-Free-Font-1.3",
"Parity-6.0.0",
"Parity-7.0.0",
"PDDL-1.0",
"PHP-3.0",
"PHP-3.01",
"Pixar",
"pkgconf",
"Plexus",
"pnmstitch",
"PolyForm-Noncommercial-1.0.0",
"PolyForm-Small-Business-1.0.0",
"PostgreSQL",
"PPL",
"PSF-2.0",
"psfrag",
"psutils",
"Python-2.0",
"Python-2.0.1",
"python-ldap",
"Qhull",
"QPL-1.0",
"QPL-1.0-INRIA-2004",
"radvd",
"Rdisc",
"RHeCos-1.1",
"RPL-1.1",
"RPL-1.5",
"RPSL-1.0",
"RSA-MD",
"RSCPL",
"Ruby",
"Ruby-pty",
"SAX-PD",
"SAX-PD-2.0",
"Saxpath",
"SCEA",
"SchemeReport",
"Sendmail",
"Sendmail-8.23",
"Sendmail-Open-Source-1.1",
"SGI-B-1.0",
"SGI-B-1.1",
"SGI-B-2.0",
"SGI-OpenGL",
"SGMLUG-PM",
"SGP4",
"SHL-0.5",
"SHL-0.51",
"SimPL-2.0",
"SISSL",
"SISSL-1.2",
"SL",
"Sleepycat",
"SMAIL-GPL",
"SMLNJ",
"SMPPL",
"SNIA",
"snprintf",
"SOFA",
"softSurfer",
"Soundex",
"Spencer-86",
"Spencer-94",
"Spencer-99",
"SPL-1.0",
"ssh-keyscan",
"SSH-OpenSSH",
"SSH-short",
"SSLeay-standalone",
"SSPL-1.0",
"StandardML-NJ",
"SugarCRM-1.1.3",
"SUL-1.0",
"Sun-PPP",
"Sun-PPP-2000",
"SunPro",
"SWL",
"swrule",
"Symlinks",
"TAPR-OHL-1.0",
"TCL",
"TCP-wrappers",
"TekHVC",
"TermReadKey",
"TGPPL-1.0",
"ThirdEye",
"threeparttable",
"TMate",
"TORQUE-1.1",
"TOSL",
"TPDL",
"TPL-1.0",
"TrustedQSL",
"TTWL",
"TTYP0",
"TU-Berlin-1.0",
"TU-Berlin-2.0",
"Ubuntu-font-1.0",
"UCAR",
"UCL-1.0",
"ulem",
"UMich-Merit",
"Unicode-3.0",
"Unicode-DFS-2015",
"Unicode-DFS-2016",
"Unicode-TOU",
"UnixCrypt",
"Unlicense",
"Unlicense-libtelnet",
"Unlicense-libwhirlpool",
"UnRAR",
"UPL-1.0",
"URT-RLE",
"Vim",
"Vixie-Cron",
"VOSTROM",
"VSL-1.0",
"W3C",
"W3C-19980720",
"W3C-20150513",
"w3m",
"Watcom-1.0",
"Widget-Workshop",
"WordNet",
"Wsuipa",
"WTFNMFPL",
"WTFPL",
"wwl",
"wxWindows",
"X11",
"X11-distribute-modifications-variant",
"X11-no-permit-persons",
"X11-swapped",
"Xdebug-1.03",
"Xerox",
"Xfig",
"XFree86-1.1",
"xinetd",
"xkeyboard-config-Zinoviev",
"xlock",
"Xnet",
"xpp",
"XSkat",
"xzoom",
"YPL-1.0",
"YPL-1.1",
"Zed",
"Zeeff",
"Zend-2.0",
"Zimbra-1.3",
"Zimbra-1.4",
"Zlib",
"zlib-acknowledgement",
"ZPL-1.1",
"ZPL-2.0",
"ZPL-2.1"
],
"$comment": "Generated by scripts/update-licenses.mjs from the official SPDX License List (v3.28.0, 3.28.0, released 2026-02-20T00:00:00Z) — github.com/spdx/license-list-data, the SPDX project's own repository. Includes deprecated IDs: SPDX states these remain valid, merely discouraged for new use. See CHANGES.log #P007."
},
{
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"}
}
]
},
"dataLicense": {
"type": "string",
"enum": [
"CC-BY-1.0",
"CC-BY-2.0",
"CC-BY-2.5",
"CC-BY-2.5-AU",
"CC-BY-3.0",
"CC-BY-3.0-AT",
"CC-BY-3.0-AU",
"CC-BY-3.0-DE",
"CC-BY-3.0-IGO",
"CC-BY-3.0-NL",
"CC-BY-3.0-US",
"CC-BY-4.0",
"CC-PDDC",
"CC-PDM-1.0",
"CC0-1.0",
"ODC-By-1.0",
"PDDL-1.0"
],
"$comment": "Generated by scripts/update-licenses.mjs, same source and release as $defs.license. The subset with no clause (NonCommercial, NoDerivatives, ShareAlike) that can block a directory from redistributing or transforming an event, and no software-copyleft ambiguity. Used only by the recommended (quality) profile, never validity. See CHANGES.log #P007 / DECISIONS.md D008."
},
"languageTag": {
"description": "The CORE of a BCP 47 (RFC 5646) language tag: language, with optional script, region and variant subtags (\"es\", \"ca\", \"en\", \"es-MX\", \"zh-Hant\", \"ca-valencia\"), each checked against the real IANA Language Subtag Registry. Deliberately does NOT accept extended language subtags, extension singletons (\"-u-...\"), private use (\"x-...\") or grandfathered tags (\"i-klingon\") — none has a real use case for the language of an event's text, and grandfathered tags are relics RFC 5646 itself deprecates in favour of the modern subtag form. Shared by languages (spoken at the event), textLanguage (the document's own text) and the keys of translations, so the three can never drift into three notions of what a language is.",
"type": "string",
"pattern": "^[A-Za-z]{2,8}(-[A-Za-z0-9]{2,8})*$",
"format": "ote-language-tag"
},
"languageMap": {
"description": "The shape every translations map shares, wherever it appears: keys are BCP 47 tags, and an empty map is invalid — saying nothing is already done by omitting the field, the same rule location follows. Defined once so the five maps of this spec can never drift into five notions of what a language key is.",
"type": "object",
"minProperties": 1,
"propertyNames": {
"$ref": "#/$defs/languageTag"
}
},
"translations": {
"description": "Free text in other languages, keyed by BCP 47 tag. A map and not a list because the language IS the key: one entry per language, and no way to publish two Spanish versions that contradict each other.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/translation"
}
},
"translation": {
"description": "One language's version of an event's OWN free text: name and description, the two fields every destination format prints. Not a mirror of the whole event — text that lives inside offers, eligibility or partOf is translated where it lives, by that object's own translations map. A positional mirror (translations.es.offers[0].name) is the one shape this spec refuses: a list has no stable keys, so reordering the offers would silently attach a translation to the wrong tier.",
"type": "object",
"minProperties": 1,
"properties": {
"name": {
"description": "The event's name in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Sesión semanal de programación — Rust Girona"
]
},
"description": {
"description": "The event's description in this language. Plain text or Markdown, like the field it translates.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Cada semana nos juntamos en línea para picar Rust un rato."
]
}
},
"anyOf": [
{
"description": "A translation entry is only a translation if it translates something OTE recognizes. Extension fields may still ride alongside name/description — this only forbids an entry whose entire content is unrecognized, which minProperties alone cannot catch.",
"required": [
"name"
]
},
{
"required": [
"description"
]
}
]
},
"feedTranslations": {
"description": "A feed's OWN title and description in other languages, keyed by BCP 47 tag. Never its events': each event carries its own translations, because each event has its own text and its own languages.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/feedTranslation"
}
},
"offerTranslations": {
"description": "This offer's name in other languages. Local to the offer, so no consumer has to line up two lists by position. It exists because offers[].name stays FREE TEXT on purpose: a kind enum (general, early-bird, student) would have been multilingual for free, and it would also have taken away the organiser's right to name their own tickets. Free text is the choice; translating it is the price.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/offerTranslation"
}
},
"offerTranslation": {
"description": "One language's version of an offer's free text. Only name: price is a number, currency is a code, availability is an enum — none of them has a language.",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"description": "This ticket's name in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Students",
"Estudiantes"
]
}
}
},
"eligibilityTranslations": {
"description": "This condition's note in other languages. Only note is translated: type is an enum, and an enum is multilingual for free — a consumer renders members-only in the reader's language without anyone translating the data.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/eligibilityTranslation"
}
},
"eligibilityTranslation": {
"description": "One language's version of the condition in words.",
"type": "object",
"required": [
"note"
],
"properties": {
"note": {
"description": "The condition, in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Miembros del Discord de Rust Girona"
]
}
}
},
"partOfTranslations": {
"description": "The series' or multi-part event's name in other languages. The id is never translated: it is an identifier, and translating it would split one series into two.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/partOfTranslation"
}
},
"partOfTranslation": {
"description": "One language's version of the series' display name.",
"type": "object",
"required": [
"name"
],
"properties": {
"name": {
"description": "The series' or multi-part event's name in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Sesión semanal de programación"
]
}
}
},
"feedTranslation": {
"description": "One language's version of a feed's own free text.",
"type": "object",
"minProperties": 1,
"properties": {
"title": {
"description": "The feed's title in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Rust Girona events"
]
},
"description": {
"description": "The feed's description in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Weekly online Rust coding sessions, in Catalan and Spanish."
]
}
},
"anyOf": [
{
"description": "Same rule as an event's own translation: extension fields may still ride alongside title/description, but an entry whose entire content is unrecognized is not a translation.",
"required": [
"title"
]
},
{
"required": [
"description"
]
}
]
},
"organizers": {
"description": "Who runs the event or the feed. Kept deliberately small: a name, and where to find them.",
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"$ref": "#/$defs/organizer"
}
},
"organizer": {
"type": "object",
"required": [
"name"
],
"$comment": "email is deliberately NOT in the recommended profile: an .ics importer does not always have it, and warning about a missing address would push someone into publishing contact data they chose not to publish.",
"properties": {
"name": {
"description": "Display name of the organiser.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"PyAlmería",
"Ada Lovelace"
]
},
"url": {
"description": "Where this organiser lives on the web — their own site, or their profile on the platform they publish from.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://pyalmeria.example",
"https://www.meetup.com/pyalmeria/"
]
},
"email": {
"description": "Address for enquiries about the event. A ROLE address (info@, hola@) rather than someone's personal mailbox: a feed is open and crawlable, and what goes in it cannot be unpublished. Optional and deliberately NOT recommended. It exists because without it there is no valid iCal ORGANIZER to emit (a CAL-ADDRESS is in practice a mailto:) and no RSS 2.0 , which requires an email. Written bare, without the mailto: prefix — the exporter adds it. Never populate it from a source that is not itself publicly published.",
"type": "string",
"format": "email",
"examples": [
"hola@pyalmeria.example",
"info@gdgmadrid.example"
]
},
"type": {
"description": "Organisation or person. A translator has to pick a schema.org @type either way, and Organization is the tolerant choice.",
"enum": [
"organization",
"person"
],
"default": "organization",
"examples": [
"organization",
"person"
]
}
}
},
"partOf": {
"description": "A reference to the whole this occurrence belongs to. Identity only — no dates: the occurrence already carries them.",
"type": "object",
"required": [
"id"
],
"properties": {
"id": {
"description": "Stable identifier of the series or multi-part event. Same rules as the event's id: an HTTP(S) URL under a domain the publisher controls — not necessarily one they own, a platform page (Meetup, GitHub Pages, LinkedIn) works too — minted once. It does NOT have to resolve to an OTE document — it is what lets a consumer group occurrences.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://rustmadrid.example/meetups",
"https://pyalmeria.example/study-jams/2026-testing"
]
},
"name": {
"description": "Display name of the series or multi-part event, so a consumer can group occurrences without resolving the id.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Rust Madrid — meetup mensual",
"Study Jam de testing en Python (3 sesiones)"
]
},
"url": {
"description": "Page describing the series or the multi-part event as a whole.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://rustmadrid.example/meetups"
]
},
"type": {
"description": "series: independent occurrences that share an identity (a monthly meetup). multipart: parts of ONE event held on non-consecutive dates (a three-session study jam on non-consecutive Saturdays, one registration). series is the tolerant choice, and the choice changes the translation: a series becomes schema.org EventSeries, a multi-part event becomes an Event whose parts are its subEvent.",
"enum": [
"series",
"multipart"
],
"default": "series",
"examples": [
"series",
"multipart"
]
},
"translations": {
"description": "The series' display name in other languages. Its id stays untranslated — an identifier with two spellings is two series.",
"$ref": "#/$defs/partOfTranslations",
"examples": [
{
"es": {
"name": "Sesión semanal de programación"
}
}
]
}
}
},
"location": {
"description": "What is KNOWN about where the event happens. Not the same question as attendanceMode, which states the organiser's intent.",
"type": "object",
"properties": {
"venue": {
"description": "Human-readable physical location, in one line of free text: the name of the place plus as much address as it takes to get there — how much is your call. Its presence means the event has a physical venue. It is not made redundant by address, because joining address's parts back up never gives you the name: a PostalAddress has no field for \"El Cable\" or \"Campus Madrid\", and the name is what people navigate by. In schema.org it is Place.name, a sibling of Place.address.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"El Cable, Almería",
"Campus Madrid, Calle de Moreno Nieto 2, Madrid"
]
},
"address": {
"description": "Postal address of the physical venue, in parts. COMPLEMENTS venue, never replaces it: venue is the one string every format can print, address is what a translator needs to emit a schema.org PostalAddress — whose subfields Google validates one by one for the Event rich result. Every part is optional; leave out what you do not know. An absent key means unknown; \"\" or null publish 'unknown' as if it were data, which is the one thing worse than saying nothing.",
"$ref": "#/$defs/address",
"examples": [
{
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"postalCode": "28005",
"country": "ES"
},
{
"locality": "Almería",
"country": "ES"
}
]
},
"onlineUrl": {
"description": "URL to attend online. Its presence means the event has online access.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://meet.example/pyalmeria"
]
},
"geo": {
"description": "Coordinates of the physical venue (WGS-84 decimal degrees). Independent of venue, which is free text — a point, not a name. Maps to iCal GEO and schema.org Place.geo (GeoCoordinates).",
"type": "object",
"required": [
"lat",
"lon"
],
"properties": {
"lat": {
"description": "Latitude in decimal degrees.",
"type": "number",
"minimum": -90,
"maximum": 90,
"examples": [
40.4168
]
},
"lon": {
"description": "Longitude in decimal degrees.",
"type": "number",
"minimum": -180,
"maximum": 180,
"examples": [
-3.7038
]
}
}
}
},
"anyOf": [
{
"required": [
"venue"
]
},
{
"required": [
"onlineUrl"
]
}
]
},
"address": {
"description": "A postal address in parts, mapped 1:1 onto schema.org PostalAddress. Five fields, all optional, none of them free-form enough to be a second venue: this is the machine-readable half of a location, not a prettier one.",
"type": "object",
"minProperties": 1,
"properties": {
"street": {
"description": "Street and number, as written locally. May carry a floor or a unit; it is one line of text, not a sub-object.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Calle de Moreno Nieto 2",
"100 West Snickerpark Dr"
]
},
"locality": {
"description": "City, town or village.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Madrid",
"Almería"
]
},
"region": {
"description": "Province, state or autonomous community — whatever level sits between locality and country in that country. Free text or an ISO 3166-2 code; both travel to schema.org addressRegion unchanged.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Comunidad de Madrid",
"PA"
]
},
"postalCode": {
"description": "Postal code, as the local post office writes it. A string, never a number: leading zeros are part of it.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"28005",
"19019"
]
},
"country": {
"description": "A real, currently-assigned ISO 3166-1 alpha-2 code (ES, US, MX), never an invented, reserved or former one. A code and not a country name, because the name has one spelling per language: \"España\", \"Spain\" and \"Espagne\" are the same country, and a consumer grouping events by country would see three. Turning a name into a code is a table lookup, not an invention — which is why the spec asks for it here and nowhere else. Common mistake: the UK is GB, not UK — \"UK\" is not an ISO 3166-1 code.",
"$ref": "#/$defs/country",
"examples": [
"ES",
"US"
]
}
}
},
"eligibility": {
"description": "The door: whether there is a condition to get in, and what it is. An enum so a consumer can filter on it, plus a note so a human can read the part no enum can carry. It models the CONDITION, not the ticketing: no capacity, no seats left, no per-attendee approval state.",
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"description": "The kind of door. open: anyone may attend — including an event that sells tickets or runs out of seats, because a price and a capacity are not conditions on WHO you are. members-only: you have to belong to something first. approval-required: you sign up and the organiser DECIDES — Luma's request-to-approve, a Meetup group with an admission question, a workshop that picks a cohort. It is about a judgement on the person, never about capacity: first-come-first-served with limited seats is open, and the seats running out is offers[].availability. restricted: there IS a condition and none of the other values names it — say which in `note`, which is why the schema demands it there. Four values, kept small on purpose: a consumer that has to handle twenty doors handles none. invite-only is deliberately NOT one of them, see the spec prose.",
"enum": [
"open",
"members-only",
"approval-required",
"restricted"
],
"examples": [
"open",
"members-only",
"approval-required"
]
},
"note": {
"description": "The condition in words, for a person to read: which community, which university, which company. REQUIRED when type is restricted, because \"restricted\" on its own tells nobody anything; worth writing whenever the enum value alone leaves a question. This is the part that survives export to every format, inside the text.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Miembros del Discord de Rust Girona",
"Solo alumnado de la Universidad de Almería"
]
},
"url": {
"description": "Where the condition is explained or met: the page to join the community, request an invitation, apply. Distinct from offers[].url, which is where a seat or money changes hands — here nothing is bought, a door is opened.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://rustgirona.example/join"
]
},
"translations": {
"description": "The note in other languages. type needs none: an enum carries no language, and a consumer renders it in the reader's.",
"$ref": "#/$defs/eligibilityTranslations",
"examples": [
{
"es": {
"note": "Miembros del Discord de Rust Girona"
}
}
]
}
},
"allOf": [
{
"description": "restricted means \"there is a door the enum cannot name\". Without a note it names nothing, and a consumer can only show the word itself — which is how a field meant to answer \"can I go?\" ends up asking it.",
"if": {
"type": "object",
"required": [
"type"
],
"properties": {
"type": {
"const": "restricted"
}
}
},
"then": {
"type": "object",
"required": [
"note"
]
}
}
]
},
"images": {
"description": "The event's images, in preference order. Two item forms on purpose: a bare URL string, which is what every 0.2 document already contains and keeps validating unchanged, and an object that adds alt text. Mixing them in one list is legal and expected — the primary image earns its alt, the extra crops of it do not need one.",
"type": "array",
"minItems": 1,
"items": {
"oneOf": [
{
"type": "string",
"format": "uri",
"pattern": "^https://",
"not": {"pattern": "^https?://[^/?#]*@"}
},
{
"$ref": "#/$defs/imageEntry"
}
]
}
},
"imageEntry": {
"description": "One image with its description. The alt travels attached to its own URL and not in a sibling field of the event, because the entries of the list are not guaranteed to be the same picture: one alt for the whole list would describe the first image and be applied to the third. Same reason there is no positional mirror in translations — a list has no stable keys.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"description": "Absolute https URL of the image file itself, never of a page showing it. The same value the bare-string form carries.",
"type": "string",
"format": "uri",
"pattern": "^https://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://rustmadrid.example/img/2026-06-16x9.png"
]
},
"alt": {
"description": "What the image SHOWS, for whoever cannot see it: screen readers, text-only clients, and any render where the image fails to load. Describe the picture, not the event — the name and description are already being read out next to it, so repeating them makes a screen reader say the same thing twice. Skip \"image of\" or \"poster showing\": the client already announces it is an image. Written in the document's textLanguage, like every other free text here, and translated by this entry's own translations map: alt is read aloud with the pronunciation of the surrounding language, so an English alt inside a Spanish document is worse for accessibility than the problem it was meant to solve. Purely decorative images have no place in a feed and no empty string here — an image with nothing to say is an image left out.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"maxLength": 250,
"examples": [
"Cartel: Ferris sobre fondo morado, «Rust Madrid · 16 junio · Impact Hub»",
"Sala diáfana con unas 60 sillas y una pantalla al fondo"
]
},
"translations": {
"description": "This image's alt text in other languages. Local to the image it describes — never a positional mirror of the image list. Requires the document's textLanguage, like every other translations map.",
"$ref": "#/$defs/imageTranslations",
"examples": [
{
"en": {
"alt": "Poster: Ferris on a purple background, “Rust Madrid · June 16 · Impact Hub”"
}
}
]
}
},
"dependentRequired": {
"translations": [
"alt"
]
}
},
"imageTranslations": {
"description": "This image's alt text in other languages. Only alt is translated: the url is a file, and a file has no language.",
"type": "object",
"allOf": [
{
"$ref": "#/$defs/languageMap"
}
],
"additionalProperties": {
"$ref": "#/$defs/imageTranslation"
}
},
"imageTranslation": {
"description": "One language's version of what the image shows.",
"type": "object",
"required": [
"alt"
],
"properties": {
"alt": {
"description": "What the image shows, in this language.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"maxLength": 250,
"examples": [
"Poster: Ferris on a purple background, “Rust Madrid · June 16 · Impact Hub”"
]
}
}
},
"offers": {
"description": "Ways of attending, with their price. A list because an event may sell several kinds of ticket, and because the answer to \"is it free?\" has to survive an event that is free for students and paid for everyone else.",
"type": "array",
"minItems": 1,
"items": {
"$ref": "#/$defs/offer"
}
},
"offer": {
"description": "One way of attending: a price, a place to get it, or both. Maps 1:1 onto a schema.org Offer, which is what Google reads to show a price. It models the ticket, not the ticketing: no capacity, no seats left, no per-ticket registration state.",
"type": "object",
"properties": {
"name": {
"description": "What this ticket is called (\"General admission\", \"Estudiantes\"). Worth writing when there is more than one offer, noise when there is only one.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"General admission",
"Estudiantes"
]
},
"price": {
"description": "Amount per attendee, in `currency`. 0 means free — and it is the ONLY way to say free: an absent offers list means the price is unknown. A number, never text: no currency symbol, no thousands separator, no range and no \"desde\", because the whole reason to publish a price as data is that someone can filter and compare on it. A price that cannot be written as one number is several offers.",
"type": "number",
"minimum": 0,
"examples": [
0,
45,
12.5
]
},
"currency": {
"description": "A real ISO 4217 alpha-3 code (EUR, USD, MXN), never an invented one. Only meaningful alongside `price` — it names what price is denominated in, and nothing else — so it requires `price` to be present at all, whatever its value. Required whenever `price` is above 0, and pointless at 0: free is free in every currency, and emitting one there is how Luma ends up publishing a currency for a meetup that costs nothing.",
"$ref": "#/$defs/currency",
"examples": [
"EUR",
"USD"
]
},
"url": {
"description": "Where to buy the ticket or register for this particular offer. Distinct from the event's own url: that page describes the event, this one is where money or a seat changes hands. Omit it when registration happens on the event page itself.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://devfest-levante.example/2026/entradas"
]
},
"availability": {
"description": "Whether this offer can still be taken. Absent never means available — a stale feed that keeps claiming in-stock is worse than one that says nothing. Only the two states an attendee can act on are modelled.",
"enum": [
"in-stock",
"sold-out"
],
"examples": [
"in-stock",
"sold-out"
]
},
"waitlistUrl": {
"description": "Where to join the queue for this offer once it is gone. It exists so \"gone, nothing to do\" and \"gone, but you can queue\" stop being the same document — the third thing an attendee can act on, and the reason it is a URL and not a third availability value: every consumer that already exists keeps reading sold-out, which is TRUE, instead of meeting an enum value it cannot interpret. Distinct from url, where the ticket is bought: nothing is bought in a queue.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://devfest-levante.example/2026/lista-espera"
]
},
"opensAt": {
"description": "When this offer goes on sale. An INSTANT, with offset or Z — unlike the event's own dates, which are wall clock: a sale opening is a moment a button starts working, not an hour on a poster.",
"$ref": "#/$defs/instant",
"examples": [
"2026-05-01T10:00:00+02:00"
]
},
"closesAt": {
"description": "When this offer stops being available. An INSTANT, with offset or Z, for the same reason as opensAt — and because \"23:59\" without an offset is the classic deadline bug.",
"$ref": "#/$defs/instant",
"examples": [
"2026-07-31T23:59:59+02:00"
]
},
"translations": {
"description": "This offer's name in other languages. Local to the offer it belongs to — never a positional mirror of the offers list. Requires the document's textLanguage, like every other translations map.",
"$ref": "#/$defs/offerTranslations",
"examples": [
{
"en": {
"name": "Students"
}
}
]
}
},
"anyOf": [
{
"description": "An offer must carry a price or a link — ideally both. One with neither says nothing that omitting the whole list does not already say, the same rule location follows with venue and onlineUrl.",
"required": [
"price"
]
},
{
"required": [
"url"
]
}
],
"dependentRequired": {
"currency": [
"price"
]
},
"orderedInstants": true,
"allOf": [
{
"description": "A waitlist for something you can still buy is not a waitlist: the queue only makes sense once the offer is gone. Availability may still be ABSENT — a publisher who knows there is a queue and does not track the ticket state should not be forced to assert sold-out to mention it. Only the incoherent combination is rejected, never the incomplete one.",
"if": {
"type": "object",
"required": [
"waitlistUrl"
]
},
"then": {
"type": "object",
"properties": {
"availability": {
"not": {
"const": "in-stock"
}
}
}
}
},
{
"description": "A non-zero amount without a currency is not a price: 45 is a different thing in EUR, USD and MXN, and a consumer that has to guess will guess its own.",
"if": {
"type": "object",
"required": [
"price"
],
"properties": {
"price": {
"type": "number",
"exclusiveMinimum": 0
}
}
},
"then": {
"type": "object",
"required": [
"currency"
]
}
}
]
},
"cfp": {
"description": "An open call for proposals. One per event: unlike organizers, no real producer publishes more than one — the CFP directories that exist (confs.tech, developers.events, Sessionize) all model exactly one link and one deadline. Deliberately small: it says where to submit and until when, not what a submission looks like.",
"type": "object",
"required": [
"url"
],
"orderedInstants": true,
"properties": {
"url": {
"description": "Where proposals are submitted — the form, or the page describing the call. Required: a CFP nobody can find is not a call, and this is the one piece of it that survives export to every format, inside the text.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://devfest-levante.example/2026/cfp"
]
},
"opensAt": {
"description": "When the call starts accepting proposals. An INSTANT, with offset or Z. Absent means it is already open — a call that has not opened yet is announced, not published.",
"$ref": "#/$defs/instant",
"examples": [
"2026-05-01T00:00:00+02:00"
]
},
"closesAt": {
"description": "Deadline for proposals. An INSTANT, with offset or Z, never a bare \"23:59\": which midnight it is is the whole question, and \"anywhere on Earth\" is a real answer (-12:00) that a wall-clock field could not express. Absent means unknown, not open forever — and it is what a consumer needs to answer \"which CFPs are open right now\".",
"$ref": "#/$defs/instant",
"examples": [
"2026-07-15T23:59:59+02:00",
"2026-07-15T23:59:59-12:00"
]
},
"coversTravel": {
"description": "Whether the event covers a selected speaker's travel. Absent never means false. It is here and \"call for sponsors\" is not because this is what a speaker filters on before deciding whether they can afford to submit.",
"type": "boolean",
"examples": [
true
]
},
"coversAccommodation": {
"description": "Whether the event covers a selected speaker's accommodation. Same rule as coversTravel: absent means unknown.",
"type": "boolean",
"examples": [
true
]
}
}
},
"source": {
"type": "object",
"anyOf": [
{
"description": "Provenance has to point somewhere: a name a person can read, a URL a machine can follow, ideally both. Either alone is enough, and demanding the name would be worse than accepting the URL — an importer of an `.ics` always knows the address it fetched and often has no publisher name to read (iCalendar's X-WR-CALNAME is optional), so the requirement would be met by inventing one. A fabricated origin is worse than an origin given only as a link. Same rule as offers with price and url, and location with venue and onlineUrl.",
"required": [
"name"
]
},
{
"required": [
"url"
]
}
],
"properties": {
"name": {
"description": "Name of the origin (e.g. \"Rust Madrid\", \"Meetup\"), as a person would read it. Write it whenever the origin has a name of its own: a consumer showing where the data came from can derive a label from `url` (its host), but a derived label is a guess.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Rust Madrid",
"Meetup"
]
},
"url": {
"description": "Link to the original record, so the data can be verified and corrected upstream.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://calendar.example/ics/rust-madrid"
]
},
"license": {
"description": "License under which the ORIGIN publishes the data. Constrains what may be republished: declaring a license does not grant rights the origin never gave.",
"$ref": "#/$defs/license",
"examples": [
"CC-BY-4.0"
]
},
"retrievedAt": {
"description": "When the data was fetched.",
"$ref": "#/$defs/instant",
"examples": [
"2026-06-01T05:00:00Z"
]
}
}
}
}
}
```
## feed.schema.json
Source: spec/v0.3/feed.schema.json — JSON Schema for a feed of events.
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://opentechevents.org/schema/v0.3/feed.schema.json",
"title": "OTE Feed",
"description": "A collection of OTE events published at a stable URL. An exchange format, not an API.",
"type": "object",
"required": [
"specVersion",
"title",
"updatedAt",
"events"
],
"properties": {
"specVersion": {
"description": "Version of OTE Spec this feed adheres to. Applies to every event in it.",
"const": "0.3.0",
"examples": [
"0.3.0"
]
},
"title": {
"description": "Human-readable name of the feed.",
"type": "string",
"minLength": 1,
"pattern": "\\S",
"examples": [
"Eventos de PyAlmería"
]
},
"description": {
"description": "Short description of the feed.",
"type": "string",
"examples": [
"Meetups mensuales de Python en Almería."
]
},
"url": {
"description": "Canonical URL of the community, directory or organisation publishing the feed.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://pyalmeria.example"
]
},
"textLanguage": {
"description": "Language this feed's own free text is written in — title and description — and the default every event inherits when it omits its own. What makes it cheap: a monolingual publisher declares it once for the whole file and no event repeats it. Not the same as organizers: an aggregator whose events don't share one language must leave this out, exactly as it must leave out organizers, so each event states its own instead of inheriting the wrong one. Absent means unknown, never English and never the language of the HTTP response.",
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/languageTag",
"examples": [
"es",
"ca"
]
},
"organizers": {
"description": "Who runs the events in this feed. Not the same as title/url, which name whoever publishes the feed: an aggregator publishes events it does not organise, and must leave this out so each event states its own.",
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/organizers",
"examples": [
[
{
"name": "PyAlmería",
"url": "https://pyalmeria.example"
}
]
]
},
"license": {
"description": "License for the feed's contents, and the default every event inherits when it omits its own. Optional only for an aggregator whose events carry different licenses: if this is absent, every event in the feed must declare its own license — no valid OTE document, standalone or inside any feed, may resolve to an unknown license. SPDX identifier (full list at https://spdx.org/licenses/) or URL.",
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/license",
"examples": [
"CC-BY-4.0",
"CC0-1.0"
]
},
"licenseUrl": {
"description": "URL of the full license text.",
"type": "string",
"format": "uri",
"pattern": "^https?://",
"not": {"pattern": "^https?://[^/?#]*@"},
"examples": [
"https://creativecommons.org/licenses/by/4.0/"
]
},
"updatedAt": {
"description": "When this feed was generated. Never earlier than any event's own updatedAt — a feed cannot contain a revision that, by its own timestamps, didn't exist yet when it was generated.",
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/instant",
"examples": [
"2026-07-06T10:00:00Z"
]
},
"translations": {
"description": "This feed's own title and description in other languages. Its EVENTS are not translated here: each one carries its own translations, and unlike license or textLanguage this field is never inherited — a feed's title is not an event's name. Requires textLanguage, like an event's translations do — see the document's constraints.",
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/feedTranslations",
"examples": [
{
"en": {
"title": "Rust Girona events",
"description": "Weekly online Rust coding sessions, in Catalan and Spanish."
}
}
]
},
"events": {
"description": "Events in this feed. Each one inherits the feed's specVersion and license unless it declares its own. No two may share an id — see the document's constraints.",
"type": "array",
"items": {
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/event"
}
}
},
"distinctTranslationLanguages": true,
"uniqueEventIds": true,
"eventsNotNewerThanFeed": true,
"eventsRespectInheritedTextLanguage": true,
"allOf": [
{
"description": "Same rule as an event: a map of translations is unusable without knowing which language the primary text is in. A feed that translates its title must say what language that title is in.",
"if": {
"type": "object",
"required": [
"translations"
]
},
"then": {
"type": "object",
"required": [
"textLanguage"
]
}
},
{
"description": "license is the one default this spec never lets go missing by omission: an aggregator MAY leave feed.license out precisely because its events carry different licenses, but only if every event then declares its own — the same disciplined-omission pattern organizers/textLanguage already use for an aggregator, applied to a guarantee that is deliberately stricter than attribution or language, because redistributing data under an unknown license is a real legal risk, not just an editorial gap. CHANGES.log #P032 / DECISIONS.md D029.",
"if": {
"type": "object",
"not": {
"required": [
"license"
]
}
},
"then": {
"type": "object",
"properties": {
"events": {
"type": "array",
"items": {
"type": "object",
"required": [
"license"
]
}
}
}
}
}
]
}
```
## event.recommended.schema.json
Source: spec/v0.3/event.recommended.schema.json — Profile: what makes an event findable and filterable. Failing it is a warning, never an error.
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://opentechevents.org/schema/v0.3/event.recommended.schema.json",
"title": "OTE Event — recommended profile",
"description": "A quality profile, NOT a validity profile. An event that fails this schema is still a valid OTE event: event.schema.json decides what is valid, and it is deliberately permissive because most published .ics files carry neither URL nor description. This schema decides something else — whether the event can actually be discovered, filtered and subscribed to. Tools SHOULD report failures here as warnings and MUST NOT reject a document for them. It applies both to a standalone event and to one inside a feed: it references #/$defs/event, so it never asks for specVersion or license.",
"type": "object",
"languagesCoveredByText": true,
"allOf": [
{
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/event"
},
{
"description": "Without these, the event is published but not findable: url is the only link RSS/Atom has, description is what every destination shows, image is what makes the event visible where it is listed (Google recommends it for the Event rich result, and every platform studied already emits one), organizers is who is trusted for it, attendanceMode and location answer 'can I go?', tags and languages are what someone filters by, and updatedAt is what lets a subscriber sync incrementally instead of refetching everything.",
"type": "object",
"required": [
"url",
"description",
"image",
"organizers",
"attendanceMode",
"location",
"tags",
"languages",
"updatedAt"
]
},
{
"description": "cfp.closesAt is recommended once there IS a call for proposals, and meaningless otherwise. The reason cfp exists is the question 'which conferences are accepting proposals right now', and without a deadline nobody can answer it: a consumer sees a link and cannot tell whether it closed last March. The warning is actionable by definition — whoever opened the call knows when it closes.",
"if": {
"type": "object",
"required": [
"cfp"
]
},
"then": {
"type": "object",
"properties": {
"cfp": {
"type": "object",
"required": [
"closesAt"
]
}
}
}
},
{
"description": "endDate is recommended only for a timed event: without it a calendar client invents a duration. For an all-day event its absence already means 'ends the day it starts', which is almost always right — so asking for it there would be noise, and a warning nobody can act on is a warning that gets ignored.",
"if": {
"type": "object",
"required": [
"startDate"
],
"properties": {
"startDate": {
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/dateTime"
}
}
},
"then": {
"type": "object",
"required": [
"endDate"
]
}
},
{
"description": "license SHOULD carry no clause that can stop a directory or aggregator from redistributing or transforming the event: NonCommercial rules out any commercial directory outright, NoDerivatives blocks the reformatting/translation an aggregator routinely does, and ShareAlike (including ODbL) is viral for a COMBINED database — merging one event into a larger feed could force the whole feed to adopt that license. Software copyleft licenses (GPL and family) are excluded from the recommendation too: their mechanics are built around 'distributing the Program', legally murky applied to a JSON document, and that ambiguity alone is enough for a cautious directory to decline rather than risk it. None of this makes the license INVALID — event.schema.json already checks it is a real SPDX identifier or a URL — it only means a directory integration may need a conversation first. A URL is exempt: it names its own terms explicitly, which is the point of offering that alternative.",
"type": "object",
"properties": {
"license": {
"anyOf": [
{
"$ref": "https://opentechevents.org/schema/v0.3/event.schema.json#/$defs/dataLicense"
},
{
"type": "string",
"format": "uri",
"pattern": "^https?://"
}
]
}
}
},
{
"description": "description is only useful if it says something: event.schema.json deliberately leaves it unconstrained beyond being a string (an optional field has no minimum bar to clear, and forcing one would just move the required-field problem this profile already avoids elsewhere), but a document that carries an empty or whitespace-only description looks complete to this profile's own required check while giving a consumer nothing to show — worse than omitting it, which at least triggers this same warning honestly. CHANGES.log #P017.",
"type": "object",
"properties": {
"description": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
},
{
"description": "attendanceMode and location are deliberately independent fields — README.md says location is observable fact, attendanceMode is organiser intent, and if they disagree attendanceMode wins — so this is not a validity rule and never overrides that precedence. It only flags that the detail a consumer needs to act on the declared mode is missing: online without location.onlineUrl cannot produce a schema.org VirtualLocation.url, in-person without location.venue cannot produce a Place.name, and hybrid needs both halves of a MixedEventAttendanceMode. Silent when location itself is absent, since the required-fields warning above already covers that case. CHANGES.log #P028.",
"if": {
"type": "object",
"required": [
"attendanceMode",
"location"
],
"properties": {
"attendanceMode": {
"const": "online"
}
}
},
"then": {
"type": "object",
"properties": {
"location": {
"type": "object",
"required": [
"onlineUrl"
]
}
}
}
},
{
"description": "See the 'online' rule above for why this is a warning, not a validity check. in-person without location.venue cannot produce a schema.org Place.name. CHANGES.log #P028.",
"if": {
"type": "object",
"required": [
"attendanceMode",
"location"
],
"properties": {
"attendanceMode": {
"const": "in-person"
}
}
},
"then": {
"type": "object",
"properties": {
"location": {
"type": "object",
"required": [
"venue"
]
}
}
}
},
{
"description": "See the 'online' rule above for why this is a warning, not a validity check. hybrid needs both halves of a schema.org MixedEventAttendanceMode: location.venue for the in-person half and location.onlineUrl for the online half. CHANGES.log #P028.",
"if": {
"type": "object",
"required": [
"attendanceMode",
"location"
],
"properties": {
"attendanceMode": {
"const": "hybrid"
}
}
},
"then": {
"type": "object",
"properties": {
"location": {
"type": "object",
"required": [
"venue",
"onlineUrl"
]
}
}
}
},
{
"description": "offers[].name is only recommended once a translation actually translates one: name itself stays fully optional (worth writing when there is more than one offer, noise when there is only one), but a producer who already wrote offers[].translations.*.name has demonstrated the name exists — leaving the primary blank then loses it for any consumer who reads offers[] directly (schema.org, RSS/iCal) instead of translations. CHANGES.log #P033 / DECISIONS.md D030.",
"type": "object",
"properties": {
"offers": {
"type": "array",
"items": {
"type": "object",
"if": {
"type": "object",
"required": [
"translations"
]
},
"then": {
"type": "object",
"required": [
"name"
]
}
}
}
}
},
{
"description": "See the offers[].name rule above for the same reasoning, applied to partOf.name: a producer who wrote partOf.translations.*.name has demonstrated the series/multipart event has a name, so leaving partOf.name itself blank loses it for any consumer who does not read translations. CHANGES.log #P033 / DECISIONS.md D030.",
"if": {
"type": "object",
"required": [
"partOf"
],
"properties": {
"partOf": {
"type": "object",
"required": [
"translations"
]
}
}
},
"then": {
"type": "object",
"properties": {
"partOf": {
"type": "object",
"required": [
"name"
]
}
}
}
},
{
"description": "See the offers[].name rule above for the same reasoning, applied to eligibility.note: a producer who wrote eligibility.translations.*.note has demonstrated the condition is stated somewhere, so leaving eligibility.note itself blank loses it for any consumer who does not read translations. Already a base-schema error when type is restricted (event.schema.json); this only adds the warning for the other eligibility types, where note stays optional. CHANGES.log #P033 / DECISIONS.md D030.",
"if": {
"type": "object",
"required": [
"eligibility"
],
"properties": {
"eligibility": {
"type": "object",
"required": [
"translations"
]
}
}
},
"then": {
"type": "object",
"properties": {
"eligibility": {
"type": "object",
"required": [
"note"
]
}
}
}
}
]
}
```
## feed.recommended.schema.json
Source: spec/v0.3/feed.recommended.schema.json — Profile: what makes a feed subscribable.
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://opentechevents.org/schema/v0.3/feed.recommended.schema.json",
"title": "OTE Feed — recommended profile",
"description": "A quality profile, NOT a validity profile. A feed that fails this schema is still a valid OTE feed. Tools SHOULD report failures here as warnings and MUST NOT reject a document for them. Deliberately short: a feed's job is to carry events, so nearly all of the quality lives in event.recommended.schema.json — which a checker applies to each entry of `events` separately.",
"allOf": [
{
"$ref": "https://opentechevents.org/schema/v0.3/feed.schema.json"
},
{
"description": "url is where the publisher lives, and it is what a consumer shows to say where these events came from. description is what a directory lists the feed under. `organizers` is NOT recommended, on purpose: an aggregator MUST leave it out so each event states its own, and a warning that pushes an aggregator to claim events it does not organise would corrupt the very data the field exists to protect.",
"type": "object",
"required": [
"url",
"description"
]
},
{
"description": "textLanguage SHOULD only be set when the feed also names its own organizers — the same field that already tells an aggregator to leave organizers out because its events are not all its own. Without organizers, textLanguage risks handing every event a single language none of them may actually share. Not a validity error: it is not this field's job to decide who is or is not an aggregator, only to flag the one combination most likely to mis-attribute a language. See CHANGES.log #P015 / DECISIONS.md D016.",
"if": {
"type": "object",
"not": {
"required": [
"organizers"
]
}
},
"then": {
"type": "object",
"properties": {
"textLanguage": {
"not": {}
}
}
}
},
{
"description": "description is only useful if it says something: feed.schema.json deliberately leaves it unconstrained beyond being a string, but a feed that carries an empty or whitespace-only description looks complete to this profile's own required check while giving a consumer nothing to show — worse than omitting it, which at least triggers this same warning honestly. CHANGES.log #P017.",
"type": "object",
"properties": {
"description": {
"type": "string",
"minLength": 1,
"pattern": "\\S"
}
}
}
]
}
```
==============================================================================
SECTION: Examples
==============================================================================
## event-all-day.json
Source: spec/v0.3/examples/event-all-day.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://devfest-levante.example/2026",
"url": "https://devfest-levante.example/2026",
"name": "DevFest Levante 2026",
"description": "Conferencia de desarrollo del sureste, edición 2026.",
"image": [
"https://devfest-levante.example/img/2026-16x9.png"
],
"organizers": [
{ "name": "GDG Levante", "url": "https://gdglevante.example" }
],
"startDate": "2026-10-16",
"endDate": "2026-10-17",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Las Naves, Valencia",
"address": {
"street": "Carrer de Joan Verdeguer 16",
"locality": "València",
"postalCode": "46024",
"country": "ES"
}
},
"tags": ["cloud", "ai", "web"],
"languages": ["es", "en"],
"status": "scheduled",
"license": "CC-BY-4.0",
"updatedAt": "2026-05-04T09:00:00Z"
}
```
## event-co-organized.json
Source: spec/v0.3/examples/event-co-organized.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://gdgmadrid.example/eventos/2026-09-devfest-warmup",
"url": "https://gdgmadrid.example/eventos/2026-09-devfest-warmup",
"name": "DevFest Warm-up — GDG Madrid × Python Madrid",
"description": "Sesión conjunta de calentamiento para el DevFest, con charla invitada.",
"image": [
"https://gdgmadrid.example/img/2026-09-devfest-warmup.png"
],
"_comment_organizers": "Tres organizadores: dos comunidades y una persona. El primero es el principal, y el orden es significativo porque iCal solo puede expresar UNO: aquí ese primero lleva `email`, así que sí se emite un ORGANIZER válido (`ORGANIZER;CN=\"GDG Madrid\":mailto:hola@gdgmadrid.example`) y los otros dos se degradan a X-OTE-ORGANIZER. El email es de ROL, no de nadie: fíjate en que la persona de la lista no lleva. schema.org y Atom aceptan la lista entera.",
"_comment_extensions": "`combuilders:communityId` NO es un campo de OTE: es vocabulario de otro proyecto (un directorio de comunidades), y por eso lleva prefijo. OTE se compromete a no acuñar nunca nombres de campo con `:`, así que no puede colisionar con un campo del núcleo. Un consumidor de OTE lo ignora; el directorio lo lee. Ver la sección «Extensiones» del README.",
"organizers": [
{
"name": "GDG Madrid",
"url": "https://gdgmadrid.example",
"email": "hola@gdgmadrid.example",
"combuilders:communityId": "gdg-madrid"
},
{ "name": "Python Madrid", "url": "https://www.meetup.com/python-madrid/" },
{ "type": "person", "name": "Ada Lovelace", "url": "https://ada.example" }
],
"startDate": "2026-09-24T19:00",
"endDate": "2026-09-24T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"postalCode": "28005",
"country": "ES"
}
},
"tags": ["python", "cloud"],
"languages": ["es"],
"license": "CC-BY-4.0",
"updatedAt": "2026-09-02T17:20:00Z"
}
```
## event-conference-cfp.json
Source: spec/v0.3/examples/event-conference-cfp.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://devfest-levante.example/2026",
"url": "https://devfest-levante.example/2026",
"name": "DevFest Levante 2026",
"description": "Conferencia de desarrollo del sureste, edición 2026. Dos días, tres tracks.",
"image": [
"https://devfest-levante.example/img/2026-16x9.png"
],
"organizers": [
{ "name": "GDG Levante", "url": "https://gdglevante.example" }
],
"startDate": "2026-10-16",
"endDate": "2026-10-17",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Las Naves, Carrer de Joan Verdeguer 16, València",
"address": {
"street": "Carrer de Joan Verdeguer 16",
"locality": "València",
"region": "Comunitat Valenciana",
"postalCode": "46024",
"country": "ES"
}
},
"tags": ["cloud", "ai", "web"],
"languages": ["es", "en"],
"textLanguage": "es",
"offers": [
{
"name": "Early bird",
"price": 35,
"currency": "EUR",
"url": "https://devfest-levante.example/2026/entradas",
"availability": "sold-out",
"closesAt": "2026-06-30T23:59:59+02:00"
},
{
"name": "General",
"price": 45,
"currency": "EUR",
"url": "https://devfest-levante.example/2026/entradas",
"availability": "in-stock",
"opensAt": "2026-07-01T00:00:00+02:00"
},
{
"name": "Estudiantes",
"price": 0,
"url": "https://devfest-levante.example/2026/entradas#estudiantes",
"availability": "sold-out",
"waitlistUrl": "https://devfest-levante.example/2026/lista-espera",
"translations": {
"en": { "name": "Students" }
}
}
],
"_comment_waitlist": "Las plazas gratuitas de estudiantes se agotaron, y hay cola. `sold-out` + `waitlistUrl` en vez de un `availability: \"waitlist\"`: así un consumidor que no conozca el campo sigue leyendo «agotado», que es cierto — no puedes comprar —, mientras el que lo conozca ofrece la cola. `price: 0` con `sold-out` no es contradictorio: `price` describe el trato, `availability` si puedes actuar sobre él ahora. Y `waitlistUrl` con `in-stock` lo rechaza el schema: una cola para algo que está a la venta no es una cola.",
"_comment_translations": "`offers[].name` es texto LIBRE a propósito: quien organiza nombra sus entradas como quiera, y un enum se lo impediría. El precio de esa libertad es que hay que traducirlo, y se traduce DENTRO de la oferta — no con un espejo posicional (`translations.es.offers[0].name`), que colgaría la traducción de la tarifa equivocada en cuanto alguien reordene la lista. Los demás campos de la oferta no llevan idioma: `price` es un número, `currency` un código y `availability` un enum. Cualquier mapa de traducciones del documento, a la profundidad que esté, exige `textLanguage`.",
"cfp": {
"url": "https://devfest-levante.example/2026/cfp",
"opensAt": "2026-05-01T00:00:00+02:00",
"closesAt": "2026-07-15T23:59:59+02:00",
"coversTravel": true,
"coversAccommodation": true
},
"status": "scheduled",
"license": "CC-BY-4.0",
"updatedAt": "2026-05-04T09:00:00Z",
"_comment_languages_translations": "languages declara \"es\" y \"en\" porque la conferencia se vive en los dos —tracks y networking en inglés—, pero antes de P019 el documento no traducía ni name ni description: el perfil recomendado no tenía forma de detectarlo, y quien solo lee inglés no encontraba ni una palabra que entendiera. Ver CHANGES.log #P019 / DECISIONS.md D019.",
"translations": {
"en": {
"name": "DevFest Levante 2026",
"description": "South-east dev conference, 2026 edition. Two days, three tracks."
}
}
}
```
## event-from-ics.json
Source: spec/v0.3/examples/event-from-ics.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://calendar.example/ics/rust-madrid#a1b2c3d4-uid-from-the-ics",
"name": "Rust Madrid — June meetup",
"description": "Talks on WASM and Rust tooling.",
"_comment_organizers": "El .ics traía ORGANIZER;CN=\"Rust Madrid\":mailto:hola@rustmadrid.example, y de ahí salen las dos cosas: nombre y email. Se copia porque este .ics está publicado en una URL pública y su licencia permite republicarlo (ver source); desde un calendario privado o un .ics compartido por enlace NO se copiaría, porque eso es cambiarle el nivel de exposición a una dirección que nadie publicó. Con el email, este evento puede volver a .ics con un ORGANIZER válido.",
"organizers": [
{ "name": "Rust Madrid", "email": "hola@rustmadrid.example" }
],
"startDate": "2026-06-26T19:00",
"endDate": "2026-06-26T21:00",
"timezone": "Europe/Madrid",
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"geo": { "lat": 40.4081, "lon": -3.7188 }
},
"tags": ["rust", "wasm"],
"languages": ["es"],
"license": "CC-BY-4.0",
"source": {
"name": "Rust Madrid",
"url": "https://calendar.example/ics/rust-madrid",
"license": "CC-BY-4.0",
"retrievedAt": "2026-06-01T05:00:00Z"
},
"updatedAt": "2026-06-10T18:00:00Z"
}
```
## event-hackathon.json
Source: spec/v0.3/examples/event-hackathon.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://hackalmeria.example/2026",
"url": "https://hackalmeria.example/2026",
"name": "HackAlmería 2026 — 36 horas de código",
"description": "Hackathon de fin de semana: se empieza el sábado a las 9:00 y se termina el domingo a las 21:00, sin parar. Comida, café y sitio para dormir incluidos.",
"image": [
"https://hackalmeria.example/img/2026-cartel.png"
],
"organizers": [
{ "name": "HackAlmería", "url": "https://hackalmeria.example" },
{ "name": "Universidad de Almería", "url": "https://ual.example" }
],
"_comment_dates": "Un hackathon SÍ es un evento continuo: empieza el sábado y no para hasta el domingo. Por eso lleva fecha-hora de inicio y fin cruzando la medianoche, y NO `partOf`: no son dos sesiones, es una. Compárese con feed-multipart.json, donde las fechas no son continuas y por eso son documentos distintos.",
"startDate": "2026-04-18T09:00",
"endDate": "2026-04-19T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Escuela de Ingeniería, Universidad de Almería",
"address": {
"street": "Carretera Sacramento s/n, La Cañada de San Urbano",
"locality": "Almería",
"region": "Andalucía",
"postalCode": "04120",
"country": "ES"
},
"geo": { "lat": 36.8283, "lon": -2.4055 }
},
"tags": ["hackathon", "open-source", "ai"],
"languages": ["es", "en"],
"status": "scheduled",
"license": "CC-BY-4.0",
"updatedAt": "2026-02-10T12:00:00Z"
}
```
## event-meetup.json
Source: spec/v0.3/examples/event-meetup.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://rustmadrid.example/meetups/2026-06",
"url": "https://rustmadrid.example/meetups/2026-06",
"name": "Rust Madrid — Meetup de junio",
"description": "Charlas sobre WASM y tooling de Rust.\n\n18:30 Puertas · 19:00 Charlas · 20:30 Networking",
"_comment_image": "Lista en orden de preferencia: quien solo pueda mostrar una, muestra la primera. Aquí las tres son el mismo cartel en tres recortes, así que solo la primera lleva `alt` — describir tres veces la misma imagen es ruido. El `alt` va pegado a su URL y no en un campo hermano del evento porque la lista NO garantiza que sea siempre el mismo cartel: si fueran imágenes distintas, un solo `alt` describiría la primera y se aplicaría a las tres. Describe lo que se VE, no repite `name`: un lector de pantalla ya acaba de leerlo.",
"image": [
{
"url": "https://rustmadrid.example/img/2026-06-16x9.png",
"alt": "Cartel sobre fondo morado: el cangrejo Ferris con casco de obra, y la fecha «26 de junio, 19:00» en grande"
},
"https://rustmadrid.example/img/2026-06-4x3.png",
"https://rustmadrid.example/img/2026-06-1x1.png"
],
"organizers": [
{ "name": "Rust Madrid", "url": "https://rustmadrid.example" }
],
"startDate": "2026-06-26T19:00",
"endDate": "2026-06-26T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "hybrid",
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"region": "Comunidad de Madrid",
"postalCode": "28005",
"country": "ES"
},
"onlineUrl": "https://meet.example/rust-madrid",
"geo": { "lat": 40.4081, "lon": -3.7188 }
},
"tags": ["rust", "wasm"],
"languages": ["es"],
"offers": [
{ "price": 0, "url": "https://rustmadrid.example/meetups/2026-06#registro" }
],
"status": "scheduled",
"license": "CC-BY-4.0",
"updatedAt": "2026-06-10T18:00:00Z",
"_comment_extensions": "El aforo (`capacity`) NO está en la v0.3: es un campo EN DISCUSIÓN (issue #5). El schema no prohíbe campos adicionales, así que este documento es válido igualmente y un consumidor puede ignorarlo sin miedo. Si lo usas y te sirve, dilo en el issue: la spec crece con campos que alguien ya usa de verdad. Así entraron `image` y `offers`.",
"capacity": 80
}
```
## event-minimal.json
Source: spec/v0.3/examples/event-minimal.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://pyalmeria.example/eventos/2026-06-async",
"name": "PyAlmería — Introducción a async/await",
"startDate": "2026-06-11T18:30",
"timezone": "Europe/Madrid",
"license": "CC-BY-4.0"
}
```
## event-online.json
Source: spec/v0.3/examples/event-online.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://rustgirona.example/sessions/2026-07-31",
"url": "https://rustgirona.example/sessions/2026-07-31",
"name": "Sessió setmanal de codificació — Rust Girona",
"description": "Cada setmana ens trobem en línia per picar Rust una estona. Canal de veu de Discord; l'enllaç és a la pàgina de l'esdeveniment.",
"_comment_image": "El `alt` va en el `textLanguage` del documento (`ca`) y se traduce en el propio objeto de la imagen, no en un espejo posicional tipo `translations.es.image[0]`: una lista no tiene claves estables y reordenarla pegaría el texto a la imagen equivocada. Se traduce, no se escribe en inglés «internacional»: el `alt` lo lee un lector de pantalla con la pronunciación del idioma que lo rodea.",
"image": [
{
"url": "https://rustgirona.example/img/sessio-setmanal.png",
"alt": "Captura d'una sessió: quadrícula de webcams i un editor amb codi Rust compartit",
"translations": {
"es": { "alt": "Captura de una sesión: cuadrícula de webcams y un editor con código Rust compartido" }
}
}
],
"organizers": [
{ "name": "Rust Girona", "url": "https://rustgirona.example" },
{ "type": "person", "name": "Ivan Fraixedes", "url": "https://ifraixedes.example" }
],
"startDate": "2026-07-31T18:00",
"endDate": "2026-07-31T19:00",
"timezone": "Europe/Madrid",
"_comment_online": "Evento solo online. `location` trae `onlineUrl` y NADA de sede física — no hay `venue` que poner, y `location: {}` sería inválido. `attendanceMode: online` no es redundante con eso: `location` son hechos observables (hay URL de conexión), `attendanceMode` es la intención de quien organiza. Si se contradicen, manda `attendanceMode`.",
"attendanceMode": "online",
"location": { "onlineUrl": "https://discord.example/rust-girona" },
"_comment_eligibility": "`attendanceMode` y `location` dicen que el evento está al alcance de cualquiera con internet; `eligibility` dice que aun así hay una puerta — el canal de voz está dentro del Discord de la comunidad, y hay que estar dentro. `url` apunta a dónde se cumple la condición, no a dónde se paga: eso es `offers[].url`. Sin este campo, el requisito solo vivía en la prosa de `description`, donde ningún consumidor lo puede filtrar.",
"eligibility": {
"type": "members-only",
"note": "Membres del Discord de Rust Girona",
"url": "https://rustgirona.example/join",
"translations": {
"es": { "note": "Miembros del Discord de Rust Girona" }
}
},
"tags": ["rust", "pair-programming"],
"_comment_languages": "`languages` y `textLanguage` no son el mismo dato y aquí se ve: en la sesión se habla catalán y castellano, y el documento está escrito SOLO en catalán. Ninguno de los dos se deriva del otro.",
"languages": ["ca", "es"],
"textLanguage": "ca",
"status": "scheduled",
"partOf": {
"id": "https://rustgirona.example/sessions",
"name": "Sessió setmanal de codificació",
"url": "https://rustgirona.example/sessions",
"translations": {
"es": { "name": "Sesión semanal de programación" }
}
},
"license": "CC-BY-4.0",
"updatedAt": "2026-07-24T08:00:00Z",
"_comment_translations": "El texto principal sigue en los campos de siempre —cadenas, no mapas de idioma—, así que un consumidor de v0.2 lee este documento sin enterarse de que existe `translations`. Solo se traducen `name` y `description`: `eligibility.note` y `partOf.name` NO están cubiertos a propósito, y se quedan en el idioma que declara `textLanguage`. Y no hay entrada `ca`: sería el mismo texto dos veces, y dos formas de afirmar lo mismo son dos formas de contradecirse.",
"translations": {
"es": {
"name": "Sesión semanal de programación — Rust Girona",
"description": "Cada semana nos juntamos en línea para picar Rust un rato. Canal de voz de Discord; el enlace está en la página del evento."
}
}
}
```
## event-recurring.json
Source: spec/v0.3/examples/event-recurring.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"id": "https://rustmadrid.example/meetups/2026-06",
"url": "https://rustmadrid.example/meetups/2026-06",
"name": "Rust Madrid — Meetup de junio",
"description": "Segundo lunes de cada mes. Esta edición: WASM y tooling.",
"image": [
"https://rustmadrid.example/img/2026-06.png"
],
"organizers": [
{ "name": "Rust Madrid", "url": "https://rustmadrid.example" }
],
"startDate": "2026-06-08T19:00",
"endDate": "2026-06-08T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"postalCode": "28005",
"country": "ES"
}
},
"tags": ["rust", "wasm"],
"languages": ["es"],
"status": "scheduled",
"_comment_partOf": "UNA ocurrencia de un meetup mensual, no la serie entera. El feed lleva un documento como este por cada edición: junio, julio, septiembre… `partOf` solo dice a qué conjunto pertenece — no genera fechas. Así, cancelar la de agosto es `status: cancelled` en SU documento, y quien ignore `partOf` sigue viendo un evento completo y correcto.",
"partOf": {
"id": "https://rustmadrid.example/meetups",
"name": "Rust Madrid — meetup mensual",
"url": "https://rustmadrid.example/meetups"
},
"license": "CC-BY-4.0",
"updatedAt": "2026-05-30T12:00:00Z",
"_comment_extensions": "`ics:rrule` NO es un campo de OTE: es vocabulario externo (lleva prefijo). Lo guarda el importador para poder volver al .ics de origen sin pérdida. Es informativo: ningún consumidor de OTE está obligado a expandirlo, porque las ocurrencias ya vienen expandidas.",
"ics:rrule": "FREQ=MONTHLY;BYDAY=2MO"
}
```
## feed-community.json
Source: spec/v0.3/examples/feed-community.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"title": "Eventos de PyAlmería",
"description": "Meetups mensuales de Python en Almería.",
"url": "https://pyalmeria.example",
"_comment_textLanguage": "Un feed monolingüe declara el idioma de su texto UNA vez y ningún evento lo repite: se hereda, como `license` y `organizers`. Es el caso del 99% — y es lo que hace que el campo cueste una línea en todo el fichero. `translations` no se hereda nunca: traduce el título DEL FEED, no el nombre de sus eventos. El primer evento traduce el suyo al inglés sin declarar su propio `textLanguage`: el idioma efectivo frente al que se valida esa traducción es el heredado del feed (\"es\"). Ver CHANGES.log #P015 / DECISIONS.md D016.",
"textLanguage": "es",
"_comment_organizers": "Feed de una comunidad: `organizers` va arriba y lo hereda todo evento que no declare el suyo, `email` incluido — una línea en el fichero y ningún evento la repite. El segundo evento SÍ declara `organizers`, y su lista REEMPLAZA la heredada; no se suma. Por eso PyAlmería aparece repetida ahí, con su email otra vez: si se omitiera la entrada, el evento pasaría a ser solo de PyData Almería, y si se omitiera el email, ese evento se quedaría sin ORGANIZER al exportar a .ics. El reemplazo es lo que garantiza que la invitada NUNCA hereda el email de la anfitriona.",
"organizers": [
{ "name": "PyAlmería", "url": "https://pyalmeria.example", "email": "hola@pyalmeria.example" }
],
"license": "CC-BY-4.0",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
"updatedAt": "2026-07-06T10:00:00Z",
"events": [
{
"id": "https://pyalmeria.example/eventos/2026-06-async",
"url": "https://pyalmeria.example/eventos/2026-06-async",
"name": "PyAlmería — Introducción a async/await",
"description": "Charla introductoria a la programación asíncrona en Python, con ejemplos en vivo.",
"image": ["https://pyalmeria.example/img/2026-06-async.png"],
"startDate": "2026-06-11T18:30",
"endDate": "2026-06-11T20:30",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "El Cable, Almería",
"address": {
"street": "Calle Poeta Villaespesa 1",
"locality": "Almería",
"postalCode": "04001",
"country": "ES"
}
},
"tags": ["python", "async"],
"languages": ["es"],
"updatedAt": "2026-05-28T11:00:00Z",
"translations": {
"en": {
"name": "PyAlmería — Introduction to async/await",
"description": "Introductory talk on asynchronous programming in Python, with live examples."
}
}
},
{
"id": "https://pyalmeria.example/eventos/2026-07-datos",
"url": "https://pyalmeria.example/eventos/2026-07-datos",
"name": "Noche de datos — PyAlmería × PyData Almería",
"description": "Sesión conjunta con PyData Almería.",
"image": ["https://pyalmeria.example/img/2026-07-datos.png"],
"organizers": [
{ "name": "PyAlmería", "url": "https://pyalmeria.example", "email": "hola@pyalmeria.example" },
{ "name": "PyData Almería", "url": "https://pydata-almeria.example" }
],
"startDate": "2026-07-09T18:30",
"endDate": "2026-07-09T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "hybrid",
"location": {
"venue": "El Cable, Almería",
"address": {
"street": "Calle Poeta Villaespesa 1",
"locality": "Almería",
"postalCode": "04001",
"country": "ES"
},
"onlineUrl": "https://meet.example/pyalmeria"
},
"tags": ["python", "data"],
"languages": ["es"],
"updatedAt": "2026-06-30T16:45:00Z"
}
]
}
```
## feed-multipart.json
Source: spec/v0.3/examples/feed-multipart.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"title": "Eventos de PyAlmería",
"description": "Meetups y formación de Python en Almería.",
"url": "https://pyalmeria.example",
"_comment_multipart": "Un study jam de tres sesiones que se celebran en TRES SÁBADOS NO CONSECUTIVOS, con una sola inscripción. Se publica como tres eventos con el mismo `partOf` y `type: multipart` — NO como un evento con startDate del primer sábado y endDate del último: eso afirmaría un evento continuo de quince días y ocuparía dos semanas enteras en el calendario de quien se suscriba. Cada parte lleva sus fechas reales; quien ignore `partOf` ve tres sesiones correctas en vez de una mentira de quince días.",
"organizers": [
{ "name": "PyAlmería", "url": "https://pyalmeria.example" }
],
"license": "CC-BY-4.0",
"updatedAt": "2026-02-25T11:00:00Z",
"events": [
{
"id": "https://pyalmeria.example/study-jams/2026-testing/1",
"url": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python — Sesión 1: pytest desde cero",
"description": "Primera sesión: pytest desde cero — fixtures, parametrización y estructura de un proyecto.",
"image": ["https://pyalmeria.example/img/2026-testing-study-jam.png"],
"startDate": "2026-03-07T10:00",
"endDate": "2026-03-07T14:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "El Cable, Almería",
"address": {
"street": "Calle Poeta Villaespesa 1",
"locality": "Almería",
"postalCode": "04001",
"country": "ES"
}
},
"tags": ["python", "testing"],
"languages": ["es"],
"partOf": {
"type": "multipart",
"id": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python (3 sesiones)",
"url": "https://pyalmeria.example/study-jams/2026-testing"
},
"updatedAt": "2026-02-18T09:00:00Z"
},
{
"id": "https://pyalmeria.example/study-jams/2026-testing/2",
"url": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python — Sesión 2: dobles de prueba",
"description": "Segunda sesión: dobles de prueba — mocks, stubs y cuándo NO usarlos.",
"image": ["https://pyalmeria.example/img/2026-testing-study-jam.png"],
"startDate": "2026-03-14T10:00",
"endDate": "2026-03-14T14:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "El Cable, Almería",
"address": {
"street": "Calle Poeta Villaespesa 1",
"locality": "Almería",
"postalCode": "04001",
"country": "ES"
}
},
"tags": ["python", "testing"],
"languages": ["es"],
"partOf": {
"type": "multipart",
"id": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python (3 sesiones)",
"url": "https://pyalmeria.example/study-jams/2026-testing"
},
"updatedAt": "2026-02-18T09:00:00Z"
},
{
"id": "https://pyalmeria.example/study-jams/2026-testing/3",
"url": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python — Sesión 3: CI y cobertura",
"description": "Tercera sesión: CI y cobertura — automatizar la suite y leer lo que mide.",
"image": ["https://pyalmeria.example/img/2026-testing-study-jam.png"],
"startDate": "2026-03-28T10:00",
"endDate": "2026-03-28T14:00",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "El Cable, Almería",
"address": {
"street": "Calle Poeta Villaespesa 1",
"locality": "Almería",
"postalCode": "04001",
"country": "ES"
}
},
"tags": ["python", "testing"],
"languages": ["es"],
"partOf": {
"type": "multipart",
"id": "https://pyalmeria.example/study-jams/2026-testing",
"name": "Study Jam de testing en Python (3 sesiones)",
"url": "https://pyalmeria.example/study-jams/2026-testing"
},
"updatedAt": "2026-02-25T10:30:00Z"
}
]
}
```
## feed.json
Source: spec/v0.3/examples/feed.json — Validated documents, one per real case. These pass `npm run validate`.
```json
{
"specVersion": "0.3.0",
"title": "Open Tech Events Spain",
"description": "Eventos de comunidades técnicas en España.",
"url": "https://opentechevents.org",
"_comment_organizers": "Feed de agregador: NO lleva `organizers`. Quien publica (title/url) no organiza estos eventos, así que cada evento declara los suyos. Un feed de una sola comunidad sí los pone arriba y los hereda todo el feed — ver feed-community.json.",
"license": "CC-BY-4.0",
"licenseUrl": "https://creativecommons.org/licenses/by/4.0/",
"updatedAt": "2026-07-06T10:00:00Z",
"events": [
{
"id": "https://rustmadrid.example/meetup/2026-06",
"url": "https://rustmadrid.example/meetup/2026-06",
"name": "Rust Madrid — June meetup",
"description": "Talks on WASM and Rust tooling.",
"image": ["https://rustmadrid.example/img/2026-06.png"],
"organizers": [
{ "name": "Rust Madrid", "url": "https://rustmadrid.example" }
],
"startDate": "2026-06-26T19:00",
"endDate": "2026-06-26T21:00",
"timezone": "Europe/Madrid",
"attendanceMode": "hybrid",
"location": {
"venue": "Campus Madrid, Calle de Moreno Nieto 2, Madrid",
"address": {
"street": "Calle de Moreno Nieto 2",
"locality": "Madrid",
"postalCode": "28005",
"country": "ES"
},
"onlineUrl": "https://meet.example/rust-madrid"
},
"tags": ["rust", "wasm"],
"languages": ["es"],
"updatedAt": "2026-06-01T07:30:00Z"
},
{
"id": "https://coolconf.example/2026/",
"url": "https://coolconf.example/2026/",
"name": "CoolConf 2026",
"description": "Conferencia de dos días sobre web, cloud y sistemas.",
"image": ["https://coolconf.example/img/2026-16x9.png"],
"organizers": [
{ "name": "CoolConf", "url": "https://coolconf.example" }
],
"startDate": "2026-10-15",
"endDate": "2026-10-16",
"timezone": "Europe/Madrid",
"attendanceMode": "in-person",
"location": {
"venue": "Valencia, Spain",
"_comment_address": "La sede aún no está cerrada, así que `address` solo trae lo que se sabe: ciudad y país. Rellenar `street` o `postalCode` con \"\" o `null` sería inválido — y peor que callarse, porque publica un desconocido como si fuera dato.",
"address": {
"locality": "València",
"country": "ES"
}
},
"tags": ["web", "cloud"],
"languages": ["es", "en"],
"status": "cancelled",
"license": "CC0-1.0",
"updatedAt": "2026-07-02T14:00:00Z"
}
]
}
```
==============================================================================
SECTION: Tools and ecosystem
==============================================================================
## tools.json
Source: docs/data/tools.json — The tools listed on the site: validators, generators, importers, and what each one does.
```json
{
"_comment": "Canonical ecosystem tools catalogue. status: working | wip | proposed. audience: organizers | attendees | developers. order sorts tools within the same status. Fields: name {en,es}, desc {en,es}, status, audience, order, category {en,es}, url (repo or issue), linkKind ('app' → 'Open the tool' for a live app URL; 'npm' → 'View npm package' for a single-package URL; 'npm-scope' → 'View npm packages' for an org/scope URL; omit for a repo URL → 'View repository' unless status is proposed → 'Discuss the idea').",
"tools": [
{
"status": "working",
"audience": ["organizers"],
"name": {
"en": "Organizer kit",
"es": "Kit del organizador"
},
"desc": {
"en": "The quickest way to start: use a ready-made GitHub repository to publish your community meetups from scratch in minutes, with three free subscribable feeds: OTE, ICS and RSS. Soon integrated with the publishing assistant to speed up distribution to sites that do not consume OTE yet.",
"es": "La forma más rápida de empezar: usa un repositorio GitHub preparado para publicar los encuentros de tu comunidad desde cero y en minutos, con tres feeds gratuitos y suscribibles: OTE, ICS y RSS. Próximamente integrado con el asistente de publicación para acelerar la distribución a sitios que aún no consumen OTE."
},
"category": {
"en": "Create & validate",
"es": "Crear y validar"
},
"url": "https://github.com/OpenTechEvents/ote-template"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "Schema validator",
"es": "Validador de esquema"
},
"desc": {
"en": "Published as @opentechevents/validate: pure validateEvent/validateFeed functions plus an ote-validate CLI, reused by the editor (live validation) and the kit's CI workflow.",
"es": "Publicado como @opentechevents/validate: funciones puras validateEvent/validateFeed y un CLI ote-validate, reutilizado por el editor (validación en vivo) y el workflow de CI del kit."
},
"category": {
"en": "Create & validate",
"es": "Crear y validar"
},
"url": "https://www.npmjs.com/package/@opentechevents/validate",
"linkKind": "npm"
},
{
"status": "working",
"audience": ["organizers"],
"name": {
"en": "Event editor / generator",
"es": "Editor / generador de eventos"
},
"desc": {
"en": "A web form that produces valid OTE JSON, validated live. With ?repo=owner/name it reads the repo's preset and proposes changes via issue→PR; with no repo it works as a standalone generator you copy or download from.",
"es": "Formulario web que produce JSON OTE válido, validado en vivo. Con ?repo=owner/name lee el preset del repo y propone cambios por issue→PR; sin repo funciona como generador suelto para copiar o descargar."
},
"category": {
"en": "Create & validate",
"es": "Crear y validar"
},
"url": "https://tools.opentechevents.org/editor/",
"linkKind": "app"
},
{
"status": "proposed",
"audience": ["organizers"],
"order": 30,
"name": {
"en": "Bot to draft OTE events",
"es": "Bot para redactar eventos OTE"
},
"desc": {
"en": "Describe an event in plain language; the bot drafts the OTE fields, lets you pick one of your usual target repos and sends you to the editor to review, complete and submit it.",
"es": "Cuentas un evento en lenguaje natural; el bot prepara los campos OTE, te deja elegir uno de tus repos destino habituales y te manda al editor para revisar, completar y enviarlo."
},
"category": {
"en": "Create & validate",
"es": "Crear y validar"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/9"
},
{
"status": "proposed",
"audience": ["organizers"],
"order": 20,
"name": {
"en": "Browser extension",
"es": "Extensión de navegador"
},
"desc": {
"en": "On your event's page in Meetup, Eventbrite or Luma, it reads the schema.org data the page already exposes, maps it to OTE and pre-fills the registration — only if you are the organiser or have permission, and you review the data before anything is sent. It can also hand you the events.json to publish on your own site.",
"es": "En la página de tu evento en Meetup, Eventbrite o Luma, lee los datos schema.org que la página ya expone, los mapea a OTE y prerrellena el alta — solo si eres el organizador o tienes permiso, y revisas los datos antes de enviar nada. También te da el events.json para publicarlo en tu propia web."
},
"category": {
"en": "Ingest",
"es": "Ingesta"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/8"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "iCalendar (.ics) importer",
"es": "Importador de iCalendar (.ics)"
},
"desc": {
"en": "Built into the editor: upload or paste an .ics (Google Calendar, Meetup…), pick the upcoming events and it prefills the form — the shortest path to adoption for many communities. Also on npm as @opentechevents/import-ics for developers. RSS→OTE import still pending.",
"es": "Integrado en el editor: sube o pega un .ics (Google Calendar, Meetup…), elige los eventos futuros y precarga el formulario — el camino más corto a la adopción para muchas comunidades. También en npm como @opentechevents/import-ics para desarrolladores. El import RSS→OTE aún está pendiente."
},
"category": {
"en": "Ingest",
"es": "Ingesta"
},
"url": "https://tools.opentechevents.org/editor/",
"linkKind": "app"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "schema.org / JSON-LD extractor",
"es": "Extractor de schema.org / JSON-LD"
},
"desc": {
"en": "Built into the editor: paste an event page's HTML, it reads the JSON-LD (schema.org/Event) and prefills the form, flagging what the markup didn't provide. Also on npm as @opentechevents/import-jsonld for developers.",
"es": "Integrado en el editor: pega el HTML de la página de un evento, lee el JSON-LD (schema.org/Event) y precarga el formulario, marcando lo que el marcado no traía. También en npm como @opentechevents/import-jsonld para desarrolladores."
},
"category": {
"en": "Ingest",
"es": "Ingesta"
},
"url": "https://tools.opentechevents.org/editor/",
"linkKind": "app"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "OTE → iCalendar",
"es": "OTE → iCalendar"
},
"desc": {
"en": "Export a feed as .ics so attendees can subscribe from their calendar app. Published as @opentechevents/export-ics and what the organizer kit serves as feed.ics.",
"es": "Exporta un feed como .ics para suscribirse desde la app de calendario. Publicado como @opentechevents/export-ics y lo que el kit del organizador sirve como feed.ics."
},
"category": {
"en": "Transform",
"es": "Transformar"
},
"url": "https://www.npmjs.com/package/@opentechevents/export-ics",
"linkKind": "npm"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "OTE → RSS / JSON Feed",
"es": "OTE → RSS / JSON Feed"
},
"desc": {
"en": "Export to feed readers, the format people have subscribed with for twenty years. Published as @opentechevents/export-rss and what the organizer kit serves as feed.xml.",
"es": "Exporta a lectores de feeds, el formato con el que la gente lleva veinte años suscribiéndose. Publicado como @opentechevents/export-rss y lo que el kit del organizador sirve como feed.xml."
},
"category": {
"en": "Transform",
"es": "Transformar"
},
"url": "https://www.npmjs.com/package/@opentechevents/export-rss",
"linkKind": "npm"
},
{
"status": "proposed",
"audience": ["organizers", "developers"],
"order": 15,
"name": {
"en": "OTE → schema.org/Event",
"es": "OTE → schema.org/Event"
},
"desc": {
"en": "Generate a schema.org/Event JSON-LD snippet from an OTE event, ready to paste into an event page. It is what turns an event you already wrote into Google's event rich results — date, venue and price shown in search — with no hand-written markup to keep in sync.",
"es": "Genera un snippet JSON-LD schema.org/Event desde un evento OTE, listo para pegar en la página del evento. Es lo que convierte un evento que ya has escrito en los resultados enriquecidos de Google —fecha, sede y precio en la búsqueda— sin marcado a mano que mantener sincronizado."
},
"category": {
"en": "Transform",
"es": "Transformar"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/11"
},
{
"status": "proposed",
"audience": ["organizers"],
"order": 10,
"name": {
"en": "Publishing assistant",
"es": "Asistente de publicación"
},
"desc": {
"en": "Turn an OTE event into organiser-reviewed outputs for non-OTE channels: copy-paste cheat sheets, email text, contact-form fields, issue links or pull request drafts.",
"es": "Convierte un evento OTE en salidas revisadas por el organizador para canales que no usan OTE: chuletas de copy-paste, texto de email, campos de formulario, enlaces a issues o borradores de pull request."
},
"category": {
"en": "Publish",
"es": "Publicar"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/12"
},
{
"status": "proposed",
"audience": ["attendees"],
"order": 20,
"name": {
"en": "Natural-language event discovery bot",
"es": "Bot de consulta de eventos"
},
"desc": {
"en": "Ask in plain language for upcoming events by technology, city, date, language, mode or CFP status; the bot searches OTE feeds and returns matching events with source links.",
"es": "Pregunta en lenguaje natural por próximos eventos según tecnología, ciudad, fecha, idioma, modalidad o CFP; el bot consulta feeds OTE y devuelve eventos relevantes con enlaces a la fuente."
},
"category": {
"en": "Consume",
"es": "Consumir"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/10"
},
{
"status": "working",
"audience": ["attendees"],
"name": {
"en": "OTE Feed Reader",
"es": "Lector de Feeds OTE"
},
"desc": {
"en": "Progressive Web App (PWA) to load, browse, and read events published in OTE feeds directly from your browser or installed on your mobile home screen.",
"es": "Aplicación Web Progresiva (PWA) para cargar, explorar y leer eventos publicados en feeds OTE directamente desde el navegador o instalada en la pantalla de inicio del móvil."
},
"category": {
"en": "Consume",
"es": "Consumir"
},
"url": "https://reader.opentechevents.org/",
"linkKind": "app"
},
{
"status": "proposed",
"audience": ["attendees"],
"order": 10,
"name": {
"en": "Event subscriptions & reminders",
"es": "Suscripciones y recordatorios de eventos"
},
"desc": {
"en": "Background subscriptions for OTE feeds: save topic filters, receive push or email notifications and event, CFP or ticket reminders.",
"es": "Suscripciones en segundo plano para feeds OTE: guardar filtros por tema, recibir notificaciones push/email y recordatorios de eventos, CFPs o entradas."
},
"category": {
"en": "Consume",
"es": "Consumir"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/issues/13"
},
{
"status": "working",
"audience": ["organizers", "developers"],
"name": {
"en": "Embeddable widget",
"es": "Widget embebible"
},
"desc": {
"en": "Embeddable event views for one or more OTE feeds, with configurable layouts such as list or calendar and optional filters by topic, place, mode or date.",
"es": "Vistas embebibles para uno o varios feeds OTE, con layouts configurables como lista o calendario y filtros opcionales por tema, lugar, modalidad o fecha."
},
"category": {
"en": "Consume",
"es": "Consumir"
},
"url": "https://tools.opentechevents.org/embed/",
"linkKind": "app"
},
{
"status": "working",
"audience": ["organizers", "developers"],
"order": 50,
"name": {
"en": "OTE feed badge",
"es": "Badge de feed OTE"
},
"desc": {
"en": "A static, RSS-style SVG badge that makes a community's feed visible in a README or site footer — full button or icon-only variant, no service or build step involved. Opening the feed in compatible tools, or turning it into an embeddable widget, is still proposed.",
"es": "Badge SVG estático, al estilo RSS, para hacer visible el feed de una comunidad en un README o en el pie de página — botón completo o solo icono, sin servicio ni paso de build de por medio. Abrir el feed en herramientas compatibles, o convertirlo en un widget embebible, sigue propuesto."
},
"category": {
"en": "Consume",
"es": "Consumir"
},
"url": "https://github.com/OpenTechEvents/opentechevents-spec/tree/main/docs/badge"
},
{
"status": "working",
"audience": ["developers"],
"name": {
"en": "Reference SDKs",
"es": "SDKs de referencia"
},
"desc": {
"en": "The JS/TS SDK is published under the @opentechevents/* npm scope: validate, export-ics, export-rss, import-ics, import-jsonld, build-feed — pure functions to read, write and validate OTE. Python and an online playground still pending.",
"es": "El SDK JS/TS está publicado en el scope npm @opentechevents/*: validate, export-ics, export-rss, import-ics, import-jsonld, build-feed — funciones puras para leer, escribir y validar OTE. Python y un playground online siguen pendientes."
},
"category": {
"en": "Libraries",
"es": "Librerías"
},
"url": "https://www.npmjs.com/org/opentechevents",
"linkKind": "npm-scope"
}
]
}
```
## adopters.json
Source: docs/data/adopters.json — Communities publishing an OTE feed today.
```json
{
"_comment": "Communities/events publishing their data in OTE format. Add yours via PR or issue. Fields: name (required), url, feed, logo (path or URL; falls back to initials), desc {en,es}.",
"adopters": [
{
"name": "Community Builders Community Events",
"url": "https://combuilderses.github.io/#events",
"feed": "https://combuilderses.github.io/events/feed.json",
"logo": "logos/community-builders.png",
"directory": "101"
}
]
}
```
## consumers.json
Source: docs/data/consumers.json — Directories and calendars consuming OTE feeds today.
```json
{
"_comment": "Directories, aggregators, apps and individuals consuming OTE feeds. Fields: name (required), url, logo, desc {en,es}, kind {en,es} (e.g. Directory / Aggregator / Newsletter).",
"consumers": [
{
"name": "OTE Feed Reader",
"url": "https://reader.opentechevents.org/",
"desc": {
"en": "Web application (PWA) to explore and view events from published OTE feeds, optimized for mobile and desktop.",
"es": "Aplicación web (PWA) para explorar y visualizar eventos de feeds OTE publicados, optimizada para móvil y escritorio."
},
"kind": {
"en": "Feed reader (PWA)",
"es": "Lector de feeds (PWA)"
}
}
],
"_comment_testimonials": "Quotes from adopters or consumers. Fields: quote {en,es}, name, role {en,es}, avatar (URL), url.",
"testimonials": []
}
```
## supporters.json
Source: docs/data/supporters.json — Who backs the spec without necessarily publishing yet: pledges to adopt, endorsements, ambassadors, advisors, offers of resources.
```json
{
"_comment": "People, communities, projects and companies backing OTE without necessarily publishing a feed. Fields: name (required), url, logo, tier (required, see below), desc {en,es}, since (YYYY-MM). One entry can carry several tiers: \"tier\": [\"pledge\", \"ambassador\"].",
"_comment_tiers": "pledge = commits to adopt OTE once there is a stable spec · endorse = publicly backs the idea · ambassador = spreads the word (talks, articles, intros) · advisor = reviews the spec / brings real cases · resources = hosting, design, data, a slot at their event. Quotes go in consumers.json → testimonials.",
"supporters": []
}
```
## contributing.md
Source: CONTRIBUTING.md — How to propose a change to the spec, and what gets rejected.
```markdown
# Cómo contribuir
Gracias por pasarte. OTE Spec está en **fase de diseño**: nada está cerrado y por eso ahora mismo una opinión vale más que un *pull request*. Si organizas eventos, montas un directorio o mantienes una herramienta, tienes justo el contexto que le falta a este proyecto.
> ⚠️ **Aviso importante.** La especificación vigente es **[OTE Spec v0.3](spec/v0.3/README.md)** y es un **borrador `0.x`: puede romper sin previo aviso**. Se publicó para que existan implementaciones reales y para que rompan lo que esté mal. El debate sigue abierto en los issues [#5 (evento)](https://github.com/OpenTechEvents/opentechevents-spec/issues/5) y [#6 (feed)](https://github.com/OpenTechEvents/opentechevents-spec/issues/6).
>
> Los documentos de `spec/data-model.md` y `spec/feed.md` son el **boceto anterior** y **no son normativos**. No los implementes ni los edites.
## Lo que más falta ahora mismo
En este orden:
1. **Casos reales que rompan el modelo.** Un evento tuyo que no se pueda describir con la spec actual es información valiosísima. Cuéntalo aunque no traigas solución.
2. **Gente que diga que la adoptaría.** Un «lo publicaremos cuando haya una spec estable» cuesta dos minutos y es lo que hace que un directorio decida que este formato merece la pena leerlo. Ver [apoyar sin publicar nada](#apoyar-sin-publicar-nada).
3. **Comunidades dispuestas a publicar un feed.** Un estándar sin datos reales es teoría. Ver [adherirse](#adherirse-publicar-tus-eventos-en-ote).
4. **Consumidores.** Directorios, newsletters o bots que lean feeds OTE. Cada consumidor hace que adherirse compense más.
5. **Difusión.** Nadie da feedback sobre algo de lo que no ha oído hablar. Ver [difusión y embajadores](#difusión-y-embajadores).
6. **Herramientas del ecosistema.** Catálogo en [`docs/data/tools.json`](docs/data/tools.json), pintado en [opentechevents.org#tools](https://opentechevents.org#tools).
7. **Código y documentación.** Llegará, pero va después de lo anterior.
## Todas las formas de participar
Cada fila es una puerta de entrada distinta, ordenadas de menos a más esfuerzo. **Ninguna es un peldaño obligatorio para la siguiente**: entra por donde te apetezca.
| Qué | Cuánto cuesta | Por dónde |
| --- | --- | --- |
| Comprometerte a adoptarla cuando haya una spec estable | 2 min | [issue de apoyo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=supporter.yml) |
| Contárnoslo en una llamada en vez de por escrito | 20 min | [reservar hueco](https://calendar.app.google/ZQuRkVw53h8nC2uQA) |
| Dar un testimonio publicable | 5 min | [Discussions](https://github.com/OpenTechEvents/opentechevents-spec/discussions) |
| Ponerte la chapa en tu README | 1 min | [`docs/badge/`](docs/badge/README.md) |
| Contar un evento tuyo que la spec no sabe describir | 10 min | [issue de caso real](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=case.yml) |
| Revisar la spec cuando toque tu caso | reactivo | [issue de apoyo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=supporter.yml) («asesora») |
| Presentarnos a un directorio, plataforma o conferencia | 1 mensaje | [issue de embajador](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=ambassador.yml) |
| Hablar de esto (charla, artículo, podcast, hilo) | variable | [issue de embajador](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=ambassador.yml) |
| Traducir la web o las descripciones de campos | 1-3 h | [traducir](#traducir) |
| Debatir la especificación | variable | [issues](https://github.com/OpenTechEvents/opentechevents-spec/issues) |
| Publicar un feed | ~1 h | [adherirse](#adherirse-publicar-tus-eventos-en-ote) |
| Consumir feeds (directorio, bot, newsletter) | días | [issue de consumidor](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=consumer.yml) |
| Montar una herramienta | días | [issue de herramienta](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=tool.yml) |
¿Prefieres que lo hablemos y no escribir un issue? **[Reserva 20 minutos](https://calendar.app.google/ZQuRkVw53h8nC2uQA)** y ya está. Quien organiza eventos tiene cosas mejores que hacer que aprenderse nuestras plantillas. Si no te encaja ningún hueco, abre un [hilo en Discussions](https://github.com/OpenTechEvents/opentechevents-spec/discussions) y buscamos uno.
## Cómo participar
### Apoyar sin publicar nada
No hace falta que publiques un feed para que tu apoyo sirva. Un estándar joven se muere de dos cosas: de que nadie lo conozca y de que nadie se crea que alguien lo va a usar. Contra la segunda solo hay un remedio, y es **decirlo en público**.
Abre un [issue de apoyo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=supporter.yml) y marca lo que te encaje:
- **Compromiso** — «publicaremos (o consumiremos) OTE cuando haya una spec estable». No es vinculante: se retira comentando en el mismo issue. Se lista **aparte** de quien ya publica, porque prometer y hacerlo no son lo mismo y mezclarlo sería mentir.
- **Apoyo** — te parece bien la idea y quieres aparecer respaldándola.
- **Asesoría** — no vas a implementar nada, pero revisas borradores y traes tu caso real cuando la spec toque tu terreno.
- **Recursos** — hosting, dominio, diseño, ilustración, una revisión legal o de accesibilidad, un volcado de eventos históricos contra el que probar, un hueco en tu evento, una sala para un taller de adopción. No todo lo útil es código.
Y si tienes una frase publicable sobre por qué esto importa en tu comunidad, déjala en [Discussions](https://github.com/OpenTechEvents/opentechevents-spec/discussions): los testimonios se revisan **a mano** y se pasan a [`docs/data/consumers.json`](docs/data/consumers.json) con tu permiso explícito.
Todo esto sale en [opentechevents.org#support](https://opentechevents.org#support), desde [`docs/data/supporters.json`](docs/data/supporters.json).
### Difusión y embajadores
Nadie da feedback sobre algo de lo que no ha oído hablar. Ahora mismo **la difusión rinde más que el código**: [issue de embajador](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=ambassador.yml).
- **Hablar de esto.** Una charla, una lightning talk, un artículo, una newsletter, un podcast, un hilo. **Pídenos el material**: slides, una demo, un diagrama, media hora para ponerte al día o que alguien copresente contigo. Si te toca fabricarte tú el material, no lo vas a hacer, y con razón.
- **Presentarnos a alguien.** Esto es lo más valioso y lo que menos cuesta. Si conoces a quien mantiene un directorio, un calendario, una plataforma de eventos o la web de una conferencia, **un mensaje tuyo vale más que cincuenta correos fríos nuestros**.
- **Abrir un issue en un proyecto de terceros** pidiendo soporte OTE. Dinos dónde y te pasamos el texto; que llegue de alguien que ya usa ese proyecto pesa mucho más que si llega de nosotros.
- **Ponerte la chapa** en tu README o en el pie de tu web: [`docs/badge/`](docs/badge/README.md). Es lo único de esta lista que sigue funcionando mientras nadie lo mira.
- **Traducir** a un tercer idioma: abre una región entera de comunidades. Ver [traducir](#traducir).
A los embajadores se les lista en la web y se les reconoce con [all-contributors](https://allcontributors.org) (`talk`, `blog`, `translation`, `ideas`…). Si has hecho algo y no apareces, dilo: es un olvido.
### Debatir la especificación
**Abre un [issue](https://github.com/OpenTechEvents/opentechevents-spec/issues)** (o comenta en uno existente). No hace falta que la propuesta esté pulida ni que sepas de estándares. Lo que sí ayuda a que un cambio avance:
- **El caso real detrás.** «En mi comunidad hacemos X y no sé cómo representarlo» pesa más que «faltaría un campo Y».
- **Qué se rompe si no se arregla.** ¿Se pierde información? ¿Un importador se inventa un dato? ¿Un evento aparece mal en un directorio?
- **Cómo lo resuelven otros.** Si iCalendar, schema.org o RSS ya tienen una solución para eso, dilo: la compatibilidad es un principio de diseño, no un extra.
**Un caso real vale más que una propuesta de campo.** Si tu evento no cabe en la spec, cuéntalo aunque no traigas solución: eso es exactamente lo que necesitamos, y tiene [plantilla propia](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=case.yml).
### Cambiar la especificación
Un cambio en la spec **no es solo editar un `.md`**. La versión vigente tiene cuatro piezas que se validan entre sí, y **van en el mismo PR**:
| Pieza | Fichero |
| --- | --- |
| El schema ejecutable | `spec/v0.3/event.schema.json` / `feed.schema.json` |
| La prosa normativa (lo que un validador no puede comprobar) | `spec/v0.3/README.md` |
| Los ejemplos, incluidos los que **deben fallar** | `spec/v0.3/examples/` y `examples/invalid/` |
| La ficha del ejemplo en la galería de la web (EN + traducciones) | `spec//examples/catalog/` → `npm run build-examples` |
| Las copias publicadas (los `$id` deben resolver) | `docs/schema/` → `npm run publish-schemas` |
Antes de enviar: `npm run validate`. **Si el cambio no viene con un ejemplo que lo demuestre, no está terminado** — y si relaja una regla, quita el ejemplo de `invalid/` que ya no debe fallar.
**Un campo nuevo se declara donde le toca.** El orden en que el schema declara sus `properties` es el orden canónico de los campos: lo hereda la referencia generada y el autocompletado del editor, y los ejemplos deben seguirlo (`npm run validate` falla si no). Está explicado, con sus bloques y el porqué, en [«El orden de los campos»](spec/v0.3/README.md#el-orden-de-los-campos-no-es-normativo-pero-hay-uno). Colocarlo al final «porque es nuevo» es lo único que no vale.
**Añadir un campo no requiere cambiar el schema.** Los schemas no prohíben campos adicionales: si tu comunidad necesita `tags` o `cfp` hoy, los pones y tu documento sigue siendo válido. La spec crece con **campos que alguien ya usa de verdad**, no con campos que imaginamos que harán falta. Trae el uso real y hablamos de estandarizarlo.
### Versionado
- **`0.x` puede romper.** No hay compromiso de compatibilidad hasta la 1.0.
- **Una versión publicada no se toca.** Los cambios que rompen van a un directorio nuevo (`spec/v0.4/`), no encima de `spec/v0.3/`. Es lo que permite que un documento diga `specVersion: "0.3.0"` y alguien sepa dentro de tres años contra qué validarlo.
- Correcciones que **no** cambian qué documentos son válidos (una errata en la prosa, una descripción) sí van sobre la versión vigente.
### Adherirse: publicar tus eventos en OTE
Tres pasos, explicados con detalle en [opentechevents.org](https://opentechevents.org#adopt):
1. Publica un archivo JSON con tus eventos en una URL que controles.
2. Enlázalo desde el `` de tu web para que las herramientas lo descubran solas.
3. **Regístralo** con el [formulario](https://opentechevents.org/register/) —que te rellena el issue— o directamente con la [plantilla de adherido](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=adopter.yml), para que lo validemos y te listemos en la web.
**Valida tu feed antes de abrir el issue.** Clona este repo y pásale tu fichero:
```bash
npm install
npm run validate -- mi-feed.json
```
Detecta si es un evento suelto o un feed, y te dice qué falta (`data/events/0 must have required property 'timezone'`). Desde código, con el paquete `@opentechevents/schema`: ver [spec/v0.3/README.md](spec/v0.3/README.md#consumir-los-schemas).
> 🗓️ **¿Ya tienes un `.ics` y no quieres escribir JSON?** El agregador —que convierte calendarios existentes a OTE— es una de las herramientas del catálogo y **está por construir**. Dinos la URL de tu calendario en un [issue de apoyo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=supporter.yml) y la damos de alta como fuente en cuanto exista: es la vía de entrada más barata y no te compromete a nada.
Al dar de alta una fuente que no sea tuya, ten en cuenta que el agregador **solo ingerirá datos con licencia abierta declarada o con permiso explícito del organizador** — y que un `.ics` público no es automáticamente reutilizable (los TdS de muchas plataformas lo restringen).
### Aparecer en la web
Las listas de la web salen de cuatro archivos JSON. Añadirte es un PR de una entrada:
| Archivo | Para |
| --- | --- |
| [`docs/data/adopters.json`](docs/data/adopters.json) | Comunidades que **ya publican** sus eventos en OTE |
| [`docs/data/supporters.json`](docs/data/supporters.json) | Quien apoya el proyecto: compromisos de adopción, apoyos, embajadores, asesoras, recursos |
| [`docs/data/consumers.json`](docs/data/consumers.json) | Quien consume feeds OTE (directorios, apps, personas) y sus testimonios |
| [`docs/data/tools.json`](docs/data/tools.json) | Herramientas del ecosistema |
Los textos libres admiten `{ "en": "…", "es": "…" }`. Detalles y ejemplos en [`docs/README.md`](docs/README.md). Si prefieres no tocar JSON, abre el issue que corresponda y lo añadimos nosotros.
### Reclamar o proponer una herramienta
El catálogo está en [`docs/data/tools.json`](docs/data/tools.json) y se pinta en [opentechevents.org#tools](https://opentechevents.org#tools). **Ninguna de las ideas marcadas como *proposed* tiene dueño.** Si te quieres poner con una, **[abre un issue diciéndolo](https://github.com/OpenTechEvents/opentechevents-spec/issues/new?template=tool.yml)** antes de empezar: te ahorra duplicar trabajo y sirve para acordar el alcance.
### Traducir
Inglés y español. Hay **tres sitios distintos**, y no se mezclan:
| Qué | Dónde |
| --- | --- |
| Los textos de la web | [`docs/i18n/`](docs/i18n/) — ver [`docs/README.md`](docs/README.md) |
| Las descripciones de los campos de la spec | [`spec/v0.3/i18n/`](spec/v0.3/i18n/) |
| Las fichas de la galería de ejemplos | [`spec/v0.3/examples/catalog/`](spec/v0.3/examples/catalog/) |
Las `description` **dentro de los schemas se quedan en inglés**: viajan en el paquete npm hacia implementadores de todo el mundo. Las traducciones van aparte, indexadas por campo, y `npm run validate` **falla si falta alguna**. Tras traducir: `npm run build-reference` regenera `reference..md` y la página de referencia, y `npm run build-examples` regenera la galería de .
**¿Un idioma nuevo?** Se puede, y hace falta: cada idioma abre una región entera de comunidades. Añade `docs/i18n/.json`, mete el código en `SUPPORTED` de [`docs/app.js`](docs/app.js) y añade el botón al grupo `.lang`. Dilo antes en un issue: hay que decidir si ese idioma se mantiene también en la spec, no solo en la web, porque una traducción que se queda a medias envejece peor que no tenerla.
## Pull requests
Para cambios pequeños (erratas, enlaces rotos, una entrada en una lista, una traducción), manda el PR directamente.
Para cualquier cosa que toque **la especificación**, abre antes un issue. Un PR al modelo de datos sin debate previo es muy probable que se quede parado, no por burocracia sino porque el acuerdo es justo la parte difícil.
- Una rama por cambio, desde `main`.
- Mensajes de commit en imperativo; si sigues [Conventional Commits](https://www.conventionalcommits.org/), mejor.
- Explica **el porqué** en la descripción del PR. El qué ya se ve en el diff.
- Si **añades un ejemplo**, catalógalo en `spec//examples/catalog/en.json` (y tradúcelo): el CI falla si un ejemplo no está catalogado o si le falta una traducción. La galería de la web lee el JSON **del propio fichero validado**, así que no puede enseñar un documento inválido.
- Si tocas los **schemas o los ejemplos**, ejecuta `npm run validate` antes de enviar. El CI lo hace igualmente y **falla si un ejemplo deja de validar** — es lo que impide que la spec y sus ejemplos se separen (ya pasó una vez).
- Si añades o cambias un schema, `npm run publish-schemas` copia la versión publicada a `docs/schema/` (las URLs de los `$id` deben resolver). El validador comprueba que no se hayan separado.
- Si tocas la **web**, levántala en local con `npm run dev` (→ ) y comprueba que no rompes nada.
## Publicar una versión (mantenedores)
Los schemas se publican en npm como [`@opentechevents/schema`](https://www.npmjs.com/package/@opentechevents/schema) y se sirven en `https://opentechevents.org/schema/v0.3/…`.
1. `npm run publish-schemas` — sincroniza las copias que sirve la web.
2. Sube la versión en `package.json`.
3. Tag: `git tag schema-v0.3.1 && git push origin schema-v0.3.1`.
El resto lo hace [`publish-schema.yml`](.github/workflows/publish-schema.yml), con dos frenos deliberados: **falla si el tag no coincide con la versión del `package.json`**, y **no publica si los ejemplos no validan** — un schema que rompe sus propios ejemplos no llega a npm. No hay token: npm confía en este repo y en este workflow (*trusted publishing*, OIDC), y el paquete se firma con *provenance*.
## Idioma
El repositorio está en **español**, pero la especificación tiene vocación internacional. **Escribe en el idioma que te resulte cómodo**: si abres un issue en inglés, se te responde en inglés. Los nombres de campo de la spec son en inglés, sin discusión.
## Reconocimiento
Se usa [all-contributors](https://allcontributors.org): se reconoce **cualquier tipo de contribución**, no solo código — ideas, investigación, documentación, traducción, difusión, charlas, presentaciones que abren una puerta, revisión. Si has aportado algo y no apareces, dilo: es un olvido, no un criterio.
## Licencia de tus contribuciones
Al contribuir aceptas que tu aportación se publique bajo las licencias del proyecto (ver [LICENSE](LICENSE)):
- **prosa** (spec, docs, web, investigación) → [CC0-1.0](LICENSES/CC0-1.0.txt), dominio público;
- **schemas y código** → [MIT](LICENSES/MIT.txt).
No hace falta firmar ningún CLA. Si esto te supone un problema, dilo en el issue **antes** de contribuir y lo hablamos.
## Conducta
Todavía no hay un código de conducta formal (falta, y se agradecen propuestas). Mientras tanto, la regla es la obvia: se debate sobre ideas, no sobre personas. Quien organiza comunidades ya sabe de qué va esto.
```
## changelog.md
Source: CHANGELOG.md — What changed between versions, and why.
```markdown
# Changelog — OTE Spec
Todos los cambios relevantes de la especificación. El formato sigue
[Keep a Changelog](https://keepachangelog.com/es/1.1.0/) y el versionado es
[SemVer](https://semver.org/lang/es/): mientras la spec esté en `0.x` se
considera **inestable** (puede romper entre versiones menores; la `1.0.0` será
la primera estable).
Cada versión publicada vive congelada en su carpeta (`spec/v0.1/`, `spec/v0.2/`, `spec/v0.3/`…)
y bajo su `$id` (`https://opentechevents.org/schema/vX.Y/…`). Un documento declara
a cuál se adhiere con `specVersion`, así que **nada se rompe al publicar una
versión nueva**: los documentos `0.1.0` siguen validando contra `spec/v0.1/`.
## [0.3.0] — 2026-07-29
Nueve campos nuevos y dos valores nuevos de `status`, todo **opcional y
retrocompatible** (por eso MINOR): un documento `0.2.0` válido, con solo cambiar
`specVersion` a `"0.3.0"`, sigue siendo válido.
Entra `organizers` —**quién organiza el evento**— porque era el único hueco del
núcleo que aparece en **las cuatro** plataformas estudiadas (Meetup, Eventbrite,
Luma, Guild) y en el ejemplo canónico de Google, y porque tiene destino nativo en
los tres formatos de salida: `organizer` de schema.org, `ORGANIZER` de iCal,
`` de Atom.
El agujero que tapa es concreto: sin él, un consumidor solo puede atribuir un
evento cayendo en `feed.title` — lo que hace que **un feed de agregador atribuya
al agregador todos los eventos que agrega**.
Y entra `partOf`, que agrupa las ocurrencias de una serie o las partes de un
evento multi-parte **sin** meter una regla de recurrencia dentro del feed.
Además, un **segundo nivel de exigencia**: los perfiles de campos recomendados.
No cambian lo que es válido —ni un solo documento deja de validar— pero ponen
nombre a la diferencia entre un evento válido y uno que de verdad se puede
descubrir y seguir.
### Added
- **`organizers`** (`array`, mín. 1) en el **evento** y en el **feed**. Cada
entrada: `name` (obligatorio), `url`, `email` y `type` (`organization` por
defecto, o `person`) opcionales. **Nada más** — sin logo ni identificadores: el
campo describe **quién organiza y dónde escribirle**, no su ficha completa.
- **Es una lista, no un objeto.** La co-organización es lo normal (dos
comunidades, o comunidad + anfitrión); Luma ya emite `organizer` como array.
Ensanchar objeto → lista después habría roto. **El orden es significativo**:
el primero es el principal, y es el único que sobrevive a iCal.
- **`type` sí tiene valor por defecto** (`organization`), a diferencia de
`attendanceMode`. Motivo: al traducir hay que elegir un `@type` de schema.org
sí o sí, y `Organization` es la opción tolerante.
- **Herencia feed → evento por REEMPLAZO, no por fusión.** Como `license`, el
`organizers` del feed es el valor por defecto de sus eventos; pero el evento
que declara el suyo **sustituye la lista entera**. Con fusión no habría forma
de *quitar* un organizador heredado. Consecuencia práctica: en un evento
co-organizado dentro de un feed comunitario hay que **repetir** la comunidad
del feed.
- **Un feed de agregador debe OMITIR `organizers`**: no organiza lo que
publica.
- **`email`** (`format: email`, sin el prefijo `mailto:`) entra por el mismo
camino que `tags`, `location.geo` y `updatedAt` en la v0.2: **el importador de
`.ics` lo tenía delante y no había dónde ponerlo**. Un `VEVENT` publicado trae
`ORGANIZER;CN="…":mailto:…` con muchísima frecuencia, y sin este campo
`.ics` → OTE → `.ics` **perdía el `ORGANIZER`** — la única pérdida que la spec
califica de grave. Arregla además el `` de RSS 2.0, que exige email.
Ninguna de las cinco plataformas estudiadas lo emite en su JSON-LD: **el
productor es iCalendar, no la web de eventos**, y por eso el campo llega con
reglas. Dirección **de rol** (`info@`, `hola@`), no el buzón de nadie; un
importador **MUST NOT** rellenarlo desde una fuente que no esté publicada
públicamente (copiar de un calendario privado a un feed abierto le cambia el
nivel de exposición a una dirección que nadie publicó); un consumidor **MUST
NOT** usarlo para nada que no sea escribir sobre el evento; y un exportador
**no se inventa** el `mailto:` a partir del dominio de `url` — sin `email` no
hay `ORGANIZER`, y punto.
- **`email` NO es un campo recomendado**, y es el único de la spec que se queda
fuera del perfil por una razón que no es técnica: avisar de un email ausente
es presionar a alguien para que publique una dirección que decidió no
publicar. (La razón técnica también aplica: quien importa un `.ics` sin
`ORGANIZER` no puede atender el aviso.)
- **`email` no añade ninguna regla de herencia.** Vive dentro de
`organizers[]`, así que hereda con la lista y **por reemplazo**: un evento que
declara `organizers` no hereda el email del feed, y un feed de agregador —que
debe omitir `organizers`— no tiene ninguno que propagar. El riesgo de heredar
un email ajeno solo existiría con un `feed.organizerEmail` suelto o con fusión
campo a campo, y no se hace ni lo uno ni lo otro.
- **`partOf`** (`object`) en el **evento**. La serie o el evento multi-parte del
que este documento es **una ocurrencia**. `id` obligatorio; `name`, `url` y
`type` (`series` por defecto, o `multipart`) opcionales.
- **Es una referencia, no una regla de recurrencia.** La norma que lo acompaña:
**un documento = una ocurrencia, y quien publica expande**. Un meetup mensual
son doce documentos, cada uno con su `id`, sus fechas y su `status`; un study
jam en tres sesiones, tres. `partOf` solo dice a qué conjunto pertenecen.
- **`type` cambia la traducción**, no es decoración: `series` → `EventSeries` de
schema.org; `multipart` → un `Event` cuyas partes son su `subEvent`. En iCal,
`RELATED-TO;RELTYPE=PARENT` en ambos casos. En Atom/RSS no hay equivalente y
**se ignora, sin daño**: la entrada sigue describiendo un evento con su fecha
real. Un campo de identidad que se ignora deja datos incompletos; uno de
fechas que se ignora deja datos **falsos**. Solo lo primero es aceptable.
- **Un evento multi-parte no se expresa estirando las fechas.** `startDate` en
la primera parte + `endDate` en la última afirma un evento continuo de quince
días y ocupa dos semanas en el calendario de quien se suscriba.
- Reglas para quien expande: horizonte acotado en series infinitas (12 meses o
12 ocurrencias), `id` por ocurrencia (`#` si no tiene página
propia — el equivalente de `RECURRENCE-ID`), y `EXDATE` deja de existir: es
*no emitir* ese documento, o `status: cancelled` si ya se había publicado.
- **`image`** (`array`, mín. 1) en el **evento**. Imágenes promocionales: cartel,
portada, tarjeta. Cada entrada es **o una URL `https` pelada, o un objeto
`{ url, alt?, translations? }`**. **El orden es significativo** — la primera es
la principal, y a menudo la única que un destino puede usar.
- **No es una galería.** Lo habitual es que las entradas sean **la misma
imagen** en distintos recortes o resoluciones, que es lo que pide Google
(1:1, 4:3, 16:9) y lo que ya emiten Meetup, Luma y Guild. Pero es lo
habitual, **no una garantía**: nadie impide publicar el cartel y una foto de
la sede, y un consumidor no puede distinguir los dos casos. La regla que vale
es la del orden; ninguna interfaz debe renderizarla como carrusel.
- **`image[].alt`** (`string`, no vacío, ≤ 250) — texto alternativo, por
accesibilidad. Describe **lo que se ve**, no lo que ya dicen `name` y
`description`: repetirlos hace que un lector de pantalla los diga dos veces.
Sin cadena vacía: el `alt=""` de HTML significa «decorativa», y una imagen
decorativa no pinta nada en un feed.
- **Va dentro de la entrada, no en un `imageAlt` hermano del evento.** Un
solo `alt` para toda la lista describiría la primera imagen y se aplicaría
a las tres; solo sería correcto si la lista fuese siempre el mismo cartel
recortado, y eso **no se puede garantizar**.
- **Las dos formas de entrada conviven** en vez de migrar la lista a objetos:
así **ningún documento `0.2` o `0.3` ya publicado deja de validar** —esta
versión sigue siendo retrocompatible— y los recortes extra, que no
necesitan `alt`, siguen siendo una cadena. Quien consume normaliza en una
línea (`typeof i === "string" ? { url: i } : i`).
- **Se traduce, y no se escribe en «inglés internacional».** Un `alt` se lee
en voz alta con la pronunciación del idioma que lo rodea, así que va en el
`textLanguage` del documento y se traduce con el `translations` **de la
propia entrada** (nunca un espejo posicional, como `offers[].name`).
Escribirlo siempre en inglés sería peor accesibilidad que no tenerlo
—[WCAG 3.1.2](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html)
existe justo por esto— y lo convertiría en el único texto libre de la spec
que no sigue a `textLanguage`.
- **No es SEO**: Google no puntúa el `alt` de `Event.image`. Entra por las
personas que no ven el cartel, y por eso **no** espera a tener un productor
que ya lo emita — es el único criterio de esta spec que cede ante la
accesibilidad.
- Traducción: en schema.org, URLs peladas para las entradas sin `alt` y
`ImageObject { url, caption }` para las que lo llevan (`Event.image` admite
`ImageObject`, así que el rich result se conserva; schema.org **no tiene
propiedad `alt`**, y `caption` —la que usa Google— pierde el matiz);
`IMAGE;VALUE=URI` (RFC 7986) en iCal, solo la primera en la práctica y **sin
`alt`, que no tiene dónde ir**; `` o `` +
`` en RSS y `` en Atom — donde el
`type` (MIME) hay que inferirlo, porque OTE no lo modela.
- **Entra en el perfil recomendado.** No rompe nada en los tres destinos, pero
el aviso es **accionable**: las cinco fuentes estudiadas ya emiten imagen, así
que un feed sin `image` casi nunca es un evento sin cartel — es un cartel sin
mapear. Sigue siendo un aviso: nada deja de validar.
- Era una **extensión sin prefijo** en los ejemplos desde la v0.1 (como cadena
suelta). Se gradúa a núcleo **como lista**: si la emitías como cadena, migra.
- **`location.address`** (`object`) en el **evento**: la dirección postal de la
sede física **por partes** — `street`, `locality`, `region`, `postalCode` y
`country`, todas opcionales, con al menos una presente.
- **Complementa a `venue`, no lo sustituye.** `venue` sigue siendo la cadena
legible que imprimen `LOCATION` de iCal y el texto de un ítem RSS —los dos
formatos que **no modelan direcciones**—, y `Place.name` de schema.org.
`address` es lo que necesita el traductor para emitir un `PostalAddress`
cuyos subcampos **Google valida uno a uno** en el rich result de `Event`.
`venue` para leer, `address` para procesar.
- **Entra porque ya existe ahí fuera**: cuatro de las cinco fuentes estudiadas
(Meetup, Eventbrite, Guild y el ejemplo canónico de Google) emiten
`PostalAddress` con sus subcampos. Sin el campo, un exportador solo podía
emitir la dirección como texto suelto o **partir `venue` por comas**, que es
inventar datos.
- **`country` es ISO 3166-1 alfa-2 en mayúsculas** (`ES`, `US`), la única parte
con formato exigido: el nombre del país tiene una grafía por idioma, y quien
agrupe por país vería «España», «Spain» y «Espagne» como tres países.
Convertir nombre → código es consultar una tabla, no inventar. `region` se
deja libre: no hay tabla universal (provincia, estado, condado, *Land*).
- **Omitir es la forma correcta de no saber**: `""` y `null` se rechazan (cada
parte es una cadena de longitud ≥ 1), y `"address": {}` también, igual que
`location: {}`. Con caso real detrás: Guild emite hoy los cinco subcampos a
`null`.
- **No satisface `location` por sí solo**: sigue haciendo falta `venue` u
`onlineUrl`, misma regla que `geo`.
- **No entra en el perfil recomendado**: lo recomendado es `location`. Qué hace
falta saber del sitio depende del tipo de evento —a uno online la dirección
postal no le aplica, y a un meetup en un bar le basta el nombre del bar—, así
que el aviso no sería accionable para buena parte de los eventos. Es una
mejora real cuando se tiene, no un mínimo de calidad.
- **`offers`** (`array`, mín. 1) en el **evento**: qué cuesta asistir y dónde
registrarse. Cada entrada: `name`, `price`, `currency`, `url`, `availability`
(`in-stock` \| `sold-out`), `opensAt` y `closesAt`, con al menos `price` o
`url` presente.
- **Ausente significa DESCONOCIDO, nunca gratis.** Decir «gratis» tiene una
forma, y es `price: 0`. Un consumidor que lea «sin `offers` = gratis»
convierte en gratuita toda conferencia de pago cuyo exportador no mapeó el
precio.
- **Es una lista** porque el precio de un evento casi nunca es un número:
early bird, general, estudiantes. Por eso **no hay rangos ni «desde 45 €»** —
`price` es un **número**, sin símbolo ni separador de miles: un precio que no
cabe en un número **son varias ofertas**, y como texto no se puede filtrar ni
comparar, que es lo único por lo que merece la pena publicarlo como dato.
- **`currency` (ISO 4217 alfa-3) es obligatoria en cuanto `price` pasa de 0**, y
sobra cuando es 0 — lo gratis es gratis en cualquier moneda. Es exactamente
cómo Luma acaba emitiendo `"price": 0, "priceCurrency": "usd"` para una sesión
que no cuesta nada. El schema lo expresa con un `if`/`then`.
- **`availability` no tiene valor por defecto**: ausente = desconocido. Un feed
desactualizado que sigue afirmando `in-stock` es peor que uno callado, porque
manda a alguien a una taquilla cerrada.
- **`waitlistUrl`**: dónde apuntarse a la cola una vez agotada la oferta.
«Agotado y nada que hacer» y «agotado, pero puedes hacer cola» dejan de ser
el mismo documento.
- **No es un tercer valor de `availability`**, y la razón es **cómo degrada**:
con `sold-out` + `waitlistUrl`, todo consumidor que ya existe sigue leyendo
«agotado», que es **verdad**. Con `availability: "waitlist"`, el que no
conozca el valor no tiene lectura segura, y el que parsee
`availability !== "sold-out"` ⇒ disponible —lo normal— anunciaría como
comprable algo que no lo es. Omitir la cola es una omisión; decir «a la
venta» es una mentira.
- **`price` no contradice a `sold-out` ni a la cola**: `price` describe el
trato, `availability` si puedes actuar sobre él ahora. El caso que más lo
necesita —**evento gratuito con aforo limitado**— es `price: 0` +
`sold-out` + `waitlistUrl`. Apuntarse a una cola no cuesta dinero.
- **El schema rechaza `in-stock` + `waitlistUrl`** con un `if`/`then`: una
cola para algo que está a la venta no es una cola. **Permite**
`waitlistUrl` sin `availability`: quien no mantiene el estado de la
taquilla no debe verse forzado a *afirmar* `sold-out` para mencionar la
cola. Se prohíbe lo incoherente, nunca lo incompleto.
- Traducción: schema.org **no tiene término** para lista de espera
(`BackOrder` y `PreOrder` significan otra cosa) → se emite `SoldOut`, que
no es falso, y la cola se degrada al texto. Un valor de enum habría
perdido lo mismo.
- **Descartado `last-tickets`** (`LimitedAvailability` de schema.org, que
Google lee): de las cinco fuentes estudiadas, las tres que emiten
`availability` emiten `InStock` y nada más; el umbral no se puede definir
—cinco plazas, el 10%, lo que diga el marketing—, así que nadie podría
filtrar ni comparar; es el estado más volátil posible; y sobre todo **no
cambia la acción**: sigues pudiendo comprar. Eso es urgencia, y la urgencia
es aforo — taquilla, que la spec deja fuera.
- **Regla de compatibilidad hacia delante para consumidores** (no es un campo,
es normativa): **un valor de enum que no conozcas se trata como desconocido,
nunca como el valor tolerante.** Es «ausente = desconocido» aplicada al futuro
en vez de al vacío, y sin ella cualquier valor añadido en una versión posterior
se convierte en «disponible» o en «presencial» para quien parsea con
desigualdades.
- **Entra porque ya existe ahí fuera**: tres de las cinco fuentes estudiadas
(Luma, Guild y el ejemplo canónico de Google) emiten `offers` con esta misma
forma, y Google lo **muestra** en el rich result de `Event`.
- **Sin `isFree`** (estaba en el boceto anterior): redundante con `price: 0`, y
dos formas de afirmar lo mismo son dos formas de contradecirse. Sin `capacity`
ni plazas restantes: eso es **taquilla**, no entrada. `registrationUrl` pasa a
llamarse `url` — el objeto ya se llama oferta.
- Traducción: `Offer` de schema.org **1:1** (`currency` → `priceCurrency`,
`opensAt`/`closesAt` → `validFrom`/`validThrough`, `availability` → las URLs
`InStock`/`SoldOut`). En **iCal y RSS/Atom no hay nada**: RFC 5545 no modela
precio. Va a la `DESCRIPTION` o al cuerpo del ítem.
- **`cfp`** (`object`) en el **evento**: la convocatoria de propuestas. `url`
obligatoria; `opensAt`, `closesAt`, `coversTravel` y `coversAccommodation`
opcionales.
- **Es el único campo de la spec sin equivalente en ninguno de los tres
destinos**, y entra igualmente — por el otro lado del tubo. «¿Qué conferencias
aceptan propuestas ahora mismo?» es una de las preguntas que este proyecto
existe para contestar, y hoy se contesta **scrapeando**: confs.tech,
developers.events o CFP Land mantienen a mano justo estos dos datos, enlace y
fecha límite. El productor existe; publica en HTML, no en JSON-LD. OTE no es
solo un formato de exportación: es también donde puede vivir un dato que los
otros tres no saben nombrar.
- **Un objeto, no una lista**, al contrario que `organizers`: allí Luma **ya
emite** varios, aquí ningún productor real publica dos convocatorias por
evento. Si el caso aparece, ensanchar objeto → lista rompe y llegará con su
versión; no se paga hoy por un caso hipotético.
- **`coversTravel` y `coversAccommodation` no tienen valor por defecto**:
ausente = desconocido, nunca `false`. Están porque son lo que se filtra antes
de decidir si uno puede permitirse enviar una propuesta.
- **`closesAt` entra en el perfil recomendado, de forma condicional** (solo si
hay `cfp`): sin fecha límite, un consumidor ve un enlace y no sabe si cerró en
marzo. `offers` y `cfp` **no** son recomendados en sí: la mayoría de eventos no
tiene convocatoria, y el precio no siempre se puede recuperar de la fuente.
- **Sin `timezone` propio** (lo tenía el boceto anterior): sus fechas son
instantes con offset, ver abajo.
- Traducción: **nada en schema.org, nada en iCal, nada en RSS/Atom.** Se
degrada a texto en la `DESCRIPTION` o en el cuerpo del ítem, o a un
`X-OTE-CFP-URL`.
- **`eligibility`** (`object`) en el **evento**: quién puede entrar, cuando la
respuesta no es «cualquiera». `type` obligatorio (`open` \| `members-only` \|
`approval-required` \| `restricted`); `note` y `url` opcionales.
- **Es la tercera parte de «¿puedo ir?»**, junto a `attendanceMode` y
`location`: esos dos dicen si el evento **está a tu alcance**, este si **te
dejan entrar**. Se contradicen sin problema — una sesión online, gratis y
abierta a cualquiera con conexión cuyo canal de voz está dentro de un Discord
al que hay que pertenecer.
- **Sustituye a la práctica de meterlo en `tags`.** En una lista libre,
`["rust","members-only"]` obliga al consumidor a **adivinar** cuál de esas
cadenas es una condición de acceso, y ninguna interfaz puede ofrecer un
filtro «solo eventos a los que puedo entrar». La descripción de `tags` ahora
dice explícitamente que es **de qué va** el evento, no quién puede entrar.
El otro eje que también se cuela ahí —**público y nivel**— sigue sin
resolver: es pregunta abierta, no este campo.
- **`approval-required` es un juicio sobre la persona, no aforo.** Alguien mira
tu solicitud y decide (el «request to approve» de Luma, un grupo de Meetup con
pregunta de admisión). Un evento al que se entra **por orden de llegada** hasta
que se acaban las plazas **no tiene puerta**: es `open`, y que se agoten es
`offers[].availability: "sold-out"` (o `capacity`, que sigue siendo extensión).
Regla que sostiene el campo: si la respuesta a «¿puedo ir?» cambia sola con el
tiempo, **no es `eligibility`** — es taquilla.
- **Sin `invite-only`**, y se cae **por alcance, no por forma**: esta spec existe
para encontrar eventos y comunidades **en los que se puede participar**, y un
evento al que solo se entra por invitación es un club privado. Los casos que
rozan el límite —una cena de speakers y patrocinadores, un encuentro de un
programa de embajadores— siguen siendo publicables con `restricted`, y con
**más** información: su `note` obligatoria dice **quién** puede entrar, donde
`invite-only` solo decía «tú no».
- **`restricted` exige `note`**, con un `if`/`then` como el de `currency`: es
el escape que mantiene el enum pequeño («solo alumnado de la UAL») sin que
nadie tenga que meter a martillazos su condición en `members-only`. Y
`restricted` a secas no dice nada.
- **Sin valor por defecto**: ausente = desconocido, **nunca `open`**. Quien
importa un `.ics` no tiene el dato, y un defecto convertiría cada evento
importado en una afirmación que nadie hizo. Corolario: `"type": "open"` **sí
aporta información**, al contrario que `"status": "scheduled"`.
- **Un objeto, no una cadena**: *qué* comunidad es un dato que quien publica ya
tiene, y ensanchar cadena → objeto después rompe. Solo `type` es obligatorio.
- **No va dentro de `offers`**, aunque el eje por tramo exista (la tarifa de
estudiante pide carné): `offers` está ausente en la mayoría de los eventos y
nunca llega desde un `.ics`, así que la puerta desaparecería donde más
eventos hay. `offers[].eligibility` queda **reservado**, con este mismo enum,
para cuando haya productor real.
- **No modela** aforo ni plazas (eso es taquilla), el estado de *tu* solicitud,
códigos de acceso, listas de invitados ni el código de conducta: describe
**la condición, no el trámite**.
- Traducción: **nada estructurado en ninguno de los tres destinos** — el caso
de `cfp`. Se degrada al texto (`DESCRIPTION`, cuerpo del ítem,
`description`), que es para lo que está `note`, o a un `X-OTE-ELIGIBILITY`.
Tres falsos amigos a evitar: `CLASS:PRIVATE` de iCal es la **visibilidad del
dato**, `Offer.eligibleCustomerType` de schema.org es B2B/B2C, y
`isAccessibleForFree` es el precio. Ninguno es la puerta.
- **`textLanguage`** (etiqueta BCP 47) en el **evento** y en el **feed**: el
idioma en que está escrito el **texto libre del documento**.
- **No es `languages`, y no se derivan.** `languages` son los idiomas que se
**hablan** en el evento; `textLanguage` es el idioma en que está **escrito**
este texto. Una sesión bilingüe descrita solo en catalán es
`languages: ["ca","es"]` + `textLanguage: "ca"`. La descripción de
`languages` ahora lo dice en el schema, que es donde alguien lo leerá.
- **Una etiqueta, no una lista**: un texto está escrito en un idioma.
- **Se hereda del feed**, como `license` y `organizers`: quien publica en un
solo idioma lo declara **una vez en todo el fichero**. Ausente = desconocido,
nunca el inglés ni el idioma de la respuesta HTTP.
- **Desbloquea cosas concretas** que hoy no se pueden ni adivinar: el `lang`
del HTML —del que dependen la separación de sílabas, el lector de pantalla y
el corrector—, la ordenación alfabética correcta y la decisión de traducir
automáticamente o no.
- Traducción: **los tres destinos lo reciben**. `LANGUAGE` es un parámetro
nativo de iCal (`SUMMARY;LANGUAGE=ca:…`, RFC 5545), RSS tiene ``,
Atom `xml:lang` y JSON-LD `@language`.
- **`translations`** (`object` indexado por etiqueta BCP 47) en el **evento**
(`name`, `description`) y en el **feed** (`title`, `description`): el mismo
texto en otros idiomas.
- **Aditivo: `name` y `description` siguen siendo cadenas.** Un consumidor de
v0.2 lee un documento con `translations` sin enterarse de que existe. La
alternativa —mapas de idioma en el propio campo, `"name": {"ca":…,"es":…}`,
el `@container: @language` de JSON-LD— es más limpia y **rompe `name` para
todos los consumidores actuales**, gravando al 99% monolingüe para servir al
1%. Descartada por eso.
- **Un mapa, no una lista**, porque el idioma **es** la clave: una entrada por
idioma y ninguna forma de publicar dos versiones en castellano que se
contradigan. Claves BCP 47 (`"castellano"` no valida) y **mapa vacío
inválido**, como `location: {}`.
- **El texto que vive dentro de un objeto se traduce donde vive**, con un
`translations` local: `offers[].translations` (`name`),
`eligibility.translations` (`note`) y `partOf.translations` (`name`).
**Nunca un espejo posicional** (`translations.es.offers[0].name`): una lista
no tiene claves estables, así que reordenar las ofertas colgaría la
traducción de la tarifa equivocada **sin que nada dejara de validar**.
- **Lo que NO se traduce, y por qué.** Nombres propios (`organizers[].name`,
`location.venue`): «PyAlmería» es «PyAlmería» en todos los idiomas.
Identificadores (`id`, `partOf.id`, las `url`): dos grafías serían dos
eventos. Códigos (`country`, `currency`, `languages`) y **enums**
(`eligibility.type`, `status`, `attendanceMode`, `availability`): un valor
cerrado es **multilingüe gratis** — se renderiza en el idioma de quien lee y
el dato no cambia. Y `tags`, que al ser texto libre deja una arista real
—`["aprenentatge-automàtic"]` y `["machine-learning"]` no se encuentran—:
la recomendación es etiquetar en el idioma del ecosistema técnico.
- **`offers[].name` sigue siendo texto libre**: se valoró un `kind` con enum,
que habría sido multilingüe gratis, y se descarta porque le quitaría a quien
organiza el derecho a nombrar sus propias entradas. Libertad ahora, y
`offers[].translations` es su precio.
- **`locality` y `region` no se traducen**: «València»/«Valencia» son grafías
del mismo sitio y no hay tabla como la de países. Regla, no validación:
**la grafía más reconocible para la audiencia mayoritaria del evento**. Quien
necesite precisión sin idioma tiene `location.geo`, que no tiene grafías.
- **Cualquier `translations` del documento exige `textLanguage`**, con un
`if`/`then` y **a cualquier profundidad**: una traducción dentro de una oferta
también lo activa. Es la **única dependencia entre campos de la spec**. Sin
saber en qué idioma está el texto principal, nadie puede saber cuál entrada
lo duplica ni a qué está cayendo de vuelta.
- **Nunca se traduce al idioma que ya declara `textLanguage`** — sería el mismo
texto dos veces. Regla **normativa que el schema no puede comprobar**:
comparar el valor de un campo con el nombre de una clave está fuera de JSON
Schema.
- **En el feed no se hereda**: `feed.translations` traduce el título DEL FEED,
nunca el nombre de sus eventos. `feed.textLanguage` sí se hereda.
- **Deuda declarada**: a diferencia del resto de campos de esta versión,
**ningún productor real lo emite** — cada plataforma sirve una página por
idioma. Entra porque en catalán, euskera, galego y valenciano el evento
bilingüe **es el caso normal**, y hoy la única salida es meter dos idiomas
dentro de la misma cadena, que es peor. Sigue en pie la alternativa de **un
feed por idioma** (`/feed.ca.json`, `/feed.es.json`). Si nadie lo emite, se
retirará igual de en voz alta.
- Traducción: **solo JSON-LD lo recibe entero** (mapas de idioma). En iCal no
hay dónde —`SUMMARY` no se repite— y en RSS el idioma es del canal; Atom
aguanta algo más porque `xml:lang` es por elemento.
- **Fechas límite como INSTANTES** (`cfp.opensAt`, `cfp.closesAt`,
`offers[].opensAt`, `offers[].closesAt`): exigen offset o `Z`, a diferencia de
`startDate`/`endDate`, que son reloj de pared. No es una incoherencia: un evento
le pasa a la gente **en un sitio**, y una fecha límite es **un botón que deja de
funcionar**, que ocurre a la vez en Madrid y en Bogotá. El caso que lo zanja es
*anywhere on Earth*: un CFP que cierra AoE lo hace en **UTC-12**, que no es la
zona del evento ni la de nadie que lo organice, y con reloj de pared +
`timezone` no se puede expresar. Un `"23:59"` pelado es el bug clásico de las
convocatorias.
- **Dos valores nuevos de `status`: `moved-online` y `tentative`.** El enum pasa a
ser `scheduled` (por defecto), `tentative`, `cancelled`, `postponed`,
`rescheduled`, `moved-online`. Añadir valores a un enum no invalida ningún
documento anterior.
- **`moved-online`** completa el enum `eventStatus` de schema.org
(`EventMovedOnline`), que es el que consume Google. **Debería** —no debe—
venir con `location.onlineUrl` y `attendanceMode: "online"`: el schema no lo
exige porque el enlace de conexión a menudo no es público todavía, y exigirlo
obligaría a quien importa a inventárselo o a descartar el evento. En iCal no
hay forma de distinguirlo: se emite `CONFIRMED` con la URL en `LOCATION`.
- **`tentative`** no viene de schema.org sino de iCal (`STATUS:TENTATIVE`), que
es lo que emite cualquier calendario para lo que aún no está cerrado. Entra
porque `status` es **el único campo de la spec con valor por defecto**: sin
`tentative`, quien importa un `.ics` solo puede **ascender el evento a
`scheduled`**, afirmando algo que nadie afirmó. Mismo argumento que el de
`attendanceMode` sin valor por defecto. Al traducir a schema.org se pierde
(se emite `EventScheduled`): es el único valor que viaja mejor a iCal.
- Se documenta también la diferencia entre **`postponed`** (aplazado, **sin**
fecha nueva: el documento conserva las fechas antiguas) y **`rescheduled`**
(ya con fecha nueva, que es la que lleva el documento). Y que **RSS/Atom no
tienen `status`**: quien exporte debe llevarlo al título de la entrada
(`[CANCELADO] …`).
- **Campos recomendados**, como dos schemas nuevos y publicados:
[`event.recommended.schema.json`](spec/v0.3/event.recommended.schema.json) y
[`feed.recommended.schema.json`](spec/v0.3/feed.recommended.schema.json).
- **Son perfiles de calidad, no de validez.** `event.schema.json` responde «¿es
esto un evento OTE?»; el perfil responde «¿sirve para algo?». Regla
normativa: una herramienta **puede avisar** de un campo recomendado que
falta y **no debe rechazar** el documento por ello. Lo contrario
reintroduciría por la puerta de atrás lo que la permisividad evita: quien
importa un `.ics` pelado tendría que inventarse el dato o tirar el evento.
- **Recomendados en el evento**: `url`, `description`, `organizers`,
`location`, `attendanceMode`, `tags`, `languages`, `updatedAt` — y `endDate`
**solo si `startDate` lleva hora** (en un evento de todo el día su ausencia
ya significa «acaba el día que empieza», así que avisar sería ruido). El
criterio no es «estaría bien tenerlo» sino **qué se rompe en los tres
destinos si falta**: sin `url` no hay enlace en RSS/Atom, sin `tags` no hay
filtrado por interés, sin `updatedAt` no hay sincronización incremental —
que es lo que hace posible *suscribirse* en vez de releerlo todo.
- **Recomendados en el feed**: solo `url` y `description`. Casi toda la calidad
de un feed está en sus eventos, y un checker aplica el perfil de evento a
cada uno **con la herencia ya resuelta**.
- **`status` NO es recomendado**: es el único campo con valor por defecto, y
escribir `"scheduled"` no añade nada a su ausencia. Lo que importa de
`status` es *actualizarlo cuando el evento se cae*, y eso ningún schema lo
puede comprobar.
- **`feed.organizers` NO es recomendado**: un agregador **debe** omitirlo. Un
aviso ahí le empujaría a atribuirse eventos que no organiza, corrompiendo el
dato que el campo existe para proteger.
- La referencia de campos pasa a tener tres niveles (`obligatorio`,
`recomendado`, `opcional`), leídos **de los perfiles**, no escritos a mano.
En el índice de la web, un punto naranja marca los recomendados junto al
punto de acento que ya marcaba los obligatorios.
`npm run validate` los reporta como avisos y **nunca** cambia el código de
salida.
- **Política de extensiones con prefijo**, en el README de la spec. Se distinguen
dos tipos de campo adicional: **candidato a núcleo** (sin prefijo: `capacity`,
`sponsors`) y **vocabulario externo** (con prefijo: `combuilders:communityId`).
- **Compromiso normativo: OTE no acuñará jamás un nombre de campo que contenga
`:`.** Es una reserva de espacio de nombres — un campo con prefijo no puede
colisionar con uno del núcleo, hoy ni en la v1.0.
- Es lo que permite que OTE **conecte** con otras especificaciones (un
directorio de comunidades, por ejemplo) sin **acoplarse** a ellas.
### Decidido NO incluir
- **`eventSchedule` (schema.org `Schedule`), en sustitución de
`timezone`/`startDate`/`endDate`.** Sería más expresivo —`repeatFrequency`,
`byDay`, `exceptDate`— y se descarta por cuatro razones:
1. **Mete un motor de expansión en un fichero.** El feed es un formato de
intercambio, no una API: el consumidor lee, no calcula. Con una regla, hasta
el script de treinta líneas que pinta un listado necesita aritmética de
calendario (DST, excepciones, series infinitas, semántica de `"2MO"`).
2. **RSS/Atom no pueden expresarla.** Quien exporte tiene que expandir
igualmente: la expansión ocurre siempre, y la pregunta es solo **quién** la
hace. Mejor quien publica —una vez, con el dato delante— que cada consumidor
por su cuenta, cada uno con su bug.
3. **Rompe reglas que la spec ya tiene.** Un `id` estable no sobrevive a N
ocurrencias en un documento (haría falta un `RECURRENCE-ID`), y
`status: cancelled` deja de ser expresable por ocurrencia: cancelar *la
sesión de agosto* volvería a ser imposible.
4. **No hay productor real.** De las cinco fuentes estudiadas (Meetup,
Eventbrite, Luma, Guild y el ejemplo canónico de Google), **ninguna** emite
`eventSchedule` — todas emiten fecha plana por ocurrencia, incluida la sesión
*semanal* de Luma. Y Google, que consume schema.org a escala, pide
explícitamente un `Event` por fecha.
- **`RRULE` como campo del núcleo.** Misma razón. Un importador que quiera
round-trip sin pérdida la guarda como vocabulario externo con prefijo
(`"ics:rrule": "FREQ=MONTHLY;BYDAY=2MO"`): informativa, y ningún consumidor de
OTE está obligado a evaluarla.
- **`organizers[].email` como campo recomendado.** El campo **sí entra** (ver
*Added*): sin él no hay `ORGANIZER` de iCal válido que emitir. Lo que se
descarta es **recomendarlo**. Publicar una dirección en un feed abierto y
rastreable es regalarla a los recolectores de spam, y lo publicado no se
despublica; un aviso por su ausencia sería el perfil de calidad empujando a
alguien a exponer datos de contacto. Sigue siendo opcional, y quien no lo ponga
degrada el `ORGANIZER` a `X-OTE-ORGANIZER` o a la `DESCRIPTION`.
- **Un `email` heredable a nivel de feed** (`feed.organizerEmail`, o herencia
campo a campo dentro de `organizers`). Sería la forma de que un feed de
agregador acabara atribuyendo **su** dirección a eventos que no organiza, o de
que un evento invitado heredara la de la comunidad anfitriona. El reemplazo de
lista que `organizers` ya tiene resuelve el caso comunitario sin abrir ninguno
de los dos: el email viaja dentro de su objeto y nunca por su cuenta.
- **`previousStartDate`** (schema.org), la fecha que tenía un evento
`rescheduled` antes de moverse. Ninguna de las fuentes estudiadas la emite, y
`updatedAt` ya dice que el dato cambió. Entra como candidata a núcleo (campo sin
prefijo) el día que alguien la use de verdad.
- **Aforo y estado de la venta** (`maximumAttendeeCapacity`, plazas restantes,
códigos de descuento, inscripción única de un evento multi-parte). Guild sí
emite el aforo, así que es un candidato razonable — pero el resto es **estado
de la taquilla**: cambia solo, caduca en minutos y un fichero JSON regenerado
cada noche no lo puede sostener. `offers` describe **la entrada**, no la venta.
El aforo entra hoy como extensión sin prefijo (ver
[`event-meetup.json`](spec/v0.3/examples/event-meetup.json)).
- **Ponentes (`speakers` / `performer`) y agenda.** Siguen fuera: `cfp` modela la
convocatoria, no lo que sale de ella. Meter a un ponente en `organizers`
corrompe el dato para todo el que lo consuma.
- **`organizers[].logo`, `organizers[].sameAs`.** Sobrecargan el campo sin que
ningún consumidor los pida todavía.
- **`organizers[].id` / `linking.communityId`.** Se valoró un identificador
(`combuilders:mi-comunidad`) que enlazara con un directorio de comunidades.
Descartado: exige una **gobernanza de prefijos** que este proyecto no tiene y
que acoplaría OTE a otro; contradice la regla de `id` de la propia spec (*una
URI bajo un dominio que controlas*, que no necesita registro central porque el
DNS ya garantiza unicidad); y **no hay todavía un consumidor real** — el
directorio no existe como especificación. Entra hoy como extensión con prefijo,
y se graduará a núcleo si sobrevive al uso real, con la forma que dicte ese uso.
### Changed
- El campo de extensión **`community`** (`{ uri, name }`) que aparecía en los
ejemplos como campo *en discusión* queda **superado por `organizers`** y se
retira de ellos. Nunca fue normativo, así que esto no rompe nada: quien lo
emita seguirá validando.
- El paquete npm exportaba solo los subpaths de `v0.1`. Ahora expone `v0.1`,
`v0.2` y `v0.3`.
- **Los campos se declaran en un orden canónico** —identidad, cuándo, dónde,
filtros, estado, procedencia— y lo siguen el schema, los ejemplos y la
referencia generada. **No cambia nada de lo que es válido**: JSON no tiene
orden y ningún consumidor debe depender de él. Cambia lo que se lee: la tabla
de referencia, el autocompletado del editor y el ejemplo que alguien copia
ahora enseñan la misma forma. `npm run validate` lo comprueba en los ejemplos
de `v0.3`; las versiones congeladas conservan el suyo. El orden y el porqué,
en [«El orden de los campos»](spec/v0.3/README.md#el-orden-de-los-campos-no-es-normativo-pero-hay-uno).
- **`source` ya no exige `name`: pide `name` **o** `url`** (lo ideal, las dos).
**Relaja** lo que era válido, así que ningún documento `0.3.0` deja de validar
—y una `source` vacía, o con solo `license`/`retrievedAt`, sigue siendo
rechazada—. El motivo: quien importa un `.ics` siempre conoce la dirección que
descargó y a menudo no tiene nombre de publicador que leer (el `X-WR-CALNAME`
de iCalendar es opcional), así que la regla anterior se cumplía **inventando**
el nombre, y una fuente inventada es peor que una fuente dada solo como
enlace. Misma regla que `offers` (`price` o `url`) y `location` (`venue` o
`onlineUrl`). Quien consuma: no dé por hecho que `source.name` está — cuando
falte, la etiqueta se saca del host de `source.url`.
- **La referencia generada ahora publica las restricciones de objeto** que el
validador aplica y que ninguna columna «Nivel» podía expresar: `location` con
`venue` o `onlineUrl`, `currency` en cuanto `price` pasa de 0, `alt` cuando una
imagen trae `translations`, `textLanguage` cuando hay traducciones… Se extraen
de los `anyOf`, `if`/`then`, `dependentRequired` y `minProperties` de los
schemas, igual que el resto de la referencia. Y un campo obligatorio **dentro
de un objeto opcional** (`source.name`, `cfp.url`, `location.geo.lat`) se
marca como tal —`obligatorio dentro de X`— en vez de como `obligatorio` a
secas: la palabra significaba dos cosas distintas en la misma página.
- **Nueva regla normativa de consumo, sin cambio de schema:** un consumidor o
agregador **MAY descartar** un evento que no traiga ni `url`, ni `location`,
ni `cfp.url`, y cuyo feed tampoco declare `url`. Ningún documento deja de
validar —la validez la sigue decidiendo `event.schema.json`— pero queda dicho
que validez y visibilidad son decisiones distintas: un evento sin ningún sitio
a donde mandar a quien lo lee no obliga a nadie a listarlo. El detalle y la
cadena de reservas (`url` → `location` → `cfp.url` → `feed.url`), en
[«Sin `url` ni `location`»](spec/v0.3/README.md#sin-url-ni-location-válido-pero-descartable).
### Migration — cómo actualizar una herramienta
- **Consumidores:** el campo es opcional; el código de v0.2 lo ignora sin
romperse. Para aprovecharlo: leer `event.organizers` y, si falta, caer en
`feed.organizers` — **nunca** en `feed.title`. Ausente en ambos = desconocido.
Si usabas el campo de extensión `community`, migra a `organizers`.
- **Validadores por paquete:** `npm install @opentechevents/schema@0.3.0`. El
paquete exporta ahora los schemas de `v0.3` y `specVersion === "0.3.0"`.
- **Validadores por URL:** apuntar a
`https://opentechevents.org/schema/v0.3/{event,feed}.schema.json`. Las URLs de
`v0.1` y `v0.2` siguen sirviéndose sin cambios.
- **Exportadores a schema.org:** `organizers` → `organizer` (array), con `@type`
`Organization` o `Person` según `type`; `email` → `Organization.email`, y
emitirlo es **opcional** — mete la dirección en una página pública y Google no
lo lee para el rich result de `Event`. `offers` → `offers` (array de `Offer`),
con `currency` → `priceCurrency` y `availability` → la URL
`https://schema.org/InStock` o `SoldOut`. `cfp` no tiene destino: menciónalo en
`description` si quieres que un humano lo vea.
- **Quien emitía `cfp` u `offers` como extensión** (estaban en los ejemplos desde
la v0.1): ya son normativos, y con otra forma. `offers` pasa a **lista**, sin
`isFree` (usa `price: 0`) y sin `capacity` (extensión); `registrationUrl` se
llama ahora `url`. `cfp` pierde su `timezone`: las fechas límite llevan offset.
- **Exportadores a Atom / RSS:** Atom → un `` por entrada, con ``,
`` y `` si lo hay. RSS 2.0 → `` **si** el organizador trae
`email` (el elemento lo exige), y `` si no. `textLanguage` →
`` del canal (RSS) o `xml:lang` (Atom, que además lo admite por
entrada).
- **Exportadores a iCal:** solo `organizers[0]`, y solo si trae `email`:
`ORGANIZER;CN="…":mailto:…`. Sin `email`, `X-OTE-ORGANIZER` — **no** un
`mailto:` deducido del dominio de `url`. Los demás organizadores no tienen dónde
ir.
`textLanguage` → el parámetro `LANGUAGE` de cada propiedad de texto
(`SUMMARY;LANGUAGE=ca:…`); de `translations` solo sobrevive el texto principal,
porque `SUMMARY` no se repite.
- **Consumidores con interfaz multilingüe:** el orden de resolución es **el
idioma que pide quien lee → `translations[idioma]` → el texto principal**, y
ese último paso necesita `textLanguage` para saber en qué idioma está lo que
está mostrando. Ausente = desconocido: no lo supongas del feed ni del
`Accept-Language`.
- **Importadores (`.ics` → OTE):** `ORGANIZER;CN="…"` → `organizers[0].name`. El
`mailto:` se descarta. **`RRULE`/`RDATE` → expandir**: un documento por
ocurrencia, todos con el mismo `partOf.id`; `EXDATE` → no emitir ese documento;
`RECURRENCE-ID` → el `id` de esa ocurrencia. La regla original, si la quieres
conservar, en `ics:rrule`.
- **Exportadores a iCal:** `partOf` → `RELATED-TO;RELTYPE=PARENT:`. **Nunca
reconstruyas un `RRULE`**: emites las ocurrencias que tienes.
## [0.2.0] — 2026-07-15
Primera ampliación del núcleo. Los tres campos son **opcionales y
retrocompatibles** (por eso MINOR, no MAJOR): un documento `0.1.0` válido, con
solo cambiar `specVersion` a `"0.2.0"`, sigue siendo válido. Entraron los tres
por la misma vía —la primera implementación real, el agregador de `.ics`
([`opentechevents-data`](https://github.com/OpenTechEvents/opentechevents-data)),
los tenía delante en cada `VEVENT` y no había dónde ponerlos— no por diseño
especulativo.
### Added
- **`tags`** (`string[]`) en el evento. Etiquetas temáticas de forma libre.
Mapea a `CATEGORIES` de iCal y a `keywords` de schema.org. Se mantiene libre a
propósito; un vocabulario controlado podría superponerse después sin cerrar el
campo. Graduó desde el estado «en discusión» de la v0.1.
- **`location.geo`** (`{ lat, lon }`, grados decimales WGS-84) en el evento.
Mapea a `GEO` de iCal y a `Place.geo` de schema.org. Va **dentro de
`location`**, hermano de `venue`/`onlineUrl` (no cuelga de `venue`, que es una
cadena). No basta por sí solo para satisfacer `location`.
- **`updatedAt`** (instante ISO 8601 con offset/Z, mismo `$defs/instant` que
`Feed.updatedAt`) en el evento. Instante en que **los datos del evento**
cambiaron por última vez — equivalente a `LAST-MODIFIED` de iCal, **no** a
`DTSTAMP`. Habilita sincronización incremental por evento
(`updatedAt > última_lectura`).
### Migration — cómo actualizar una herramienta
- **Consumidores:** los tres campos son opcionales; el código de v0.1 los ignora
sin romperse. Para aprovecharlos, leer `tags`, `location.geo` y `updatedAt`
cuando estén presentes; ausente significa *desconocido*, nunca un valor por
defecto.
- **Validadores por paquete:** `npm install @opentechevents/schema@0.2.0`. El
paquete ahora exporta los schemas de `v0.2` y `specVersion === "0.2.0"`.
- **Validadores por URL:** apuntar a
`https://opentechevents.org/schema/v0.2/{event,feed}.schema.json`. Las URLs de
`v0.1` siguen sirviéndose sin cambios.
- **Productores / importadores (`.ics` → OTE):**
- `CATEGORIES` → `tags` (separar por coma, `trim`, dedupe).
- `GEO` → `location.geo` (parsear `"lat;lon"`, separador `;`, a `number`).
⚠️ Es `location.geo`, **no** `location.venue.geo`: en OTE `venue` es una
cadena.
- `LAST-MODIFIED` → `updatedAt` (si falta, `DTSTAMP` como último recurso, pero
es ruidoso: marca generación, no edición).
- `CATEGORIES` no viaja por Google Calendar (no lo emite ni lo lee). Un
importador puede recuperar temáticas de *hashtags* (`#rust`) en la
`description`; es convención del importador, no del schema.
## [0.1.0] — 2026-07
Primera versión publicada. Núcleo mínimo para describir un evento de comunidad
técnica y publicarlo en un feed reutilizable: `id`, `name`, `startDate`,
`timezone` obligatorios (más `specVersion` y `license` en un documento suelto);
`url`, `description`, `endDate`, `location` (`venue`/`onlineUrl`),
`attendanceMode`, `languages`, `status`, `source` opcionales. Feed con
`specVersion`, `title`, `license`, `updatedAt`, `events`. Congelada en
[`spec/v0.1/`](spec/v0.1/).
[0.3.0]: https://github.com/OpenTechEvents/opentechevents-spec/tree/main/spec/v0.3
[0.2.0]: https://github.com/OpenTechEvents/opentechevents-spec/tree/main/spec/v0.2
[0.1.0]: https://github.com/OpenTechEvents/opentechevents-spec/tree/main/spec/v0.1
```
==============================================================================
SECTION: Research
==============================================================================
## research.md
Source: research/README.md — How the research was done and what it was looking for.
```markdown
# Investigación
Esta carpeta recoge la investigación previa al diseño de la especificación **OpenTechEvents (OTE Spec)** (nombre provisional).
## Objetivo
Antes de diseñar el estándar necesitamos saber **qué datos usan hoy** las plataformas, directorios y estándares de eventos tecnológicos: qué campos requieren, cuáles son opcionales, cómo se contribuye y en qué formatos se pueden consumir. El estándar debe poder cubrir las necesidades de todos ellos y ser fácilmente convertible a los formatos que ya usan.
Los hallazgos de esta carpeta alimentan directamente el diseño de la spec (modelo de datos, campos núcleo vs. módulos opcionales, reglas de compatibilidad).
## Qué extraemos de cada fuente
Plantilla común que aplicamos a cada plataforma/proyecto analizado, para poder comparar:
- **Qué soporta**: eventos, call for papers/speakers (CFP), ponentes, etc.
- **Datos y tipos**: qué campos maneja cada formato soportado y de qué tipo son.
- **Obligatorio vs. opcional**: qué exige y qué es opcional.
- **Formas de contribuir**: manual (formulario, issue, PR) o automatizable (API).
- **Consumo estándar**: si ya expone los datos en algún formato estándar (JSON, iCal, RSS, JSON-LD…).
- **Licencia de los datos** 🔲: bajo qué licencia/términos publica sus datos la fuente y qué permite (reutilización, redistribución, atribución requerida). **Crítico**: determina si las herramientas del ecosistema pueden legalmente **ingerir y re-publicar** esos eventos. Pendiente de revisar en todas las fuentes.
- **¿Es agregador?**: si a su vez recopila datos de otras fuentes (y cuáles).
- **URLs relevantes**: de dónde se extrajo la información (verificable), dónde se envían los datos (formulario/endpoint), etc.
Para los **estándares** existentes (iCal, RSS, schema.org…) el enfoque cambia: nos interesa su modelo de datos, campos y cómo mapear hacia/desde ellos, no el "alta de evento".
## Inventario de fuentes
### Plataformas — [findings/platforms.md](findings/platforms.md)
| Fuente | URL | Estado |
| --- | --- | --- |
| Meetup | https://www.meetup.com/ | ✅ |
| Sessionize | https://sessionize.com/ | ✅ |
| Luma | https://luma.com/ | ✅ |
| joind.in | https://joind.in/ | ✅ |
| Papercall.io | https://www.papercall.io/ | ✅ |
| Guild | https://guild.host/ | 🔲 |
| Saraos.tech | https://saraos.tech/ | 🔲 |
| Eventos de Linkedin | https://www.linkedin.com/help/linkedin/answer/a552496 | 🔲 |
### Directorios y agregadores — [findings/directories.md](findings/directories.md)
| Fuente | URL | Estado |
| --- | --- | --- |
| EventosWiki | https://github.com/achamorro-dev/eventoswiki | ✅ |
| Event Garden | https://eventgarden.io/ | ✅ |
| developers.events (Developers-Conferences-Agenda) | https://github.com/scraly/developers-conferences-agenda | ✅ |
| Confs.tech | https://github.com/tech-conferences/confs.tech | ✅ |
| CallingAllPapers | https://callingallpapers.com/ | ✅ |
| CFP Tracker (bendechrai/cfps) | https://github.com/bendechrai/cfps | ✅ |
| TechConf.Directory | https://github.com/DeclanChidlow/techconf.directory | ✅ |
| dev.events | https://dev.events/ | ✅ |
| Developer Events.org | https://www.developerevents.org/ | ✅ |
| Sesamers | https://sesamers.com/ | 🔲 |
| Vendelux | https://www.vendelux.com/ | 🔲 |
| LegalTechConference.com | https://www.legaltechnologyconference.com/ | 🔲 |
| iotevents.org / marketing-events.net (TechForge) | — | 🔲 |
### Estándares — [findings/standards.md](findings/standards.md)
| Estándar | Referencia | Estado |
| --- | --- | --- |
| iCalendar | RFC 5545 | ✅ |
| RSS 2.0 | https://www.rssboard.org/rss-specification | ✅ |
| schema.org / `Event` (JSON-LD) | https://schema.org/Event | ✅ |
| hCalendar / microformats | http://microformats.org/wiki/h-event | ✅ |
| JSON Feed | https://www.jsonfeed.org/ | ✅ |
## Análisis y conclusiones
- [findings/analysis.md](findings/analysis.md) — comparación, patrones comunes y conclusiones para el diseño del estándar.
## Índice de ficheros
- [findings/platforms.md](findings/platforms.md) — plataformas que crean/gestionan eventos.
- [findings/directories.md](findings/directories.md) — directorios y agregadores que listan eventos.
- [findings/standards.md](findings/standards.md) — estándares existentes y cómo mapear hacia/desde ellos *(pendiente)*.
- [findings/analysis.md](findings/analysis.md) — comparación, patrones y conclusiones.
```
## research-analysis.md
Source: research/findings/analysis.md — What the survey concluded and which gaps OTE exists to close.
```markdown
# Análisis — comparación, patrones y conclusiones
Síntesis de los hallazgos de [platforms.md](platforms.md), [directories.md](directories.md) y [standards.md](standards.md), orientada al diseño de OTE Spec.
## Resumen
Se analizaron plataformas populares para anunciar eventos tecnológicos (Meetup, Sessionize y Luma, además de joind.in y Papercall) y varios proyectos/directorios de comunidad (EventosWiki, Event Garden, developers.events, Confs.tech, CallingAllPapers, CFP Tracker, TechConf.Directory, dev.events y Developer Events.org). Las plataformas difieren en el modo de creación de eventos y los datos que requieren, pero comparten un núcleo común de información: nombre del evento, descripción, fechas y horarios (con zonas horarias), ubicación, enlaces de registro y, en su caso, fechas de apertura y cierre de la llamada a propuestas (CFP). Los directorios basados en GitHub utilizan estructuras de datos YAML o JSON, mientras que las plataformas ofrecen APIs (GraphQL o REST) o formularios web.
## Comparación y patrones comunes
1. **Datos imprescindibles**. La mayoría de plataformas exigen, como mínimo, nombre del evento, descripción breve, fechas de inicio y fin, zona horaria y ubicación. Joind.in especifica además la zona geográfica (`tz_continent` y `tz_place`)[docs.joind.in](https://docs.joind.in/joindin-api/events.html#:~:text=the%20images%20associated%20with%20this,See%20also%20%202).
2. **Información de CFP**. En los proyectos que recogen CFP (developers.events, Confs.tech, joind.in, CallingAllPapers), los datos clave incluyen URL de la convocatoria, fecha de apertura y fecha de cierre[docs.joind.in](https://docs.joind.in/joindin-api/events.html#:~:text=the%20images%20associated%20with%20this,See%20also%20%202)[developers.events](https://developers.events/all-events.json#:~:text=%5B%7B%22name%22%3A%22Craft%20Conf%22%2C%22date%22%3A%5B1493078400000%2C1493337600000%5D%2C%22hyperlink%22%3A%22https%3A%2F%2Fcraft,ta).
3. **Imágenes y redes sociales**. Algunos directorios permiten indicar logotipo, imagen de portada y redes sociales (Twitter, Mastodon, Bluesky, etc.)[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/conferences/afup-day-bordeaux.yaml#:~:text=title%3A%20AFUP%20Day%20Bordeaux%20website%3A,location%3A%20country%3A%20FR%20city%3A%20Bordeaux)[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/speakers/barret-blake.yaml#:~:text=name%3A%20Barret%20Blake%20website%3A%20barretblake,location%3A%20country%3A%20US%20city%3A%20Columbus). Estos campos suelen ser opcionales, pero enriquecen la ficha.
4. **Etiquetas y categorías**. Muchos repositorios utilizan etiquetas (`tags`) para clasificar las conferencias por temática (AI, cloud, etc.)[raw.githubusercontent.com](https://raw.githubusercontent.com/tech-conferences/confs.tech/main/README.md#:~:text=pull%20requests,contributing%3F%20Tag%20any%20of%20the).
5. **Automatización**. Algunos sitios soportan APIs o feeds que devuelven datos estructurados (Meetup GraphQL, Sessionize JSON/iCal, Luma API, developers.events JSON, joind.in REST, CallingAllPapers API). Otros confían en plantillas YAML/Markdown y en la revisión manual. dev.events menciona la utilización de metadatos **JSON‑LD** en la web del evento para detectar datos automáticamente[dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria).
6. **Proceso de contribución**. Las plataformas como Meetup, Luma y Papercall gestionan eventos desde sus propias interfaces; los directorios comunitarios basados en GitHub (EventosWiki, developers.events, Confs.tech, TechConf.Directory) requieren Pull Requests o issues. dev.events y Developer Events tienen formularios de envío que se revisan manualmente.
7. **Agregadores**. CallingAllPapers y CFP Tracker no permiten alta manual; dependen de otras fuentes. dev.events también funciona como agregador combinando automatización y envíos manuales[dev.events](https://dev.events/about#:~:text=The%20project%20is%20coded%20and,organizers%2C%20tech%20community%2C%20and%20volunteers).
8. **Licencia de los datos** 🔲 _(pendiente en todas las fuentes)_. No hemos revisado aún bajo qué términos publica cada fuente sus datos. Es **crítico**: condiciona si las herramientas pueden ingerir y re-publicar legalmente, y si hace falta **atribución**. Implicaciones para OTE: (a) la spec debe permitir declarar **procedencia/atribución** y **licencia** de cada evento (ver `source`/`license` en [../../spec/data-model.md](../../spec/data-model.md)); (b) conviene recomendar a las comunidades adheridas una licencia abierta y clara para sus eventos.
## Conclusiones para el diseño de un nuevo estándar
- **Esquema modular**: El nuevo estándar debería definir un núcleo obligatorio (nombre, descripción, fechas, zona horaria, ubicación) y módulos opcionales para CFP, redes sociales, etiquetas, imágenes y logística. La experiencia de joind.in demuestra que basta con unos pocos campos obligatorios[docs.joind.in](https://docs.joind.in/joindin-api/events.html#:~:text=the%20images%20associated%20with%20this,See%20also%20%202), mientras que otros datos enriquecen la ficha.
- **Compatibilidad con JSON e YAML**: La mayoría de proyectos usan estos formatos. El estándar debería ofrecer ambas serializaciones, e idealmente una representación JSON‑LD para permitir la detección automática por motores como dev.events[dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria).
- **Soporte de CFP**: Incluir campos `cfp_url`, `cfp_start` y `cfp_end` como opcionales pero normalizados; son clave para agregadores[docs.joind.in](https://docs.joind.in/joindin-api/events.html#:~:text=the%20images%20associated%20with%20this,See%20also%20%202).
- **Zonas horarias y localización**: Es recomendable separar los campos de zona horaria (`tz_continent` y `tz_place`) de la ubicación para facilitar la conversión de horarios y permitir eventos online.
- **Extensibilidad de redes sociales**: Un bloque `socials` debería aceptar múltiples plataformas (Twitter, Mastodon, Bluesky, LinkedIn, etc.) con la posibilidad de incluir identificadores descentralizados (DID) como en techconf.directory[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/speakers/barret-blake.yaml#:~:text=name%3A%20Barret%20Blake%20website%3A%20barretblake,location%3A%20country%3A%20US%20city%3A%20Columbus).
- **Sistema de etiquetas**: Permitir una lista de `tags` normalizada; ello facilita la clasificación por temáticas y la interoperabilidad con motores de búsqueda y agregadores[raw.githubusercontent.com](https://raw.githubusercontent.com/tech-conferences/confs.tech/main/README.md#:~:text=pull%20requests,contributing%3F%20Tag%20any%20of%20the).
- **Licencias y privacidad**: Incluir metadatos sobre derechos de imagen, consentimiento para publicación, enlaces al código de conducta y políticas de privacidad; varios formularios (Confs.tech, Developer Events) contemplan estos elementos.
- **Canales de contribución**: Prever tanto contribución manual (formularios o PRs) como automática mediante API o detección de JSON‑LD[dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria).
- **Consumo sencillo**: Publicar un feed unificado (JSON y iCal) y permitir filtros (por fecha, localización, tags) similares a joind.in y developers.events[docs.joind.in](https://docs.joind.in/joindin-api/events.html#:~:text=the%20images%20associated%20with%20this,See%20also%20%202)[developers.events](https://developers.events/all-events.json#:~:text=%5B%7B%22name%22%3A%22Craft%20Conf%22%2C%22date%22%3A%5B1493078400000%2C1493337600000%5D%2C%22hyperlink%22%3A%22https%3A%2F%2Fcraft,ta).
La consolidación de estas prácticas facilitará la adopción del nuevo estándar en distintos ecosistemas y herramientas.
## Interoperabilidad con estándares existentes
Del análisis de [standards.md](standards.md):
- **Modelo de referencia: schema.org/`Event`** (JSON-LD) — el más alineado con OTE (online/híbrido nativo vía `eventAttendanceMode`, ponentes, estado, offers) y detectado automáticamente por buscadores y agregadores como dev.events.
- **iCalendar (`VEVENT`)** — mapeo sólido del núcleo y **única fuente con recurrencia formal** (`RRULE`), clave para meetups periódicos.
- **RSS 2.0 y JSON Feed** — sin modelo de evento; se usan como **formato de difusión** (un evento por `item`, enlace a la ficha), con datos estructurados vía namespace (RSS) o campo `_ote` (JSON Feed).
- **h-event** — marcado opcional en HTML; baja prioridad.
- **Hueco común**: ningún estándar generalista modela el **CFP** → OTE debe definirlo como módulo propio.
```
## research-standards.md
Source: research/findings/standards.md — iCalendar, RSS and schema.org/Event: what each covers and where each stops.
```markdown
# Hallazgos — Estándares existentes
A diferencia de plataformas y directorios, aquí no analizamos "cómo dar de alta un evento" sino **el modelo de datos del estándar** y **cómo mapear hacia/desde él**. Son la base de la interoperabilidad que persigue OTE Spec: queremos que un evento descrito en nuestro formato se pueda transformar sin pérdida a estos.
## Resumen
Dos familias claras:
- **Estándares con modelo de evento nativo**: **iCalendar** (`VEVENT`) y **schema.org/`Event`** (JSON-LD) y, en HTML, **h-event** (microformats). Cubren nombre, fechas, ubicación, zona horaria, organizador/ponentes y, en iCal, recurrencia. Son los objetivos de mapeo prioritarios.
- **Estándares de feed sin concepto de evento**: **RSS 2.0** y **JSON Feed**. No modelan eventos ni rangos de fechas ni ubicación; sirven como **formato de distribución/anuncio** (un evento por `item`), apoyándose en extensiones (namespaces en RSS, campos `_` en JSON Feed) para los datos estructurados.
Conclusión de diseño: OTE Spec debe mapear **1:1 con schema.org/Event e iCalendar** (cubren el núcleo), y ofrecer feeds RSS/JSON Feed como salida de difusión, metiendo lo estructurado vía extensión + enlace a la ficha completa.
## iCalendar — `VEVENT` (RFC 5545)
- **Referencia**: https://datatracker.ietf.org/doc/html/rfc5545
- **Serialización**: texto plano `text/calendar` (.ics). Muy soportado por clientes de calendario (Google Calendar, Apple, Outlook).
| Aspecto | Detalle |
| --- | --- |
| **Obligatorios** | `UID` (identificador único), `DTSTAMP` (timestamp de creación) y `DTSTART` (inicio). |
| **Núcleo opcional** | `DTEND` o `DURATION` (fin/duración), `SUMMARY` (título), `DESCRIPTION`, `LOCATION`, `URL`, `CATEGORIES` (tags), `GEO` (lat/long), `STATUS`, `ORGANIZER`, `ATTENDEE`, `TRANSP`. |
| **Fechas/zonas horarias** | DATE-TIME en 3 formas: flotante (`19970714T133000`), UTC (`...Z`) y local con zona (`TZID=America/New_York:...`). El `TZID` referencia un componente `VTIMEZONE` que define reglas de la zona (offsets, DST). |
| **Recurrencia** | `RRULE` (con `FREQ` obligatorio; `INTERVAL`, `COUNT`/`UNTIL`, `BYDAY`, `BYMONTHDAY`, `BYMONTH`, `WKST`). Más `RDATE` (fechas extra), `EXDATE` (excluidas) y `RECURRENCE-ID` (instancia modificada). Ideal para meetups recurrentes. |
| **Eventos online** | No hay campo nativo de "online/híbrido"; se suele poner la URL en `URL`/`LOCATION`. Lo cubre peor que schema.org. |
| **CFP / ponentes** | Sin concepto de CFP. Ponentes solo encajan forzados en `ATTENDEE`/`ORGANIZER`. |
**Mapeo OTE → iCal**: `name→SUMMARY`, `description→DESCRIPTION`, `start/end→DTSTART/DTEND`, `timezone→TZID`+`VTIMEZONE`, `location→LOCATION`+`GEO`, `url→URL`, `tags→CATEGORIES`, recurrencia→`RRULE`. **Se pierde**: CFP, distinción online/híbrido fina, ponentes estructurados, redes sociales. Generar UID/DTSTAMP en la exportación.
## schema.org / `Event` (JSON-LD)
- **Referencia**: https://schema.org/Event
- **Serialización**: JSON-LD embebido en HTML (`
## Eventbrite
Example: https://www.eventbrite.es/e/entradas-biznagafest-2025-1052728016837
### WebPage, SpeakableSpecification
### BusinessEvent, Place, PostalAddress, Organization
## Luma
Example: https://luma.com/vbe4s6ji
### Event, VirtualLocation, Organization, Person, Offer
## Guild
Example: https://guild.host/events/ontologas-anlisis-bayesiano-fg0e21
### Organization
### Event, Organization, VirtualLocation, Place, PostalAddress, Offer
Example https://developers.google.com/search/docs/appearance/structured-data/event
```html
The Adventures of Kira and Morrison
```
```
## research-directories.md
Source: research/findings/directories.md — Existing event directories and how they ingest data.
```markdown
# Hallazgos — Directorios y agregadores
Proyectos que **listan/recopilan** eventos o CFP (no son la plataforma origen). Ver criterio común en [../README.md](../README.md).
## EventosWiki (eventoswiki)
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Repositorio en GitHub que recopila eventos tecnológicos hispanohablantes; las contribuciones se realizan mediante issues. | |
| **Campos necesarios** | El template YAML para solicitar un nuevo evento incluye campos obligatorios: `event-name`, `event-website`, `short-description`, `start-date`, `end-date` y `location`[raw.githubusercontent.com](https://raw.githubusercontent.com/achamorro-dev/eventoswiki/main/.github/ISSUE_TEMPLATE/solicitud-nuevo-evento.yaml#:~:text=name%3A%20Solicitud%20nuevo%20evento%20description%3A,como%20contenido%20del%20evento%20validations). | |
| **Campos opcionales** | `cover-image` (imagen de portada), `social-networks` y `details` para ampliar información[raw.githubusercontent.com](https://raw.githubusercontent.com/achamorro-dev/eventoswiki/main/.github/ISSUE_TEMPLATE/solicitud-nuevo-evento.yaml#:~:text=name%3A%20Solicitud%20nuevo%20evento%20description%3A,como%20contenido%20del%20evento%20validations). | |
| **Contribución** | Manual: se abre un issue con el template completado y los mantenedores lo revisan. No se indica API. | |
| **Consumible estándar** | No se proporciona exportación; el valor está en la página web generada desde GitHub. | |
| **Agregador** | No. | |
| **URL relevantes** | [Plantilla de solicitud de evento](https://github.com/achamorro-dev/eventoswiki/blob/main/.github/ISSUE_TEMPLATE/solicitud-nuevo-evento.yaml) [raw.githubusercontent.com](https://raw.githubusercontent.com/achamorro-dev/eventoswiki/main/.github/ISSUE_TEMPLATE/solicitud-nuevo-evento.yaml#:~:text=name%3A%20Solicitud%20nuevo%20evento%20description%3A,como%20contenido%20del%20evento%20validations). | |
## Event Garden
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Directorio comunitario en español para eventos y conferencias tecnológicas. | |
| **Campos necesarios** | Formulario de alta de evento incluye campos: título (requerido), descripción en Markdown, idiomas (español/inglés), si es gratuito o de pago, fechas de inicio y fin con selección de hora, ubicación (remoto o país/ciudad), etiquetas principales (hay que elegir al menos una) y correo de contacto opcional[eventgarden.io](https://eventgarden.io/new-event#:~:text=Quieres%20compartir%20algo%20con%20la,ten%20en%20cuenta%20algunas%20consideraciones). | |
| **Campos opcionales** | Enlace a página principal del evento, imagen (subida o URL), etiquetas personalizadas y correo de contacto[eventgarden.io](https://eventgarden.io/new-event#:~:text=Quieres%20compartir%20algo%20con%20la,ten%20en%20cuenta%20algunas%20consideraciones). | |
| **Contribución** | Se realiza mediante formulario web; la página indica que el equipo revisa cada envío antes de publicarlo[eventgarden.io](https://eventgarden.io/new-event#:~:text=Quieres%20compartir%20algo%20con%20la,ten%20en%20cuenta%20algunas%20consideraciones). | |
| **Consumible estándar** | No se observó una API pública; el directorio muestra los datos en la web. | |
| **Agregador** | No. | |
| **URL relevantes** | [Formulario de nuevo evento](https://eventgarden.io/new-event) [eventgarden.io](https://eventgarden.io/new-event#:~:text=Quieres%20compartir%20algo%20con%20la,ten%20en%20cuenta%20algunas%20consideraciones). | |
## developers.events (Scraly/Developers‑Conferences‑Agenda)
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Repositorio colaborativo que lista conferencias con un enfoque comunitario y sus llamadas a ponencias. | |
| **Campos necesarios y formato** | Las conferencias se añaden en el `README.md` usando el formato `* fecha: [Nombre de la conferencia](URL) – Ciudad, estado (País)`[raw.githubusercontent.com](https://raw.githubusercontent.com/scraly/developers-conferences-agenda/master/CONTRIBUTING.md#:~:text=format%3A%20%60%60%60%20,Remove%20any%20trailing). El CSV `METADATA.csv` puede añadir el número de asistentes (`YYYY-MM-DD-Conference Name,attendees:NUMBER`)[raw.githubusercontent.com](https://raw.githubusercontent.com/scraly/developers-conferences-agenda/master/CONTRIBUTING.md#:~:text=format%3A%20%60%60%60%20,Remove%20any%20trailing). | |
| **JSON de eventos** | El sitio genera `all-events.json`, donde cada evento contiene campos: `name`, `date` (array de timestamps de inicio/fin), `hyperlink`, `location`, `city`, `country`, `misc`, objeto `cfp` (con URL y fechas), `closedCaptions`, `scholarship`, `sponsoringBadge`, `status` y `tags`[developers.events](https://developers.events/all-events.json#:~:text=%5B%7B%22name%22%3A%22Craft%20Conf%22%2C%22date%22%3A%5B1493078400000%2C1493337600000%5D%2C%22hyperlink%22%3A%22https%3A%2F%2Fcraft,ta). El archivo `all-cfps.json` lista llamadas a ponencias con campos: `link`, `until` (cadena de cierre), `untilDate` (timestamp) y un objeto `conf` con nombre, fechas, URL y ubicación[developers.events](https://developers.events/all-cfps.json#:~:text=%5B%7B%22link%22%3A%22https%3A%2F%2Fhashiconfeu.hashicorp.com%2F%23submit,31). | |
| **Contribución** | A través de Pull Request siguiendo las reglas de `CONTRIBUTING.md`; se requiere que el evento sea una conferencia comunitaria y que tenga CFP. | |
| **Consumible estándar** | JSON (all‑events.json y all‑cfps.json), además del README. | |
| **Agregador** | No, aunque los datos pueden utilizarse en otros proyectos (CFP Tracker, CallingAllPapers). | |
| **URL relevantes** | [directorio en GitHub](https://github.com/scraly/developers-conferences-agenda) y [all-events.json](https://developers.events/all-events.json) [developers.events](https://developers.events/all-events.json#:~:text=%5B%7B%22name%22%3A%22Craft%20Conf%22%2C%22date%22%3A%5B1493078400000%2C1493337600000%5D%2C%22hyperlink%22%3A%22https%3A%2F%2Fcraft,ta). | |
## CFP Tracker (bendechrai/cfps)
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Aplicación web que permite a los ponentes hacer seguimiento de CFP y envío de propuestas. | |
| **Fuentes de datos** | Agrega CFP de **Codosaurus**, Confs.tech, developers.events, joind.in, Leon Adato y Papercall.io[raw.githubusercontent.com](https://raw.githubusercontent.com/bendechrai/cfps/main/README.md#:~:text=desktop%20and%20mobile%20,0%20or%20later). | |
| **Campos requeridos** | La documentación pública no detalla el formato; el proyecto es un agregador que consume los feeds de las fuentes mencionadas. | |
| **Contribución** | No se ofrecen formularios; los datos provienen de otras fuentes. | |
| **Consumible estándar** | Presenta la información en interfaz web y permite guardar el estado de las presentaciones localmente (en el navegador). | |
| **Agregador** | Sí; es un agregador de múltiples fuentes de CFP[raw.githubusercontent.com](https://raw.githubusercontent.com/bendechrai/cfps/main/README.md#:~:text=desktop%20and%20mobile%20,0%20or%20later). | |
| **URL relevantes** | [Repositorio CFP Tracker](https://github.com/bendechrai/cfps) y [web](https://cfp.bendechr.ai/). | |
## Confs.tech
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Directorio de conferencias de tecnología de código abierto. | |
| **Campos obligatorios (formulario)** | El formulario web para añadir conferencia solicita: idioma, temas (uno o varios), nombre de la conferencia (sin el año), URL, fechas de inicio y fin, tipo de evento (presencial/online/híbrido), país, URL de la llamada a ponencias (si existe), fecha de cierre del CFP, URL del código de conducta, casilla de interpretación en lengua de signos o subtítulos, cuentas de redes sociales (Bluesky, Mastodon, Twitter) y nombre de usuario de GitHub para contacto. _(Fuente: captura del formulario de alta; pendiente de URL verificable.)_ | |
| **Campos opcionales** | Algunos campos como redes sociales, código de conducta y fechas de CFP se pueden dejar vacíos. | |
| **Datos en JSON** | En el repositorio, cada conferencia se almacena en archivos JSON con campos `name`, `url`, `startDate`, `endDate`, `city`, `country`, `cfpUrl`, `cfpEndDate`, `bluesky`, `mastodon`, `twitter`[raw.githubusercontent.com](https://raw.githubusercontent.com/tech-conferences/confs.tech/main/README.md#:~:text=pull%20requests,contributing%3F%20Tag%20any%20of%20the). | |
| **Contribución** | Vía formulario web (genera un Pull Request) o directamente mediante PR en GitHub. | |
| **Consumible estándar** | JSON (dentro del repositorio). | |
| **Agregador** | No, aunque es utilizado por otros agregadores. | |
| **URL relevantes** | Formulario nuevo, [README del repositorio](https://github.com/tech-conferences/confs.tech) [raw.githubusercontent.com](https://raw.githubusercontent.com/tech-conferences/confs.tech/main/README.md#:~:text=pull%20requests,contributing%3F%20Tag%20any%20of%20the). | |
## CallingAllPapers
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Sitio que recopila llamadas a ponencias abiertas y publica recordatorios en redes sociales. | |
| **Fuentes de datos** | Extrae información rastreando **joind.in**, Confs.tech, Papercall.io y Sessionize[callingallpapers.com](https://callingallpapers.com/#:~:text=To%20retrieve%20the%20list%20we,basis%20to%20find%20new%20CfPs). | |
| **Campos disponibles (API)** | Su API pública devuelve una lista de CFP con campos: `name`, `uri` (enlace de presentación), `dateCfpStart`, `dateCfpEnd`, `location`, `latitude`, `longitude`, `description`, `dateEventStart`, `dateEventEnd`, `iconUri`, `eventUri`, `timezone`, `tags`, `sources`, `lastChange` y `_rel`[api.callingallpapers.com](https://api.callingallpapers.com/v1/cfp#:~:text=CFP%20%7BCall%20For%20Papers%7D%20,Edi%C3%A7%C3%A3o%20v21). | |
| **Contribución** | No acepta envíos manuales; para aparecer hay que listar el CFP en alguna de las fuentes soportadas[callingallpapers.com](https://callingallpapers.com/#:~:text=To%20retrieve%20the%20list%20we,basis%20to%20find%20new%20CfPs). | |
| **Consumible estándar** | JSON a través de la API pública. El sitio también ofrece un feed iCal. | |
| **Agregador** | Sí; unifica datos de varias plataformas. | |
| **URL relevantes** | [API de CallingAllPapers](https://api.callingallpapers.com/v1/cfp) [api.callingallpapers.com](https://api.callingallpapers.com/v1/cfp#:~:text=CFP%20%7BCall%20For%20Papers%7D%20,Edi%C3%A7%C3%A3o%20v21). | |
## TechConf.Directory
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Repositorio que almacena conferencias y ponentes como archivos YAML. | |
| **Campos en conferencias** | Cada archivo YAML incluye `title`, `website`, `tags`, y un diccionario `events` donde cada año contiene `dates` (`start` y `end`), `format` (presencial o en línea) y `location` (país y ciudad). También puede haber sección `socials` con cuentas de Bluesky, Fediverse, etc., y lista de etiquetas[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/conferences/afup-day-bordeaux.yaml#:~:text=title%3A%20AFUP%20Day%20Bordeaux%20website%3A,location%3A%20country%3A%20FR%20city%3A%20Bordeaux). | |
| **Campos en ponentes** | Los archivos de ponentes contienen `name`, `website`, secciones `socials` con identidades (Bluesky DID, YouTube, LinkedIn, etc.) y `location` (país y ciudad)[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/speakers/barret-blake.yaml#:~:text=name%3A%20Barret%20Blake%20website%3A%20barretblake,location%3A%20country%3A%20US%20city%3A%20Columbus). | |
| **Contribución** | Se aceptan issues para solicitar inclusión de conferencias o ponentes; los mantenedores procesan las solicitudes[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/README.md#:~:text=starting%20a%20local%20development%20server,https%3A%2F%2Fgithub.com%2FDeclanChidlow%2Ftechco). | |
| **Consumible estándar** | Los datos están en YAML en el repositorio; aún no se ofrecen APIs. | |
| **Agregador** | No, pero se puede consumir por otros proyectos. | |
| **URL relevantes** | [Repositorio techconf.directory](https://github.com/DeclanChidlow/techconf.directory) y archivos YAML (p. ej., `afup‑day‑bordeaux.yaml`)[raw.githubusercontent.com](https://raw.githubusercontent.com/DeclanChidlow/techconf.directory/main/data/conferences/afup-day-bordeaux.yaml#:~:text=title%3A%20AFUP%20Day%20Bordeaux%20website%3A,location%3A%20country%3A%20FR%20city%3A%20Bordeaux). | |
## dev.events
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Portal que lista conferencias, meetups y hackathons de tecnología. | |
| **Contribución** | Se puede añadir un evento mediante el botón _new event_; tras enviar el formulario, el equipo revisa y aprueba. Para que se agreguen automáticamente, el sitio detecta metadatos estructurados en la web del evento usando JSON‑LD[dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria). | |
| **Campos requeridos** | No se muestran públicamente todos los campos del formulario, pero el FAQ indica que un evento debe incluir fecha, ubicación, enlace de registro y algunos ponentes para comprobar la legitimidad[dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria). | |
| **Consumible estándar** | dev.events ofrece un feed RSS con las últimas cien conferencias, categorizadas mediante etiquetas[dev.events](https://dev.events/about#:~:text=subscribe%20to%20the%20RSS%20feed%2C,For%20example%2C%20%203%20this). | |
| **Agregador** | Sí; obtiene aproximadamente 20 % de los eventos de fuentes automáticas y 80 % de contribuciones manuales[dev.events](https://dev.events/about#:~:text=The%20project%20is%20coded%20and,organizers%2C%20tech%20community%2C%20and%20volunteers). | |
| **URL relevantes** | [FAQ dev.events](https://dev.events/about) [dev.events](https://dev.events/about#:~:text=You%20can%20submit%20an%20event,it%20meets%20the%20eligibility%20criteria). | |
## Developer Events.org
| Aspecto | Información | Fuentes |
| --- | --- | --- |
| **Soporte** | Directorio de eventos de TechForge que permite listar conferencias y ferias. | |
| **Campos requeridos** | El formulario de envío solicita nombre y apellidos del remitente, correo electrónico, teléfono, título del evento, fechas de inicio y fin (MM/DD/AAAA), descripción del evento, URL de registro, enlaces a redes sociales (Twitter, Facebook, Instagram, YouTube, LinkedIn), logotipo del evento, fotos (hasta 50), y la ubicación completa (dirección, ciudad, estado, código postal, país)[developerevents.org](https://www.developerevents.org/submit-event/#:~:text=Name). | |
| **Contribución** | Manual vía formulario web; los datos se revisan antes de publicar. | |
| **Consumible estándar** | La información se muestra en la página web; no se indica una API. | |
| **Agregador** | No. | |
| **URL relevantes** | [Formulario de Developer Events](https://www.developerevents.org/submit-event/) [developerevents.org](https://www.developerevents.org/submit-event/#:~:text=Name). | |
## Otros directorios y proyectos para investigar
Surgieron durante la investigación; pendientes de analizar en detalle (estado 🔲 en el inventario):
- **Sesamers** — plataforma comercial que lista eventos de startups/tecnología. Suscribirse permite filtrar por industria; habría que revisar sus campos y API.
- **Vendelu/Vendelux** — directorio de conferencias de marketing y tecnología. Puede ofrecer insights sobre campos de patrocinio.
- **LegalTechConference.com** — especializado en conferencias legales. Podría aportar campos específicos de sector (CLE credits, normativa).
- **StartUpPeople/FinDev Gateway** — ofrecen formularios de eventos con campos financieros.
- **iotevents.org / marketing-events.net** — portales temáticos de TechForge similares a Developer Events.org.
```