- 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.examplestands 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
- Facts, not prose. Prefer typed fields over free text. Free text is length-bounded and localized.
- 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.
- 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.
- 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.
- 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.
- Tolerant readers. Consumers ignore unknown fields and enum values (identifiers.md §5), so additions never break them.
- 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,
postalCodeis left out, never filled with a stand-in, and the address is placed by its locality. addressVisibility: locality_onlyis 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"). unitmaps 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. warrantyis the statutory Gewährleistung.guaranteeis 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:
- 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 audienceanywith exact intent; thenany/other. Ties are resolved by list order.
- Audiences:
- 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 istrue.
- Relationships to other organizations as checkable data:
- In v0.1: credentials and memberships with an issuer.
issuer.entryIdlinks 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.
- In v0.1: credentials and memberships with an issuer.
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_quoteAsks the business for an offer. request_callbackAsks the business to call back. request_appointmentAsks for an appointment. It is a request: the business confirms it. Real-time booking against availability is v0.2. send_messageSends 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 privatefulfillmentconfig, 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.
provenanceis keyed by entry path:/{section}or/{section}/{itemId}, for example/operatoror/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?}.importedmarks a value the owner imported in the console from their website or a profile, until they confirm it. - Default for owner-published entries (level
contactand 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: ownermeans self-declared.source: daawidwith areferencemeans Daawid checked it, for example against the Handwerksrolle. - Freshness:
capacity.expiresAtis at mostasOf+ 30 days. After that, consumers must treat capacity as unknown.- Prices may carry
validThrough. lastConfirmedAtolder 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.
languagesincludesdefaultLanguage. Every localized text containsdefaultLanguage, and its keys are a subset oflanguages. - I3. Status and verification. Levels rank
none < contact < domain < business;identityranks withbusinessand applies to persons.verifiedrequires either level ≥domainwith at least oneprimarydomain whose status isverifiedorgrace, or levelidentity.publishedrequires levelcontactand acontactConfirmedAt.unclaimedrequires levelnone,kind: organization, and content that validates againstUnclaimedContent.lapsedrequires levelnone.
- 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 itsoperator.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-timemin ≤ max, and hours exceptionsfrom ≤ through. - I9. Street address of unclaimed entries. On an unclaimed entry,
operator.address.streetmust 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
publishedandverifiedentries. Below leveldomain, 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}/datenschutzfor operators in Germany). delivery.mode = daawidrequiresfulfillment: an email contact point that has an email channel, or a webhook.- Changing
fulfillmenttriggers a domain re-check and an owner notification (verification.md §6).
- Actions are allowed on
- I11. Handoff URLs. A
handoffURL must be HTTPS, and its host must not be a Daawid origin. - I12. Unclaimed entries.
kindisorganization, andoperator.legalFormis one ofUNCLAIMED_ALLOWED_FORMS.- Contact channels are limited to phone, email and web.
- Every present path has provenance from
register,business_websiteordaawid. summarycomes from a template.
- I13. Colour contrast. The WCAG contrast of
brand.colors.onPrimaryagainstprimaryis 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.taglineandvoice.emphasis: unsupported superlatives or comparisons, and generic environmental claims ("klimaneutral", "umweltfreundlich", "eco-friendly"). Directive (EU) 2024/825 restricts those, so environmental claims go intocredentialswith an issuer.
- I15. Locality-only addresses.
addressVisibility: locality_onlymeans 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
PublicEntryis at most 256 KB. The markdown target is at most about 8k tokens; offerings beyond 25 move to/{slug}/offerings. - I17. Kind.
kindnever changes after creation.organizationentries need 1–5categoriesonce published.personentries need 1–5categoriesas 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.
personentries are neverunclaimed.
- I18. Listing.
listed: falsekeeps the entry out of search results, sitemaps andllms.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>;