Skip to content

Neo4j Recommendations & Risk Graph

Related: Data ownership model · Service authentication · Access matrix

IN PROGRESS. The shared service, the graph boundary, the projection feeds, per-module feedback capture and the module surfaces exist and have been verified against a real Neo4j. Ranking depth (per-recipe queries), reviewer actions, metrics, rollout gates and the AI Graph MCP are not done. This page describes what is implemented today and marks everything else explicitly.


Neo4j adds two things the module databases are bad at:

  1. Explainable recommendations — who fits this event, this artist, this venue, and why.
  2. Relationship-based risk detection in Finance — connected vendors, approval concentration, related-party patterns.

Delivery order is Promoters → Artists → Venues → Finance → AI. Each phase must pass its own security and quality gates before the next widens graph use.

  • Every current database stays the source of truth. Neo4j holds only rebuildable IDs, relationships, ranking signals and explanations. Deleting and rebuilding the graph loses no official business data.
  • One shared service owns the graph. Backend-Kisum-Recommendations is the only thing that talks to Neo4j. Frontends never connect to it; they call their own backend, which calls the service internally.
  • Fixed recipes only. Callers pass a recipe name. Raw Cypher and client-supplied queries are not accepted from a browser, a module backend or AI.
  • Every tenant-private node and edge carries owner_org_id. Competitor-private history never contributes to another company’s score.
  • Recommendations are advisory. They may open an existing action but never book, approve, reject, hold or pay. Current data and permissions are revalidated at action time.
  • A graph outage must not break normal work. Every caller fails open: recommendation panels disappear, the underlying workflow is untouched.
Frontend (Promoters / Artists / Venues / Finance)
│ Bearer JWT + x-org
Module backend (BFF + permission gate)
│ internal token + x-org + x-kisum-permissions + recipe name
Backend-Kisum-Recommendations ──► Neo4j (derived, rebuildable)
│ projection feeds (internal key only, cursor-paged)
Module backends (Promoters, Artists, Venues, Finance)

The module backend is the permission boundary. The recommendation service additionally checks the caller service, the module, the recipe and the declared permission keys, and rejects any tenant-private recipe without a canonical x-org UUID.

4. Projection feeds — what may leave each module

Section titled “4. Projection feeds — what may leave each module”

Feeds are internal-key only, cursor-paged and page-size limited.

One company at a time. Events, lineups, bookings, and venue/agency/vendor relationship references. Excluded: free-text notes, contracts, tokens, full financial detail.

Promoters pushes this feed (POST /internal/projections/promoters) before a recommendation read. History rebuild after lineup-id fixes uses Backend-Kisum-Promoters/scripts/reproject-recommendations.js --confirm.

Order matters for BOOKED: Artist Candidate nodes (artists:<numericId>) must already exist (Artists catalog sync). Relationship ingest checks both ends; a missing artist (or event) node is quarantined and returned in quarantined on the projection response / reproject script. Before the fix, missing ends were a silent no-op (pushed=N with BOOKED still 0).

Diagnose in Neo4j Browser:

MATCH (e:Candidate:Event) WHERE e.source = 'promoters' RETURN count(e) AS promoterEvents
MATCH (a:Candidate:Artist) WHERE a.sourceId STARTS WITH 'artists:' RETURN count(a) AS artists
MATCH ()-[r:BOOKED]->() RETURN count(r) AS booked

If promoterEvents > 0 and booked = 0, the artist ids on those BOOKED rows are not in the graph yet — wait for catalog sync, then re-run reproject.

This is the usual cause, and it looks like a code bug. Measured 2026-08-06: booked = 0 while the artist catalog stood at 62,215 of 1,312,500. Every one of the 37 lineup edges was quarantined for a missing end. After the sync completed, the same reproject gave booked = 37 with no code change. Check the artist count before suspecting the lineup ids.

Signals recorded outside an event — shortlists, marketplace offers, offline offers — have no event to hang off, so they use a per-company anchor node:

promoters:company-interest:<coreCompanyUuid>

Projected by internal/recommendation-projection.query.js as an organization entity on every feed page (MERGE is idempotent), tenant-private, with publicScore and discoveryScore at 0 — it is the subject side of an edge, never a candidate in a result list.

It must be per company. The recipes read a company’s past feedback as (otherSubject:Candidate {ownerOrgId: $orgId}) (recipes/shared.ts, FEEDBACK_MATCH), so a single shared anchor with no owner is invisible there: the node would exist and every shortlist would still rank as nothing.

Before 2026-08-06 the node was never created at all, and relationship ingest quarantined every non-event feedback edge. Rows stored against the older bare promoters:company-interest are rewritten to the per-company id on projection by feedbackToProjectionRecordsno data migration is required.

MATCH (c:Candidate) WHERE c.sourceId STARTS WITH 'promoters:company-interest'
RETURN c.sourceId, c.ownerOrgId
MATCH ()-[r:RELATED_TO]->() WHERE r.relation = 'recommendation_feedback'
RETURN count(r)

GET /internal/catalog/recommendation-projection — artists, agencies, genres, countries. GET /internal/catalog/recommendation-projection/listingspublished marketplace availability listings as opportunity nodes carrying title, territories, event types and the availability window (availableFrom / availableTo / flexibleDates), plus a RELATED_TO edge to the artist.

Excluded: private notes, flags, ratings, relationship inputs, fee terms, commission, disclaimer copy, representation labels. Private artist calendars are never projected — the published listing window is the only public availability signal.

GET /internal/catalog/recommendation-projection — active venues with location, type, capacity band and marketplace publish/feature state. ?entity=spacespublished spaces only, with capacity and capacity models, plus a RELATED_TO edge to the parent venue. Unpublished spaces never appear.

Excluded: operations, contracts, deposits, finance detail, pricing, setup timings, technical and accessibility notes, media and floorplans.

Date-level venue availability is deliberately not public. Open and blocked dates reveal another company’s bookings. It belongs in the venue-private feed, scoped by owner_org_id. That feed is not built yet.

Narrow, company-scoped, and gated on a security/privacy review that has not been granted yet. Full sensitive values never enter the graph — only approved identity fingerprints.

Recipes are named and versioned, e.g. promoters.event-artists.v1. Each declares its module, result type and the exact permission keys the caller must already hold.

Promoters ranking is 70% own-company private experience, 20% public fit, 10% discovery. A company with no reliable private history uses 80% public fit, 20% discovery instead.

Each recipe has its own Cypher query, assembled from shared fragments so tenant scoping, feedback handling and the result shape live in one place. There is no generic fallback: an unregistered recipe throws, because a default query would let a half-built recipe return plausible rows produced by the wrong logic.

16 of 28 recipes are real implementations. The other 12 are wired and tenant-safe but return nothing until the feed their result type needs is projected — they are declared explicitly, so an empty panel is never mistaken for a working one:

AwaitingNeeds
Promoters timing, risks, next actionsa Promoters timing / risk / next-action feed
Artists promoter-fit, collaborators, follow-upsthe private Artists relationship feed
Artists booking opportunities, tour routesa promoter-demand feed and projected tour stops
Venues event-fit, promoter-fit, repeat customers, operational risksthe venue-private feed

Isolation is per module, not just per company

Section titled “Isolation is per module, not just per company”

ownerOrgId alone is not sufficient. Promoters and Finance both project Vendor nodes, and for a company using both modules those nodes share one canonical company UUID — so owner scoping matched and Finance vendors surfaced inside a Promoters panel, to a user who may hold promoter.vendor.view and no Finance permission at all. Every private recipe pins candidate.source as well.

ModuleSurfaceState
PromotersEvent → Booking tab only, below the lineupLive
PromotersArtists → Discover (GET /recommendations/discovery/artists)Live
ArtistsArtist detail → Profile tab (promoter fit, venue fit, booking opportunities, tour routes)Live
VenuesVenue dashboard (event fit, promoter fit, open dates, repeat customers)Live
FinanceContextual vendor risk card + /risk-review inboxLive

Every panel hides itself when a recipe is empty, forbidden, or the graph is unavailable. Save and Dismiss are live in Promoters, Artists and Venues. Shortlist, Compare and Start-booking hand-offs are still open.

What a card shows, and where it comes from

Section titled “What a card shows, and where it comes from”

A card’s title, subtitle and link are not built by the frontend — they are projected properties on the Candidate node:

Card elementNode propertyFallback
Titlecoalesce(name, title, sourceId)the raw source id
Subtitlesubtitlehidden
Linkhrefno link; title renders as plain text

The title fallback is the trap: a projection that omits name puts a raw source id in front of the user (promoters:vendor:123), which reads as a frontend bug and is not one. href must start with / or the repository drops it.

Titles link to the entity and open in a new tab — comparing several suggestions must not cost the page the user is working on.

Each module owns its own feedback table — not Neo4j. The graph is derived and rebuildable, so a learning signal held only there dies at the next rebuild.

ModuleTableScope
Promotersrecommendation_feedbackcompany + user
Artistsartist_recommendation_feedbackcompany + user
Venuesvenue_recommendation_feedbackcompany + user + space (space nullable)

Rules that hold in all three:

  • The action list is closed — viewed, dismissed, saved, shortlisted, requested, offered, confirmed — enforced in code and by a database CHECK. An open string would let a caller record signals that silently rank as zero.
  • Repeating an action is idempotent. It refreshes the row; it is not a second signal.
  • company_id is the canonical Core company UUID and is part of the uniqueness key, so one company can never read or influence another’s feedback.
  • Rows are projected tenant-private with owner_org_id. Rows belonging to another company are dropped even if handed to the projector.
  • Feedback writes require exactly the permission the matching read required.
  • Artist candidates are artists:<numericDirectoryId> only — not Mongo ObjectIds and not Artists-service UUIDs. Promoters enforces this in artistId() / pickArtistsDirectoryId. Verified 2026-08-06: all 37 live lineup entries are numeric, so scripts/migrate-lineup-artist-ids.js reports “nothing to translate”. It is kept for future bulk imports only.
  • Signals with no event use the per-company anchor promoters:company-interest:<orgId> as their subject — see above.

Artist discovery — the cold-start engine

Section titled “Artist discovery — the cold-start engine”

The recipes above rank artists a company already booked. That is the right shape once a company has years of history; a promoter with ten bookings has none. Discovery inverts it: the bookings become the seed, and every result is an artist the company has never touched.

It is deliberately not a recipe. It reads a live provider rather than the graph, so it has no recipe name, no x-org recipe scope, and the AI graph tools cannot reach it.

GET /recommendations/discovery/artists?capacity=800&genres=Rock,Metal&local=true&limit=5
QueryMeaning
capacityRequired. Sizes the listener band.
genresComma-separated veto, not a ranking term.
localtrue restricts to the company’s sub-region. Off by default.
roomsfalse skips the setlist.fm enrichment (one search per card).
limit1–20, default 5.

Fewer than limit is a valid answer and nothing is padded. diagnostics explains an empty result — droppedWrongGenre, droppedWrongMarket, droppedOutOfBand, unresolvedSeeds — because “no results” with no reason is indistinguishable from a broken feature.

  1. Market — opt-in. markets is a LIST because a promoter’s catchment is their sub-region. markets[0] is the home market and is the only thing that sizes the band; the rest only widen who is eligible. Markets carry both the country name and the nationality, because Last.fm tags US acts american, never united states.
  2. Genre — a veto. Kisum genre names are mapped to Last.fm tags in src/discovery/genres.ts (Hip Hophip-hop, rap, trap), matched with CONTAINS so “rock” returns alternative rock.
  3. Size — the listener band, market-relative. Last.fm’s user base is Western, so the same room needs 10–30× more listeners in Berlin than in Jakarta.

Gates 1 and 2 read the same tag list and share one deep artist.gettoptags lookup — the deciding tag is often outside the five artist.getinfo returns.

Name → directory id, observed room sizes and sub-region expansion all live in Backend-Kisum-Promoters, not here. Promoters already holds the Artists directory client, the licensed setlist.fm client, the Venues client and the countries collection, so building there added no environment variables and no new upstreams to this service, which stays a pure function of its inputs.

ConcernModuleFile
Seeds, exclusions, home countryPromotersrecommendations/discovery-seeds.query.js
Sub-region marketsPromotersrecommendations/subregion-markets.js
Name → numeric idPromotersrecommendations/artist-name-bridge.js
Observed room sizesPromotersrecommendations/artist-rooms.js
SIMILAR_TO edgesPromotersrecommendations/similarity-projection.js
Gates, band, scoringRecommendationssrc/discovery/engine.ts
  • Name → id is exact normalized name only. Case, accents and punctuation are normalized; nothing else. A near match resolves to null. Attaching feedback to the wrong artist is permanent and invisible — the graph just quietly learns the wrong taste.
  • A venue capacity is used only at similarity ≥ 0.75. Setlist.fm publishes no coordinates, so the Venues matcher already applies its strict name-only threshold rather than the 0.45 used when two venues can also be shown to be physically close. Pinned as MIN_SIMILARITY in Promoters so a later default change cannot loosen it silently.
  • A candidate that fails a check is dropped, never defaulted. The publicScore = 0.5 constant is what “fill it in for now” looks like a year later: every artist scoring the same and the ranking meaning nothing.
  • Blind spots are printed on the screen, not hidden. Listener counts are global, there is no ticket-price data, and an act with no gigography keeps a listener estimate and says so.

/artists/discoversrc/app/@main/artists/discover/page.tsx plus src/components/recommendations/discovery-artist-card.tsx. Built from the Claude Design Kisum App.dc.html mockup, so it uses the shared design-system tokens directly (--surface-card, --accent-tint, tone pairs) rather than app-local styling.

ElementSource
Room size chips (Club 300 → Arena 10,000)capacity query param
Genre pillsgenres veto
”Local acts only”local
”Seeded by” pillsbasedOn.seedNames
”Came up from” on the cardcard.seedNames
Fit badge + barcard.capacityRange first, diagnostics.band otherwise
Dashed note at the bottomunion of card.blindSpots, deduped

The fit badge reads room evidence before listener counts. When capacityRange exists the artist demonstrably played those rooms, so it decides the verdict: smaller than the promoter’s room → Tight for N, bigger → Room to grow, overlapping → Fits N. Without it the position inside diagnostics.band is the fallback. The bar is a comparison between the cards on screen — it is never a claim about tickets sold.

Blind spots are one note per result set, not one line per card. Repeating the same caveat on five cards trains people to stop reading it.

Written as a by-product of someone searching, not by sweeping 1.3M artists. Edges are public and unowned — “Perunggu sounds like Hindia” is a fact about music, not about a company — so the network improves for every tenant as any one promoter uses it.

They store providers: ['lastfm'], the provider names, never an agreement: N count. Last.fm identifies artists by name and the other providers by their own ids, with no resolution layer between them, so a count would be counting fuzzy name matches while presenting the result as confidence.

MATCH ()-[s:SIMILAR_TO]->() RETURN count(s)

Higher privateScore = stronger “want similar options”. BOOKED lineup history uses the same bucket as confirmed (~0.55), not higher than request/shortlist/offer.

RankActionWeightHow it fires
1requested0.95Auto on booking-request create
2shortlisted0.85Auto on shortlist add
3offered0.75Auto on offer create (marketplace / booking-offer / offline)
4confirmed (+ BOOKED)0.55Auto on event confirm; BOOKED projection same level
5saved0.40Recommendations panel
6viewed0.10Auto when artist suggestions are returned
dismissed−0.60Recommendations panel

Signals never block booking/shortlist/offer flows. Offer status changes (pending/accepted/rejected) do not each get a new signal — create/send once.

Not yet: true genre/sound similarity (“only rock”, “similar to Backstreet Boys”). Today’s mix is ~70% own history + feedback / 20% public+country / 10% discovery. An AI/model similarity layer is a later addition, not part of these weights.

How the graph acts on it:

  • A dismissal on the same subject removes the candidate outright — the user has already answered that exact question.
  • A dismissal elsewhere in the company applies a penalty only, clamped at zero. Saying no for one event must not blacklist an artist everywhere.
  • Positive actions raise the private-experience component, which is 70% of the Promoters weighting.

GET /api/risk-review runs every risk recipe the caller is permitted to see and merges them into one company-scoped list ordered by confidence, banded low/medium/high. A missing permission drops that risk type rather than failing the whole inbox, so a reviewer with partial access still sees what they may see.

Two gates are required, not one: the Finance permission and the reviewer role.

Every entry is review-needed. The graph finds a relationship pattern; a human decides what it means. The route is read-only — it can never mutate a bill, payment or approval — and a graph outage returns available: false while leaving every Finance workflow untouched.

Reviewer actions (acknowledge, dismiss with reason, escalate, resolve) and their audit history are not built yet.

Verified 2026-08-02 against Docker Neo4j 2026.06.0:

  • valid config starts and reports /health ok and /ready ready; malformed caller credentials fail fast at startup;
  • projection ingest and a full recommendation call work end to end;
  • two-company isolation holds. The same public artist scored 0.875 with a private reason for the company that had booked it, and 0.78 with a generic reason for a second company. The second company’s score came only from public/discovery signals, and no competitor-private node or reason text appeared in its results.
  • dismissals behave as designed. A candidate disappeared from the event where it was dismissed, and stayed visible but penalised on another event of the same company.
  • all 28 recipe queries plan cleanly against a real Neo4j (EXPLAIN), and the Promoters, Venues and Finance recipes were each run against seeded data and returned the expected candidates, scores and reasons.

Deployed-environment proof, latency gates (graph p95 under 200 ms, hydrated p95 under one second) and the pilot cohorts are still required before Phase 1 can be called complete.

EndpointAuthPurpose
GET /healthnoneprocess alive
GET /readynoneNeo4j reachable (503 when not)
GET /internal/freshnessinternalper-feed cursor, last success, circuit state, quarantine
GET /metricsinternalPrometheus exposition

Rollout controls. RECOMMENDATIONS_ENABLED (kill switch), RECOMMENDATION_TENANT_ALLOWLIST (empty means every company) and RECOMMENDATION_SHADOW_MODE (compute and measure, return nothing). A blocked company receives an empty result, never an error — callers treat recommendations as optional, and an error would read as “something broke” rather than “not enabled”.

Sync resilience. Bounded retries with backoff, no retry on 4xx, a per-source circuit breaker, nightly reconciliation, and per-feed cursors (Venues has two feeds, so a source-only key collides).

Quarantine. A malformed record is set aside and the batch continues. A batch-level ownership violation still rejects the whole envelope.

Metrics never carry a company or subject label — cardinality, and it would publish which companies exist to anything able to scrape the port.

Full procedures: Backend-Kisum-Recommendations/RUNBOOK.md.

Two planned controls cannot be built on the current deployment. They are recorded here so they are not mistaken for oversights:

  • Least-privilege Neo4j users. The instance is Community Edition, which has a single user and no role-based access control (SHOW ROLES returns 51N27, not supported). Separate projection-write and recommendation-read users need Neo4j Enterprise or Aura Professional — a licensing decision. The exact role and user statements are written and waiting in the runbook.
  • Graph generations. Per-database generations also need Enterprise; the per-node alternative adds a filter to all 28 recipes. Idempotent upserts, tombstones and the documented rebuild procedure already cover the failure mode.

7bb. Environment wiring — the silent-failure trap

Section titled “7bb. Environment wiring — the silent-failure trap”

Ports, all bound to 127.0.0.1 and none on the public load balancer:

ServicePort
Recommendation Service3811
Neo4j bolt7687
Neo4j browser7474

Each calling backend needs three variables in its .env:

VariableLocalBackend running in Docker
RECOMMENDATIONS_INTERNAL_BASE_URLhttp://localhost:3811http://recommendations:3811
RECOMMENDATIONS_INTERNAL_API_KEYa copy of the service’s one master key. Sent as X-Internal-API-Keysame
RECOMMENDATIONS_ENABLEDtrue — only the literal false disables itsame

Promoters and Finance accept RECOMMENDATIONS_TIMEOUT_MS (default 30000). Artists and Venues use a hard-coded 30s client timeout. Projection writes and ranking under load routinely exceed the old 1.5s default; callers fail open, so a too-short timeout looks like an empty recommendations panel.

Container-to-container uses the service name, same convention as every other internal URL in .env.dockerauth:3801, core:3802, musicdata:3808, recommendations:3811.

Starting it. docker-compose.backends.yml declares neo4j and recommendations as ordinary service blocks, the same shape as every other module: build.context + dockerfile, image, container_name: kisum-neo4j-v2 / kisum-recommendations-backend-v2, env_file (.env then .env.docker), networks: [kisum-backends-v2] (Docker network name kisum-backends-v2-network), ports, depends_on and a healthcheck.

Host app-log bind mounts use KISUM_LOG_ROOT (default ./.docker-logs on Mac; /home/api/logs on prod via ./run-Docker_Composer.sh). See docker/backends-unified.README.md.

modules/Backend-Kisum-Recommendations/docker-compose.yml remains the v1 per-module stack for graph work. Do not run both — they publish the same host ports.

Neo4j Browser publishes 7474 (HTTP UI) and 7687 (Bolt) on the host in the unified stack. Open http://<server>:7474, then connect with neo4j://<server>:7687 (not 127.0.0.1 — that is your laptop). Recommendations still uses Docker DNS neo4j://neo4j:7687. Prefer a Contabo firewall allowlist on both ports. The per-module stack still publishes 127.0.0.1:7474 / 7687 for local graph debugging.

The module backends do not depends_on recommendations. Every client fails safe, so a graph outage must never delay or block a module backend from starting. recommendations does depends_on neo4j with condition: service_healthy.

Every client fails safe on an empty base URL or token: the call is skipped, the panel returns empty, and nothing is logged. That is correct behaviour — a graph outage must never break a module workflow — but it also means a missing variable looks exactly like “no results”. Until 2026-08-03 none of the four .env files had any of these variables, so a fully built feature never ran. A wrong token is a 401 the module swallows the same way.

If a recommendation panel is empty, check these variables before Neo4j.

Finance additionally has FINANCE_RECOMMENDATION_PROJECTION_ENABLED (default false, pending the data-protection review) and FINANCE_RECOMMENDATION_FINGERPRINT_SECRET.

Catalog PULL feeds — one plain line per value

Section titled “Catalog PULL feeds — one plain line per value”

Going the OTHER way: here the Recommendation Service is the caller, so it presents each destination’s own master key — the same keys Promoters and Finance already hold. Nothing new is issued.

VariableValue
ARTISTS_PROJECTION_FEED_URLhttp://artists:3804/internal/catalog/recommendation-projection
ARTISTS_INTERNAL_API_KEYthe Artists master key
VENUES_PROJECTION_FEED_URLhttp://venues:3807/internal/catalog/recommendation-projection
VENUE_INTERNAL_API_KEYthe Venues master key

A feed runs only when BOTH its URL and its key are set. Half-configured throws at startup rather than calling the catalog unauthenticated on every sync tick and logging a 401 forever.

These replaced a single PROJECTION_FEEDS_JSON blob (removed 2026-08-04). Only two sources will ever exist, so JSON bought nothing and cost real readability: a missing comma broke boot, and the keys were buried where nobody could see or rotate them.

PULL is public catalogs only. Promoters and Finance push their tenant-private projections instead — handing this service a key that could continuously drain another module’s private data is exactly the blast radius the owner-push model exists to avoid.

Symptom of an unset feed: artists and venues appear on cards as raw source ids instead of names, because their Candidate nodes were never projected. The service logs feeds: 0 at boot.

Sync resilience (defaults): PROJECTION_FEED_TIMEOUT_MS=120000 in Docker (.env.docker) / env files (code default 30s; max 120s). A mid-run timeout after pages were ingested keeps the cursor and does not open the circuit — the next tick resumes. Timeouts are not retried (retrying hammered Artists). The circuit only opens when a run ingests zero pages repeatedly (source truly down).

Same-machine Docker: feed URLs must be http://artists:3804/... and http://venues:3807/... via .env.docker. Do not use https://api.kisum.io/... from a container on the same host — hairpinning through Cloudflare was 20–36s/page. Artists catalog pages skip COUNT(*) and sort by id so deep OFFSET stays fast.

modules/Backend-Kisum-AI/mcp-kisum-graph exposes four read-only tools: graph_recommendations, graph_relationship_path, graph_similar_entities, graph_risk_context. No free-form input exists — module is an enum, ids are regex-bounded, recipe kinds are a closed list, limit caps at 25.

It reads through the module backends, not the Recommendation Service. The service takes an internal token plus a permission list it trusts, so a direct hop would require the AI tool server to decide the user’s permissions itself. Calling the same recommendation route the browser calls means the existing permission stack is the gate, and the MCP holds no credential of its own.

It is a MODULE server, not a knowledge server. Knowledge servers receive no identity; graph tools there would read relationship data with no company scope.

Router binding. A dedicated graph group with an exact ^graph_ rule — without it the tools fall into the research catch-all, which is bound on simple lookups. The group is added additively and never as a topic group, because a topic group replaces the category defaults and would unbind the module tools needed to confirm a suggestion. Excluded from the lookup tier.

Off by default. AI_GRAPH_TOOLS_ENABLED=false unless deliberately enabled; unsetting it restores the agent exactly as it was.

Measured: lookup-style questions bind identically before and after; relationship questions add 4 schemas; peak 32 of the 128 provider cap.

  • Venue-private and Finance projection feeds.
  • Bulk hydration of current owner data before results are shown, and dropping stale candidates.
  • Finance reviewer actions and audit history; the security/privacy approval that gates the Finance projection.
  • Shortlist, Compare and Start-booking hand-offs from a recommendation card.
  • AI Graph MCP: bulk headless question suite, prompt-injection and cross-company tests, and end-to-end call-reduction proof — all need the live agent stack.

Master tracker: TODO_NEO4J.md at the workspace root.