Neo4j Recommendations & Risk Graph
Related: Data ownership model · Service authentication · Access matrix
Status
Section titled “Status”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.
1. What the graph is for
Section titled “1. What the graph is for”Neo4j adds two things the module databases are bad at:
- Explainable recommendations — who fits this event, this artist, this venue, and why.
- 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.
2. The non-negotiable rules
Section titled “2. The non-negotiable rules”- 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-Recommendationsis 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.
3. Shape
Section titled “3. Shape”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.
Promoters (tenant-private)
Section titled “Promoters (tenant-private)”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 promoterEventsMATCH (a:Candidate:Artist) WHERE a.sourceId STARTS WITH 'artists:' RETURN count(a) AS artistsMATCH ()-[r:BOOKED]->() RETURN count(r) AS bookedIf 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.
The company-interest anchor
Section titled “The company-interest anchor”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
feedbackToProjectionRecords — no data migration is required.
MATCH (c:Candidate) WHERE c.sourceId STARTS WITH 'promoters:company-interest'RETURN c.sourceId, c.ownerOrgIdMATCH ()-[r:RELATED_TO]->() WHERE r.relation = 'recommendation_feedback'RETURN count(r)Artists (public)
Section titled “Artists (public)”GET /internal/catalog/recommendation-projection — artists, agencies, genres, countries.
GET /internal/catalog/recommendation-projection/listings — published 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.
Venues (public)
Section titled “Venues (public)”GET /internal/catalog/recommendation-projection — active venues with location, type, capacity
band and marketplace publish/feature state.
?entity=spaces — published 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.
Finance (tenant-private, approval-gated)
Section titled “Finance (tenant-private, approval-gated)”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.
5. Recipes
Section titled “5. Recipes”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:
| Awaiting | Needs |
|---|---|
| Promoters timing, risks, next actions | a Promoters timing / risk / next-action feed |
| Artists promoter-fit, collaborators, follow-ups | the private Artists relationship feed |
| Artists booking opportunities, tour routes | a promoter-demand feed and projected tour stops |
| Venues event-fit, promoter-fit, repeat customers, operational risks | the 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.
6. Module surfaces today
Section titled “6. Module surfaces today”| Module | Surface | State |
|---|---|---|
| Promoters | Event → Booking tab only, below the lineup | Live |
| Promoters | Artists → Discover (GET /recommendations/discovery/artists) | Live |
| Artists | Artist detail → Profile tab (promoter fit, venue fit, booking opportunities, tour routes) | Live |
| Venues | Venue dashboard (event fit, promoter fit, open dates, repeat customers) | Live |
| Finance | Contextual vendor risk card + /risk-review inbox | Live |
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 element | Node property | Fallback |
|---|---|---|
| Title | coalesce(name, title, sourceId) | the raw source id |
| Subtitle | subtitle | hidden |
| Link | href | no 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.
6a. Recommendation feedback
Section titled “6a. Recommendation feedback”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.
| Module | Table | Scope |
|---|---|---|
| Promoters | recommendation_feedback | company + user |
| Artists | artist_recommendation_feedback | company + user |
| Venues | venue_recommendation_feedback | company + 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_idis 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 inartistId()/pickArtistsDirectoryId. Verified 2026-08-06: all 37 live lineup entries are numeric, soscripts/migrate-lineup-artist-ids.jsreports “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.
The call
Section titled “The call”GET /recommendations/discovery/artists?capacity=800&genres=Rock,Metal&local=true&limit=5| Query | Meaning |
|---|---|
capacity | Required. Sizes the listener band. |
genres | Comma-separated veto, not a ranking term. |
local | true restricts to the company’s sub-region. Off by default. |
rooms | false skips the setlist.fm enrichment (one search per card). |
limit | 1–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.
The three gates
Section titled “The three gates”- Market — opt-in.
marketsis 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 actsamerican, neverunited states. - Genre — a veto. Kisum genre names are mapped to Last.fm tags in
src/discovery/genres.ts(Hip Hop→hip-hop,rap,trap), matched withCONTAINSso “rock” returns alternative rock. - 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.
Where the enrichment happens, and why
Section titled “Where the enrichment happens, and why”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.
| Concern | Module | File |
|---|---|---|
| Seeds, exclusions, home country | Promoters | recommendations/discovery-seeds.query.js |
| Sub-region markets | Promoters | recommendations/subregion-markets.js |
| Name → numeric id | Promoters | recommendations/artist-name-bridge.js |
| Observed room sizes | Promoters | recommendations/artist-rooms.js |
SIMILAR_TO edges | Promoters | recommendations/similarity-projection.js |
| Gates, band, scoring | Recommendations | src/discovery/engine.ts |
Rules that are not negotiable
Section titled “Rules that are not negotiable”- 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_SIMILARITYin Promoters so a later default change cannot loosen it silently. - A candidate that fails a check is dropped, never defaulted. The
publicScore = 0.5constant 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.
The Discover screen (Promoters)
Section titled “The Discover screen (Promoters)”/artists/discover — src/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.
| Element | Source |
|---|---|
| Room size chips (Club 300 → Arena 10,000) | capacity query param |
| Genre pills | genres veto |
| ”Local acts only” | local |
| ”Seeded by” pills | basedOn.seedNames |
| ”Came up from” on the card | card.seedNames |
| Fit badge + bar | card.capacityRange first, diagnostics.band otherwise |
| Dashed note at the bottom | union 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.
SIMILAR_TO: the artist network
Section titled “SIMILAR_TO: the artist network”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)Promoters interest ladder (auto + panel)
Section titled “Promoters interest ladder (auto + panel)”Higher privateScore = stronger “want similar options”. BOOKED lineup history uses
the same bucket as confirmed (~0.55), not higher than request/shortlist/offer.
| Rank | Action | Weight | How it fires |
|---|---|---|---|
| 1 | requested | 0.95 | Auto on booking-request create |
| 2 | shortlisted | 0.85 | Auto on shortlist add |
| 3 | offered | 0.75 | Auto on offer create (marketplace / booking-offer / offline) |
| 4 | confirmed (+ BOOKED) | 0.55 | Auto on event confirm; BOOKED projection same level |
| 5 | saved | 0.40 | Recommendations panel |
| 6 | viewed | 0.10 | Auto when artist suggestions are returned |
| — | dismissed | −0.60 | Recommendations 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.
6b. Finance risk inbox
Section titled “6b. Finance risk inbox”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.
7. Verified behavior
Section titled “7. Verified behavior”Verified 2026-08-02 against Docker Neo4j 2026.06.0:
- valid config starts and reports
/healthok and/readyready; 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.
7a. Operations
Section titled “7a. Operations”| Endpoint | Auth | Purpose |
|---|---|---|
GET /health | none | process alive |
GET /ready | none | Neo4j reachable (503 when not) |
GET /internal/freshness | internal | per-feed cursor, last success, circuit state, quarantine |
GET /metrics | internal | Prometheus 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.
7b. Blocked, not missing
Section titled “7b. Blocked, not missing”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 ROLESreturns 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:
| Service | Port |
|---|---|
| Recommendation Service | 3811 |
| Neo4j bolt | 7687 |
| Neo4j browser | 7474 |
Each calling backend needs three variables in its .env:
| Variable | Local | Backend running in Docker |
|---|---|---|
RECOMMENDATIONS_INTERNAL_BASE_URL | http://localhost:3811 | http://recommendations:3811 |
RECOMMENDATIONS_INTERNAL_API_KEY | a copy of the service’s one master key. Sent as X-Internal-API-Key | same |
RECOMMENDATIONS_ENABLED | true — only the literal false disables it | same |
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.docker — auth: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.
| Variable | Value |
|---|---|
ARTISTS_PROJECTION_FEED_URL | http://artists:3804/internal/catalog/recommendation-projection |
ARTISTS_INTERNAL_API_KEY | the Artists master key |
VENUES_PROJECTION_FEED_URL | http://venues:3807/internal/catalog/recommendation-projection |
VENUE_INTERNAL_API_KEY | the 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.
7c. AI Graph MCP
Section titled “7c. AI Graph MCP”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.
8. Known open work
Section titled “8. Known open work”- 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.