07 — Knowledge-Graph Schema & Operations: knoll-advisory

Purpose. This document is the operational spec for the Knoll Analyzer knowledge graph on GRAG's kg-service: the complete knoll-advisory v1 schema (registerable as-is), the v2 relationship preview, registration/activation procedures, seed entity dictionaries (11 success levers, service-provider catalog), the deterministic fact-writing contract, the reconcile job, deferred Cypher intents, and the query patterns available today. It implements plan tasks 3.4–3.8 and the KG parts of 0.7 (D7), 1.11, 5.4 and 7.6. Ground rule from the plan: Knoll's own Postgres is the source of truth for all relationship facts (see 06-knoll-db-schema.md); the KG is a projection for graph/semantic consumption, kept idempotently reconcilable.

Status / verified against: 2026-07-06, repo document-processing-pipelines @ db63a95. Payload shapes verified against pipeline-common/src/pipeline_common/schemas/__init__.py (SchemaDefinition), kg-service/src/kg_service/api/schemas/{entities,intents,documents,ingest,search}.py, kg-service/src/kg_service/api/routes/schemas.py, kg-service/src/kg_service/workers/extraction.py (slug rule), kg-service/src/kg_service/services/graph/entities.py (AGE MERGE semantics), and kg-service/seed_schemas/legal-ilgs.v2.json. Domain facts from next-monorepo/apps/web/lib/types.ts and lib/mock-data/service-providers.ts.

Sibling docs: 01-architecture.md (why KG is a projection), 02-grag-api-cookbook.md (auth/headers/error taxonomy), 03-id-conventions.md (all id patterns), 04-provisioning-runbook.md (when activation runs), 08-gutachten-pipeline-spec.md (who triggers fact writes), 11-resilience-and-errors.md, 12-gdpr-compliance.md, 13-platform-gaps-issues.md (gap #4 = the 1.11 write route).


0. Conventions used in every example

All kg-service calls go through Traefik at $GRAG_URL (= https://app.grag.ai), prefix /kg-service, internal API under /api/v1. kg-service mounts WorkspaceMiddleware(required=True): every non-public route requires X-Workspace-ID (HTTP 400 without it), even for tenant-scoped resources like schemas and entities. Use the client workspace for analysis-scoped writes (the audit stream is keyed t:{tenant}:w:{workspace}:...) and general for tenant-wide administration (schema registration, dictionary seeds).

# Shared header block — every request in this doc uses it
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT" \          # "knoll" in prod, "knoll-dev" in dev
-H "X-Workspace-ID: general" \            # or client-<slug>-<shortid>
-H "Content-Type: application/json"

Error taxonomy (see 02-grag-api-cookbook.md): 401 missing bearer, 403 bad key, 400 missing workspace header, 404 tenant-miss parity (a foreign tenant's entity looks nonexistent, never 403), 409 duplicate schema/intent version, 422 validation, 503 subsystem dark (e.g. entity-match while KG_EMBEDDINGS_ENABLED=false).


1. knoll-advisory schema v1 (draft, registerable)

The full SchemaDefinition JSON. It validates against pipeline_common.schemas.SchemaDefinition including validate_internal_consistency(): every entity_types key appears in ner_labels, every ner_thresholds.per_label key is a valid label, and the (empty) relationships list trivially passes the endpoint check.

Why v1 has NO relationships block — this is deliberate, not an omission. The prod baseline (.env.production.example) ships KG_RELATION_EXTRACTION_ENABLED=true (plus entity extraction and embeddings true). Relation auto-extraction fires exactly when: flag on ✚ entity extraction on ✚ the workspace's ACTIVE schema declares relationships ✚ the envelope has no producer-supplied entities (kg-service/CLAUDE.md, "Consumer-side relation extraction"). Today it stays dormant only because no workspace activates a relationship-declaring schema. Shipping v1 without relationships is the per-tenant lever that keeps un-harnessed German relation extraction off our workspaces (plan 0.7/D7, 1.4, 3.4 — review finding: the quality bar was passed on the legal fixture, not German advisory text). Deterministic edge writes (§5) and Cypher intents do not require schema-declared relationships — the declaration gates auto-extraction only.

{
  "schema_version": "1.0.0",
  "name": "knoll-advisory",
  "version": 1,
  "description": "Knoll Analyzer advisory ontology: clients, the 11 success levers of the Knoll methodology, recommendations from expert reports, service-provider catalog, implementation projects, and metrics. v1 WITHOUT relationships block (see plan 3.4/D7); edges come deterministically from the Knoll backend.",
  "based_on": "Knoll methodology (Methodenhandbuch: 11 success levers, checklist C.I.3, rating matrix D.III.2)",
  "license_attribution": "Firm Knoll — proprietary advisory know-how",
  "ner_labels": [
    "Client",
    "SuccessLever",
    "Recommendation",
    "ServiceProvider",
    "Project",
    "Metric"
  ],
  "ner_thresholds": {
    "default": 0.5,
    "per_label": {
      "Client": 0.6,
      "SuccessLever": 0.55,
      "Recommendation": 0.7,
      "ServiceProvider": 0.6,
      "Project": 0.7,
      "Metric": 0.65
    }
  },
  "entity_types": {
    "Client": {
      "description": "A mid-sized company advised by the firm under an Analyzer engagement: company name, legal form, industry, location, HGB size class.",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": true, "min_similarity": 0.7 }
    },
    "SuccessLever": {
      "description": "One of the 11 success levers of the Knoll methodology (MarketPosition, Strategy, Brand, Competition, ProductsAndServices, Pricing, Customers, Sales, PlanningAndControlling, MarketingImplementation, Personnel) — the fixed scoring dimension of every expert report.",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": true, "min_similarity": 0.8 }
    },
    "Recommendation": {
      "description": "A concrete recommendation from an approved expert report, assigned to a success lever, with SWOT category, priority (1-3), and time horizon.",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": false, "min_similarity": 0.85 }
    },
    "ServiceProvider": {
      "description": "A vetted external service provider from the firm catalog (Anlage D.III.1) with a category (e.g. Online Marketing, print shop, IT-Consulting), location, and rating per rating matrix D.III.2.",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": true, "min_similarity": 0.7 }
    },
    "Project": {
      "description": "An implementation project from the Project Generator that operationalizes one or more recommendations of an expert report (project brief, calculation, status).",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": false, "min_similarity": 0.85 }
    },
    "Metric": {
      "description": "A business metric of a client (e.g. annual revenue, headcount, balance sheet total) with value, unit, and reference year from the questionnaire or annual report.",
      "aliases_field": "aliases",
      "embedding_template": "{label}: {description}",
      "candidate_search": { "fuzzy": false, "min_similarity": 0.85 }
    }
  },
  "relationships": [],
  "templates": []
}

Design notes:

ChoiceRationale
English description textsThey feed the GLiNER NER prompt labels (consumer-side extraction calls enrichment ner_gliner with config.gliner_labels = schema.ner_labels) and the L3 cross-encoder embedding_template. TODO-VERIFY: align the six description texts with the exact Methodenhandbuch wording in Knoll-Analyzer-Docs/ before production registration — the drafts above are faithful to the prototype but not literal quotes.
High thresholds for Recommendation/Project/Metric (0.65–0.7)All entity_types keys MUST appear in ner_labels (validate_internal_consistency), so all six are extraction targets whenever KG_ENTITY_EXTRACTION_ENABLED=true (prod example: true). Abstract types extracted from uploaded documents are noise-prone; high thresholds keep auto-extraction conservative. Extracted entities carry properties.detector="ner_gliner" and slug-derived ids — they never collide with deterministic Knoll-DB-keyed fact entities (§5).
embedding_template left at the default "{label}: {description}"The embeddings backfill runs tenant-wide without a workspace binding and always uses the default template (kg-service/CLAUDE.md, pgvector section) — a custom template would only apply inconsistently.
candidate_search per typeConsumed by the entity-linking CandidateStore path and mirrored by POST /entities/search defaults. Fuzzy on for name-like types (Client, ServiceProvider, SuccessLever), off for id-like types.
Thresholds are starting valuesRecalibrate together with the German quality harness (plan 7.1) before trusting extraction output in any user-facing surface.

2. v2 preview — the five relationships (gated on plan 7.1)

v2 = v1 plus the relationships block below. Register it only after the German-domain quality harness passes (plan 7.1; the platform's own bar is precision ≥ 0.6 / recall ≥ 0.4 on a labelled fixture, docs/runbooks/rag-quality-profile.md), and after re-checking the live value of KG_RELATION_EXTRACTION_ENABLED (plan 0.9/1.4) — the moment a relationship-declaring schema is ACTIVE on a workspace with that flag on, relation auto-extraction fires on every uploaded document in that workspace.

Property types must be one of string | int | float | bool | date | datetime (RelationshipPropertyDef).

EdgeFrom → ToMeaningProperties
CONCERNSRecommendation → ClientRecommendation applies to this clientanalysis_id: string
ADDRESSESRecommendation → SuccessLeverRecommendation addresses this lever
RECOMMENDSRecommendation → ServiceProviderService provider proposed for implementationanalysis_id: string
IMPLEMENTSProject → RecommendationProject operationalizes the recommendation
SCORESClient → SuccessLeverLever score of the client from the approved expert reportscore: float (1.0–5.0), traffic_light: string (Red/Yellow/Green), analysis_id: string

SCORES is anchored Client → SuccessLever (design decision: the expert report/analysis is not an entity type; provenance rides in analysis_id). ⚠ Durability nuance: AGE MERGE (a)-[r:REL]->(b) SET r += props keeps one edge per (from, relation, to) triple (services/graph/entities.py:258–263) — a second analysis for the same client overwrites the first SCORES edge's properties. The graph holds the latest state only; score history lives in Knoll's expert_reports table (06-knoll-db-schema.md). Same applies to RECOMMENDS/CONCERNS across repeat analyses.

"relationships": [
  { "name": "CONCERNS", "from_entity_type": "Recommendation", "to_entity_type": "Client",
    "description": "The recommendation applies to this client.",
    "properties": [ { "name": "analysis_id", "type": "string", "required": false } ] },
  { "name": "ADDRESSES", "from_entity_type": "Recommendation", "to_entity_type": "SuccessLever",
    "description": "The recommendation addresses this success lever.", "properties": [] },
  { "name": "RECOMMENDS", "from_entity_type": "Recommendation", "to_entity_type": "ServiceProvider",
    "description": "Service provider proposed for implementing the recommendation.",
    "properties": [ { "name": "analysis_id", "type": "string", "required": false } ] },
  { "name": "IMPLEMENTS", "from_entity_type": "Project", "to_entity_type": "Recommendation",
    "description": "The project implements the recommendation.", "properties": [] },
  { "name": "SCORES", "from_entity_type": "Client", "to_entity_type": "SuccessLever",
    "description": "Lever score (1.0-5.0) of the client from the approved expert report.",
    "properties": [
      { "name": "score", "type": "float", "required": true },
      { "name": "traffic_light", "type": "string", "required": true },
      { "name": "analysis_id", "type": "string", "required": true }
    ] }
]

Registered as {"name": "knoll-advisory", "version": 2, ...} — versions are immutable; change = new version + re-activate per workspace.

Deterministic edge writes do NOT wait for v2. The worker's Stage-3 edge path (EntityService.upsert_relationship) and the future 1.11 HTTP route take free-form relation_type regardless of what the active schema declares — the schema relationships block gates auto-extraction only (plan review finding [17]). So §5 can ship on v1.


3. Registration & per-workspace activation

Schemas are tenant-scoped; registration is open to any valid tenant API key (no admin gate — governance note in 04-provisioning-runbook.md: we register via the provisioning module with the Knoll runtime key, plan 1.1/D5).

3.1 Register (plan 3.4)

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/schemas" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  --data @knoll-advisory.v1.json

Responses: 201 + Location: /api/v1/schemas/knoll-advisory/v1 + body {"name":"knoll-advisory","version":1,"fully_qualified":"knoll-advisory@v1"}; 422 on internal inconsistency (entity type missing from ner_labels, unknown relationship endpoint); 409 if knoll-advisory v1 already exists for this tenant (treat as already-registered — same idempotency convention as provisioning, plan 3.1; no Idempotency-Key header exists anywhere on the platform).

Inspect: GET /kg-service/api/v1/schemas, GET .../schemas/knoll-advisory, GET .../schemas/knoll-advisory/v1.

3.2 Activate per workspace (provisioning hook, plan 3.1 + 3.4)

The active schema is pinned per (tenant, workspace) in kg_active_schemas; kg-service refreshes its cache on every /activate (no restart). Workspace existence is not validated — a typo silently activates for a nonexistent workspace, so the provisioning module must activate only after the workspaces-service create succeeded.

# Called by the client-provisioning module for every new workspace
curl -sS -X POST "$GRAG_URL/kg-service/api/v1/schemas/knoll-advisory/v1/activate" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  -d '{"workspace": "client-hartmann-a1b2"}'
# → {"tenant_scoped": true, "workspace": "client-hartmann-a1b2",
#    "schema_name": "knoll-advisory", "schema_version": 1}

Also activate for general (so kb-methodology document uploads get entity extraction against our labels). Verify per workspace:

curl -sS "$GRAG_URL/kg-service/api/v1/workspaces/client-hartmann-a1b2/schema" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2"

Add both calls to the 05-verification-runbook.md checklist. Consequence of activation with prod-example flags (KG_ENTITY_EXTRACTION_ENABLED=true): every document ingested into a client workspace gets GLiNER entity extraction with our six English labels. TODO-VERIFY (plan 0.9): live values of KG_ENTITY_EXTRACTION_ENABLED, KG_EMBEDDINGS_ENABLED, KG_RELATION_EXTRACTION_ENABLED on app.grag.ai — compose defaults (all false) ≠ .env.production.example (all true) ≠ live.


4. Seed entity dictionaries (plan 3.5)

4.1 Entity-id rule (shared with auto-extraction)

Consumer-side extraction derives ids as {entity_type_lower}:{slug} where slug = NFKC-normalise → lowercase → whitespace→- → keep only alphanumerics and - → strip outer - → cap 120 chars (kg-service/src/kg_service/workers/extraction.py::slugify). Umlauts survive (münchen stays münchen). Seed ids follow the same rule so a document mention of "Sales" dedupes onto the seeded lever:sales instead of minting a duplicate. Note the quirk: & is stripped after whitespace joining, so "Products & Services"products--services (double hyphen — intentional, matches extraction exactly).

4.2 The 11 success levers — full upsert payload

Closed taxonomy from apps/web/lib/types.ts SUCCESS_LEVERS (fixed order). One idempotent call (POST /entities/upsert takes 1–1000 items; response {"inserted": n, "updated": m}). Run once at tenant bootstrap and again from every reconcile (§6) — upserts are idempotent (relational ON-CONFLICT + AGE MERGE).

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/entities/upsert" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  --data @- <<'JSON'
{
  "entities": [
    { "entity_id": "lever:market-position", "entity_type": "SuccessLever",
      "label": "MarketPosition",
      "aliases": ["Market position", "Position in the market"],
      "description": "Success lever 1: the company's position in the relevant market — market share, differentiation, perception by customers and competitors.",
      "properties": { "order": 1, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:strategy", "entity_type": "SuccessLever",
      "label": "Strategy",
      "aliases": ["Corporate strategy", "Strategic direction"],
      "description": "Success lever 2: existence, quality, and implementation of a documented corporate and growth strategy.",
      "properties": { "order": 2, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:brand", "entity_type": "SuccessLever",
      "label": "Brand",
      "aliases": ["Brand management", "Branding"],
      "description": "Success lever 3: brand building and brand management — awareness, brand core, consistent appearance.",
      "properties": { "order": 3, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:competition", "entity_type": "SuccessLever",
      "label": "Competition",
      "aliases": ["Competitive analysis", "Competitor analysis"],
      "description": "Success lever 4: knowledge and systematic observation of the competition and differentiation from competitors.",
      "properties": { "order": 4, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:products--services", "entity_type": "SuccessLever",
      "label": "ProductsAndServices",
      "aliases": ["Products and services", "Service portfolio", "Assortment"],
      "description": "Success lever 5: quality, structure, and future viability of the product and service portfolio.",
      "properties": { "order": 5, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:pricing", "entity_type": "SuccessLever",
      "label": "Pricing",
      "aliases": ["Pricing strategy", "Price policy"],
      "description": "Success lever 6: systematic pricing, price enforcement, and conditions policy.",
      "properties": { "order": 6, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:customers", "entity_type": "SuccessLever",
      "label": "Customers",
      "aliases": ["Customer structure", "Customer relationships"],
      "description": "Success lever 7: customer structure, customer retention, dependencies, and new customer acquisition.",
      "properties": { "order": 7, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:sales", "entity_type": "SuccessLever",
      "label": "Sales",
      "aliases": ["Sales organization", "Sales"],
      "description": "Success lever 8: sales organization, sales channels, sales steering, and performance.",
      "properties": { "order": 8, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:planning--controlling", "entity_type": "SuccessLever",
      "label": "PlanningAndControlling",
      "aliases": ["Planning and controlling", "Corporate planning", "Controlling"],
      "description": "Success lever 9: corporate planning, budgeting, KPI systems, and controlling processes (incl. the existence of a business plan).",
      "properties": { "order": 9, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:marketing-implementation", "entity_type": "SuccessLever",
      "label": "MarketingImplementation",
      "aliases": ["Marketing implementation", "Operational marketing"],
      "description": "Success lever 10: operational implementation of marketing measures across all channels (online/offline).",
      "properties": { "order": 10, "status": "active", "source": "knoll-methodik" } },
    { "entity_id": "lever:personnel", "entity_type": "SuccessLever",
      "label": "Personnel",
      "aliases": ["Employees", "Human Resources", "HR"],
      "description": "Success lever 11: personnel acquisition, development, and retention as well as leadership structure.",
      "properties": { "order": 11, "status": "active", "source": "knoll-methodik" } }
  ]
}
JSON
# → {"inserted": 11, "updated": 0}   (re-run → {"inserted": 0, "updated": 11})

(Descriptions carry the same TODO-VERIFY as §1: align wording with the Methodenhandbuch before production registration. Ids and labels are final — labels are the literal SUCCESS_LEVERS strings from types.ts.)

4.3 Service-provider catalog — sync-job spec (Knoll DB master → KG projection)

Master data: Knoll service_providers + service_provider_ratings tables (plan 2.1; prototype shape in lib/mock-data/service-providers.ts — Anlage D.III.1, rating matrix D.III.2). The KG copy exists for semantic discovery (entity-match, plan 5.4) and graph context only; ranking stays deterministic in Knoll SQL.

RuleSpec
Entity idservice-provider:<slug> — slug derived from name via the §4.1 rule once, at first sync, then persisted in the Knoll DB row (service_providers.kg_entity_id) and never re-derived. A rename updates label and appends the old name to aliases; the id stays stable (it is the identity across kg_entities PK / spans / embeddings / AGE vertex key). Example: "Digital Wings KG" → service-provider:digital-wings-kg.
Label / aliaseslabel = current name; aliases = former names + common short forms.
Description"<category> in <city>" — feeds the embedding for entity-match, so keep it descriptive: e.g. "Full-service internet agency in Hamburg — web development, online marketing, SEO."
Properties{ "category": "...", "city": "...", "status": "active", "approval_status": "Approved" | "UnderReview" | "Blocked", "overall_grade": 4.6, "knoll_id": "d-01" }. GDPR (12-gdpr-compliance.md): company-level data only — never sync contact_person / email into the KG (tenant-wide visibility, no delete route).
Trigger(a) Server-action hook on every service-provider create/update/delete in the Knoll backend; (b) nightly full-catalog reconcile (same scheduler as §6). Both are the same idempotent upsert.
Tombstoningkg-service has NO entity delete route (verified: api/routes/entities.py exposes upsert / get / neighborhood / documents / search only). Deleting or deactivating a service provider in Knoll DB → upsert with properties.status = "inactive". Every consumer filters: 5.4 candidate assembly drops matches with status != "active" or approval_status != "Approved" after the entity-match call (the route has no property filter).
Failure handlingSync failures land in grag_refs.sync_status/last_error (plan 2.1) and are retried by the nightly reconcile — never block the Knoll-side write.

Example (one item; the sync job batches up to 1000 per call):

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/entities/upsert" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  -d '{
    "entities": [
      { "entity_id": "service-provider:digital-wings-kg",
        "entity_type": "ServiceProvider",
        "label": "Digital Wings KG",
        "aliases": ["Digital Wings"],
        "description": "Online marketing agency in Vienna (AT) — performance campaigns, SEA, social ads.",
        "properties": { "category": "Online Marketing", "city": "Vienna (AT)",
                        "status": "active", "approval_status": "Approved",
                        "overall_grade": 4.6, "knoll_id": "d-01" } }
    ]
  }'

5. Deterministic fact writing (plan 3.6 — gated on platform gap 1.11)

5.1 What gets written, when

All triggers are Knoll-backend server actions; all payloads are computed from Knoll DB (source of truth), never from LLM output directly. Fact entities use Knoll-DB-keyed ids (opaque, stable — see 03-id-conventions.md), unlike the slug-keyed dictionaries: they must survive renames and never collide with extraction-derived slug ids.

TriggerEntities upsertedEdges written (v2 edge set, §2)
Client provisioning (plan 3.1)client:<slug>-<shortid> (id mirrors the workspace id client-<slug>-<shortid>, 03-id-conventions.md) — label = company name, properties: {industry, city, legal_form, hgb_size_class, status: "active", knoll_id}
Questionnaire submit (plan 4.5)Client entity refresh (properties from master-data chapter); Metric entities metric:<client-shortid>-<metric>-<year> (revenue, employees, balance_sheet_total, …) with {value, unit, year, analysis_id, client_id, status: "active"}none in wave 1 — Metric→Client linkage rides in properties (keeps the lossy edge surface minimal; promote to a declared edge type only with a concrete graph consumer)
Expert-report approval (Partner-only, plan 5.3/2.3)recommendation:<analysis-shortid>-<n> per approved recommendation (n = 1-based position in the approved expert-report version, 03-id-conventions.md §3) — label = title, properties: {swot, priority, time_horizon, analysis_id, status: "active"}SCORES Client→SuccessLever {score, traffic_light, analysis_id} (11 edges, one per lever); CONCERNS Recommendation→Client; ADDRESSES Recommendation→SuccessLever; RECOMMENDS Recommendation→ServiceProvider (where 5.4 matched one)
Project creation (plan 5.5)project:<project-shortid> (03-id-conventions.md §3) — label = project title, properties: {status, analysis_id, client_id}IMPLEMENTS Project→Recommendation
Questionnaire re-submit (plan 4.5)re-run the same upserts (idempotent) after the KB-doc supersedesame

GDPR guardrail: entity labels/properties carry company facts only (12-gdpr-compliance.md). No contact names, no free-text questionnaire answers as properties — free text lives in the analysis-KB documents, which have a delete path; KG entities have only tombstones.

Headers for all fact writes: X-Workspace-ID: client-<slug>-<shortid> (audit stream keying), tenant as usual.

5.2 Entities: possible today

Entity upserts use POST /kg-service/api/v1/entities/upsert exactly as §4 — available now.

5.3 Edges: BLOCKED on platform gap #4 (plan 1.11) — required route contract

Verified state @ db63a95: DocumentIngestRequest (api/schemas/documents.py:42–52, extra="forbid") has no entities[]/relationships[] fields; entity→entity edges ride only the internal Redis IngestEnvelope (api/schemas/ingest.py), whose worker-side AGE writes are best-effort (swallowed, counted on kg_service_age_mirror_failures_total). There is no authenticated HTTP path to write an edge today.

The 1.11 platform work must add one (tracked in 13-platform-gaps-issues.md). Required contract, reusing the shapes that already exist in pipeline-common/kg-service so no new models are invented:

POST /kg-service/api/v1/graph/facts            # route name TBD by the platform PR
Headers: Authorization: Bearer, X-Tenant-ID, X-Workspace-ID (required)

Request body:
{
  "entities":      [ <EntityUpsertItem>, ... ],        # same shape as /entities/upsert items
  "relationships": [ <IngestRelationshipItem>, ... ]   # {from_entity_id, from_entity_type,
                                                       #  to_entity_id, to_entity_type,
                                                       #  relation_type, properties{}}
}

Semantics (differs from the worker on purpose):
- Entities: relational upsert + AGE vertex MERGE, as today.
- Edges: AGE MERGE (a)-[r:TYPE]->(b) SET r += props — MUST FAIL LOUDLY:
  any AGE failure => non-2xx (or per-item "failed" status), NEVER the worker's
  silent swallow. The caller (Knoll ai_runs state machine) retries/records.
- Free-form relation_type: MUST NOT require the active schema to declare the
  relationship (declaration gates auto-extraction only).
- Response echoes per-edge results so the caller can verify:
  { "entities": {"inserted": n, "updated": m},
    "relationships": [ {"from": "...", "relation_type": "...", "to": "...",
                        "status": "merged" | "failed", "error": null} ] }
- Idempotent by construction (upsert + MERGE). Ledger emit + audit-stream entry
  like the sibling write routes.

Until this lands: entity projection runs (§5.2), edge projection is deferred, and every Phase-5 query need is served from Knoll SQL anyway (plan 3.7) — nothing user-facing blocks on it.


6. Reconcile job (plan 3.6 / 7.6)

Because edges are AGE-only and best-effort (see caveats box, §9), the projection must be cheaply rebuildable. The reconcile job is a Knoll-backend worker that re-derives the complete entity+edge set for a scope from Knoll DB and re-upserts it:

AspectSpec
ScopePer analysis (entities+edges of §5.1 for that analysis) + the two dictionaries (§4.2 full 11-item upsert, §4.3 full catalog).
Schedule(a) Immediately after every deterministic write batch (write → reconcile → compare counts = self-verification); (b) nightly for all active analyses; (c) on demand after an alert or platform restore/incident.
MechanismPOST /entities/upsert (idempotent) + the 1.11 facts route (AGE MERGE is idempotent; re-MERGE of an existing edge is a no-op plus SET r += props refresh). Re-running the whole job N times converges to the same graph.
Deletion semanticsReconcile never deletes (no delete route). Facts retracted in Knoll DB (e.g. Recommendation removed in an expert-report re-issue) → tombstone upsert properties.status="inactive"; stale edges to tombstoned entities are tolerated because all consumers filter on entity status.
AlertingGrafana alert on a sustained non-zero rate of kg_service_age_mirror_failures_total{tenant="knoll"} (labels: stage ∈ upsert_entity | upsert_relationship | neighborhood) — plan 7.6. Response runbook: run the reconcile job (restores edges + refreshes vertices); for a mass vertex loss the operator additionally runs the platform one-shot DPP_ROLE=backfill-age with KG_BACKFILL_TENANTS=knoll (vertices only — edges are NOT replayable platform-side).
BookkeepingEach run records counts + duration in Knoll audit_log; per-entity sync state in grag_refs.

7. Cypher intents — draft, deferred (plan 3.7)

Do not register these yet. Resequenced by the adversarial review: every Phase-5 query these would serve is a 1-hop lookup over facts whose source of truth is Knoll's own Postgres — answer them from Knoll SQL. Register intents only when the first real graph consumer lands (plan 3.8 knowledge-graph Explorer via graph-gateway, or KG-augmented chat). They also require the v2 edge set (§2/§5) to exist in AGE.

Non-negotiable design rule (review finding [2]/[19]): all parameters UNTYPED"entity_type": null. A param declared with an entity_type is force-routed through entity-linking POST /resolve (no canonical-id bypass; services/intents/executor.py:191–237), and entity-linking loads schemas from image-baked files onlyknoll-advisory is invisible to it, so typed params fail with 422/502 until platform gap 1.12 is solved (explicitly deferred). Untyped params pass through unchanged and bind positionally; Knoll always has the canonical ids (grag_refs/own tables).

Registration constraints (verified): Cypher templates containing CREATE|DELETE|SET|REMOVE|MERGE are rejected with 422 (read-only by construction); LIMIT is auto-appended from max_rows if absent; execution has a 5 s statement timeout and intent_caps {max_rows: 1000, max_depth: 5}; intents reference a registered schema_name/schema_version. Parameters bind by declared position (1-based); the Cypher body references them as $p1, $p2, … (the positional style used throughout the AGE client — services/graph/entities.py:182,259). TODO-VERIFY: round-trip one intent (register → execute) on knoll-dev to confirm the $pN placeholder token is what the intent executor's binding expects before registering the full set.

Draft definitions (register against the then-active schema version — v2):

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/intents" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  -d '{
    "schema_name": "knoll-advisory",
    "schema_version": 2,
    "name": "recommendations_for_client",
    "version": 1,
    "description": "All recommendations that concern a client.",
    "cypher": "MATCH (e:Recommendation)-[:CONCERNS]->(m:Client {entity_id: $p1}) RETURN e",
    "parameters": [
      { "name": "client_id", "position": 1, "entity_type": null, "required": true }
    ],
    "returns": { "columns": ["e"] },
    "max_rows": 100,
    "max_depth": 3
  }'

Remaining drafts (same envelope, cypher + parameters differ):

IntentCypher (read-only)Params (all untyped)
service_providers_for_leverMATCH (e:Recommendation)-[:ADDRESSES]->(h:SuccessLever {entity_id: $p1}) MATCH (e)-[:RECOMMENDS]->(d:ServiceProvider) RETURN DISTINCT dlever_id @1
clients_with_weak_leverMATCH (m:Client)-[r:SCORES]->(h:SuccessLever {entity_id: $p1}) WHERE r.score <= $p2 RETURN m, rlever_id @1, max_score @2 (float)
projects_for_recommendationMATCH (p:Project)-[:IMPLEMENTS]->(e:Recommendation {entity_id: $p1}) RETURN precommendation_id @1

Execution (canonical ids from Knoll DB — never free text):

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/intents/recommendations_for_client/execute" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2" \
  -H "Content-Type: application/json" \
  -d '{"params": {"client_id": "client:hartmann-a1b2"}}'
# → {"intent": "recommendations_for_client", "resolved_params": {...},
#    "rows": [...], "row_count": n, "max_rows_applied": 100}

Consumers must filter tombstones (properties.status == "inactive") client-side — intents cannot be written to exclude them without baking the filter into each Cypher body (do that: add WHERE coalesce(e.status,'active') <> 'inactive' when registering for real. TODO-VERIFY: whether vertex properties written via SET r += $p3 / vertex MERGE expose status at Cypher level with that exact accessor — confirm on the dev round-trip).


8. Query patterns available TODAY (no 1.11, no intents)

All four are live routes; the only flag dependency is noted inline.

8.1 Dictionary lookup — POST /entities/search

Label+alias hybrid search, used by 5.4 as the non-semantic candidate fallback and by admin tooling:

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/entities/search" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general" \
  -H "Content-Type: application/json" \
  -d '{"entity_type": "ServiceProvider", "query": "druck",
       "max_candidates": 20, "fuzzy": true, "min_similarity": 0.6}'
# → {"candidates": [{"entity_id": "service-provider:samson-druck", "label": "Samson Druck", ...}]}

8.2 Entity detail, neighborhood, provenance

# One entity (404 = unknown OR foreign tenant — parity by design)
curl -sS "$GRAG_URL/kg-service/api/v1/entities/lever:sales" \
  -H "Authorization: Bearer $GRAG_API_KEY" -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: general"

# AGE traversal, depth 1..5 (edges appear only after §5.3/1.11 lands)
curl -sS "$GRAG_URL/kg-service/api/v1/entities/client:hartmann-a1b2/neighborhood?depth=2" \
  -H "Authorization: Bearer $GRAG_API_KEY" -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2"

# Reverse index: which documents mention this entity (≤200 docs, ≤50 ordinals each, truncated flag)
curl -sS "$GRAG_URL/kg-service/api/v1/entities/lever:sales/documents" \
  -H "Authorization: Bearer $GRAG_API_KEY" -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2"

/documents is the cheap "evidence locations" feature: after entity extraction runs on uploaded client documents, it answers "in which documents does lever X appear" with (document_id, segment_ordinals) — resolvable to full text via 8.4.

8.3 Semantic entity match — POST /search/entity-match (plan 5.4)

pgvector cosine over entity embeddings (openai/text-embedding-3-small, 1536 dims). 503 unless KG_EMBEDDINGS_ENABLED=true (prod example: true; verify live per plan 0.9). This is the discovery half of service-provider matching; ranking stays deterministic in Knoll SQL, and consumers filter tombstones + approval_status:

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/search/entity-match" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2" \
  -H "Content-Type: application/json" \
  -d '{"query": "Agency for search engine advertising and online campaigns",
       "entity_type": "ServiceProvider", "top_k": 10, "include_neighbors": false}'
# → {"matches": [{"entity_id": "service-provider:digital-wings-kg", "label": "Digital Wings KG",
#     "score": 0.83, ...}], "model": "openai/text-embedding-3-small", "dimensions": 1536}

Seeded dictionary entities get embeddings via the ingest hook / the operator backfill one-shot (DPP_ROLE=backfill-embeddings, requires KG_BACKFILL_TENANTS=knoll) — schedule the backfill once after the initial §4 seeding (operator task, 04-provisioning-runbook.md).

8.4 Full-text segment fetch — POST /segments/text:batch (plan 5.3 step 2)

The expert-report pipeline's Document step resolves retrieved voyager hits (doc_id, ordinal) — plus ordinal±radius neighbours — into full chunk text exactly like the chat BFF's expand stage. ≤64 keys per call; response is found-only (missing/soft-deleted keys are silently absent — never infer emptiness as an error):

curl -sS -X POST "$GRAG_URL/kg-service/api/v1/segments/text:batch" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-a1b2" \
  -H "Content-Type: application/json" \
  -d '{"keys": [{"document_id": "doc-abc123", "ordinal": 4},
                {"document_id": "doc-abc123", "ordinal": 5}]}'
# → {"items": [{"document_id": "doc-abc123", "ordinal": 4, "text": "...", ...}]}

document_id here == workspaces documents.id == the voyager payload doc_id (the platform-wide join key; store it in grag_refs).


9. Durability caveats — read before building on the graph

What the KG projection does and does not guarantee

  1. Edges live ONLY in AGE. No relational copy exists (kg_crossreferences is span→document, kg_spans is span→entity). The worker-side AGE write path is best-effort by design — failures are swallowed and only counted on kg_service_age_mirror_failures_total. A lost edge is recoverable only via our reconcile job (§6) or document re-ingest; the platform's backfill-age one-shot replays vertices only.
  2. One edge per (from, relation, to) triple. AGE MERGE ... SET r += props overwrites edge properties — SCORES holds the latest analysis's score only. History = Knoll expert_reports table, always.
  3. No entity delete route. Retire entities by tombstoning (properties.status="inactive"); every consumer must filter. The KG only grows.
  4. Entities are tenant-wide (ADR 0025 Amendment 2026-07-05): client-A entities are visible from client-B workspaces of the same firm-tenant. Accepted for one firm (plan D1); conflict-of-interest walls would force tenant-per-client later.
  5. Cypher = pre-registered read-only intents only (write keywords rejected at registration); caps: ≤1000 rows / 5 s / depth ≤5 (neighborhood), subgraph depth ≤3 via graph-gateway (GRAPH_GATEWAY_ENABLED=true in the prod baseline, .env.production.example:160 — verify live per plan 0.9 / 05-verification-runbook.md A8; usage additionally gated per tenant by the graph.enabled tenant setting; no Knoll consumer until plan 3.8).
  6. Therefore: never serve an authoritative answer from the KG. Every fact the KG holds is a projection of Knoll Postgres rows; UI features answer from Knoll SQL (plan 3.7) and use the KG for semantic discovery (8.3), provenance (8.2), full-text expansion (8.4), and — post-3.8 — graph visualisation.

10. Task cross-reference

Plan taskCovered in
0.7 (D7) keep relation auto-extraction dormant§1 (no relationships block), §2 (v2 gate), §3.2 flag TODO-VERIFY
3.4 schema register + activate§1, §3
3.5 seed dictionaries + tombstoning§4
3.6 deterministic writer + reconcile + alert§5, §6
3.7 intents (deferred, untyped params)§7
3.8 Explorer (graph-gateway)§7 intro, §9 item 5
1.11 platform gap: edge write route§5.3 (contract), 13-platform-gaps-issues.md
5.3 step 2 full-text expansion§8.4
5.4 service-provider discovery§4.3, §8.3
7.1 German quality harness gate§2
7.6 monitoring§6 (alert row)