An experiment in source-aware AI publishing.
ClaimGraph is a small prototype of one idea: every factual claim in AI-generated content should remember the exact source version that supported it, and scheduled content should pause automatically when that source changes.
It's written as an internal product concept that could plug into an AI social media platform with brand sources, guardrails, approvals, and scheduling. It is not a clone of one.
More screens in docs/screenshots.
AI-generated content gets harder once a business wants to automate publishing.
The difficult question isn't only "does this sound like the brand?" It's also:
What evidence supported this claim when we approved it, and is that evidence still current when we publish it?
A source link on its own doesn't answer that. Sources change after approval: a website is re-crawled, a pricing PDF is replaced, a policy is reworded. A post approved on Monday against Monday's website can be wrong by Thursday, and nothing in a typical approval flow notices.
ClaimGraph explores one possible answer. The goal is to reduce what humans need to review by surfacing the claims that are unsupported, contradicted, stale, or affected by a source change. Everything else stays out of their way.
Claim ──► Source version ──► Scheduled post
- A claim is an atomic factual statement, shared across posts ("Northstar Legal offers free initial consultations.").
- A source version is an immutable snapshot of a brand source (Website v3, Fee Policy v2) and the facts extracted from it.
- A claim–source link records that a claim is supported (or contradicted) by a specific fact in a specific version, with the evidence and confidence at that moment.
- A scheduled post uses claims, so it depends on the source versions behind them.
Why versioning matters. If claims linked to a source, a re-crawl would silently change what the claim is "supported by". Linking to a version keeps the approval evidence fixed. It also makes the change detectable: when v4 arrives, the system knows exactly which links pointed at v3 facts that changed, and which posts used those claims.
Links are never deleted. They move between three states:
| State | Meaning |
|---|---|
active |
Checked against the current version of its source. |
stale |
The fact changed in a newer version. Needs a human decision. |
superseded |
The fact did not change; the link was carried forward to the new version. |
- Claim extraction: a post is broken into checkable claims, each tied to an exact span of text.
- Evidence mapping: each supported claim shows source, version, evidence excerpt, confidence, and review date.
- Unsupported claim detection: "$50 million recovered" has no approved source and is flagged, even though the workspace has sources.
- Contradictions: "We guarantee you'll get the compensation you deserve" is contradicted by the Services Guide.
- Source versioning: every capture is a version; identical re-crawls create nothing.
- Source change detection: facts are diffed by stable key; only material changes count.
- Automatic stale-post pausing: affected scheduled posts pause, with the reason and the exact change.
- Published content is left alone: it's reported, never edited or pulled automatically.
- Human review: accept a suggested revision, edit, remove the claim, or keep the post paused.
- Decision history: every automatic and human action is recorded.
- Risk routing (small, visible): Low is Autopilot eligible, Medium goes to any reviewer, High to a named reviewer, Regulated to compliance review.
- Monday: a law firm's website says "Free initial consultation." An AI-generated post says the same. It's approved and scheduled for Friday.
- Wednesday: the website changes to "Initial consultations are subject to eligibility review."
- Friday: without ClaimGraph, the old post goes out and is now wrong.
ClaimGraph remembers that the post's claim came from Website v3. When v4 changes that fact, it pauses the post before publishing, shows exactly what changed, and suggests a fix for a person to approve.
- It does not determine legal compliance. Routing a claim to "compliance review" is a workflow, not a legal judgment.
- A source being present doesn't guarantee authority or scope. A website page supporting a fee claim doesn't make it the right document to rely on. Confidence reflects wording match only, and the UI says so.
- Human review is still required for sensitive decisions. The system narrows what people look at; it doesn't decide for them.
- Semantic source comparison is simplified. Facts are compared by stable key and normalised statement. Production needs a semantic step for "reworded but equivalent".
- The AI steps are deterministic fixtures (see below). They show the contracts and the data flow, not model quality.
- Production hardening is out of scope: real crawling, authentication and permissions, multi-tenancy isolation, rate limits, and a tamper-evident audit store.
- V1 (built): claim extraction, source-version linking, stale dependency detection, auto-pause, review.
- V2: risk-based routing to named reviewers with permissions (only compliance can resolve regulated claims), SLAs and notifications.
- V3: tamper-evident decision records (hash-chained events) and export to external archives for regulated teams.
flowchart LR
subgraph Web["Laravel 12 + Inertia"]
UI["Vue 3 pages"] --> C["Controllers"]
C --> RS["ReviewService"]
C --> SI["SourceIngestor"]
end
subgraph Claims["Claim services"]
PA["PostAnalyzer"] --> EX["ClaimExtractor"]
PA --> VE["ClaimVerifier"]
SCP["SourceChangeProcessor"] --> DI["SourceDiffer"]
SCP --> RV["ClaimReviser"]
SCP --> RR["RiskRouter"]
end
SI -- "new version" --> Q[("Queue")]
Q --> J["ProcessSourceUpdate job"] --> SCP
RS --> PA
PA & SCP & RS --> DB[("PostgreSQL: versioned sources, claims, links, reviews, events")]
The flow for a source change:
SourceIngestorhashes the captured facts. If the hash matches the latest version, nothing happens ("no changes since v4").- Otherwise it writes a new
SourceVersionwith its facts and dispatchesProcessSourceUpdateafter the transaction commits. SourceChangeProcessordiffs against the previous version, carries unchanged links forward, and marks links to changed facts stale. It then pauses pausable scheduled posts, opens one review per affected claim (with a suggested revision when one is safe), and records events.- A reviewer accepts, edits, or removes the claim.
PostAnalyzerre-verifies the whole post against current sources, andScheduleRefresherreturns it to Ready only if nothing is open and every claim is supported.
Claims link to source versions, not sources. Approval evidence must stay fixed after approval, and change detection needs something to compare against.
Processing is asynchronous. A single re-crawl can touch many facts and many posts. Capture is fast and transactional; the fan-out runs on the queue and can be retried without re-capturing. The UI makes the queue visible: it shows "Comparing Website v4 with v3…" and says plainly if no worker is running.
Update processing is idempotent, in layers. Queues deliver at least once, so ProcessSourceUpdate has to be safe to run twice:
ShouldBeUniquestops duplicate dispatches while one is pending, and the lock expires after 5 minutes so it can't wedge.- The version row is locked (
SELECT … FOR UPDATE) andprocessed_atis checked inside the transaction, so a duplicate execution exits immediately. - Every state change is conditional. Only
activelinks go stale, and onlyreadyorneeds_reviewposts pause. Re-applying the work is a no-op even withoutprocessed_at. - Reviews have a unique key
(post, claim, reason, to_version), and system events carry idempotency keys, so nothing is duplicated.
Test 6 checks both paths: a redelivered job, and a re-run with processed_at cleared.
The AI provider is abstracted. ClaimExtractor, ClaimVerifier, and ClaimReviser are interfaces bound in AppServiceProvider. The default fixture driver is deterministic, so the demo and tests run with no API key and no flakiness. SourceDiffer stays deterministic in every environment on purpose, because pausing content should never depend on a model's mood.
Humans remain in the loop. Nothing is re-linked to a new version automatically when a fact changed, and published content is never altered. The system's job is to stop, explain, and suggest.
A contradiction beats a support. If one approved source says yes and another says no, the claim is Contradicted, not Supported.
Each fixture marks where a model goes. A production verifier would:
- Retrieve candidate facts for the claim (embeddings or keyword) from the latest version of each source.
- Call the model with structured output constrained to those candidate ids, returning
{status, source_fact_id, confidence, evidence}. The model can only cite facts that exist in an approved version. - Return a
Verification. Nothing else in the system changes.
To add it, implement the three contracts, add an anthropic (or openai) case to AppServiceProvider::register(), and set CLAIMGRAPH_AI_DRIVER.
Requirements: PHP 8.2+ (tested on 8.3), Composer, Node 20+, PostgreSQL 14+ (tested on 16). SQLite also works for a quick look.
# 1. Install and configure
composer install
cp .env.example .env
php artisan key:generate
# 2. Create the database (adjust to your Postgres setup)
createuser -P claimgraph # password: secret (or change .env)
createdb -O claimgraph claimgraph
# 3. Schema + demo data
php artisan migrate:fresh --seed
# 4. Frontend
npm install
npm run build
# 5. Run the app AND a queue worker (source updates are processed on the queue)
composer dev # serve + queue:listen + vite, together
# or, in two terminals:
php artisan serve
php artisan queue:workOpen http://127.0.0.1:8000.
No Postgres? In .env, set DB_CONNECTION=sqlite and delete the DB_HOST, DB_PORT, DB_DATABASE, DB_USERNAME and DB_PASSWORD lines (otherwise SQLite looks for a file named claimgraph). Then run touch database/database.sqlite and php artisan migrate:fresh --seed.
No queue worker? Set QUEUE_CONNECTION=sync to process source updates inline.
composer test # SQLite in-memory (default in phpunit.xml)
DB_CONNECTION=pgsql DB_DATABASE=claimgraph_test \
DB_USERNAME=claimgraph DB_PASSWORD=secret vendor/bin/phpunit # against PostgreSQLtests/Feature/ClaimLedgerTest.php covers the core model:
- A supported claim links to a specific source version.
- An unsupported claim has no link and is routed for review.
- A new source version marks dependent claims stale, and carries unchanged facts forward.
- A scheduled post depending on a stale claim is paused, with a review.
- Unrelated scheduled posts are not paused, and published posts aren't touched.
- Running
ProcessSourceUpdatetwice duplicates nothing. - Accepting a revision links the new claim to the new version and restores Ready.
Plus a source status test: only source-change reviews make a source "Changed"; an overdue review interval makes it "Needs review".
tests/Feature/DemoFlowTest.php drives the walkthrough over HTTP and checks every screen renders.
Every test starts from the seeded workspace, and the seeder builds that workspace by running the real pipeline at back-dated times. That means the seed data is also a test of the pipeline.
- Scheduled: Post #104 is Ready. It depends on Website v3 and Services Guide v5.
- Posts: click "offers free initial consultations" to see its source, version, evidence, and confidence. Draft #108 shows an unsupported "$50 million" claim and a contradicted guarantee.
- Click Run source-change scenario in the sidebar. The website is re-crawled, and v4 now says "Initial consultations are subject to eligibility review."
- The source page shows the comparison, then the result: "A supporting source changed. 1 scheduled post paused." Post #103 is reported as already published and left alone.
- Review: see exactly what changed, the affected claim, and a suggested revision shown as a before/after sentence.
- Accept revision. The post is re-checked, the new claim links to Website v4, and Post #104 is Ready again.
- Claim ledger: open the old claim to see its lineage (claim, Website v3, stale in v4, the review decision, and the published #103 that still uses it). Decision history shows every step.
Reset demo data in the sidebar restores the starting state. It's disabled in production.
| File | Role |
|---|---|
app/ClaimGraph/Services/SourceChangeProcessor.php |
The core: diff, carry forward, mark stale, pause, open reviews. Idempotency notes in the docblock. |
app/Jobs/ProcessSourceUpdate.php |
Queued, unique job wrapping the processor. |
app/ClaimGraph/Services/PostAnalyzer.php |
Extract, verify, link to the exact source version. |
app/ClaimGraph/Services/ReviewService.php |
Human decisions; every path re-verifies and recomputes status. |
app/ClaimGraph/Services/ScheduleRefresher.php |
When a post may return to Ready. |
app/ClaimGraph/Services/SourceIngestor.php |
Versioned capture with content-hash dedupe. |
app/ClaimGraph/Contracts/* |
AI boundaries: extractor, verifier, reviser, differ. |
app/ClaimGraph/Fixtures/* |
Deterministic implementations used by default. |
app/ClaimGraph/Demo/NorthstarContent.php |
Northstar Legal sources, versions, and posts. |
database/migrations/2026_09_21_* |
Schema; comments explain the key constraints. |
database/seeders/DatabaseSeeder.php |
Builds realistic history by running the real pipeline. |
resources/js/Pages/* |
The six screens. |
tests/Feature/* |
Core model and walkthrough tests. |
- The claim status is denormalised onto
claimsfor cheap ledger filtering.ClaimStatusResolveris the only writer, so it can't drift silently. - Claims are canonical per organisation (deduplicated by normalised text). That's what makes "which posts use this claim?" answerable. The cost is that the extractor must produce stable wording.
- Removing a claim removes its whole sentence, because cutting a phrase mid-sentence breaks grammar. The UI shows the exact sentence before confirming.
- The review queue and draft actions are open to the acting user. Routing is shown but not enforced as a permission (that's V2).
- The fixture AI is rule-based, so it only "understands" the demo's phrasings plus a heuristic fallback that flags unrecognised factual sentences.
- There's no login. Every request acts as the seeded reviewer, so decisions are attributed to a real user in the history.
- The source status is derived (Changed if reviews from a newer version are open, Needs review if past the review interval), not stored.
- Times are stored in UTC and shown in the viewer's locale.
Any standard Laravel host works (Laravel Cloud, Forge + a VPS, Fly.io, Render):
- Provision PostgreSQL and set
DB_*,APP_KEY,APP_ENV=production,APP_DEBUG=false, andAPP_URL. - Build:
composer install --no-dev --optimize-autoloader && npm ci && npm run build. - Release:
php artisan migrate --force && php artisan config:cache && php artisan route:cache. - Run a queue worker as a supervised process:
php artisan queue:work --tries=3 --max-time=3600. Redis is the natural queue and cache store in production (QUEUE_CONNECTION=redis,CACHE_STORE=redis); the database driver is fine for a demo. - Seed the demo workspace once with
php artisan db:seed --force. The reset endpoint is disabled whenAPP_ENV=production.
Laravel 12.69, Inertia (Laravel adapter 2.0, Vue adapter 2.3), Vue 3.5, Vite 7, Tailwind CSS 4.3, PHPUnit 11.5, PHP 8.3, and PostgreSQL 16. The suite has 11 tests and 155 assertions, and passes on both PostgreSQL and SQLite.
Built by Rashmi Basavaraj as a product engineering exploration.
