ADR: Document Storage, DB Schema & Conflict Model¶
Status¶
Draft — design only. Backoffice UI and GDrive migration are out of scope (separate tickets).
Amendment 2026-06-26 — ixscraping pipeline (PR #1475) landed with a scraping.* schema.
This amendment merges that schema into knowledge.*, replacing the proposed
crawl_url_attempts table with a richer stateful scrape_pages table and enriching
crawl_runs. The scraping.* schema is deprecated; ixscraping must be updated to write
to knowledge.* (separate implementation ticket).
Amendment 2026-06-29 — three modeling fixes (this revision):
- Killed the
scrape_pages↔documents/document_snapshotsoverlap.scrape_pagesduplicatedcanonical_url,content_hash/raw_urianddecision, then "promoted" them into the durable tables. It is reduced to a thin per-run ledger and renamedcrawl_page_decisions(run membership + LLM include/exclude decision + sample only). Because discovery already creates thedocumentsnode, scraping now writes adocument_snapshotsrow directly — no promotion back-fill. The durable columns moved todocuments(canonical_url,url_hash) anddocument_snapshots(content_hash,raw_markdown_ref, fetch_*,produced_by_run_id). - Composite docs are now their own table (
composite_documents), notdocumentsrows. They reuse the singledocument_snapshotstable (referenced by two nullable owner FKs + a CHECK, not a type tag — Postgres keeps real referential integrity that way); scrape-only snapshot columns are NULL for composite snapshots. The Knowledge Agent ingests both source documents and composite documents.document_sourcesis renamedcomposite_document_sources. - Scrape source host decoupled from the tenant key. The host a doc is scraped from is
not necessarily a Rose-hosted
public.domainsrow (it may be a competitor / external blog / partner). A newsource_hoststable holds scrape source hosts (RLS bysite_domain, free-texthost, nopublic.domainsFK);crawl_runsanddocumentsreference it.site_domainremains the tenant / RLS key. - Hierarchy derived from
path, not a storedparent_idadjacency list.pathalready encodes the tree for both URLs and uploaded files. The priorparent_idself-FK plus synthesizedkind=folderrows was a denormalized cache of whatpathalready says — and a cache that drifts: synthesized folders had to be created, recomputed, and kept id-stable across runs. Dropped.pathis the single hierarchy source; the backoffice tree is built by sorting onpathand splitting segments; subtrees and gate toggles use path-prefix matching.kindstays a display hint (page | file); folders are render-time grouping, not rows.
Net: nine knowledge.* tables (was seven). documents loses parent_id and the
synthesized-folder rows.
Date¶
2026-06-09
Context¶
Today, per-client source documents live in Google Drive and are ingested by the
document_loader Cloud Run Job (backend/apps/jobs/document_loader/), which drives
the ixrag document pipeline (DocumentProcessor → GoogleDriveLoader →
markdown-aware chunking → LightRAG → Neo4j + MongoDB). Knowledge FAQs are loaded
separately from config.knowledge_faqs (Supabase) via SupabaseKnowledgeFaqLoader.
What already exists (do not claim these are missing):
- Change detection: per-document SHA1 content hash
(
ixrag/document_pipeline/hash_utils.py) stored on the MongoDBdoc_statusrow. Unchanged docs are skipped; changed docs are delete + re-insert. - Incremental loads: Redis-backed
TimestampTracker({env}:{domain_id}:last_run). - Config history:
config.client_config_daily_snapshots(90-day retention).
What is genuinely missing and motivates this ADR:
- No immutable scrape-time snapshot (raw md → processed md) for audit / reproducibility. (HTML is not retained — we keep the markdown views, not the source HTML.)
- No version chain as a queryable history (the current SHA1 hash is a single
per-doc state on
doc_status, not a revision table). - No global, cross-entity conflict view (document vs document, document vs FAQ, etc.).
- No way to block a page: neither a never-fetch path blocklist, nor "scrape but exclude from ingestion" (some pages should be stored as docs yet never reach RAG/skill/config).
- Hard to surface any of the above in backoffice.
- Google Drive itself is a liability (no versioning audit, no blocklist, permissions drift) and will be migrated out — object storage becomes the single body store for all docs.
Companion ticket (scraping pipeline + change detection): IX-3171.
Goal¶
Pick a storage model that lets us answer at any time:
- Which documents are indexed for a client?
- When were they last scraped / processed?
- What did the page look like at scrape time? (audit / reproducibility)
- Which documents contradict each other, or contradict an FAQ / skill / config?
- Which URLs / paths must never be fetched, or be scraped but never ingested?
Decision¶
Storage split¶
- Blob store = Google Cloud Storage (GCS) bucket for immutable, document-scoped
snapshot bodies. Each durable snapshot lives under the document id and the canonical content
hash, with a stable
content.mdbody and, for scraped pages, an optionalraw.mdcompanion. The platform already runs on GCP/Cloud Run and already serves widget bundles fromgs://inboundx-cdn/, so GCS keeps the blob concern in the existing infra and IAM model. Cloudflare R2 is a viable alternative (it already backs the GEO pages worker,backend/cloudflare/cloudflare-geo-pages.js) but adds a second cloud for a backend-only concern. The content-hash path segment makes writes idempotent for a given document version.
Correction from prior draft: there is no "Cloudflare Artifacts" product in this stack. The repo uses GCS (widget bundles via
gsutil, served through the Cloudflare CDN proxy) and Cloudflare R2 (GEO static pages). Cloudflare Workers here are an API gateway / edge proxy, not a blob store.
- Metadata + version chain + conflicts = Supabase (queryable, backoffice-native, RLS).
- Source documents themselves are NOT blobs — they need relational queries and CRUD.
The blob store holds only snapshot markdown bodies: canonical
content.md, optional scraperaw.md, and original binaries when reprocessing needs them (no HTML).
Why not blob store alone¶
- No relational queries ("show all scraped docs for client X older than 30d").
- Backoffice listing needs a DB join anyway.
- Versioning is solved by content-hash + a metadata row.
Why not Google Drive (current)¶
- No programmatic versioning audit.
- No blocklist enforcement.
- Hard to surface in backoffice.
- Permissions drift.
Tenant key — resolve the schema conflict¶
This is the core conflict the ticket names. The two existing systems disagree on the tenant key, and the new tables must not invent a third convention.
- Supabase isolates by
domain/site_domain text(FK →public.domains.domain), with RLS viaget_accessible_domains(). Apublic.clientsrow (id uuid) can own multiple domains, soclient_idalone does not identify a knowledge scope.- Backend (MongoDB / Neo4j / LightRAG working dir) isolates by
tenantId, a string derived fromsiteName(e.g.info.matera.eu→info_matera_eu), not a uuid FK.Decision: partition the new tables by
site_domain text(FK →public.domains.domain), matching every other Supabase knowledge table and reusing the existing RLS helper. Keepclient_id uuidas a denormalized convenience column for cross-domain rollups, but it is not the RLS scope. This makes the new tables joinable todomains,config.knowledge_faqs, andconfig.client_configswithout a mapping table.Crossing into the backend stores still needs a translation. Neo4j / MongoDB / LightRAG are keyed by
tenantId(sanitizedsiteName). So when the Ingestor writes to those stores it mapssite_domain→tenantIdwith the same deterministic sanitization asrag_instance_manager.get_tenant_context(e.g.info.matera.eu→info_matera_eu) — a pure function, no mapping table. Supabase stays the system of record keyed bysite_domain;tenantIdis derived at the boundary, never stored as the Supabase partition key.
Unified document model — two axes + routing¶
Single body store: the body of every doc — raw scrape, internal upload, and composite/generated — lives as an md blob in cloud object storage (GCS), referenced from its snapshot. Google Drive is removed (see migration note); it is replaced by object storage as the universal body store, not by another relational store.
Two document tables. A document is a raw, single-origin unit (a scraped URL,
an uploaded file, a feedback note). A composite_document is generated by the Knowledge
Agent by distilling one or more source documents — it has its own validatable lifecycle and
no scrape/crawl/tree concerns, so it lives in a separate table (composite_documents) rather
than as a document row. Both share the single document_snapshots body store.
A document is described by two independent axes plus a separate routing fan-out:
origin— where the doc came from:scrape | upload | feedback | unknown(unknownfor migrated docs whose provenance can't be reconstructed;gdrivekept transitionally during migration only — see below). Generated bodies are no longer adocumentorigin — they arecomposite_documents.knowledge_type— how it is treated:generic | faq.- destinations — where it is routed (rag / config / skill), in
document_routings(a routing targets a source or a composite doc).
Flow (target state). Discovery first registers each URL/file as a document
(status=discovered). Gate 1 (scrape_policy) decides fetch vs skip. Once scraped, the
Knowledge Agent, per doc, either ingests it directly (gate 2) or combines it into a
composite_document (gate 3) — a combined source doc is not ingested on its own; only the
composite is.
A composite_document is produced by the Knowledge Agent by distilling one or more parent
source documents — its provenance is recorded in composite_document_sources (every source is a
document row; there are no bare URLs). Note: feedback is an origin, not a
knowledge_type — a FAQ-from-feedback row is a document with origin=feedback,
knowledge_type=faq.
Schema overview (ERD)¶
The nine new tables and their links to the existing public.domains / public.clients.
Body blobs (raw md, processed md, original binary) live in object storage and are referenced
by *_ref columns on document_snapshots.
Schema placement: all nine new tables live in a new knowledge Postgres schema
(knowledge.documents, knowledge.document_snapshots, …), following the per-concern
precedent (config.*, geo.*). They reference public.domains / public.clients
cross-schema; document_conflicts references config.knowledge_faqs for knowledge_faq
entities. Table names below are unqualified for brevity — read them as knowledge.<table>.
The nine tables: source_hosts, documents, composite_documents,
document_snapshots, composite_document_sources, document_routings, document_conflicts,
crawl_runs, crawl_page_decisions.
Audit-column convention (generic audit columns omitted from the ERD for brevity, except where a timestamp is the table's primary event —
crawl_runs.started_at/finished_at): every table hascreated_at timestamptz not null default now(); mutable tables also haveupdated_at timestamptzmaintained by a trigger (the existing Supabase pattern). Immutable rows —document_snapshots,composite_document_sources— carrycreated_atonly.crawl_page_decisionsis stateful (one row per URL per run, updated in place across phases): it carriescreated_at+ per-phase timestamps (sampled_at). Row versioning lives ondocument_snapshots.version;detector_version/extraction_metadatacapture algorithm versions.
source_hosts — scrape source hosts (decoupled from tenant)¶
The host a document is scraped from is not necessarily a Rose-hosted domain. A client
(site_domain ∈ public.domains) may have Rose scrape a competitor site, an external blog, or
a partner doc whose host is not in public.domains. source_hosts is the registry of those
hosts, owned by a tenant for RLS but free of any public.domains FK on the host itself.
id uuid PKsite_domain textFK →public.domains.domain(RLS scope — the tenant that registered the source)client_id uuidFK →public.clients.id(denormalized)host text— the scraped hostname (e.g.competitor.com,blog.external.io). Plain text, not apublic.domainsFK — that is the whole point; a source host can be external.kind enum (own_site | external_reference)—own_sitewhenhostmatches apublic.domainsrow for this client;external_referenceotherwise.created_at,updated_at- Unique
(site_domain, host).
The tenant key stays
site_domain(the Rose client).source_hostsanswers "where did this come from",site_domainanswers "whose knowledge is it". Two tenants scraping the same external host get two rows (one persite_domain), keeping RLS trivial. Per-host scrape settings (cadence, robots, allow/block globs) can later hang off this row without touchingdocuments.
documents — one row per logical doc per domain¶
id uuid PK(on GDrive migration, reuse the existing document id so the loader's content-hash dedup still matches and docs are not reprocessed — see migration note)site_domain textFK →public.domains.domain(RLS scope)client_id uuidFK →public.clients.id(denormalized, for cross-domain rollups; not RLS scope)source_host_id uuidFK →source_hosts.idnullable (where the doc was scraped from; set whenorigin=scrape, null forupload/feedback). The host ofcanonical_urlequalssource_hosts.host.kind enum (page | file)— display hint only.page= a scraped URL;file= an uploaded file. There is nofoldervalue — folders are render-time grouping ofpathsegments, not rows (see hierarchy note below).path textnot null — the sole hierarchy key: URL path (/pricing/enterprise) or file path (/decks/2026/pricing.pdf). The full tree (URLs and files) is reconstructed frompathalone; no stored parent pointer.origin enum (scrape | upload | feedback | unknown)— provenance kind (unknown= migrated doc with no reconstructable source;gdriveis transitional during the migration window only — see the migration note for how migrated docs are classified). Generated bodies are not here — they arecomposite_documents.knowledge_type enum (generic | faq)— treatment (compositeis gone — composites are a separate table)source_url text(set whenorigin=scrape; null for upload/feedback sources)canonical_url text(normalized: https, lowercase host, no UTM, no fragment; null when no URL)url_hash text—SHA1(canonical_url), null when no URL. Fast lookup key for the scraper to match a discovered URL to its existing node (replacesscrape_pages.url_hash).title textscrape_policy enum (scrape | skip)— gate 1 (fetch?).skip= never fetched (the path-blocklist decision, materialized per node); onlyscraperows are crawled.current_snapshot_id uuidFK →document_snapshots.id(null until first scraped)status enum (discovered | active | blocked | stale | failed | superseded)(discovered= known URL/file, not yet scraped — no snapshot;blocked= gate 2 scraped + stored but excluded from ingestion, Ingestor skips it, no rag/skill/config routing)blocked_reason textnullable,skip_reason textnullablelast_seen_at,last_fetched_at,last_processed_at timestamptzkb_indexed booltags text[]is_deleted bool(soft-delete, audit trail)created_at,updated_at
Unique (site_domain, canonical_url) where canonical_url is not null.
Index (site_domain, path text_pattern_ops) — serves both the sorted full-tree read and
prefix-scoped subtree reads (path LIKE '/blog/%').
Hierarchy from
path, noparent_id. The tree (URL structure and uploaded files) is derived frompath, which already encodes it. The backoffice renders it by... order by pathand splitting segments client-side — for the few-thousand-node scale of a domain this is a sort + group, no recursive CTE. A subtree (lazy-load on a big site, or a folder-level toggle) is a path-prefix scan (path LIKE 'prefix%'), index-served bytext_pattern_ops. Intermediate folders (/blog/,/blog/2026/) are synthesized at render time from the segments — they are not rows, so there is nothing to create, recompute, or keep id-stable. A folder-level gate decision (skip / block a whole subtree) is a path-prefix rule (the glob editor, see backoffice surfaces) materialized onto the matching leaf rows'scrape_policy/status, so per-node queries stay flat.Path normalization (required for a clean tree).
pathis the URL path ofcanonical_url(same normalization: lowercase host already stripped, no trailing slash, no fragment/UTM) or the upload file path. An index/landing page is the leaf at its own path (/pricingis one node), and a deeper child (/pricing/enterprise) makes/pricingalso a branch — the same node is both, no separate folder node. Without this single rule the tree double-renders a path as folder + page.One table, three gates. A node enters as
status=discovered(from sitemap/crawl discovery or a Drive listing) — it exists in the tree before any fetch. Gate 1scrape_policydecides fetch vs skip; gate 2 (status=blocked/ having noragrouting) decides ingest vs not after scraping; gate 3 (composite_document_sources) folds a doc into a composite (subsumption). Because every URL and every file is onedocumentrow carryingpath, the backoffice renders the whole website + file hierarchy — including skipped and not-yet-scraped nodes — from one sortedpathread, annotating each node with its scrape/ingest state.Two axes instead of one
source_type: the prior draft conflated origin and treatment (and listednotion, which has no ingestion path). Splitting them lets a raw scrape beorigin=scrape, knowledge_type=genericand lets feedback be an origin without inventing a "feedback" knowledge type. Composites are no longer anorigin/knowledge_typecombination on this table — they are first-class rows incomposite_documents.
document_snapshots — immutable history (source and composite docs)¶
One snapshot table serves both document tables. The owner is addressed by two nullable FK
columns with an exactly-one CHECK — not a (type, id) tag. Postgres cannot FK a single
untyped owner_id to two tables, so a type tag would silently drop referential integrity; two
real FK columns keep cascade + integrity on both sides.
Universal columns (every snapshot):
id uuid PKdocument_id uuidFK →documents.idnullablecomposite_document_id uuidFK →composite_documents.idnullableCHECK (num_nonnulls(document_id, composite_document_id) = 1)— exactly one ownerversion int(monotonic per owner)content_hash text(SHA1 of normalized body, matchingixraghash_utils.calculate_content_hash)processed_markdown_ref text(historical column name: object key for the canonicalcontent.mdbody used downstream; for a scrape this is the cleaned/processed md, for a composite this is the generated md)extraction_metadata jsonb(chunker version, embed model, prompt hash; for a composite this carries the generator prompt hash + model — no extra column)superseded_by uuidFK nullablecreated_at
Scrape-only columns (NULL for composite snapshots):
raw_markdown_ref text(object key for the optional scrape-time / first-passraw.mdcompanion in the same snapshot folder)original_ref text(nullable — object key for the original source binary when one exists: PDF / DOCX / XLSX uploads. Kept for reference + reprocessing with a better extractor. Null for scrapes, where HTML is not retained)render_mode enum (plain_fetch | browser | sandbox)fetch_status int,fetch_time_ms int,byte_size intchange_signal enum (etag_changed | lastmod_changed | hash_changed | jsonld_changed | forced)produced_by_run_id uuidFK →crawl_runs.idnullable (which crawl produced this snapshot)CHECK: all scrape-only columns are NULL whencomposite_document_id is not null
Unique (document_id, content_hash) and (composite_document_id, content_hash) — dedup
unchanged refetches / regenerations. Index per owner (..., version DESC).
content_hashis SHA1, not SHA256, to match the existing pipeline (hash_utils.py:26,hashlib.sha1). Hash is for change detection, not security. If a migration to SHA256 is wanted, change both sides together — do not silently diverge.
composite_documents — distilled, validatable knowledge units¶
A composite_document is generated by the Knowledge Agent by distilling one or more source
documents. It has its own validation lifecycle (generated → in_validation → … → prod) and
none of the scrape/crawl/tree machinery, so it is a separate table rather than a
document row. Its body lives in document_snapshots (owned via composite_document_id).
id uuid PKsite_domain textFK →public.domains.domain(RLS scope)client_id uuidFK →public.clients.id(denormalized)title textcomposite_kind textnullable — optional label (feature_map | pricing | competitors | positioning | …); free text in this design, may FK a registry latercurrent_snapshot_id uuidFK →document_snapshots.idstatus enum (generated | in_validation | validated | staged | prod | stale | superseded)— the Composite Knowledge lifecycle (see the CK lifecycle diagram)validated_by_user_id uuidnullable,validated_at timestamptznullableis_deleted bool(soft-delete, audit trail)created_at,updated_at
A composite is routed exactly like a source doc — via
document_routings(rag/skill/config). The Knowledge Agent ingests both source documents (directly) and composite documents; a source doc folded into a composite gets no routing of its own (subsumption).
composite_document_sources — composite provenance (many-to-many)¶
Records what each composite doc was distilled from. Every source is itself a
document — there are no bare URLs. A URL the Knowledge Agent reads is first
materialized as a document (origin=scrape) whose snapshot carries raw md
(raw_markdown_ref) and processed md (processed_markdown_ref). So provenance always points
at a row, never a loose string.
composite_document_id uuidFK →composite_documents.iddocument_id uuidFK →documents.idsource_snapshot_id uuidFK →document_snapshots.id(nullable — pins the distilled version)created_at- PK
(composite_document_id, document_id)
Index (composite_document_id), (document_id).
Enables reproducibility ("what produced this CK?") and re-distill triggers (when a parent source doc gets a new snapshot, flag dependent composites
stale).Subsumption rule: once a source doc is absorbed into a composite, it gets no
ragrouting of its own — only the composite is ingested. The parent stays stored (provenance + reprocess), not ingested alone. A source doc not (yet) combined can be ingested directly via its ownragrouting.
document_routings — destinations / fan-out¶
One doc (source or composite) can route to several sinks (e.g. a feature-map composite both
injected into RAG and distilled into a skill). The routed entity is addressed by two nullable
FK columns + CHECK (same reasoning as document_snapshots — real FKs, not a type tag).
document_id uuidFK →documents.idnullablecomposite_document_id uuidFK →composite_documents.idnullableCHECK (num_nonnulls(document_id, composite_document_id) = 1)destination enum (rag | config | skill)target_ref text(skill slug, or configslug+ key path; null forrag)priority int(RAG high-priority ordering; null for non-rag)enabled booldefault truecreated_at,updated_at- PK
(document_id, composite_document_id, destination, target_ref)
Each destination maps to a different terminal action by the Knowledge Agent: -
rag→ invoke the rose document loader to ingest the md into LightRAG (prioritycontrols high-priority injection). -skill→ write a DB instruction row (deterministic block: pricing, competitors). -config→ write a client-editable config value (e.g.engagement.tov).
target_refis free text in this design (skill slug / configslug.key); a later iteration may FK it to a skill/config registry once those have stable identifiers. Theskillpath is net-new: today skills are repo markdown resolved byixskills, not DB rows (see IX-3171).
document_conflicts — global cross-entity view¶
id uuid PK,site_domain textFK →public.domains.domain(RLS scope)entity_a_type enum (document | composite_document | knowledge_faq | skill | config_value)entity_a_id uuidnullable,entity_a_ref textnullableentity_b_type enum,entity_b_id uuidnullable,entity_b_ref textnullableconflict_type enum (factual_contradiction | stale_info | duplicate | policy_mismatch)severity enum (info | warn | error)evidence_a text,evidence_b text(quoted spans)detected_by enum (embedding_diff | llm_judge | manual),detector_version textstatus enum (open | acknowledged | resolved | false_positive)resolved_by_user_id uuidnullable,resolved_at timestamptznullablecreated_at,updated_at(status transitions open → acknowledged → resolved bumpupdated_at)
Index (site_domain, status, severity).
entity_*_type = knowledge_faqrefers only toconfig.knowledge_faqs(retrieval knowledge). It must never referencegeo.faqs— that is generated GEO publishing content and is a totally separate concept (see project FAQ-boundary rule). Naming the enumknowledge_faq(not barefaq) makes the boundary explicit.Why id/ref polymorphism here (and only here). Unlike
document_snapshots/document_routings— whose operands are always one of two real tables, so they use two nullable FK columns — a conflict side can be an entity that has no DB table at all. That forces a generic(type, id-or-ref)address: -document→entity_*_id=documents.id. -composite_document→entity_*_id=composite_documents.id. -knowledge_faq→entity_*_id=config.knowledge_faqs.id. -skill→entity_*_ref= the skill slug (e.g.response_handling/competitors). Skills are repo markdown resolved byixskills, not DB rows — there is no uuid. -config_value→entity_*_ref=config_slug.key_path(e.g.engagement.tov).Set exactly one of
id/refper side (CHECK). When the ADR'sdestination=skillpath later materializes instructions as DB rows, those gain a uuid and move toentity_*_id.
crawl_runs — job-level telemetry (populated by scraping pipeline)¶
One row per scraping job. Status enum: queued → mapping → mapped | failed.
| Column | Type | Notes |
|---|---|---|
id |
uuid PK | |
site_domain |
text FK | → public.domains.domain (RLS scope — tenant) |
source_host_id |
uuid FK | → source_hosts.id (the host being crawled; may be external) |
target_url |
text | Root URL passed to Firecrawl Map |
raw_map_uri |
text | GCS path to Firecrawl Map JSON response (domains/{domain}/runs/{id}/map/links.json) |
options |
jsonb | Firecrawl Map request params (limit, exclude patterns, …) |
mode |
enum | website_mapping \| incremental \| reprocess_from_snapshot \| manual |
triggered_by |
enum | cron \| webhook \| backoffice \| api |
status |
enum | queued \| mapping \| mapped \| failed |
started_at |
timestamptz | |
finished_at |
timestamptz | |
urls_discovered |
int | Phase 1 output count |
urls_included |
int | Phase 2 decision=include count |
urls_scraped |
int | Phase 3 success count |
urls_failed |
int | Phase 3 error count |
error |
text | Top-level failure message |
crawl_page_decisions — per-URL run ledger (thin; replaces scrape_pages)¶
A thin per-run ledger: run membership + the LLM include/exclude decision + the sample. It no
longer duplicates document content. The durable columns the old scrape_pages carried
(canonical_url, url_hash, content_hash, raw_uri, scrape timings) now live where they
belong — documents and document_snapshots. There is no promotion back-fill: the
discovered URL is already a documents node (created at map time), and the scrape writes
a document_snapshots row directly.
Stateful (not append-only): one row per (run_id, document_id), updated in place as the URL
progresses through the phases.
PK: (run_id, document_id).
| Column | Type | Notes |
|---|---|---|
run_id |
uuid FK | → crawl_runs.id |
document_id |
uuid FK not null | → documents.id — the discovery node, created at map time (status=discovered) |
| Phase 2 — LLM decision | ||
decision |
enum | unlabeled \| include \| exclude \| needs_review — default unlabeled |
decision_source |
enum | system \| operator \| rule |
decision_reason |
text | Human-readable reason (e.g. "blog_post, low relevance") |
decision_confidence |
float | 0–1 score from LLM |
decision_metadata |
jsonb | {content_kind, evidence[]} |
sample_uri |
text | GCS path to sample markdown used for LLM decision |
sampled_at |
timestamptz | |
sample_error |
text | |
| Phase 3 — scrape link | ||
produced_snapshot_id |
uuid FK nullable | → document_snapshots.id — the snapshot this run created, if the page was scraped and changed; null otherwise |
The page identity (page_id = SHA1(canonical_url)) lives on the documents node as
url_hash; the scraper matches a discovered URL to its node by (site_domain, url_hash). The LLM
decision materializes onto the node too: decision=exclude → documents.scrape_policy=skip.
Phase 3 write (no back-fill): when decision=include and the freshly-processed markdown
hash differs from the current document_snapshots.content_hash (or no snapshot exists), the
Scraper writes a new document_snapshots row directly (document_id = the node,
raw_markdown_ref + processed_markdown_ref = durable GCS snapshot bodies,
produced_by_run_id = this run), advances
documents.current_snapshot_id, and sets crawl_page_decisions.produced_snapshot_id. Unchanged
pages just touch last_seen_at.
Two hashes, both SHA1.
document_snapshots.content_hashis the SHA1 of the processed markdown body used downstream, so it gates snapshot identity and dedup. The raw markdown SHA1 is kept as provenance inextraction_metadata.raw_content_hash. Both SHA1 for consistency withhash_utils.py.
GCS artifact and snapshot body paths¶
Run-scoped ixscraping artifacts land in gs://rose-scraping/ under a deterministic
execution hierarchy:
domains/{site_domain}/runs/{run_id}/
├── map/links.json → crawl_runs.raw_map_uri
├── samples/{page_id}.md → crawl_page_decisions.sample_uri (Phase 2)
├── raw/{page_id}.md → scrape execution artifact / replay input
└── reports/*.jsonl → run diagnostics
Durable document snapshot bodies are written outside the run folder and grouped by document id then snapshot content hash:
domains/{site_domain}/document-snapshots/{document_id}/{content_hash}/content.md
→ document_snapshots.processed_markdown_ref
domains/{site_domain}/document-snapshots/{document_id}/{content_hash}/raw.md
→ document_snapshots.raw_markdown_ref (scrape-only, optional)
content.md is the canonical markdown body represented by the document_snapshots row. For
scrapes, it is the cleaned/processed markdown; for uploads or generated composite documents, it
is the extracted or generated markdown. We use the neutral name content.md instead of
processed.md or body.md so every producer writes the same shape, even when there is no
raw/processed pipeline.
raw.md is a scrape provenance companion for the same snapshot, not a separate snapshot. The
snapshot identity remains content_hash = SHA1(content.md), and the raw body hash is recorded
in extraction_metadata.raw_content_hash. If a later scrape changes only raw boilerplate but
produces the same content.md, no new document_snapshots row is created; the run-scoped raw
artifact remains available for run replay/debugging. If raw-only audit history becomes a
first-class requirement, add an explicit raw revision model rather than inferring it from GCS
object names.
The path is keyed by
{site_domain}(the tenant) for storage layout; the scrape source host lives insource_hosts, not in the object key.{page_id}=SHA1(canonical_url)= the node'surl_hash.{document_id}is the durableknowledge.documents.id(or, once implemented for composites,knowledge.composite_documents.id).produced_by_run_idrecords which run produced a snapshot; the snapshot body URI itself is not run-scoped.
Supabase remains the source of truth for the current version. Readers should follow
documents.current_snapshot_id → document_snapshots.processed_markdown_ref instead of listing
GCS and guessing which object is latest. The document-scoped prefix is for operator ergonomics
and consistency with other knowledge storage use cases: listing
domains/{site_domain}/document-snapshots/{document_id}/ shows every durable body version for
that logical document.
RLS / lifecycle¶
- Per-domain RLS on every table via
site_domain+get_accessible_domains()(the existing Supabase pattern — not aclient_idpolicy). service_roleretains full access for backend loaders and jobs.- Snapshot retention prune job respects
snapshot_retention_versions(default 10) but never deletescurrent_snapshot_id. - Soft-delete flag (
is_deleted) ondocumentsandcomposite_documents(no row delete) for audit trail. source_hostscarries no body and no RLS exception — samesite_domainpolicy. Itshostcolumn is intentionally not FK'd topublic.domains(external hosts are allowed).
Conflict detection model¶
- Cron job: embedding similarity (cheap first pass) + LLM judge (expensive, gated).
- Scans combinations per domain across both document tables: (doc × doc, where doc ∈ documents ∪ composite_documents), (doc × knowledge_faq), (doc × skill), (skill × knowledge_faq), (skill × skill), (× config_value).
- Skills are read by name from the repo, not the DB. The detector is a backend job with the
shared_dataprompts; for a domain it resolves the effective skill set viaixskills(global skills +clients/{domain}/overrides, filtered byagent_config.skill_toggles), loads each skill's markdown body, and compares it like any other knowledge unit. The conflict row stores the skill slug inentity_*_refand the prompt/commit version indetector_versionfor reproducibility (the body itself isn't a DB row). - Likewise
config_valueoperands are read fromconfig.client_configsand addressed byconfig_slug.key_pathinentity_*_ref. - Writes
document_conflictsrows; idempotent on(entity_a, entity_b, detector_version)where each entity is its uuid or its string ref. - Versioned
detector_versionso old conflicts can be re-validated when the detector improves. - Severity surfaced in the backoffice conflict inbox.
Actors & responsibilities¶
Three components act on this data (plus the Conflict detector). The split is decide vs
execute, with document_routings as the persisted seam between them.
Scraper (ixscraping) — mechanical I/O, no judgment. Runs as a three-phase pipeline.
- Phase 1 — URL Mapping: resolves/creates the
source_hostsrow for the host being crawled, calls Firecrawl Map, creates onecrawl_runsrow, and for each discovered URL upserts adocumentsnode (origin=scrape, status=discovered,url_hash=SHA1(canonical_url),source_host_idset) plus onecrawl_page_decisionsrow (decision=unlabeled); stores the raw map response in GCS (raw_map_uri).crawl_runs.status → mapping. - Phase 2 — LLM Page Decider: samples a subset of page content (stored in GCS as
sample_uri), calls an LLM to classify each URL asinclude | excludewith a confidence score andcontent_kindlabel; updatescrawl_page_decisionsin place and materializes the decision onto the node (exclude→documents.scrape_policy=skip). - Phase 3 — Firecrawl Batch Scrape: fetches only
decision=includepages in batches of 100 (8 concurrent); stores raw run artifacts in GCS.crawl_runs.status → mapped | failed. - Snapshot write (end of Phase 3, no back-fill): for each
decision=includepage whose freshly-processed markdown SHA1 differs from the currentdocument_snapshots.content_hash(or no snapshot exists): writescontent.mdplus optionalraw.mdto the durable document-scoped snapshot namespace, writes a newdocument_snapshotsrow directly (document_id= the existing node,raw_markdown_ref,processed_markdown_ref,produced_by_run_id), advances the node'scurrent_snapshot_id, setscrawl_page_decisions.produced_snapshot_id. Unchanged pages touchlast_seen_atonly. - Detects change by SHA1 and skips unchanged pages; decides nothing about meaning, knowledge type, or routing destination.
Knowledge Agent — judgment, writes intent only.
- Reads source documents and assigns
knowledge_type(generic/faq); distills several source docs into acomposite_documentwhen useful, recording lineage incomposite_document_sources. - Emits a plan — writes
document_routingsrows (targeting a source or composite doc) withrag/skill/config+target_ref+priority. - Is pure and re-runnable: re-running rewrites rows at worst, never double-applies effects.
Ingestor — execution, no judgment.
- Reads
document_routingsand ingests each doc into its destination store:ragvia the rose document loader (which itself skips unchanged docs);skill/configby writing the instruction / config rows directly. - Maps
site_domain→ backendtenantId(deterministic sanitization) when writing to Neo4j / MongoDB / LightRAG — the only place this translation happens. - Owns all side effects in one place, keeping the Scraper mechanical and the KA pure.
- Idempotent and replayable: re-applying a plan converges to the same state without re-deciding.
Naming. Not "Router" — that name is taken by the runtime query/intent router (chat-time intent classification → pricing-skill vs RAG). The Ingestor ingests knowledge into whichever store the destination names; the rose document loader is its RAG-specific backend, not the whole thing.
v1 simplification. The Ingestor can run inline in the Knowledge Agent for v1 (decide then ingest in one job). Because
document_routingsis a persisted plan — not in-memory — a standalone Ingestor can be split out later with zero schema change, the day an independent re-route or a human-approval gate (review plan before it is applied) is needed.
Diagrams¶
Diagrams adapted from the meeting note "RAG vs skills" (2026-06-08). Participants and stores are renamed to the real code components (
document_loaderJob,DocumentProcessor,GoogleDriveLoader, LightRAG) and to this ADR's tables. The Knowledge Agent (KA) and Composite Knowledge (CK) store are proposed (companion ticket IX-3171), not built — flagged as such.
Storage architecture¶
Layers and who writes what:
- Object storage (GCS) — single body store: raw md + processed md for every doc (incl. composite). HTML is not retained.
- Supabase — metadata, version chain, provenance, routing, conflicts (refs into object storage).
- Knowledge Agent (decision layer, skill-like; proposed, IX-3171) — reads docs, decides
type + composites, and writes a plan (
document_routings). Writes intent only. - Ingestor — reads
document_routingsand applies each:ragvia the rose document loader,skill/configby writing the rows. May run inline in the KA for v1. - rose document loader (
document_loaderCloud Run Job) — the Ingestor's RAG backend; today it writes only LightRAG (Mongo+Neo4j). - Instruction / config rows — the deterministic path (skill / config destinations).
Current vs proposed. Today the document loader writes only LightRAG (no object storage, no Supabase rows), and skills/instructions are repo markdown files resolved at runtime by
ixskills(backend/apps/shared_data/prompts/website-agent/skills/), with onlyagent_config.skill_toggles(JSONB) in the DB. This ADR introduces (1) object storage + the Supabase tables, populated by the scraper/snapshot step before the loader runs, and (2) a DB-stored instruction/config path so a Knowledge Agent can emit a deterministic block without going through RAG. Moving instructions from repo files to DB rows is net-new and belongs to IX-3171 — flagged proposed.
Knowledge layers (raw → generated → routed)¶
A URL is a document (origin=scrape) with raw md + processed md in its snapshot.
The Knowledge Agent distills one or more such docs into a composite_document (separate table),
recording provenance in composite_document_sources. Any doc (source or composite) routes to one or more
sinks via document_routings.
Worked routings:
| Domain | Doc | document_routings |
|---|---|---|
| Feature map (700 features) | composite | destination=rag, priority=high |
| Pricing | composite | destination=skill, target_ref=pricing |
| Competitors / battle-cards | composite | destination=skill, target_ref=competitors |
| Tone of voice / positioning | composite | destination=config, target_ref=engagement.tov |
| Feature-map that also seeds a skill | composite | two rows: rag + skill (fan-out) |
Ingestion pipeline¶
Update & contradiction loop¶
Periodic re-scrape → version chain → cross-entity conflict detection → client validation. No fire-hose for slow-changing data (pricing): versioned minimal edits.
Feedback loop¶
Client feedback ("price is 24, not 22") is stored as an attributed, dated document
(origin = feedback, tags = [feedback]) that can contradict the KB and trigger a
conflict. Feedback capture does not exist today — it is a prerequisite for this loop.
Graph note: prefer a versioned entity update (new
document_snapshotsrow +superseded_by) over the current destructive "delete doc + linked entities/relations → regenerate all" strategy, so feedback is not drowned by regeneration.
Document lifecycle¶
Aligned to documents.status enum.
Companion context (IX-3171 — knowledge taxonomy, not decided here)¶
These describe the proposed Knowledge Agent / Composite Knowledge / runtime-routing model and are kept for design continuity. They are out of scope for this storage/schema ADR and reference components that do not exist in code yet.
Knowledge taxonomy¶
Confidence scoring (proposed)¶
Composite Knowledge lifecycle (proposed)¶
Runtime retrieval (proposed)¶
Backoffice surfaces (separate impl tickets)¶
- Website + file hierarchy tree (per domain) — renders
documentssorted bypath(split into a folder tree client-side) spanning both the site URL structure and uploaded files, with each node annotated byscrape_policy(scrape/skip),status(discovered/active/ blocked/…), andknowledge_type. The primary surface for gate 1 + gate 2 decisions. A big site lazy-loads subtrees by path prefix instead of reading the whole tree at once. - Document list (flat view; filter by status, origin, knowledge_type, kb_indexed, tag).
- Composite list (
composite_documentsby lifecycle status; validate / edit / promote). - Snapshot diff viewer (snapshot N vs N-1 markdown diff).
- Conflict inbox (group by severity, resolve / mark false-positive).
- Manual rescrape buttons (full / single URL / replay-from-snapshot).
- Per-node toggles from the tree (or a glob editor that sets them in bulk): gate 1
scrape_policy=skip(never-fetch) and gate 2status=blocked(scrape-but-don't-ingest).
Consequences¶
Positive¶
- Single queryable source of truth for "what is indexed, when, and what did it look like".
- New tables join cleanly to existing
domains/knowledge_faqs/client_configsbecause they share thesite_domainpartition key — no new tenant convention introduced. - Immutable snapshots give audit + reproducibility the current SHA1-on-
doc_statusmodel lacks. - Global conflict view spans source docs, composite docs, knowledge FAQs, skills, and config.
- External scrape sources (competitor / blog / partner hosts) are first-class via
source_hostswithout pollutingpublic.domains; the tenant key stayssite_domain. - The thin
crawl_page_decisionsledger removes thescrape_pagescontent duplication + promotion back-fill — a discovered URL is onedocumentfrom the start.
Negative¶
- Adds a second persistence concern alongside the existing MongoDB
doc_status/ LightRAG state; the relationship between Supabasedocument_snapshots.content_hashand the MongoDB hash must be kept consistent (same SHA1). - Blob store cost + API-limit projection is unproven (open question below).
- Conflict detection (embeddings + LLM judge) is a new recurring cost.
Neutral¶
- Supabase is a single shared DB across environments; these tables follow that constraint
(writes are effectively production writes regardless of
IX_ENVIRONMENT).
Alternatives Considered¶
- Blob store only (no Supabase tables) — rejected: no relational queries, backoffice needs a DB join anyway, versioning still needs a metadata row.
- Keep Google Drive — rejected: no versioning audit, no blocklist, permissions drift, hard to surface in backoffice.
client_id uuidas RLS scope — rejected: conflicts with the establishedsite_domainpartitioning and RLS helper, and a client can own multiple domains.- Cloudflare R2 instead of GCS for blobs — viable; deferred. R2 already backs GEO pages, but blobs here are a backend/GCP concern and GCS avoids a second cloud + IAM surface.
- Composites as
documentsrows (knowledge_type=composite) — rejected: composites have a distinct validatable lifecycle (generated → … → prod) and none of the scrape/crawl/tree concerns, so cramming them in forces many inapplicable nullable columns and muddies the tree query. A dedicatedcomposite_documentstable is cleaner; both still share the singledocument_snapshotsbody store. - Type-tag polymorphism (
owner_kind+ untypedowner_id) on snapshots/routings — rejected: Postgres cannot FK an untyped id to two tables, so it would silently lose referential integrity. Two nullable FK columns + an exactly-oneCHECKkeep real FKs and cascade. (document_conflictskeeps id/ref polymorphism only because its operands include non-table entities — skill markdown, config keys.) - Scrape source host as a plain
source_host textcolumn (no table) — rejected in favor of asource_hostsregistry: a table dedups hosts shared across docs and gives per-host scrape settings (cadence, robots, allow/block globs) a home without touchingdocuments. - Scrape source host FK'd to
public.domains— rejected: scrape sources can be external (competitor / blog / partner) and are not Rose-hosted tenant domains. The tenant key stayssite_domain; the source host is decoupled. - Keeping
scrape_pagesas a stateful content table (the prior amendment) — rejected: it duplicateddocuments/document_snapshotscolumns and needed a promotion back-fill. Reduced to the thincrawl_page_decisionsledger; the discovered URL is adocumentfrom the start and the scrape writes a snapshot directly. parent_idadjacency list + synthesizedkind=folderrows for the hierarchy (the prior amendment) — rejected:pathalready encodes the tree for URLs and files, soparent_idwas a denormalized cache that drifts, and synthesized folders had to be created/recomputed/kept id-stable across runs. Deriving the tree frompath(sort + split; prefix scan for subtrees) is strictly less schema and removes the folder-identity churn. The cost — a folder has no row, so nothing can FK or attach metadata to it — is moot today (routing and conflicts target leaf docs, never folders); addparent_idback only if a folder must become an addressable entity.ltree/ materialized-path / closure-table for the hierarchy — rejected as premature: a domain's tree is a few thousand nodes, well within sort-and-split + atext_pattern_opsprefix index. Revisit only if a single domain's tree is measurably too big to lazy-load.
Open questions¶
- Blob store API limits + per-client cost projection: GCS vs R2 vs Supabase Storage.
- Snapshot retention default — 10 versions per doc reasonable? Per-tier?
- Conflict detector — embedding-only first pass vs LLM judge from day 1?
- ~~What triggers a re-scrape?~~ ✅ Resolved: a new
crawl_runsrow created via Cloud Run Job (env varSCRAPE_RUN_ID); triggered by cron/webhook/backoffice. - ~~How is change detected at the scraping layer?~~ ✅ Resolved: the SHA1 of the processed
markdown body compared against the current
document_snapshots.content_hash; on change the Scraper writes a new snapshot directly (noscrape_pagesstaging row). The raw markdown SHA1 is stored separately as provenance. - Migration out of Google Drive (separate ticket):
- Move each doc body GDrive → object storage; create one synthetic
document_snapshotsrow withcontent_hash= the current hash. - Preserve the existing document id on the
documentsrow so the loader's content-hash dedup still matches and docs are not reprocessed. - Classify
originfrom the md frontmatter: website docs reconstructsource_url/canonical_urlfrom frontmatter →origin=scrape; internal-doc conversions →origin=upload(keep the original binary inoriginal_refif available); docs with no reconstructable source →origin=unknown. - Tag all migrated rows
tags=[migrated-from-gdrive]; drop the transitionalgdriveorigin value once complete.
- Move each doc body GDrive → object storage; create one synthetic
Related, already-solved (not new "blob store" use cases)¶
These were listed in the prior draft as future blob-store work but already exist:
- Widget bundle releases (
frontend/widget/) — already versioned on GCS (gs://inboundx-cdn/widget/.../versions/{YYYYMMDD}/), served via the Cloudflare CDN proxy. - Generated GEO static pages (
geo.faqsHTML export) — already published to Cloudflare R2 and served bycloudflare-geo-pages.js(faq.userose.ai).
Genuinely new candidates for the same blob store (separate scope): LightRAG index snapshots / KB exports, compiled prompt bundles tied to a release tag.
Out of scope (for this design ticket)¶
- Knowledge Agent design (IX-3171).
- Updating
ixscrapingto write toknowledge.*instead ofscraping.*, including: writing adocumentsdiscovery node per mapped URL (not a self-containedpagesrow), writingdocument_snapshotsdirectly on scrape, and registering the scrape source insource_hosts(decoupled frompublic.domains) (implementation ticket). - Supabase migration:
scraping.runs→knowledge.crawl_runs,scraping.pages→ the thinknowledge.crawl_page_decisionsledger +knowledge.documents/document_snapshots(the durable columns move out of the pages table); seedknowledge.source_hostsfrom existingscraping.runs.target_urlhosts (data migration ticket). - Backoffice UI mockups.
- Migration of existing GDrive content (separate ticket).