Entry schema

As markdown

  • Status: version 0.1 of Daawid's open specification.
  • Licence: this text is CC 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

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.

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/.

// 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>;