# SignalGraph Produces deterministic evidence for conversion delivery and marketing-journey health from sanitized aggregates, then attaches DataHub owner and lineage context when configured. Built for [DataHub's Agent Hackathon](https://datahub.com/blog/build-with-datahub-agent-hackathon/), **Agents that do real work** category: read DataHub for context, evaluate a deterministic rule, write one explicit action back. See [Project status](#project-status) for what is verified today and [docs/hackathon-demo.md](docs/hackathon-demo.md) for the judge path. ## The problem Someone clicks your ad. The click carries a `gclid`. Days later that person books, and the booking has to travel back to the ad platform as an offline conversion so the platform learns which clicks turned into money. When that last hop fails, nothing errors. The booking is still in your database. The dashboard still renders. The ad platform simply never hears about the sale, so it keeps bidding as if those clicks were worthless. There is no alert, because from every individual system's point of view nothing went wrong. Teams usually find out weeks later, by noticing that reported revenue drifted away from real revenue. Two questions follow, and neither is quick to answer during the incident. How much money is affected? And who owns the thing that broke? The counts live in a warehouse. The ownership lives in a catalog. Nobody is joining them at 9pm. ## How it works ```text sanitized aggregate evidence (operator JSON, or local sanitized CSV rows) -> one deterministic rule (conversion delivery, marketing-journey health, signal admissibility, or delivery-window pricing) -> DataHub readback for owner and lineage (skipped only with --offline) -> stable JSON stdout + Markdown evidence file -> optional, explicit, idempotent DataHub incident (--apply-incident) ``` Four independent evaluators share this shape; each is documented in its own section below and in [docs/architecture.md](docs/architecture.md). None of them share state, open a connection you did not ask for, or read a clock. DataHub is asked three read-only questions through its [official MCP server](https://github.com/acryldata/mcp-server-datahub) — `search`, then `get_entities`, then `get_lineage` — never mutated except through the two opt-in write actions below. ## Use cases | Situation | Evaluator | Section | | --- | --- | --- | | An offline conversion may not have reached the ad platform | `revenue_signal_integrity` | [What it tells you](#what-it-tells-you) | | A marketing signal's trustworthiness is unknown before any rule touches it | signal admissibility gate | [Deciding whether a signal is usable at all](#deciding-whether-a-signal-is-usable-at-all) | | A whole delivery window needs a price, a likely failure stage, and an owner to route to | `conversion_delivery` | [Pricing the whole delivery window](#pricing-the-whole-delivery-window) | | Click-context, consent, and delivery health across a full marketing journey needs a read | `marketing_health` | [Marketing journey health](#marketing-journey-health) | | *Roadmap, not built:* the same aggregate-contract shape applied to a ClickHouse web-events warehouse, a BigQuery-hosted CRM, HubSpot, or Typeform | — | [docs/roadmap.md](docs/roadmap.md) | ## What it tells you SignalGraph compares how many conversions were eligible at the source against how many the destination actually received, succeeded, and left pending. It sorts that comparison into one of five outcomes, then reads DataHub for the ownership and lineage of the asset involved. Here is committed synthetic output, from `examples/broken-report.md`: ```markdown ## `missing_upload` - Failure code: `MISSING_DELIVERY` - Severity: `medium` - Affected conversions: `1` - Declared exposure estimate: `120.00 EUR` (not measured loss) - Next action: Reconcile missing offline conversions and retry destination delivery. ``` The five outcomes: | Outcome | Severity | What it means | | --- | --- | --- | | `NO_RECENT_UPLOAD` | critical | Supplied counts show no received, successful, or pending conversions. This does not prove the upload job is down. | | `HIGH_MISMATCH` | high | Supplied counts show more than half of eligible conversions as unsuccessful. | | `MISSING_DELIVERY` | medium | Supplied counts show some eligible conversions did not arrive or succeed. | | `PENDING_OVER_TOLERANCE` | low | Conversions are sitting in the destination queue past the tolerance you set. | | `HEALTHY` | none | The counts line up. | When DataHub is configured, the same report also carries the destination asset's owner and how many assets sit downstream of it, so the finding arrives with a name attached instead of a dataset URN you then have to look up. ## Marketing journey health The DataHub-first extension checks aggregate health across the path from landing event to mature conversion record: provider click-context capture, storage context, UTM/click-context mismatches, consent state, unauthorized tag firing, context propagation, and destination delivery. It accepts counts only; it never accepts raw `gclid`, `fbclid`, cookie values, customer identifiers, or probabilistic attribution. An upstream data-lake or warehouse job is expected to emit the sanitized contract. SignalGraph evaluates it, then uses DataHub to answer the operational questions: which asset represents the evidence, who owns it, and what is downstream of it. DataHub is context, not the event source of truth. ```sh python3 -m signalgraph.marketing_stub \ --source-dir demo/stubs/marketing_health_alert \ --output /tmp/marketing-health.json python3 -m signalgraph.cli \ --offline \ --marketing-health /tmp/marketing-health.json \ --markdown-out /tmp/marketing-health.md \ --json-out /tmp/marketing-health.json ``` Denied consent is reported as context, not automatically treated as a defect. Observed tags firing without required consent are `critical`. UTM/click-ID mismatches are surfaced as `observe` until a source-specific policy says they are an incident. Aggregate counts remain explicitly unverified. ## What it does not do This matters more than the feature list, so it is not buried at the bottom. **An upstream producer supplies aggregate evidence.** SignalGraph reads a small JSON file emitted by a local fixture or a future data-lake/warehouse adapter. It does not query your warehouse, browser, Google Ads, Meta, or CRM today. Producing trustworthy aggregates from those systems remains outside this repository. **You supply the value assumption.** `declared_exposure_estimate` is `conversion_value` multiplied by the number of affected conversions, and `conversion_value` is a field in the file you wrote. It is not measured lost revenue, and nothing looks up a real price. **The evidence is labeled.** The finding records `counts` as `operator_supplied`, `conversion_value` as `operator_declared_per_conversion`, and `independent_verification` as `false`. DataHub context is separate metadata readback; it does not verify the conversion counts. **It is read-only by default.** The MCP server's mutation, user, and document paths remain switched off. Two independent, opt-in flags are the only writes: `--apply-incident` may raise one idempotent operational incident on the validated destination asset, and `--apply-assertion` may upsert one DataHub Custom Assertion and report the current finding as its result. Neither retries, repairs, replays, or changes conversion data. It reports by default; you must opt into either audited action. **There is no model in it.** No LLM, no autonomy, no planning, no tool selection. One rule, five branches, the same input always producing the same bytes out. **It refuses rather than guesses.** If DataHub returns an ambiguous search result, a malformed entity, an asset with no owner, or a missing lineage edge, SignalGraph exits non-zero. It will not fall back to a cached answer or a fixture and present that as evidence. The committed examples in `examples/` are synthetic offline output, marked `datahub_evidence: false`. They are proof that the rule behaves, not proof of a live DataHub run. ## Try it Python 3.11 or newer. No dependencies to install, nothing to configure, no DataHub needed: ```sh git clone https://github.com/Atroci/signalgraph.git cd signalgraph ./demo/run_demo.sh ``` That runs a healthy fixture and a broken one, and prints the paths to the JSON and Markdown evidence it wrote. The broken one is the `MISSING_DELIVERY` report shown above. You can also read the output without running anything, at . ## The file you have to write One fixture is one comparison. This is the whole input format: ```json { "fixture_id": "missing_upload", "source": { "eligible_count": 2 }, "destination": { "received_count": 1, "success_count": 1, "pending_count": 0 }, "conversion_value": "120.00", "currency": "EUR", "lineage": { "source_asset": "urn:li:dataset:(urn:li:dataPlatform:postgres,signalgraph.booking_events,PROD)", "queue_asset": "urn:li:dataset:(urn:li:dataPlatform:kafka,signalgraph.qualified_conversion_queue,PROD)", "destination_asset": "urn:li:dataset:(urn:li:dataPlatform:postgres,signalgraph.offline_conversions,PROD)" } } ``` Two eligible conversions, one of which arrived and succeeded. The arithmetic shows one affected conversion and a declared exposure estimate of 120.00 EUR; it does not prove a loss. The three URNs tell SignalGraph which DataHub assets to ask about. To derive counts from local sanitized rows instead of typing them into the fixture, run the CSV path in [docs/demo-script.md](docs/demo-script.md#local-csv-derived-counts). ## Adding DataHub context With DataHub configured, SignalGraph starts the official [DataHub MCP Server](https://github.com/acryldata/mcp-server-datahub) and asks it three questions, in order: `search`, then `get_entities`, then `get_lineage`. It reads ownership and lineage for the three assets your fixture named, and attaches the destination owner and downstream count to the finding. You need `uv` on your PATH, which provides `uvx`, and a reachable DataHub instance holding the demo assets. ```sh export DATAHUB_GMS_URL=http://localhost:8080 # Optional, only if your instance needs it: # export DATAHUB_TOKEN='' ./demo/run_mcp_demo.sh ``` The generic script runs one already-seeded fixture. For the complete operator proof, `./demo/run_vps_mcp_demo.sh` reseeds two conversion fixtures, one CSV-derived reconciliation fixture, and one marketing-health aggregate fixture on an existing private VPS DataHub, opens an SSH tunnel, and verifies the reports. The full procedure is in [docs/datahub.md](docs/datahub.md). Direct GraphQL is still supported as a legacy mode, and it is never used as a fallback when MCP fails. To demonstrate the explicit incident action after validated MCP context: ```sh ./demo/run_vps_mcp_demo.sh --apply-incident ``` To demonstrate the DataHub Custom Assertion action instead — one `upsertCustomAssertion` plus one `reportAssertionResult`, run for every finding rather than only a failing one — use the CLI directly, since the VPS runner does not forward this flag yet: ```sh python3 -m signalgraph.cli --mcp --apply-assertion \ --markdown-out /tmp/evidence.md --json-out /tmp/evidence.json \ demo/fixtures/missing_upload.json ``` Both flags are optional, apply only to conversion fixtures, and are not used by the default judge path. Marketing-health reports remain read-only in this slice. See [docs/datahub.md](docs/datahub.md#explicit-custom-assertion-action) for the field-by-field detail. ## Deciding whether a signal is usable at all `--signal` is a separate, optional gate that answers a different question: before any rule reads a marketing signal, is that signal admissible as evidence? ```sh python3 -m signalgraph.cli \ --offline \ --markdown-out /tmp/evidence.md \ --signal demo/fixtures/marketing_signal_admit.json ``` It returns `admit`, `quarantine`, or `deny` with a reason code. It denies raw and derived identifiers, personal and health data, identity resolution, CRM synchronisation, attribution modelling, and probabilistic scores. It collects nothing, resolves no identity, keeps no state between runs, and opens no connection. See [docs/marketing-signal-contract.md](docs/marketing-signal-contract.md). ## Pricing the whole delivery window The rule above answers one comparison. The commercial question is bigger: over a whole delivery window, how many conversions did not make it, what were they worth, and where did they stop? `signalgraph.conversion_delivery` answers that from one sanitized aggregate document: ```text warehouse / CRM / booking platform -> eligible conversions -> aligned time window -> upload attempts -> normalized destination diagnostics -> pending / rejected / duplicated / missing -> affected count and affected value -> likely failure location -> DataHub owner -> recommendation-only alert / incident / optional retry actions ``` The committed golden incident, `demo/fixtures/conversion_delivery_golden.json`, reads like this. Ten conversions were eligible and worth 1000.00 EUR. Nine were attempted, eight were normalized by the destination, four were accepted, and three came back successful in both the CRM and the booking platform. An excerpt of what comes back, alongside a full `evidence` block and recommended actions: ```json { "affected_count": 7, "affected_value": "700.00", "currency": "EUR", "failure_location": "upload_job", "owner_routing": { "label": "growth_operations" }, "reason_codes": ["duplicated", "missing", "pending", "rejected"], "state": "alert" } ``` Seven conversions with an affected value of 700.00 EUR are reported, the first place to look is the upload job, and the finding routes to `growth_operations`. The `recommended_actions` list contains alert, open incident, and retry suggestions; every one carries `executed: false` and `requires_approval: true`. Nothing is sent, opened, or enqueued. The same boundaries apply here as everywhere else in this repository. It consumes aggregate evidence you produce; it is not a warehouse, CRM, booking platform, GA4, Ads, destination connector, CDP, attribution warehouse, or identity system. Aggregate CRM and booking outcomes are allowed as evidence; rows, references, and identifiers are refused outright. DataHub stays read-only context for lineage, contracts, owners, and diagnostics. There are no live destination adapters and nothing runs on a schedule. One flag runs it, offline, against the committed golden fixture: ```sh python3 -m signalgraph.cli \ --offline \ --conversion-delivery demo/fixtures/conversion_delivery_golden.json \ --markdown-out /tmp/evidence.md ``` It is fail-closed. Only a `healthy` or `alert` evaluation renders a report; contradictory or excluded evidence exits non-zero with its reason code rather than publishing a partial finding. Full explanation, the step-to-evidence table, the privacy boundaries, and the MVP-versus-later split are in [docs/conversion-delivery-assurance.md](docs/conversion-delivery-assurance.md). ## Project status SignalGraph was built for the DataHub Agent Hackathon and is a working prototype rather than a product. Two deterministic evidence paths, four synthetic demo assets, no packaging for production use. The rule engine, the offline path, the MCP adapter, the signal gate, the conversion delivery evaluator, the CSV reconciliation path, the marketing health evaluator, the lake-shaped marketing stub, and the explicit incident action are built and covered by 159 tests. Legacy direct-GraphQL readback passed against DataHub 1.7.0 on 2026-08-05. The fresh MCP path passed against DataHub 1.7.0 on 2026-08-06 through a private SSH tunnel: the two conversion fixtures, CSV-derived reconciliation, and marketing-health fixture returned validated metadata, one owner, one downstream asset, and their expected deterministic findings. The live JSON and Markdown remain outside Git; committed examples remain synthetic offline output. Scope and acceptance gates are in [MVP.md](MVP.md); the specifications are in [specs/001-datahub-presentation-readiness/spec.md](specs/001-datahub-presentation-readiness/spec.md), [specs/003-conversion-delivery-assurance/spec.md](specs/003-conversion-delivery-assurance/spec.md), and [specs/005-marketing-journey-health/spec.md](specs/005-marketing-journey-health/spec.md). Do not commit tokens, live owner data, raw DataHub responses, or live reports to this repository. ## Roadmap Everything above is built, tested, and passed a live DataHub gate. The longer-term idea is bigger than the MVP: a DataHub-grounded signal-detection layer that reads sanitized, aggregate-only signals from wherever a marketing or growth team already has them — ad platforms, email, organic/search, website and backend events, a ClickHouse web-events warehouse, a BigQuery-hosted CRM, HubSpot, Typeform — and attaches DataHub owner and lineage context to whatever it finds. None of that is built. [docs/roadmap.md](docs/roadmap.md) states the long-term category, the candidate sources, and exactly what has to happen before a candidate stops being a roadmap bullet: its own feature spec, its own sanitized-aggregate contract, tests, and the same live-DataHub verification gate every shipped feature in this repository already passed. The constitution's ["Vision and roadmap (not built)"](.specify/memory/constitution.md#vision-and-roadmap-not-built) section is the binding version of this boundary. ## Checks ```sh python3 -m unittest discover -v ./demo/judge_demo.sh ``` Reviewers need no credentials and no DataHub for the offline gate. `demo/judge_demo.sh` checks the suite, syntax, byte-stable offline artifacts, and the CSV-derived path. `demo/run_vps_mcp_demo.sh` is the separate operator gate for live MCP context and optional incident writeback. ## Code map `signalgraph.contracts` defines the fixture and finding shapes. `signalgraph.rules` holds the conversion integrity rule. `signalgraph.mcp_context` performs read-only MCP normalization. `signalgraph.datahub` performs the legacy GraphQL readback and the explicit, opt-in incident and Custom Assertion actions. `signalgraph.signal_contract` gates signal admissibility. `signalgraph.conversion_delivery` evaluates one delivery window over sanitized aggregates. `signalgraph.marketing_health` evaluates sanitized marketing journey-health aggregates. [docs/architecture.md](docs/architecture.md) describes the evidence boundary. The repository root also carries a conformant [Agent Plugins](https://github.com/agentplugins/agent-plugins-spec) v1.0.0 package (`plugin.json` plus `skills/signalgraph-evidence-check/`), so a compatible agent client can discover this CLI as a portable skill. See [docs/agent-plugin.md](docs/agent-plugin.md) for the conformance checklist; [tests/test_agent_plugin.py](tests/test_agent_plugin.py) enforces it. ## License Apache 2.0. See [LICENSE](LICENSE).