# Entry schema — spec v0.1

- **Status:** version 0.1 of Daawid's open specification.
- **Licence:** this text is [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The reference schemas and validator are Apache 2.0, at `/schemas/0.1/`. Anyone may implement, host or read entries in this format. The category list at `/taxonomy/` isn't under either licence: all rights reserved for now.
- **Change rule:** field names and enum values are append-only. Schema changes are versioned.
- **Source of truth:** the Zod definition in §9. The API's checks, the MCP tools' schemas, the JSON Schemas (`/schemas/0.1/*.json`) and the rendered pages all derive from it.
- **Names:** `daawid.example` stands for daawid.com. Every name, number and address in the examples is fictional.

---

## 1. What an entry is

An **entry** is the public presence of a person or an organization: what a personal or business website represents today. Every entry has a `kind`:

- **`organization`**: companies, sole traders' businesses, clubs, associations and public bodies. Usually one brand of one operator, the legal entity behind it.
- **`person`**: an individual as themselves: profile, work, projects, links, how to reach them, and optionally offerings (freelancers, tutors, artists).

A sole trader may have both, like a personal and a business website; neither requires the other. `kind` is set at creation and never changes, because the JSON-LD `@type` behind an ID must stay stable (I17). Daawid never creates an entry about a person; only people create their own (§3).

An entry contains:

- identity
- operator (legal data and the Impressum), when the owner gives it; never on a person entry that offers nothing (§4.3)
- contact points and routing
- locations and service area
- offerings and prices
- capacity
- policies
- credentials
- FAQs
- voice
- actions
- brand kit and media

It also carries record metadata: status, verification, provenance and revision.

Not in v0.1:

- **Multi-entity graphs** such as parent companies, franchises or "authorized dealer of" links. These need both entries to confirm the relationship and are reserved for v0.2.
- **Large catalogues.** v0.1 allows at most 100 offerings and 50 locations.

## 2. Design rules

1. **Facts, not prose.** Prefer typed fields over free text. Free text is length-bounded and localized.
2. **Every fact has a source.** Each section and item carries provenance (§7). Consumers always know whether a fact was declared by the owner, taken from a public register, or compiled by Daawid.
3. **Closed vocabularies where consumers act on the value**: action types, routing intents, tone words, payment methods, units. Owners pick from lists so agents can rely on the meaning.
4. **Text that businesses write is data, never instructions to AI.** Voice and routing are bounded preferences (§5). A write-time lint (I14) rejects text addressed to models.
5. **Volatile facts expire.** Capacity lasts at most 30 days. Prices can carry validity dates. Owners re-confirm the whole entry at least every 12 months.
6. **Tolerant readers.** Consumers ignore unknown fields and enum values (identifiers.md §5), so additions never break them.
7. **Global from the start.** The shapes are generic (ISO country, currency and language codes), for every kind of person and organization in every country. What one country's law asks for, such as Germany's Impressum, is a first-class field that applies to operators there.

## 3. Field tiers

| Tier | Meaning |
|---|---|
| **R** (public record) | May appear on an **unclaimed** entry. The value must come from a public register or from the business's own published Impressum or website, and it carries explicit provenance. |
| **O** (owner) | Only on entries published by their owner, at level `contact` or higher. Declared by the owner. |
| **S** (system) | Set by Daawid. |

### Unclaimed entries

The format has a status for an entry its owner hasn't claimed, compiled from public sources. **Daawid publishes none today:** every entry is published by its owner, and no code makes such entries. The rules stay in the format:

- **Legal persons only:** `gmbh ug gmbh_co_kg ag se kgaa eg ev`. Sole traders, freelancers and partnerships of natural persons have no unclaimed entry (`einzelunternehmen ek gbr ohg kg partg partg_mbb`): their entry exists only once they create it. People (`kind: person`) never have unclaimed entries: Daawid never creates an entry about a person.
- **Sources:** only the business's own website and Impressum, and public registers reached through a permitted channel. Never third-party directories or maps. A crawler honours robots.txt and machine-readable reservations against text and data mining.
- **Deliberately thin:** no opening hours, prices, descriptions or logos, and a summary from a template, not a model: *"Tischlerei in Wuppertal. GmbH, Amtsgericht Wuppertal HRB 99901."*

## 4. Field list

Req.: ✓ = required, – = optional, **c** = conditionally required (see the invariant noted).

### 4.1 Record (system)

| Field | Type | Req. | Tier |
|---|---|---|---|
| `id` | `ent_…` TypeID | ✓ | S |
| `kind` | `person \| organization`, set at creation, immutable (I17) | ✓ | S/O |
| `slug` | slug (identifiers.md §2) | ✓ | S (owner may request a change) |
| `schemaVersion` | `"0.1"` | ✓ | S |
| `revision` | int ≥ 1 | ✓ | S |
| `status` | `unclaimed \| published \| verified \| lapsed \| suspended` | ✓ | S |
| `defaultLanguage` | BCP 47 (`de`) | ✓ | S/O |
| `languages` | BCP 47[], includes the default | ✓ | S/O |
| `createdAt`, `updatedAt` | datetime | ✓ | S |
| `lastConfirmedAt` | datetime (owner taps "still accurate") | – | S |
| `listed` | bool, default `true`. `false`: reachable by URL and ID, absent from search and sitemaps (I18) | ✓ | O |
| `verification` | level, `contactConfirmedAt`, domains; business and identity (both reserved) | ✓ | S |
| `provenance` | map from entry path to `{source, observedAt, reference?, machineTranslated?}` | ✓ (may be empty) | S |

### 4.2 Identity

| Field | Type | Req. | Tier |
|---|---|---|---|
| `displayName` | string 2–120 | ✓ | R |
| `alternateNames` | string[] ≤ 10 | – | R |
| `summary` | localized text ≤ 160 | ✓ | R (template text when unclaimed) / O |
| `description` | localized text ≤ 3000 | – | O |
| `categories` | Daawid category code[] 1–5 (`trades.carpentry`) | c (I17): organizations, and persons with offerings | R |
| `naceCodes` | WZ 2008 / NACE Rev. 2 code[] ≤ 5 (`43.32`) | – | O |
| `foundingYear` | int | – | O |
| `employees` | `1 \| 2-9 \| 10-49 \| 50-249 \| 250-999 \| 1000+` (EU SME bands) | – | O |
| `languagesSpoken` | BCP 47[], the languages the business serves customers in | – | O |

### 4.3 Operator: legal data and the Impressum

These fields follow **§5(1) DDG**, what German law asks of an Impressum, the most detailed of the legal notices; operators elsewhere give what applies to them. The legal section of every entry page with an operator block is rendered from them (data-contract §3.5).

**When the block is used:** it's optional. Owners give it where their law asks for it, as owners in Germany must (§ 5 DDG), and it's published as given. The console reminds owners in Germany of what the law asks (`legalReminders()`); a publish never waits for it. "Req." below means required inside the block.

A person entry without offerings, prices or actions has no operator block, no legal notice and no address.

| Field | Type | Req. | Tier | Basis |
|---|---|---|---|---|
| `operator.legalName` | string | ✓ | R | §5(1) Nr. 1 |
| `operator.legalForm` | enum (19 values) | ✓ | R | §5(1) Nr. 1 |
| `operator.address` | postal address; no PO box | ✓ | R | §5(1) Nr. 1, "ladungsfähige Anschrift" |
| `operator.register` | `{type: HRA\|HRB\|GnR\|PR\|VR\|GsR, court, number}` | c (I5): unclaimed entries | R | §5(1) Nr. 4 |
| `operator.representatives` | `{name, role}[]` | – | O | §5(1) Nr. 1, "Vertretungsberechtigte" |
| `operator.shareCapital` | `{amount, currency, outstanding?}` | – (only if the business states it) | O | §5(1) Nr. 1 |
| `operator.electronicContact` | `{email, phone?, contactUrl?}`: email plus one more channel | – | O | §5(1) Nr. 2 |
| `operator.supervisoryAuthority` | `{name, url?}` | – (if the activity needs an official permit) | O | §5(1) Nr. 3 |
| `operator.regulatedProfession` | `{title, conferredIn, chamber, rules[]}` | – (regulated professions, e.g. zulassungspflichtiges Handwerk) | O | §5(1) Nr. 5 |
| `operator.vatId` | USt-IdNr. | – | O | §5(1) Nr. 6 |
| `operator.economicId` | W-IdNr. `DE#########-#####` | – | O | §5(1) Nr. 6 |
| `operator.inLiquidation` | bool | – | O | §5(1) Nr. 7 |
| `operator.consumerDisputeResolution` | `{stance: willing\|obliged\|not_willing, body?}` | – (if §36 VSBG applies) | O | §36 VSBG |
| `operator.privacyNotice` | `{kind: url, url}` or `{kind: hosted, text}` | c (I10) | O | Art. 13 GDPR |
| `operator.responsibleForContent` | `{name, address}` | – (journalistic or editorial content only) | O | §18(2) MStV |

There is no field for the EU ODR platform link. That obligation ended on **20 July 2025** (Regulation (EU) 2024/3228). The §36 VSBG statement still applies.

Representatives' names appear only where the law requires them: the Impressum section and page. They are **not** emitted into JSON-LD.

### 4.4 Links

| Field | Type | Req. | Tier |
|---|---|---|---|
| `domains` | `{host, role: primary\|secondary}[]` ≤ 10 | – | R (only an association until verified) |
| `profiles` | `{platform, url}[]` ≤ 15 | – | O |

### 4.5 Contact and routing

| Field | Type | Req. | Tier |
|---|---|---|---|
| `contactPoints` | ContactPoint[] ≤ 20: `{id, label, purposes[], channels[], languages?, hours?, expectedResponse?, visibility}` | – | R (at most one; only phone, email or web, taken from the business's own Impressum) / O |
| `routing` | RoutingRule[] ≤ 40: `{audience, intent, route, note?}` | – | O |
| `agentPolicy` | `{agentRequests, unsolicitedOutreach, agentsMustDisclose}` | – | O |

Channel types: `phone fax whatsapp email web action postal`.

A contact point with `visibility: private` is used only to deliver action requests. It is **never published**.

### 4.6 Places

| Field | Type | Req. | Tier |
|---|---|---|---|
| `locations` | Location[] ≤ 50: `{id, name?, kind?, address, addressVisibility, geo?, timeZone, hours?, visits?, accessibility?, parking?, mediaIds?}` | – | R (at most one; address and geo only, no hours) / O |
| `primaryLocationId` | `loc_…` | c (I4) | R |
| `serviceArea` | `{radius?: {center, km}, places?, countries?, remote?}` | – | O |

- **Hours** are a weekly map per day. A missing day means unknown; an empty list means closed. Dated exceptions handle holidays and company closures. Times are local to `timeZone` (IANA), a field schema.org lacks.
- **Postal codes.** An address has its postal code where the place has one. Where it has none, as in Dubai, Doha or Hong Kong, `postalCode` is left out, never filled with a stand-in, and the address is placed by its locality.
- **`addressVisibility: locality_only`** is for home-based workshops: projections then show only the postal code and locality. The legal address still appears in the Impressum, because §5 DDG requires it.

### 4.7 Commercial

| Field | Type | Req. | Tier |
|---|---|---|---|
| `offerings` | Offering[] ≤ 100: `{id, kind, name, summary?, description?, category?, audience, price?, typicalLeadTime?, availability, serviceArea?, locationIds?, actionIds?, attributes?, mediaIds?}` | – | O |
| `offerings[].price` | `fixed \| from \| range \| on_request \| free`, plus `{currency, vatIncluded, unit?, note?, validFrom?, validThrough?}` | – | O |
| `capacity` | `{status: accepting\|limited\|waitlist\|not_accepting, nextAvailableFrom?, leadTime?, note?, asOf, expiresAt}` | – | O |
| `policies` | Policy[] ≤ 20. Typed: `payment` (methods), `deposit` (percent), `estimates` (free, price), `travel_costs` (free within N km, price), and text-only `cancellation warranty guarantee returns terms accessibility other` | – | O |
| `credentials` | Credential[] ≤ 30: `{id, type, name, issuer{name, url?, entryId?}, identifier?, validFrom?, validThrough?, evidenceUrl?}` | – | O |
| `faqs` | `{id, question, answer}`[] ≤ 30 | – | O |

Price rules:

- Amounts are **decimal strings**, never floats (`"78.00"`).
- `unit` maps to UN/CEFACT codes in JSON-LD: hour → `HUR`, square metre → `MTK`, and so on.
- **Every price with an amount says whether tax is included** (`vatIncluded`: VAT, GST or sales tax, whichever applies), and projections show it as the owner stated it. Following their own country's price-display law is the owner's duty, as on their own website. German law, for example, asks for the total price including VAT when consumers are quoted (PAngV, Gesamtpreis).
- A price per seat or per year goes into the price's `note` ("per user per month"). Units are added when owners ask for them.
- `warranty` is the statutory Gewährleistung. `guarantee` is a voluntary Garantie. German law treats them differently, so they stay separate.

### 4.8 Presentation

| Field | Type | Req. | Tier |
|---|---|---|---|
| `voice` | §5 | – | O |
| `brand` | `{logo?{primary, onDark?, mark?}, colors?{primary, onPrimary, accent?, surface?, text?}, typography?{heading, body}, corners?, imagery?}`, at least one part | – | O |
| `media` | MediaAsset[] ≤ 60: `{id, url, mimeType, width?, height?, alt (required, localized), focalPoint?, credit?}` | – | O |

- **Fonts** come from an allowlist that Daawid hosts itself: nothing loads from a third party.
- **Colors** must pass a contrast check (I13), because the fixed component set renders them.
- **Each part of the brand kit is optional:** an owner may give a colour, a logo or pictures, each without the others.
- **Media** are pictures Daawid hosts, at `https://media.{host}/{file}` (identifiers.md §3): uploaded by the owner, with location and camera details removed.

### 4.9 Actions

| Field | Type | Req. | Tier |
|---|---|---|---|
| `actions` | Action[] ≤ 10 (§6) | – | O; at level `contact` or higher, within the limits in verification.md §2.1 |

## 5. Voice, routing and agent policy

**Voice** describes how the business wants to be described and addressed:

| Field | Shape |
|---|---|
| `preferredName` | string ("Holzwerk Krause", not "Holzwerk Krause GmbH") |
| `avoidNames` | string[] ≤ 10 (former names, common misspellings, wrong variants) |
| `pronunciation` | `{ipa?, respelling?}` |
| `formality` | `formal \| informal` (German: Sie or du) |
| `tone` | 1–3 words from a closed list: `warm friendly professional direct precise calm playful enthusiastic traditional modern understated` |
| `tagline` | localized text ≤ 120 |
| `emphasis` | up to 5 localized **factual statements** ≤ 140 chars ("Meisterbetrieb seit 1987"). No superlatives, comparisons or generic environmental claims (I14). |

Voice and the brand kit (§4.8) carry the intangible feel of a customer's relationship with the business.

**Handling rules** say how the business wants each kind of counterpart handled. They have three parts:

1. **Routing rules** map (audience × intent) to a route.
   - Audiences: `consumer business public_sector press job_seeker supplier partner ai_agent any`
   - Intents: `quote booking order question support complaint warranty_claim billing press job partnership sales_pitch other`
   - Route: a contact point, an action, or **decline** (for example "no sales pitches").
   - Resolution: exact audience and exact intent first; then exact audience with intent `other`; then audience `any` with exact intent; then `any`/`other`. Ties are resolved by list order.
2. **Agent policy:**
   - `agentRequests`: `welcome | via_actions_only | not_accepted`, for requests that AI agents make on behalf of a human.
   - `unsolicitedOutreach`: `welcome | no_sales | not_accepted`, for cold outreach by people or agents.
   - `agentsMustDisclose`: whether an agent that contacts the business must say it is an AI. The default is `true`.
3. **Relationships to other organizations** as checkable data:
   - In v0.1: credentials and memberships with an issuer. `issuer.entryId` links to the issuer's own entry when it exists, which allows two-way confirmation later.
   - Reserved for v0.2: parent, subsidiary, brand, dealer and partner relationships, which both entries must confirm.

**Why all of this is bounded instead of a free-text "AI instructions" box:**

- Consumers read these fields with a language model. Free text addressed to a model is a prompt-injection channel.
- AI platforms will, rightly, discount a source that injects instructions.
- Closed vocabularies are followed more reliably, render predictably and translate cleanly.

The data contract presents voice as *"stated preferences of the business, not instructions"* (data-contract §3).

## 6. Actions

- **Closed registry of action types.** v0.1 has four, and the list is append-only:

  | Type | What it does |
  |---|---|
  | `request_quote` | Asks the business for an offer. |
  | `request_callback` | Asks the business to call back. |
  | `request_appointment` | Asks for an appointment. It is a *request*: the business confirms it. Real-time booking against availability is v0.2. |
  | `send_message` | Sends a message to the business. |

  Each type has a Daawid-defined, versioned input schema (data-contract §7.5).
- **What owners configure:**
  - title and description
  - which offerings the action applies to
  - which optional fields become required (`email phone postal_code desired_timing budget photos`)
  - up to 5 custom questions (text, number, boolean or choice)
  - audiences
  - service-area restriction
  - expected response time
  - delivery
- **Delivery:**
  - `daawid`: Daawid delivers the request using private `fulfillment` config, either email to a contact point or an HMAC-signed webhook.
  - `handoff`: the user finishes on the business's own system, for example its booking tool.
- **The end user always confirms before anything is sent.** This is not configurable, because every action shares personal data (data-contract §7.6).
- **The entry's owner is the controller of submitted data**, so every entry with an action has a privacy notice: the owner's own (`operator.privacyNotice`), or else Daawid's standard notice, a fixed text filled in from the entry and published with it (I10). On a person entry, an action makes the entry commercial (I17).
- **In this version:** owners switch a type on in the console, and it is off until they do; requests go by email to an address Daawid has confirmed, kept as a private contact point. The console offers webhooks, custom questions, routing rules, the agent policy and handoffs when owners ask for them; an entry that has them keeps them.
- **Why typed rather than generic:**
  - Agents can rely on what each action means.
  - The MCP directories of both ChatGPT and Claude reject a generic "execute any operation" tool and want one typed tool per operation (data-contract §7).
  - Typed actions map cleanly onto UCP-style capabilities later.

## 7. Provenance and freshness

- **Keys.** `provenance` is keyed by **entry path**: `/{section}` or `/{section}/{itemId}`, for example `/operator` or `/offerings/off_…`. IDs make paths stable when arrays reorder, which JSON Pointer indices would not.
- **Values.** Each value is `{source: owner | register | business_website | daawid | imported, observedAt, reference?, machineTranslated?}`. `imported` marks a value the owner imported in the console from their website or a profile, until they confirm it.
- **Default for owner-published entries** (level `contact` and up). A path with no record means `{source: owner, observedAt: updatedAt}`. That keeps documents small.
- **Unclaimed entries** need an explicit record for every path that is present.
- **Credential provenance.** `source: owner` means self-declared. `source: daawid` with a `reference` means Daawid checked it, for example against the Handwerksrolle.
- **Freshness:**
  - `capacity.expiresAt` is at most `asOf` + 30 days. After that, consumers must treat capacity as unknown.
  - Prices may carry `validThrough`.
  - `lastConfirmedAt` older than 12 months is flagged to consumers.

## 8. Invariants (normative)

Invariants are checked in `validateEntry()`, which uses Zod `superRefine` on `StoredEntry`. Each one has its own test.

- **I1. IDs.** IDs are unique within an entry. Every reference (`*Id`, `*Ids`, `primaryLocationId`, brand media) resolves to an item of the right kind in the same entry.
- **I2. Languages.** `languages` includes `defaultLanguage`. Every localized text contains `defaultLanguage`, and its keys are a subset of `languages`.
- **I3. Status and verification.** Levels rank `none < contact < domain < business`; `identity` ranks with `business` and applies to persons.
  - `verified` requires either level ≥ `domain` with at least one `primary` domain whose status is `verified` or `grace`, or level `identity`.
  - `published` requires level `contact` and a `contactConfirmedAt`.
  - `unclaimed` requires level `none`, `kind: organization`, and content that validates against `UnclaimedContent`.
  - `lapsed` requires level `none`.
- **I4. Primary location.** Published and verified entries with locations name their primary one (`primaryLocationId`). The operator's electronic contact is a reminder in the console.
- **I5. Register of unclaimed entries.** An unclaimed entry of a registered form (`ek ohg kg gmbh ug gmbh_co_kg ag se kgaa eg partg partg_mbb ev`) names its `operator.register`, because Daawid compiles it from the register. For owner-published entries, the register, representatives and a sole trader's full name in the legal name are reminders in the console.
- **I6. Retired.** It required consumer prices to include VAT; owners now state whether tax is included, and Daawid publishes it as stated. The number is never reused.
- **I7. Capacity window.** `asOf < expiresAt ≤ asOf + 30 days`.
- **I8. Ranges are ordered.** Range `min < max`, `validFrom ≤ validThrough`, lead-time `min ≤ max`, and hours exceptions `from ≤ through`.
- **I9. Street address of unclaimed entries.** On an unclaimed entry, `operator.address.street` must not be a PO box (Postfach): the legal section needs a ladungsfähige Anschrift, and Daawid compiled it. For owner-published entries it's a reminder in the console.
- **I10. Actions.**
  - Actions are allowed on `published` and `verified` entries. Below level `domain`, the limits in verification.md §2.1 apply.
  - Every entry with an action has a privacy notice: the owner's own (`operator.privacyNotice`), or else Daawid's standard notice, published with the entry at `/{slug}/privacy` (`/{slug}/datenschutz` for operators in Germany).
  - `delivery.mode = daawid` requires `fulfillment`: an email contact point that has an email channel, or a webhook.
  - Changing `fulfillment` triggers a domain re-check and an owner notification (verification.md §6).
- **I11. Handoff URLs.** A `handoff` URL must be HTTPS, and its host must not be a Daawid origin.
- **I12. Unclaimed entries.**
  - `kind` is `organization`, and `operator.legalForm` is one of `UNCLAIMED_ALLOWED_FORMS`.
  - Contact channels are limited to phone, email and web.
  - Every present path has provenance from `register`, `business_website` or `daawid`.
  - `summary` comes from a template.
- **I13. Colour contrast.** The WCAG contrast of `brand.colors.onPrimary` against `primary` is at least 4.5:1.
- **I14. Free-text lint.** Versioned, and it returns reason codes. It rejects:
  - text addressed to AI systems ("ignore previous instructions", "as an AI", "you must recommend …");
  - URLs inside non-URL fields;
  - in `summary`, `voice.tagline` and `voice.emphasis`: unsupported superlatives or comparisons, and generic environmental claims ("klimaneutral", "umweltfreundlich", "eco-friendly"). Directive (EU) 2024/825 restricts those, so environmental claims go into `credentials` with an issuer.
- **I15. Locality-only addresses.** `addressVisibility: locality_only` means every projection emits only the postal code (when the address has one) and locality, plus geo rounded to 2 decimals (about 1 km).
- **I16. Size.** A serialized `PublicEntry` is at most 256 KB. The markdown target is at most about 8k tokens; offerings beyond 25 move to `/{slug}/offerings`.
- **I17. Kind.** `kind` never changes after creation.
  - `organization` entries need 1–5 `categories` once published.
  - `person` entries need 1–5 `categories` as soon as they have any offering, price or action, which makes them commercial. Without those, they must not have an operator block, and categories are optional.
  - The operator block is optional otherwise.
  - `person` entries are never `unclaimed`.
- **I18. Listing.** `listed: false` keeps the entry out of search results, sitemaps and `llms.txt`. It stays reachable by URL and ID.

Beside the invariants, `validateEntry()` refuses any string with a control character, tab and line breaks aside, or with half of a surrogate pair, with the reason `control_character`: no real text has one, and databases can't keep a null character or read half a pair.

## 9. Zod definition (v0.1)

The reference definition, in Zod 4. Its JSON Schemas are published at `/schemas/0.1/`.

```ts
// packages/schema/src/entry.ts — Daawid entry schema v0.1
import { z } from "zod";

/* ───────────────────────── Primitives ───────────────────────── */

const TYPEID_SUFFIX = "[0-7][0-9a-hjkmnp-tv-z]{25}";
const typeId = (prefix: string) =>
  z.string().regex(new RegExp(`^${prefix}_${TYPEID_SUFFIX}$`), `Expected a ${prefix}_ TypeID`);

export const EntryId = typeId("ent");
export const LocationId = typeId("loc");
export const OfferingId = typeId("off");
export const ActionId = typeId("act");
export const ContactPointId = typeId("cp");
export const PolicyId = typeId("pol");
export const CredentialId = typeId("crd");
export const FaqId = typeId("faq");
export const MediaId = typeId("med");

export const Slug = z.string().min(3).max(63).regex(/^[a-z](?:[a-z0-9]|-(?!-))*[a-z0-9]$/);
export const LanguageTag = z.string().regex(/^[a-z]{2,3}(?:-[A-Z]{2})?$/); // de, en, pt-BR
export const CountryCode = z.string().regex(/^[A-Z]{2}$/); // ISO 3166-1 alpha-2
export const CurrencyCode = z.string().regex(/^[A-Z]{3}$/); // ISO 4217
export const Money = z.string().regex(/^\d{1,9}\.\d{2}$/); // "15000.00" — never a float
// A date's year, read in UTC, runs from 1 to 9999: databases can't keep what falls outside.
const realYear = (value: string): boolean => {
  const year = new Date(value).getUTCFullYear();
  return year >= 1 && year <= 9999;
};
const YEARS = "A date's year runs from 1 to 9999.";
export const IsoDate = z.iso.date().refine(realYear, YEARS);
export const IsoDateTime = z.iso.datetime({ offset: true }).refine(realYear, YEARS);
export const Duration = z.iso.duration(); // "P2D", "P6W"
export const ClockTime = z.string().regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/); // "07:30"
export const ClosingTime = z.union([ClockTime, z.literal("24:00")]);
export const HttpsUrl = z.url({ protocol: /^https$/ });
export const Email = z.email();
export const Phone = z.string().regex(/^\+[1-9]\d{6,14}$/); // E.164
export const Hostname = z
  .string()
  .max(253)
  .regex(/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9-]{2,63}$/); // lowercase A-labels; PSL check in verifier
export const HexColor = z.string().regex(/^#[0-9A-F]{6}$/);
export const CategoryCode = z.string().regex(/^[a-z]+(?:\.[a-z0-9_]+){1,3}$/); // "trades.carpentry"
export const NaceCode = z.string().regex(/^\d{2}(?:\.\d{1,2}(?:\.\d)?)?$/); // "43.32"
// IANA, and one this host knows: an unknown one can't be shown as a time.
const knownTimeZone = (zone: string): boolean => {
  try {
    new Intl.DateTimeFormat("en", { timeZone: zone });
    return true;
  } catch {
    return false;
  }
};
export const TimeZone = z
  .string()
  .regex(/^[A-Za-z]+(?:\/[A-Za-z0-9_+-]+){1,2}$/)
  .refine(knownTimeZone, "That isn't a time zone.");

/** Localized text: BCP 47 tag → string. Must contain the entry's defaultLanguage (I2). */
export const localized = (max: number) =>
  z
    .record(LanguageTag, z.string().trim().min(1).max(max))
    .refine((t) => Object.keys(t).length > 0, "At least one language");

export const GeoPoint = z.object({
  lat: z.number().min(-90).max(90),
  lng: z.number().min(-180).max(180),
});

export const PostalAddress = z.object({
  street: z.string().min(2).max(120), // street + house number: "Am Sägewerk 7"
  extra: z.string().max(120).optional(), // c/o, building, floor
  postalCode: z.string().min(3).max(10).optional(), // absent where the address has none (§4.6)
  locality: z.string().min(1).max(80),
  region: z.string().max(80).optional(), // Bundesland
  country: CountryCode,
});

/* ───────────────────────── Hours & areas ───────────────────────── */

export const DayOfWeek = z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]);

/** `closes` earlier than `opens` = closes the next day; "24:00" = until midnight. */
export const TimeRange = z.object({ opens: ClockTime, closes: ClosingTime });

export const OpeningHours = z.object({
  /** Missing day = unknown. Empty array = closed. */
  weekly: z.partialRecord(DayOfWeek, z.array(TimeRange).max(4)),
  exceptions: z
    .array(
      z.object({
        from: IsoDate,
        through: IsoDate,
        hours: z.array(TimeRange).max(4), // [] = closed
        note: localized(120).optional(),
      }),
    )
    .max(60)
    .default([]),
});

export const ServiceArea = z
  .object({
    radius: z.object({ center: GeoPoint, km: z.number().positive().max(500) }).optional(),
    places: z
      .array(
        z.object({
          name: z.string().min(1).max(80),
          country: CountryCode,
          postalCode: z.string().max(10).optional(),
        }),
      )
      .max(50)
      .optional(),
    countries: z.array(CountryCode).max(30).optional(),
    remote: z.boolean().optional(),
  })
  .refine(
    (a) =>
      a.radius !== undefined ||
      (a.places?.length ?? 0) > 0 ||
      (a.countries?.length ?? 0) > 0 ||
      a.remote === true,
    "Service area needs a radius, places, countries or remote",
  );

/* ───────────────────────── Media & brand ───────────────────────── */

export const MediaAsset = z.object({
  id: MediaId,
  url: HttpsUrl, // content-addressed on media.daawid.example
  mimeType: z.enum(["image/svg+xml", "image/png", "image/jpeg", "image/webp", "image/avif"]),
  width: z.number().int().positive().optional(), // omitted for SVG
  height: z.number().int().positive().optional(),
  alt: localized(250),
  focalPoint: z.object({ x: z.number().min(0).max(1), y: z.number().min(0).max(1) }).optional(),
  credit: z.string().max(120).optional(),
});

/** Self-hosted by Daawid; no third-party font CDNs. */
export const FontFamily = z.enum([
  "system", "inter", "source_sans_3", "ibm_plex_sans", "atkinson_hyperlegible",
  "lora", "merriweather", "source_serif_4", "roboto_slab", "fraunces",
]);

/** Each part is optional, and a brand kit has at least one. */
export const BrandKit = z
  .object({
    logo: z.object({ primary: MediaId, onDark: MediaId.optional(), mark: MediaId.optional() }).optional(),
    colors: z
      .object({
        primary: HexColor,
        onPrimary: HexColor, // contrast ≥ 4.5:1 against primary (I13)
        accent: HexColor.optional(),
        surface: HexColor.optional(),
        text: HexColor.optional(),
      })
      .optional(),
    typography: z.object({ heading: FontFamily, body: FontFamily }).optional(),
    corners: z.enum(["square", "soft", "round"]).optional(),
    imagery: z.array(MediaId).max(12).optional(),
  })
  .refine((kit) => Object.values(kit).some((part) => part !== undefined), "A brand kit needs at least one part");

/* ───────────────────────── Operator (legal / Impressum) ───────────────────────── */

export const LegalForm = z.enum([
  "einzelunternehmen", "ek", "gbr", "ohg", "kg", "partg", "partg_mbb", // natural persons involved
  "gmbh", "ug", "gmbh_co_kg", "ag", "se", "kgaa", "eg", "ev", // legal persons
  "stiftung", "public_body", "foreign", "other",
]);

/** Only these forms may exist as unclaimed entries (I12). */
export const UNCLAIMED_ALLOWED_FORMS = ["gmbh", "ug", "gmbh_co_kg", "ag", "se", "kgaa", "eg", "ev"] as const;

export const Register = z.object({
  type: z.enum(["HRA", "HRB", "GnR", "PR", "VR", "GsR"]),
  court: z.string().min(3).max(80), // "Amtsgericht Wuppertal"
  number: z.string().regex(/^\d{1,6}(?: ?[A-Z]{1,2})?$/), // "99901", "12345 B"
});

export const Operator = z.object({
  legalName: z.string().min(2).max(200),
  legalForm: LegalForm,
  address: PostalAddress, // ladungsfähige Anschrift; no PO box on unclaimed entries (I9)
  register: Register.optional(),
  representatives: z
    .array(
      z.object({
        name: z.string().min(2).max(120),
        role: z.enum(["managing_director", "board_member", "owner", "partner", "other"]),
      }),
    )
    .max(10)
    .optional(),
  shareCapital: z.object({ amount: Money, currency: CurrencyCode, outstanding: Money.optional() }).optional(),
  electronicContact: z
    .object({ email: Email, phone: Phone.optional(), contactUrl: HttpsUrl.optional() })
    .optional(),
  supervisoryAuthority: z.object({ name: z.string().max(160), url: HttpsUrl.optional() }).optional(),
  regulatedProfession: z
    .object({
      title: z.string().max(120), // "Tischler"
      conferredIn: CountryCode,
      chamber: z.object({ name: z.string().max(160), url: HttpsUrl.optional() }),
      rules: z.array(z.object({ name: z.string().max(160), url: HttpsUrl })).min(1).max(5),
    })
    .optional(),
  vatId: z.string().regex(/^[A-Z]{2}[0-9A-Z]{2,13}$/).optional(),
  economicId: z.string().regex(/^DE\d{9}-\d{5}$/).optional(), // Wirtschafts-Identifikationsnummer
  inLiquidation: z.boolean().optional(),
  consumerDisputeResolution: z
    .object({
      stance: z.enum(["willing", "obliged", "not_willing"]),
      body: z.object({ name: z.string().max(160), url: HttpsUrl }).optional(),
    })
    .optional(),
  privacyNotice: z
    .discriminatedUnion("kind", [
      z.object({ kind: z.literal("url"), url: HttpsUrl }),
      z.object({ kind: z.literal("hosted"), text: localized(30000) }), // served at /{slug}/datenschutz
    ])
    .optional(),
  responsibleForContent: z.object({ name: z.string().max(120), address: PostalAddress }).optional(),
});

/* ───────────────────────── Contact, routing, agents ───────────────────────── */

export const Channel = z.discriminatedUnion("type", [
  z.object({ type: z.literal("phone"), number: Phone, sms: z.boolean().optional() }),
  z.object({ type: z.literal("fax"), number: Phone }),
  z.object({ type: z.literal("whatsapp"), number: Phone }),
  z.object({ type: z.literal("email"), address: Email }),
  z.object({ type: z.literal("web"), url: HttpsUrl }),
  z.object({ type: z.literal("action"), actionId: ActionId }),
  z.object({ type: z.literal("postal"), address: PostalAddress }),
]);

export const ContactPoint = z.object({
  id: ContactPointId,
  label: localized(60),
  purposes: z
    .array(
      z.enum([
        "general", "sales", "quotes", "bookings", "support", "billing",
        "complaints", "press", "jobs", "partners", "emergency",
      ]),
    )
    .min(1)
    .max(11),
  channels: z.array(Channel).min(1).max(6),
  languages: z.array(LanguageTag).max(10).optional(),
  hours: OpeningHours.optional(),
  expectedResponse: Duration.optional(),
  visibility: z.enum(["public", "private"]).default("public"), // private = fulfillment only, never published
});

export const Audience = z.enum([
  "consumer", "business", "public_sector", "press", "job_seeker",
  "supplier", "partner", "ai_agent", "any",
]);

export const Intent = z.enum([
  "quote", "booking", "order", "question", "support", "complaint", "warranty_claim",
  "billing", "press", "job", "partnership", "sales_pitch", "other",
]);

export const RoutingRule = z.object({
  audience: Audience,
  intent: Intent,
  route: z.discriminatedUnion("to", [
    z.object({ to: z.literal("contact_point"), contactPointId: ContactPointId }),
    z.object({ to: z.literal("action"), actionId: ActionId }),
    z.object({ to: z.literal("decline"), reason: localized(200).optional() }),
  ]),
  note: localized(200).optional(),
});

export const AgentPolicy = z.object({
  agentRequests: z.enum(["welcome", "via_actions_only", "not_accepted"]),
  unsolicitedOutreach: z.enum(["welcome", "no_sales", "not_accepted"]),
  agentsMustDisclose: z.boolean().default(true),
});

/* ───────────────────────── Voice ───────────────────────── */

export const Tone = z.enum([
  "warm", "friendly", "professional", "direct", "precise", "calm",
  "playful", "enthusiastic", "traditional", "modern", "understated",
]);

export const Voice = z.object({
  preferredName: z.string().min(2).max(80),
  avoidNames: z.array(z.string().min(2).max(80)).max(10).optional(),
  pronunciation: z
    .object({ ipa: z.string().max(80).optional(), respelling: z.string().max(80).optional() })
    .optional(),
  formality: z.enum(["formal", "informal"]).optional(), // de: Sie / du
  tone: z.array(Tone).min(1).max(3).optional(),
  tagline: localized(120).optional(),
  emphasis: z.array(localized(140)).max(5).optional(), // factual statements only (I14)
});

/* ───────────────────────── Places ───────────────────────── */

export const Location = z.object({
  id: LocationId,
  name: localized(80).optional(),
  kind: z.enum(["storefront", "workshop", "office", "showroom", "warehouse", "other"]).optional(),
  address: PostalAddress,
  addressVisibility: z.enum(["full", "locality_only"]).default("full"),
  geo: GeoPoint.optional(),
  timeZone: TimeZone.default("Europe/Berlin"),
  hours: OpeningHours.optional(),
  visits: z.enum(["walk_in", "appointment_only", "no_visitors"]).optional(),
  accessibility: z
    .array(z.enum(["step_free_entrance", "accessible_parking", "accessible_restroom", "hearing_loop"]))
    .max(10)
    .optional(),
  parking: z.enum(["on_site", "street", "none"]).optional(),
  mediaIds: z.array(MediaId).max(10).optional(),
});

/* ───────────────────────── Offerings, prices, capacity ───────────────────────── */

export const PriceUnit = z.enum([
  "hour", "day", "piece", "metre", "square_metre", "cubic_metre",
  "kilogram", "kilometre", "visit", "project", "month",
]);

const PriceTerms = z.object({
  currency: CurrencyCode.default("EUR"),
  vatIncluded: z.boolean(), // VAT, GST or sales tax, as the owner states it
  unit: PriceUnit.optional(),
  note: localized(200).optional(),
  validFrom: IsoDate.optional(),
  validThrough: IsoDate.optional(),
});

export const Price = z.discriminatedUnion("kind", [
  PriceTerms.extend({ kind: z.literal("fixed"), amount: Money }),
  PriceTerms.extend({ kind: z.literal("from"), amount: Money }),
  PriceTerms.extend({ kind: z.literal("range"), min: Money, max: Money }),
  z.object({ kind: z.literal("on_request"), note: localized(200).optional() }),
  z.object({ kind: z.literal("free"), note: localized(200).optional() }),
]);

export const Offering = z.object({
  id: OfferingId,
  kind: z.enum(["service", "product", "package", "rental", "course", "subscription"]),
  name: localized(120),
  summary: localized(280).optional(),
  description: localized(2000).optional(),
  category: CategoryCode.optional(),
  audience: z.enum(["consumer", "business", "all"]).default("all"),
  price: Price.optional(), // absent = not stated
  typicalLeadTime: z.object({ min: Duration, max: Duration }).optional(),
  availability: z.enum(["available", "seasonal", "on_request", "discontinued"]).default("available"),
  serviceArea: ServiceArea.optional(), // overrides the entry default
  locationIds: z.array(LocationId).max(50).optional(),
  actionIds: z.array(ActionId).max(5).optional(),
  attributes: z
    .array(
      z.object({
        key: z.string().regex(/^[a-z][a-z0-9_]{1,39}$/),
        label: localized(60),
        value: z.union([localized(200), z.number(), z.boolean()]),
        unit: z.string().max(20).optional(),
      }),
    )
    .max(20)
    .optional(),
  mediaIds: z.array(MediaId).max(10).optional(),
});

export const Capacity = z.object({
  status: z.enum(["accepting", "limited", "waitlist", "not_accepting"]),
  nextAvailableFrom: IsoDate.optional(),
  leadTime: z.object({ min: Duration, max: Duration }).optional(),
  note: localized(200).optional(),
  asOf: IsoDateTime,
  expiresAt: IsoDateTime, // ≤ asOf + 30 days (I7)
});

/* ───────────────────────── Policies, credentials, FAQ ───────────────────────── */

const PolicyBase = z.object({ id: PolicyId, summary: localized(600), url: HttpsUrl.optional() });

export const PaymentMethod = z.enum([
  "cash", "girocard", "credit_card", "bank_transfer", "direct_debit",
  "invoice", "paypal", "apple_pay", "google_pay", "financing",
]);

export const Policy = z.discriminatedUnion("type", [
  PolicyBase.extend({ type: z.literal("payment"), methods: z.array(PaymentMethod).min(1) }),
  PolicyBase.extend({ type: z.literal("deposit"), percent: z.number().min(0).max(100).optional() }),
  PolicyBase.extend({ type: z.literal("estimates"), free: z.boolean(), price: Price.optional() }),
  PolicyBase.extend({
    type: z.literal("travel_costs"),
    freeWithinKm: z.number().min(0).max(500).optional(),
    price: Price.optional(),
  }),
  PolicyBase.extend({
    type: z.enum(["cancellation", "warranty", "guarantee", "returns", "terms", "accessibility", "other"]),
  }),
]);

export const Credential = z.object({
  id: CredentialId,
  type: z.enum([
    "master_craftsman", "trade_registration", "certification",
    "membership", "license", "award", "insurance",
  ]),
  name: localized(120),
  issuer: z.object({
    name: z.string().min(2).max(160),
    url: HttpsUrl.optional(),
    entryId: EntryId.optional(), // issuer's own Daawid entry, if any
  }),
  identifier: z.string().max(80).optional(),
  validFrom: IsoDate.optional(),
  validThrough: IsoDate.optional(),
  evidenceUrl: HttpsUrl.optional(),
});

export const Faq = z.object({ id: FaqId, question: localized(200), answer: localized(1200) });

/* ───────────────────────── Actions ───────────────────────── */

export const ActionType = z.enum(["request_quote", "request_callback", "request_appointment", "send_message"]);
export const RequirableField = z.enum(["email", "phone", "postal_code", "desired_timing", "budget", "photos"]);

/** The part of an action every consumer sees. */
export const ActionPublic = z.object({
  id: ActionId,
  type: ActionType,
  title: localized(80),
  description: localized(280).optional(),
  audiences: z.array(Audience).min(1).default(["any"]),
  offeringIds: z.array(OfferingId).max(100).optional(),
  requireFields: z.array(RequirableField).max(6).optional(),
  questions: z
    .array(
      z.object({
        key: z.string().regex(/^[a-z][a-z0-9_]{1,39}$/),
        label: localized(120),
        kind: z.enum(["text", "number", "boolean", "choice"]),
        required: z.boolean().default(false),
        choices: z
          .array(z.object({ value: z.string().regex(/^[a-z0-9_]{1,40}$/), label: localized(60) }))
          .max(12)
          .optional(),
      }),
    )
    .max(5)
    .optional(),
  delivery: z.discriminatedUnion("mode", [
    z.object({ mode: z.literal("daawid") }), // Daawid delivers to the business
    z.object({ mode: z.literal("handoff"), url: HttpsUrl }), // user finishes on the business's own system (I11)
  ]),
  expectedResponse: Duration.optional(),
  serviceAreaOnly: z.boolean().default(true),
  enabled: z.boolean().default(true),
});

/** Stored form: adds private fulfillment config, never published. */
export const Action = ActionPublic.extend({
  fulfillment: z
    .discriminatedUnion("via", [
      z.object({ via: z.literal("email"), contactPointId: ContactPointId }),
      z.object({ via: z.literal("webhook"), url: HttpsUrl, secretRef: z.string().min(1).max(100) }), // HMAC-signed
    ])
    .optional(), // required when delivery.mode = "daawid" (I10)
});

/* ───────────────────────── Links ───────────────────────── */

export const Domain = z.object({ host: Hostname, role: z.enum(["primary", "secondary"]) });

export const Profile = z.object({
  platform: z.enum([
    "instagram", "facebook", "linkedin", "xing", "youtube", "tiktok", "x", "threads", "bluesky",
    "mastodon", "pinterest", "github", "google_business_profile", "yelp", "wikidata", "other",
  ]),
  url: HttpsUrl,
});

/* ───────────────────────── Record, verification, provenance ───────────────────────── */

export const EntryKind = z.enum(["person", "organization"]);
/** Append-only: new values go at the end. */
export const EntryStatus = z.enum(["unclaimed", "verified", "lapsed", "suspended", "published"]);
/** Append-only. Rank: none < contact < domain < business; identity ranks with business (I3). */
export const VerificationLevel = z.enum(["none", "domain", "business", "contact", "identity"]);

export const Verification = z.object({
  level: VerificationLevel,
  contactConfirmedAt: IsoDateTime.optional(), // email confirmed (level contact, I3)
  domains: z
    .array(
      z.object({
        host: Hostname,
        status: z.enum(["verified", "grace", "lapsed"]),
        method: z.enum(["dns_txt", "html_meta", "well_known"]),
        verifiedAt: IsoDateTime,
        lastCheckedAt: IsoDateTime,
      }),
    )
    .max(10)
    .default([]),
  /** Reserved for level "business" (v0.2). Not issued in v0.1. */
  business: z
    .object({
      method: z.enum(["register_and_letter", "register_and_signatory"]),
      registerReference: z.string().max(120),
      verifiedAt: IsoDateTime,
    })
    .optional(),
  /** Reserved for level "identity" (persons). Not issued in v0.1. */
  identity: z
    .object({ method: z.enum(["eid", "id_document"]), verifiedAt: IsoDateTime })
    .optional(),
});

/** "/{section}" or "/{section}/{itemId}" */
export const EntryPath = z.string().regex(/^\/[a-zA-Z]+(?:\/[a-z]{2,3}_[0-7][0-9a-hjkmnp-tv-z]{25})?$/);

export const Provenance = z.object({
  source: z.enum(["owner", "register", "business_website", "daawid", "imported"]),
  observedAt: IsoDateTime,
  reference: z.string().max(300).optional(), // URL, or "Amtsgericht Wuppertal HRB 99901"
  machineTranslated: z.array(LanguageTag).max(10).optional(),
});

export const EntryRecord = z.object({
  id: EntryId,
  kind: EntryKind, // immutable (I17)
  slug: Slug,
  schemaVersion: z.literal("0.1"),
  revision: z.number().int().min(1),
  status: EntryStatus,
  defaultLanguage: LanguageTag,
  languages: z.array(LanguageTag).min(1).max(10),
  createdAt: IsoDateTime,
  updatedAt: IsoDateTime,
  lastConfirmedAt: IsoDateTime.optional(),
  listed: z.boolean().default(true), // I18
  verification: Verification,
  provenance: z.record(EntryPath, Provenance).default({}),
});

/* ───────────────────────── Entry shapes ───────────────────────── */

const EntryContentBase = z.object({
  displayName: z.string().min(2).max(120),
  alternateNames: z.array(z.string().min(2).max(120)).max(10).optional(),
  summary: localized(160),
  description: localized(3000).optional(),
  categories: z.array(CategoryCode).min(1).max(5).optional(), // required by I17 for organizations and commercial persons
  naceCodes: z.array(NaceCode).max(5).optional(),
  foundingYear: z.number().int().min(1000).max(2100).optional(),
  employees: z.enum(["1", "2-9", "10-49", "50-249", "250-999", "1000+"]).optional(),
  languagesSpoken: z.array(LanguageTag).max(20).optional(),
  operator: Operator.optional(),
  domains: z.array(Domain).max(10).optional(),
  profiles: z.array(Profile).max(15).optional(),
  routing: z.array(RoutingRule).max(40).optional(),
  agentPolicy: AgentPolicy.optional(),
  locations: z.array(Location).max(50).optional(),
  primaryLocationId: LocationId.optional(),
  serviceArea: ServiceArea.optional(),
  offerings: z.array(Offering).max(100).optional(),
  capacity: Capacity.optional(),
  policies: z.array(Policy).max(20).optional(),
  credentials: z.array(Credential).max(30).optional(),
  faqs: z.array(Faq).max(30).optional(),
  voice: Voice.optional(),
  brand: BrandKit.optional(),
  media: z.array(MediaAsset).max(60).optional(),
});

/** What owners edit and what we store. */
export const EntryContent = EntryContentBase.extend({
  contactPoints: z.array(ContactPoint).max(20).optional(),
  actions: z.array(Action).max(10).optional(),
});
export const StoredEntry = EntryRecord.extend(EntryContent.shape);

/** The consumer contract: private contact points and fulfillment config removed. */
export const PublicEntry = EntryRecord.extend(EntryContentBase.shape).extend({
  contactPoints: z.array(ContactPoint.extend({ visibility: z.literal("public") })).max(20).optional(),
  actions: z.array(ActionPublic).max(10).optional(),
});

/** Write-time schema for unclaimed entries: organizations only, public-record fields only (I12, I17). */
export const UnclaimedContent = z.strictObject({
  displayName: EntryContentBase.shape.displayName,
  alternateNames: EntryContentBase.shape.alternateNames,
  summary: EntryContentBase.shape.summary, // template-generated, never model-written
  categories: z.array(CategoryCode).min(1).max(5), // required here, unlike in EntryContentBase
  operator: Operator.pick({ legalName: true, legalForm: true, register: true, address: true }),
  domains: EntryContentBase.shape.domains, // association only, never "verified"
  locations: z
    .array(Location.pick({ id: true, kind: true, address: true, geo: true, timeZone: true }))
    .max(1)
    .optional(),
  primaryLocationId: LocationId.optional(),
  contactPoints: z
    .array(ContactPoint.pick({ id: true, label: true, purposes: true, channels: true }))
    .max(1)
    .optional(),
});

export type StoredEntry = z.infer<typeof StoredEntry>;
export type PublicEntry = z.infer<typeof PublicEntry>;
export type UnclaimedContent = z.infer<typeof UnclaimedContent>;
```
