Knowledge/AlgoliaCrawler/07-schema-contracts.md
07 — Schema Contracts: Pydantic Models + Contract Tests
Source of truth for CodingSOPs §7 + TestingSOPs Layer 3 compliance at the L1→L2 boundary. The "schema lock" risk (L1 produces
industry, L2 expectsindustry_vertical) is eliminated here — not in documentation, not in convention, but in code that fails loudly at commit time.
The two-layer type safety model (from CodingSOPs §7)
pyright --strict → catches field name drift and type mismatches at WRITE TIME
Pydantic v2 → catches bad data shapes at RUNTIME when records cross boundaries
Contract tests → validates L1 output satisfies L2 input schema on every COMMIT
None of these is optional. All three run before any merge.
Verified schema source
All field names, types, and values below are derived from:
- Live query of algolia-central_enterprise_ledger (app 0EXRPAXB56) — 2026-04-30
- n8n Firehose v2 (Normalize fields + Classify Content nodes)
- n8n Refinery (Split & Inject, Chunk Configurator nodes)
Not from assumption or training data. See 03-record-extraction.md for the full field reference.
Layer 1 module boundary: CrawlerRecord (L1 output)
This is what recordExtractor returns — expressed as a Pydantic model. The JavaScript extractor produces a dict that lands in Algolia; the Python module that reads from Algolia (for L2 handoff) must parse it through this model.
# packages/l1_crawler/src/l1_crawler/models.py
from __future__ import annotations
from pydantic import BaseModel, Field, model_validator
from enum import Enum
import time
class SourceType(str, Enum):
"""source_type taxonomy — from n8n Firehose URL routing, verified live in index."""
DOC = "doc"
SUPPORT = "support"
BLOG = "blog"
OTHER = "other"
DEVELOPER = "developer"
CUSTOMER_STORY = "customer_story"
ACADEMY = "academy"
RESOURCE = "resource"
CHANGELOG = "changelog"
class RecordStatus(str, Enum):
PENDING = "pending" # set by L1; L2 reads this to find work
INDEXED = "indexed" # set by L2 after enrichment + chunking
class CrawlerRecord(BaseModel):
"""
Canonical output of the L1 Algolia Crawler — one parent doc record per indexed page.
Schema verified against live algolia-central_enterprise_ledger (app 0EXRPAXB56) on 2026-04-30.
is_chunk is always False here — chunks are created by L2 Enrichment.
"""
version: str = "1.0.0"
# Identity
object_id: str = Field(min_length=1, max_length=64, alias="objectID")
record_type: str = "enterprise_ledger"
url: str = Field(min_length=10)
# Content — L1 populates these
title: str = Field(min_length=1)
description: str = ""
content: str = Field(min_length=300) # quality gate: <300 chars = skip
source_type: SourceType
language_code: str = "en"
is_chunk: bool = False
status: RecordStatus = RecordStatus.PENDING
created_at: int = Field(default_factory=lambda: int(time.time()))
updated_at: int = Field(default_factory=lambda: int(time.time()))
# L2 placeholder fields — MUST be None from L1
# L2 Enrichment fills these; values are proper-case strings (not snake_case)
summary: str | None = None # GPT 3-sentence summary
product_tag: str | None = None # "AI Search", "InstantSearch", "Analytics", etc.
feature_tag: str | None = None # "Rules", "Faceting", "A/B Testing", etc.
solution_tag: str | None = None # "Site Search", "Analytics", etc.
customer_tag: str | None = None
industry_tag: str | None = None
model_config = {"populate_by_name": True}
@model_validator(mode="after")
def l2_fields_must_be_null(self) -> "CrawlerRecord":
"""L1 must never pre-populate L2 classification fields."""
assert self.summary is None, "L1 must not set summary — that is L2 (GPT)"
assert self.product_tag is None, "L1 must not set product_tag — that is L2"
assert self.feature_tag is None, "L1 must not set feature_tag — that is L2"
assert self.solution_tag is None, "L1 must not set solution_tag — that is L2"
assert self.is_chunk is False, "L1 never creates chunk records"
assert self.status == RecordStatus.PENDING, "L1 must set status=pending"
return self
Layer 2 module boundary: EnrichmentInput (L2 input)
This is what the L2 Enrichment module accepts. It reads from Algolia where status=pending.
It must be parseable from any CrawlerRecord with no field-name translation.
# packages/l2_enrichment/src/l2_enrichment/models.py
from __future__ import annotations
from pydantic import BaseModel, Field
from l1_crawler.models import SourceType, RecordStatus
class EnrichmentInput(BaseModel):
"""
What L2 Enrichment reads from Algolia — records where status=pending.
Mirrors CrawlerRecord exactly for the fields L2 needs; L2 fields are absent (not set yet).
Verified against live index schema 2026-04-30.
"""
version: str = "1.0.0"
object_id: str = Field(alias="objectID")
record_type: str
url: str
title: str
content: str # full page text — L2 uses this for summarization + chunking
source_type: SourceType
language_code: str
is_chunk: bool
status: RecordStatus # must be "pending" — L2 only processes pending
@classmethod
def from_algolia_record(cls, record: dict) -> "EnrichmentInput":
"""Parse a raw Algolia hit dict. Fails loudly if schema has drifted."""
return cls.model_validate(record)
Layer 2 output: EnrichedRecord
The full record L2 writes back to Algolia after enrichment (parent) + chunks:
# packages/l2_enrichment/src/l2_enrichment/models.py
class EnrichedRecord(BaseModel):
"""Parent doc record after L2 enrichment. Written back via saveObjects."""
object_id: str = Field(alias="objectID")
record_type: str = "enterprise_ledger"
url: str
title: str
content: str
summary: str # GPT 3-sentence summary — L2 fills
source_type: SourceType
language_code: str
is_chunk: bool = False
status: RecordStatus = RecordStatus.INDEXED
updated_at: int
# Classification — L2 fills, proper case strings
product_tag: str | None = None
feature_tag: str | None = None
solution_tag: str | None = None
customer_tag: str | None = None
industry_tag: str | None = None
class ChunkRecord(BaseModel):
"""Chunk record created by L2. objectID = '{parent_uuid}_chunk_{index}'"""
object_id: str = Field(alias="objectID") # "{parent_id}_chunk_{chunk_index}"
record_type: str = "enterprise_ledger"
parent_id: str
chunk_index: int = Field(ge=0)
chunk_total: int = Field(ge=1)
url: str
title: str # "{original_title} (Part {n}/{total})"
content: str # "Document Context: {summary}\n\nFragment: {chunk_text}"
summary: str
source_type: SourceType
language_code: str
is_chunk: bool = True
status: RecordStatus = RecordStatus.INDEXED
# Tags inherited from parent
product_tag: str | None = None
feature_tag: str | None = None
solution_tag: str | None = None
customer_tag: str | None = None
industry_tag: str | None = None
@model_validator(mode="after")
def content_must_have_context_prefix(self) -> "ChunkRecord":
assert self.content.startswith("Document Context:"), \
"Chunk content must start with 'Document Context: {summary}'"
return self
Contract test: L1 output satisfies L2 input
# packages/l1_crawler/tests/contract/test_l1_l2_contract.py
import hashlib
import pytest
import time
from l1_crawler.models import CrawlerRecord, SourceType, RecordStatus
from l2_enrichment.models import EnrichmentInput, EnrichedRecord, ChunkRecord
def make_sample_crawler_record() -> dict:
"""Minimal valid L1 record matching the live RC2 Algolia schema."""
url = "https://www.algolia.com/doc/guides/building-search-ui/upgrade-guides/js/"
return {
"objectID": hashlib.sha256(url.encode()).hexdigest()[:36],
"record_type": "enterprise_ledger",
"url": url,
"title": "Upgrade InstantSearch.js - Algolia",
"description": "How to upgrade InstantSearch.js",
"content": "This guide explains how to upgrade InstantSearch.js from v3 to v4. " * 10,
"source_type": "doc",
"language_code": "en",
"is_chunk": False,
"status": "pending",
"created_at": int(time.time()),
"updated_at": int(time.time()),
# L2 fields — all null from L1
"summary": None,
"product_tag": None,
"feature_tag": None,
"solution_tag": None,
"customer_tag": None,
"industry_tag": None,
}
class TestL1L2Contract:
def test_crawler_record_is_valid(self):
"""CrawlerRecord parses cleanly from a well-formed record."""
record = make_sample_crawler_record()
parsed = CrawlerRecord.model_validate(record)
assert parsed.status == RecordStatus.PENDING
assert parsed.is_chunk is False
def test_crawler_record_satisfies_enrichment_input(self):
"""L1 output is directly parseable as L2 EnrichmentInput — no field mapping needed."""
record = make_sample_crawler_record()
enrichment_input = EnrichmentInput.from_algolia_record(record)
assert enrichment_input.url == record["url"]
assert enrichment_input.source_type == SourceType.DOC
assert enrichment_input.status == RecordStatus.PENDING
def test_l1_cannot_set_summary(self):
"""summary is L2's job — L1 setting it fails the validator."""
record = make_sample_crawler_record()
record["summary"] = "This page explains InstantSearch.js."
with pytest.raises(AssertionError):
CrawlerRecord.model_validate(record)
def test_l1_cannot_set_product_tag(self):
"""product_tag is L2's job — L1 setting it fails the validator."""
record = make_sample_crawler_record()
record["product_tag"] = "InstantSearch"
with pytest.raises(AssertionError):
CrawlerRecord.model_validate(record)
def test_l1_cannot_set_status_indexed(self):
"""L1 must set status=pending, never indexed."""
record = make_sample_crawler_record()
record["status"] = "indexed"
with pytest.raises(AssertionError):
CrawlerRecord.model_validate(record)
def test_missing_title_fails(self):
"""Quality gate: empty title must be rejected."""
record = make_sample_crawler_record()
record["title"] = ""
with pytest.raises(Exception):
CrawlerRecord.model_validate(record)
def test_short_content_fails(self):
"""Quality gate: content < 300 chars must be rejected."""
record = make_sample_crawler_record()
record["content"] = "Too short."
with pytest.raises(Exception):
CrawlerRecord.model_validate(record)
def test_invalid_source_type_fails(self):
"""source_type must be from the SourceType enum."""
record = make_sample_crawler_record()
record["source_type"] = "marketing" # old n8n content_type value, not valid source_type
with pytest.raises(Exception):
CrawlerRecord.model_validate(record)
def test_objectid_determinism(self):
"""Same URL must always produce same objectID — dedup guarantee."""
url = "https://www.algolia.com/doc/guides/building-search-ui/upgrade-guides/js/"
oid1 = hashlib.sha256(url.encode()).hexdigest()[:36]
oid2 = hashlib.sha256(url.encode()).hexdigest()[:36]
assert oid1 == oid2
def test_chunk_content_format(self):
"""Chunk content must start with 'Document Context:' prefix."""
bad_chunk = {
"objectID": "some-uuid_chunk_0",
"parent_id": "some-uuid",
"chunk_index": 0,
"chunk_total": 2,
"url": "https://example.com",
"title": "Example (Part 1/2)",
"content": "Fragment text without context prefix", # wrong format
"summary": "A 3-sentence summary.",
"source_type": "doc",
"language_code": "en",
"is_chunk": True,
"status": "indexed",
}
with pytest.raises(AssertionError):
ChunkRecord.model_validate(bad_chunk)
Where this eliminates the schema lock risk
The problem I flagged: L1 produces industry, L2 expects industry_vertical → silent breakage.
How the contract closes it:
- Both sides define Pydantic models — field name is code, not convention
from_crawler_record()inEnrichmentInputfails at parse time if field names don't aligntest_crawler_record_satisfies_enrichment_input()runs on every commit — if L1 renames a field without updating L2's model, the contract test fails before the commit lands- pyright --strict catches
record.industry_verticalvsrecord.industryat write time if someone uses the wrong name in code
The schema lock conversation becomes: "does the contract test pass?" If yes, L1 and L2 are aligned. Full stop.
Pre-commit + CI gates
# Run before every commit (CodingSOPs §9)
ruff format .
ruff check .
uv run pyright packages/ --strict
uv run pytest packages/ -k "contract" -v # contract tests are fast, run always
uv run pytest packages/ -v # full suite
The contract tests (Layer 3) are designed to be fast (no live API calls, no crawl) and run on every commit, not just pre-merge.
What to do when n8n workflow reveals new L2 fields
- Add field to
CrawlerRecordwith= Nonedefault (L1 remains unaware) - Add field to
EnrichmentInputas required (L2 will populate it) - Update
from_crawler_record()to map the field (or leave it asNonefor L2 to fill) - Add a contract test case for the new field
- Run
pyright --strict— it will catch any callers that don't handle the new field
This is the process. No whiteboard, no verbal agreement, no "we'll sync later." Code + test + commit.