The Data Layer Manual
On this page
- 1. What a data layer is
- 2. The pipeline
- 3. Choosing sources
- 4. Ingestion
- 5. Normalisation: identity and definitions
- 6. The fact store
- 7. Validation gates
- 8. Generating pages
- 9. Publishing
- 10. Freshness
- 11. AI search (GEO) without the folklore
- 12. Staying on the right side of Google
- 13. Monitoring
- 14. Runbooks
- 15. Checklists
- 16. Pitfalls (each one happened to us or to someone we cite)
- 17. Who does what
- 18. Where to read next
Version 1, 25 Oct 2026, Dubai time (UTC+4). Written for Anthony Booth's team. Everything here is grounded in what we built for buydubai.io and benidorm.co.uk. Where a rule is our own, it says so. Where outside best practice differs from what we do, it says that too.
Evidence labels: [V] checked this run, [R] from a team report, [U] unverified.
1. What a data layer is
A data layer is the part of a website that holds facts nobody else has put in one place, with a source and a date on each one, and turns them into pages and answers that change when the facts change.
Without it, a site is words. Search engines and AI assistants already have words. They do not have, for example, how many apartments sold in JVC last week (buydubai.io), or what a Benidorm Palace dinner show costs this month (benidorm.co.uk). That difference is the whole business case.
Our working definition:
Ingest → clean and structure → validate → generate pages → publish → watch. Every number on a page can be traced to a row, a source and a date.
What it is not: a scraper, a CMS, or a prompt that writes articles. If a model writes the number, it is not a data layer.
2. The pipeline
Stage by stage, with what we do
| Stage | Rule | buydubai.io | benidorm.co.uk |
|---|---|---|---|
| Source | Official or open first. Record licence and date. No scraped review text. | DLD transactions, Ejari, KHDA, OSM, Central Bank rules; Property Finder; Google Places photos behind a spend gate [R] | Seven numbered data feeds, each figure with source URL and "checked" date; data/eeat/sources.md register [R/V] |
| Ingest | Raw files are kept. Fetch scripts are not part of the build. | Nightly job; on failure restores last good build [R] | Fetch scripts write to data/; build never fetches [R] |
| Normalise | Deterministic keys first. Fuzzy matches go to a review queue, never auto-merge. | Registers: communities, buildings, aliases [R] | Venue and hotel entities from OSM and venue IDs [U on match rate] |
| Fact store | Each fact has an ID, a definition and a revision hash. | facts.parquet + cube_meta.json, DuckDB, fact_id, cube_hash, metrics.yaml, factlib.py [R] |
JSON/CSV feeds plus build_all.py manifest of about 55 steps [R] |
| Validate | A failing check blocks publishing. | lint_pages.py, community_eval.py (pass 85), spend gate [R] |
CI rebuild-and-diff described; I could not locate the workflow file [U] |
| Generate | Templates read facts. Nobody edits generated HTML. | Builders must call factlib.py (FL.get, FL.series) [R] |
"HTML is output, never edit by hand"; frozen clock via freezegun [R] |
| Publish | One route to live. Rollback id recorded. | Gated deploy scripts; Vercel overlay from MacPro [R] | Only from GitHub main through Vercel [R] |
| Surfaces | Sitemap, canonical, JSON-LD that matches visible text. Optional llms.txt and MCP. | Sitemap 8,156 URLs; llms.txt (4,265 B); no llms-full.txt [V] |
Sitemap 752; llms.txt 221 KB; llms-full.txt 4.9 MB; MCP server with 9 tools [V] |
| Monitor | Freshness, gate failures, search and AI traffic. | GSC and GA4 connected; Traks tag check [V/R] | GSC and GA4 connected [V] |
3. Choosing sources
Ask five questions of every candidate source. If two answers are "no", skip it.
- Official or primary? (DLD, Ejari, INE, AENA, a venue's own site.)
- Open licence or clear terms? Write the licence into the register.
- Changes often enough to matter? Daily or weekly sources create "changes daily" content. Annual ones do not.
- Nobody else has joined it? The value is in the join (sales + rents + building identity), not in the single feed.
- Can we state its limits? Example: DLD villa records give plot area, so per-sq-ft is not shown for villas (our own page says so [V]).
Never: review text copied from Google Places or TripAdvisor (Fintan's Google Places lesson [R]); a source you cannot name on the page; an LLM's recollection.
Open item: Property Finder and Google Places carry rights questions. Tiers (public_citable, internal_assist_only, citable_with_attribution) were decided on 24 Oct but are not implemented [R].
4. Ingestion
- Keep raw files exactly as received, with a fetch timestamp.
- One fetch script per source, writing only to the data folder. The page build never fetches.
- Make every run idempotent: running it twice gives the same result.
- On failure, do not publish partial data. Restore the last good snapshot and mark the pages as "as of" that date. (Our nightly job did restore
last_goodon 21 and 24 Oct [R]. The pages did not say so [V]. Fix: put the as-of date in the page, and alert.) - Record cost. Paid calls (Google Places photos) sit behind a gate: file
GOOGLE_PAID_OFF, cap USD 10 per community, USD 25 per day, every call logged (178 records, max cumulative about USD 0.78) [R].
5. Normalisation: identity and definitions
Identity. Give every real-world thing one ID (CM… community, BL… building). Join on the strongest key (DLD project number, licence). Keep an alias table and keep retired IDs with redirects. This is Gully's crosswalk rule and standard master-data practice [V].
Definition. Give every number one definition: name, formula, unit, population, window, minimum sample. Put it in a register (ours: metrics.yaml). Pages read the register. Language models read numbers; they never calculate them.
Suppress small samples. Our cube suppresses cells under its minimum counts (for example lr_* values with n under 10) [R].
Publish match rates. Our own reports: sales to community 87.9%; sales to tower 75.5%; leases to building 30.8% [R]. Decide a floor for each and show the rate on an internal dashboard.
Conflicts. If two sources disagree, keep both with their basis and explain. Do not pick by confidence score. Example: Peninsula Three, 156 on the site at build time, 158 in the cube, 35 in the old Supabase card [R].
6. The fact store
- buydubai.io:
facts.parquet(9.6 million rows on 23 Oct; 9,645,491 on the staged rebuild) andcube_meta.json, queried with DuckDB. Columns carryfact_id, value, suppression flag.cube_hashidentifies the revision. [R] - benidorm.co.uk: flat feeds under
data/, derived bybuild_all.py. [R]
Test for any store: build it twice, diff it. Zero diff or it is not a fact store. The staged cube passed (0 rows differ both ways). The 23 Oct cube failed (13,547 rows differ; 972 NULL fact_id) [R].
7. Validation gates
Gates run before publish. A gate that only warns is not a gate.
| Gate | What it checks | Where |
|---|---|---|
| Number lint | Every number on a page matches a fact in its manifest; manifest hash equals the live cube; no page prints numbers without a manifest | lint_pages.py (written 21 Oct; no-manifest check 24 Oct) [R] |
| Quality eval | 0–100 score on data, content, place, quality, links; pass at 85 | community_eval.py (24 Oct; wired into release scripts 25 Oct) [R] |
| Parity | Page, JSON-LD, exports and assistant answer give the same figures | Gully section 11; Town Square proof [R] |
| Spend | Paid calls under cap | GOOGLE_PAID_OFF, spend log [R] |
| Rebuild-diff | Rebuild from main; fail if output differs |
Benidorm CI build-check.yml [R, U on file] |
| Independent audit | A second agent that never writes code checks diffs, runs fixtures, signs off a status ladder | Grok auditor [R] |
Status ladder (Gully): source-present → implemented/wired → locally tested → independently reviewed → integrated → live. "Live" requires a fetch of the live URL, not a green log.
Rule from our own RULES file: every post to the team ends with a SELF-CHECK with real clock times and pasted gate output [R].
Known gaps to close before you rely on gates (from 20-scorecard.md): coverage (5,931 agent pages have no manifest [V]); the eval's pass line was set against one gold page and is uncalibrated [R].
8. Generating pages
- A page earns its place. It has a question people ask, a table nobody else has, and a dated line saying when data was checked.
- Templates read facts only. No numbers typed in templates. (JVC video section hard-coded 1,296 vs cube 1,273 [R]. That is the failure.)
- State limits in the text. Example: "Per sq ft is not shown for villas: many villa records give the plot area" [V].
- Prose from a model is allowed; numbers are not. The model gets the fact list and writes sentences. The lint checks the sentences.
- Name a human only when the human wrote it. The agent pages carry a templated quote attributed to a named broker; whether he wrote it is unverified [V/U]. Do not ship that.
- Size. A gold buydubai community page is 8,000–18,000 words across its pages [V]. A Benidorm page has a median of about 1,900 words [V]. A 5,000-character page repeated 5,900 times is a risk (section 12).
9. Publishing
- One route to live. Benidorm:
main→ Vercel. buydubai: gated script →cwdeploy→ Vercel overlay onmain, from MacPro [R]. - Record the deployment id and the rollback target in the post. (Town Square:
buydubai-e1nr16pt1; rollbackdpl_BoPd1R8fLttWm7GdUZmSvWAaDHPA[R].) - One revision per release. Live today: five community pages with the 23 Oct hash and Town Square with the new one [V]. Avoid that. Rebuild the set or hold the new page.
- After deploy, fetch the live URL and check status, canonical, manifest hash, one number. Then say "live".
- Takedowns. Prove removal and cache purge by fetching. (Gully section 11.)
10. Freshness
Say what you mean by "daily".
| Claim | Check |
|---|---|
| "Updated every morning" | data_as_of on the page is within 48 hours of today. On 25 Oct buydubai pages say 22 and 23 Oct [V]. |
Sitemap lastmod |
Moves only when the page's data changes. 98.6% of buydubai URLs show 23 Oct [V]. Benidorm: 588 of 752 show 24 Oct [V]; confirm that is data, not rebuild date [U]. |
llms.txt counters |
Rebuilt with the site. buydubai's says data to 22 Oct, rebuilt 23 Oct [V]. |
Show dates on the page. Benidorm's asof.py takes "data captured" from git history, never the build date [R]. Copy that.
11. AI search (GEO) without the folklore
What Google says [V]: no extra requirements for AI features; no special schema; no AI text files needed; the page must be indexed and snippet-eligible; structured data must match visible text.
What the X and study evidence says [R, from research/x-posts.md]: schema gave no lift in an Ahrefs test; llms.txt showed no effect in an SE Ranking study; many success claims are self-reported.
So we do these because they are cheap and honest, not because they are proven drivers:
- Clean HTML with the answer near the top, dated.
- JSON-LD that matches visible text (FAQPage, BreadcrumbList, entity types). No invented reviews. Hotel
starRatingis fine [V]. robots.txtthat allows search and answer bots. buydubai allows GPTBot, OAI-SearchBot, ChatGPT-User, ClaudeBot, Claude-SearchBot, PerplexityBot, Google-Extended, Applebot-Extended [V].llms.txt(optional).llms-full.txt(optional; Benidorm has 4.9 MB).- An MCP server where it serves a real task. Benidorm's answers
tools/listlive withplan_holidayand seven others [V].
Measure what happens. Our first signal: ChatGPT referrals (78 on buydubai, 101 on Benidorm, 20 Sep–25 Oct) against Google organic (77 and 84) [V]. Tiny numbers; treat as a start.
12. Staying on the right side of Google
Google's spam policy on scaled content abuse: many pages generated mainly to manipulate rankings, however created, may be excluded from Search [V]. Related: doorway pages, thin affiliate pages, scraped content.
A page set passes when:
- Each page has information a person can use that is not on the page next to it.
- The unique part is real (our own joined data), not swapped place names.
- A human could defend any page if asked.
Our check by page set:
| Set | Count | Test |
|---|---|---|
| buydubai communities, towers, weeks | about 2,200 | Data-rich, manifest, gate. Pass, with placeholders on three community pages. |
| buydubai agents | 5,931 | Thin, templated, no manifest. Fail until fixed. |
| buydubai units | about 90,000 (two orphan sitemaps) | No manifest; per-unit "worth" estimate; about 915 words per page with navigation. Unproven. Decide before it is crawled at scale. [V] |
| Benidorm venues | 499 of 752 | Substantive in sample (median 1,878 words); watch same-template risk. |
When in doubt: noindex the weak set, keep the strong set, and remeasure in four weeks. (This is reversible. A site-wide penalty is not quickly so. See the National Today case in research/x-posts.md.)
13. Monitoring
Weekly, one page:
- GSC clicks, impressions, pages indexed (per site).
- GA4: sessions by source, with internal traffic filtered.
- Gate log: failures this week, by gate.
- Freshness: oldest
data_as_ofon live pages. - Link rates and suppressed-cell counts.
- Spend.
Alert on: nightly job failed; live hash differs from latest good hash; any 4xx or 5xx on a sitemap URL; GSC impressions down more than 50% week on week.
14. Runbooks
14.1 Daily run (buydubai)
- Check the nightly job log. If failed, read why before publishing anything.
- Build the cube. Diff the second build. Zero diff.
- Run
lint_pages.pyandcommunity_eval.pyon the pages that changed. - Run the gated deploy script. Record the deployment id.
- Fetch the live pages. Check the manifest hash, one number, one date.
- Post the SELF-CHECK with times.
14.2 New community page (buydubai)
- Register the community (
CM…) and its page folder. - Build all pages from the cube.
- Eval pass at 85 or more. Lint zero problems.
- Review the "what we could not confirm" lines.
- Deploy through the gated script. Fetch. Post.
14.3 New feed (Benidorm)
- Add the source to
data/eeat/sources.md(URL, licence, checked date). - Write the fetch script (writes to
data/only). - Add a step to the
build_all.pymanifest. - Rebuild twice; diff must be zero.
- Commit to a branch; merge to
main; Vercel publishes.
14.4 A number is wrong
- Find the page's manifest and the
fact_id. - Trace the row in the cube or feed. Fix at the source or the definition.
- Rebuild affected pages. Re-lint.
- Deploy. Fetch. Note the correction on the page if it was public for more than a day.
14.5 Rollback
Use the recorded rollback deployment id. Fetch the live URL after.
15. Checklists
Before a page set goes live - [ ] Every sitemap URL has a manifest or a written exclusion - [ ] Lint 0 problems; eval at or above pass line - [ ] One revision hash across the release - [ ] As-of dates visible and recent - [ ] No placeholder text ("not shown yet") - [ ] No named person quoted unless they wrote it - [ ] Sources and licences in the register - [ ] Rollback id recorded - [ ] Live fetch done
Weekly
- [ ] Traffic report
- [ ] Gate failures reviewed
- [ ] Oldest data_as_of
- [ ] Spend log
16. Pitfalls (each one happened to us or to someone we cite)
| Pitfall | Our example |
|---|---|
| Publishing before the gate exists | Five thin community pages live before the eval was wired [R] |
| Gate that covers a quarter of pages | 1,469 of 17,359 on disk had a manifest in the PROJECT report [R] |
| Hybrid hand-typed numbers | JVC 1,296 vs 1,273 [R] |
| Two data revisions live at once | 661f7039 vs 74bc4c52 [V] |
| Claim says daily, data says three days ago | Town Square "to 22 October" on 25 Oct [V] |
| Fuzzy matching by language model | 13 guessed project mappings dropped [R] |
| Counting AI files as ranking drivers | Google and two studies say otherwise [V/R] |
| Scale before proof | National Today deindex [R] |
| Treating "direct" traffic as real | May include our own testing [U] |
| Quoting a plan as a result | The INVRT spec is a plan; 3 of 36 fixtures pass [R] |
17. Who does what
- Anthony: decides what to publish and what to risk; approves spend and deploys.
- Builder agent: writes code and SQL in bounded slices.
- Auditor agent (Grok): never writes code; runs fixtures; signs the ladder.
- Gully (INVRT): spec author for the Dalia knowledge service; plan, not code.
- Fintan O'Rourke (Visit Travel Group): Benidorm partner; travel affiliate test with a 50/50 revenue share and a three-month A/B [R].
18. Where to read next
10-gully-audit.mdfor the spec against best practice.20-scorecard.mdfor scores and the top ten fixes.30-case-buydubai.md,31-case-benidorm.md.research/x-posts.mdfor X evidence.MISSING.mdfor what I could not find.PROJECTS.mdfor repo, folder, Vercel and pipeline locations.