03 — Identifiers & Naming Conventions

Purpose. This document is the single normative reference for every identifier the Knoll Analyzer backend creates in or exchanges with the GRAG platform: the charset rules GRAG enforces, the reserved ids we must avoid, the Knoll ID scheme (tenants, workspaces, KBs, conversations, KG entities), the exact German-aware slugification algorithm, the grag_refs mapping-table contract, idempotency semantics for provisioning, the canonical success-lever slug list, and the X-Pipeline-Id convention for cost attribution. Provisioning code (plan 3.1, 3.5), the KG writer (plan 3.6), and the cost rollup (plan 1.6/7.3) MUST follow this document. Siblings: 01-architecture.md for the resource mapping rationale, 04-provisioning-runbook.md for operator steps, 06-knoll-db-schema.md for the full DB schema around grag_refs, 07-kg-schema-knoll-advisory.md for the KG schema the entity ids live in, 14-cost-model.md for the spend rollup that consumes pipeline ids.

Status / verified against: 2026-07-06, repo document-processing-pipelines @ db63a95, adversarial-review corrections applied (no Idempotency-Key support; ledger paths /ledger/api/v1/ledger/*; kg-service has no entity delete route).


1. The DNS-label charset rule

GRAG validates almost every resource id against one regex:

^[a-z0-9][a-z0-9-]{0,62}$

Lowercase ASCII letters, digits, hyphens; must start with a letter or digit; 1–63 chars. No umlauts, no uppercase, no underscores, no dots, no colons. German names must be slugified by Knoll before provisioning (section 4).

Where the rule is enforced (verified in source):

IdentifierEnforced byNotes
Tenant id (X-Tenant-ID header + POST /workspaces/api/v1/tenants body)pipeline-common/src/pipeline_common/tenant_context.py:50; workspaces/src/workspaces/schemas.py:39-45Invalid header → 400. Missing header → silent fallback to tenant default (allow_default_tenant=true platform-wide) — packages/grag-client hard-fails instead (plan 2.5, 7.7)
Workspace id (X-Workspace-ID header + workspace create body)workspace_context.py:67; schemas.pykg-service and entity-linking require the header on every non-public route (400 without)
Project idscope_context.py:68; schemas.pyNot used by Knoll — no project layer (plan 0.1 / D1)
KB idschemas.pyThe KB id doubles as the voyager collection name — another reason it must be a clean slug
Conversation id (client-chosen id in POST /workspaces/api/v1/conversations)schemas.py:224-240 (_validate_id)Server auto-generates conv-<8 urlsafe chars> when omitted (routers/conversations.py:46-50,146) — Knoll always chooses its own (section 3)
Document idnot regex-validated (DocumentCreate.id has no validator, schemas.py:310-326); server auto-generates doc-<8 chars>Knoll never chooses document ids — the upload BFF creates them and returns documentId; we only store what we get back (invariant I3, section 5)
KG entity idkg-service/src/kg_service/api/schemas/entities.py:13 — free string, 1–256 charsNOT a DNS label: colons and unicode are allowed (extraction derives {type}:{slug} with umlauts preserved, e.g. location:münchen). Knoll follows the same extraction slug rule for label-derived entity ids (sections 3, 7)
X-Pipeline-Id valuenot validated anywhere (free string; read at ai-gateway/src/ai_gateway/api/routes/completion.py:78)Keep it DNS-label-charset anyway for log/Grafana hygiene (section 8)

Rule of thumb: everything Knoll sends as a resource id or scope header on the workspaces plane (tenant, workspace, KB, conversation) is a DNS label produced by the section-4 slugifier. KG entity ids are the one exception: {type}:{slug} in the platform's extraction slug charset (unicode alphanumerics incl. umlauts, plus -) — never the section-4 DNS-label slugifier (sections 3, 7). Nothing else.

2. Reserved ids — never create, never reuse

Reserved idLevelWhy
defaulttenantPlatform fallback tenant — every request that forgets X-Tenant-ID lands here (allow_default_tenant=true). Data in it is unowned.
systemtenantReserved for internal platform jobs (ADR 0001 §Tenant ID format).
canarytenantLive synthetic-traffic tenant (24/7 canary probes); dashboards filter tenant!="canary".
generalworkspaceAuto-created in every tenant on POST /tenants, undeletable (409 on DELETE). Knoll uses it (it hosts kb-methodology) but must never try to create or delete it.

Plan 1.3 codifies this for tenant creation. Additionally, treat every id prefix in section 3 as reserved within the Knoll tenant: do not hand-create resources matching client-*, kb-analysis-*, conv-* outside the provisioning module, or the already-provisioned detection (section 6) breaks.

3. The Knoll ID scheme

<shortid> = the first 8 hex characters of the owning Knoll-DB row's UUID primary key (lowercase, hyphens removed). It is derived once at provisioning time and stored in grag_refs — never re-derived. On a grag_refs unique-constraint collision (≈2⁻³² per pair, effectively never at pilot scale) extend to the first 12 hex chars and retry.

<slug> = output of the algorithm in section 4, capped at 32 chars.

Knoll conceptGRAG resourceID patternExamplePlan
Firm (platform customer)tenantknoll (pilot single tenant); later firm-<slug>; dev/staging knoll-devknoll, firm-mueller, knoll-dev0.1, 1.3, 2.8
Clientworkspaceclient-<slug>-<shortid>client-hartmann-maschinenbau-9f3ab2c13.1
Analysis (engagement)KB in the client workspacekb-analysis-<shortid>kb-analysis-4b0c77de3.1
Methodenhandbuch / firm knowledgeKB in generalkb-methodology (fixed singleton)kb-methodology3.3
Chat threadconversationconv-<purpose>-<shortid> with purpose ∈ file | acquisition | iuconv-file-c41f09aa5.1a, 5.1b, 5.2
Uploaded documentworkspaces documentGRAG-assigned (doc-<8> from the upload BFF response documentId) — never composed by Knolldoc-x1y2z3a44.1
Ingest joborchestrator jobGRAG-assigned (jobId from POST /next/api/ingest/start)4.1, 4.2
Success lever (11, closed taxonomy)KG entitylever:<slug>lever:market-position3.5
Service provider (catalog)KG entityservice-provider:<slug> — extraction slug of name (section 7), derived once at first sync, persisted, never re-derived (07 §4.3); collision suffix per section 4service-provider:digital-wings-kg3.5
Client (graph node)KG entityclient:<slug>-<shortid> — same <slug>-<shortid> suffix as the workspace idclient:hartmann-maschinenbau-9f3ab2c13.6
RecommendationKG entityrecommendation:<analysis-shortid>-<n>, n = 1-based position in the approved expert-report versionrecommendation:4b0c77de-33.6
Project (graph node)KG entityproject:<project-shortid>project:77aa41b03.6, 5.5
MetricKG entitymetric:<client-shortid>-<metric>-<year> — per-client fact instance (value/unit/year ride in properties), not a closed taxonomy; instance-id rule in 07-kg-schema-knoll-advisory.md §5.1metric:9f3ab2c1-revenue-20253.6
Cost-attribution runX-Pipeline-Id header valueknoll-<scope>-<step>-<runid> (section 8)knoll-4b0c77de-scoring-4e21ac031.6, 7.3

Notes:

  • KG entity ids use : as the type separator only (one colon, immediately after the lowercase entity-type prefix). Everything after the colon obeys the extraction slug charset (unicode alphanumerics — umlauts survive — plus -; section 7), not the section-4 DNS-label charset. Knoll caps entity ids at 120 chars (= the extraction slugifier's own cap, SLUG_MAX_CHARS; the platform hard limit is 256).
  • The lever: / service-provider: / client: / recommendation: / project: / metric: prefixes match the lowercase entity_type values of the knoll-advisory schema v1 (SuccessLever, ServiceProvider, Client, Recommendation, Project, Metric — plan 3.4), mirroring the platform's own extraction convention {entity_type_lower}:{slug} (kg-service/src/kg_service/workers/extraction.py:85-89).
  • Two slugifiers — do not mix them. Label-derived dictionary entities (lever:<slug>, service-provider:<slug>) use GRAG's own extraction slugifier (extraction.py:70-82: NFKC → lowercase → whitespace→- → strip non-alphanumerics → strip outer -, cap 120; umlauts survive, e.g. Münchenmünchen) — precisely so auto-extracted document mentions (D7 allows entity-only extraction) dedupe onto the seeded entities instead of minting duplicates (07 §4.1). The section-4 DNS-label slugifier applies only to workspaces-plane ids (tenant/workspace/KB/conversation) — never to KG entity ids. The shortid-embedding graph nodes (client:, recommendation:, project:, metric:) reuse Knoll-composed suffixes (the client: slug is copied verbatim from the workspace id, section-4 transliteration included) and can never match an extraction-derived id — auto-extraction may therefore still create a stray for the same surface form (client:muller vs. Knoll's client:hartmann-maschinenbau-9f3ab2c1). Consumers treat Knoll DB + grag_refs as canonical and the reconcile job (plan 3.6) tombstones strays via properties.status="inactive" — there is no entity delete route (plan 3.5).
  • Because the KG has no delete route, entity ids are forever: never recycle a slug for a different real-world object.

4. Slugification algorithm (normative)

Scope: this DNS-label slugifier applies only to workspaces-plane ids — tenant, workspace, KB, conversation — and, by extension, to the <slug>-<shortid> suffix that client: graph nodes copy verbatim from the workspace id. It is never used to derive label-based KG entity ids (lever:, service-provider:): those use the platform's extraction slugifier (section 7), which preserves umlauts and produces different output.

Input: any German (or other) display name. Output: a DNS-label-safe slug. The step order is load-bearing (umlaut transliteration must precede accent stripping, otherwise äa instead of ae).

  1. Unicode-normalize NFKC.
  2. Lowercase.
  3. Transliterate German characters: ä→ae, ö→oe, ü→ue, ß→ss.
  4. Unicode-normalize NFD and strip combining marks (handles é→e, à→a, ç→c, …).
  5. Replace every run of characters outside [a-z0-9] with a single -.
  6. Trim leading/trailing -.
  7. Truncate to the max length (default 32 chars), then trim trailing - again.
  8. If the result is empty (nothing alphanumeric survived), the caller MUST fall back to the <shortid> alone (id families that have one) or reject with a validation error (families that don't).

Collision handling:

  • Id families that embed a <shortid> (client-…, recommendation:…, project:…, metric:…) are unique by construction — no suffix needed.
  • Slug-only families (firm-<slug>, service-provider:<slug>): before provisioning, check grag_refs for the candidate id; on collision append -2, -3, … (before truncation-safety: the suffix counts toward the length cap — truncate the slug first, then append). Note: the candidate slug for service-provider: comes from the extraction rule (section 7), not from this section's algorithm — only the collision protocol is shared.

Length budgets (full id ≤ 63 for DNS-label resources):

FamilyFixed partsSlug budget
firm-<slug>532 (37 available; 32 by convention)
client-<slug>-<shortid>7 + 1 + 8 = 1632 (total ≤ 48)
kb-analysis-<shortid>17 total— (no slug)
conv-<purpose>-<shortid>≤ 16 total— (no slug)
KG entity <type>:<slug…>type prefix + :up to 120 total

Reference implementation (TypeScript — lives in packages/grag-client, plan 2.5):

const DE_MAP: Record<string, string> = { "ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss" };

export function slugify(input: string, maxLen = 32): string {
  let s = input.normalize("NFKC").toLowerCase();
  s = s.replace(/[äöüß]/g, (c) => DE_MAP[c]);          // ä ö ü ß
  s = s.normalize("NFD").replace(/[̀-ͯ]/g, ""); // strip accents: é → e
  s = s.replace(/[^a-z0-9]+/g, "-");                      // replace + collapse
  s = s.replace(/^-+|-+$/g, "");
  s = s.slice(0, maxLen).replace(/-+$/g, "");
  return s; // "" → caller falls back to shortid or rejects
}

Worked examples (the acceptance fixtures for the unit test):

InputSlug
Kanzlei Müllerkanzlei-mueller → tenant firm-mueller (drop the redundant word if you prefer: slugify only the proper name)
Hartmann Maschinenbau GmbHhartmann-maschinenbau-gmbh
Bäckerei Großmann-Söhne GmbH & Co. KGbaeckerei-grossmann-soehne-gmbh (truncated at 32, trailing - trimmed)
Produkte & Dienstleistungenprodukte-dienstleistungen — DNS slug only; the KG entity id is lever:products--services (extraction rule, section 7)
Planung & Controllingplanung-controlling — DNS slug only; the KG entity id is lever:planning--controlling (extraction rule, section 7)
Café Renécafe-rene
Straßenbau Weißstrassenbau-weiss
株式会社 (nothing survives)"" → shortid fallback / reject

Slugs are frozen at provisioning: renaming a client in Knoll DB updates display names (Knoll DB + the GRAG resource's name field via PATCH) but never the id — GRAG ids are immutable, and slug drift is accepted (invariant I2 below).

5. The grag_refs table contract

grag_refs is the only authoritative mapping between Knoll rows and GRAG resources (plan 2.1). Full schema context in 06-knoll-db-schema.md; the contract:

CREATE TABLE grag_refs (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  knoll_type        text NOT NULL,   -- 'firm'|'client'|'analysis'|'methodology'|'file'
                                     -- |'questionnaire_doc'|'expert_report_doc'|'conversation'
                                     -- |'kg_entity'|'kg_schema'|'ingest_job'
  knoll_id          uuid,            -- PK of the owning Knoll row (NULL for singletons
                                     -- like kb-methodology)
  grag_kind         text NOT NULL,   -- 'tenant'|'workspace'|'kb'|'document'
                                     -- |'conversation'|'entity'|'schema'|'job'
  grag_tenant       text NOT NULL,   -- 'knoll' | 'knoll-dev' | 'firm-<slug>'
  grag_workspace_id text,            -- set for kb/document/conversation/entity refs
  grag_kb_id        text,            -- set for document/conversation refs
  grag_id           text NOT NULL,   -- the resource id in GRAG — NEVER parsed
  sync_status       text NOT NULL DEFAULT 'pending',
  last_error        text,
  payload_sha256    text,            -- hash of last-pushed projection content
                                     -- (KG/markdown re-push short-circuit)
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),
  UNIQUE (grag_tenant, grag_kind, grag_id),
  UNIQUE (knoll_type, knoll_id, grag_kind)
);

sync_status lifecycle:

pending ──► creating ──► ready ──► deleting ──► deleted
               │           │
               ▼           ▼
             failed     superseded        (documents only: replaced via
               │                           POST /documents/{id}/supersede, plan 4.4/4.5)
               └──► creating (retry)
  • pending — row written in the same Knoll-DB transaction as the domain row; no GRAG call made yet.
  • creating — GRAG call in flight (crash-safe: the reconcile sweep re-drives any row stuck in pending/creating/failed).
  • ready — GRAG confirmed (2xx, or 409 = already provisioned, section 6).
  • failed — non-retryable error captured in last_error; operator surface in 11-resilience-and-errors.md.
  • superseded — the GRAG document was replaced; the successor has its own row.
  • deleting/deleted — de-provisioning fan-out (plan 3.2) in flight / confirmed.

Invariants:

  • I1 — Write-ahead: every GRAG write is preceded by a grag_refs row (pending) in the same transaction as the Knoll domain write. A crash between DB commit and GRAG call leaves a re-drivable pending row, never an untracked GRAG resource.
  • I2 — GRAG ids are never parsed for meaning. The embedded slugs and shortids exist for operator grep-ability only. All joins Knoll→GRAG and GRAG→Knoll go through grag_refs (knoll_type+knoll_idgrag_id, or grag_idknoll_id). Never extract a shortid from a grag_id, never reconstruct a grag_id from a Knoll row outside the provisioning module.
  • I3 — GRAG-assigned ids are stored verbatim. documentId from POST /next/api/upload/start and jobId from POST /next/api/ingest/start are opaque strings; record them (grag_kind='document'|'job') and pass them back unchanged.
  • I4 — Single mapping point. Other Knoll tables (e.g. files.grag_document_id, ai_runs.pipeline_id) may hold denormalized copies for query convenience, but sync state lives only in grag_refs, and the reconcile job (plan 3.6) reads only grag_refs.
  • I5 — No cross-tenant refs: grag_tenant on every row; every GRAG call resolves its X-Tenant-ID from the ref row (dev rows carry knoll-dev, prod rows knoll), never from global config alone.

6. Idempotency rules

The workspaces service offers exactly one idempotency mechanism: client-chosen ids + 409 Conflict on duplicate (tenant, id).

There is NO Idempotency-Key header. workspaces/CLAUDE.md mentions accepting one, but a grep over the workspaces and pipeline-common source trees finds zero implementation — the header is silently ignored (adversarial review finding [5], overriding the exploration reports). Do not send it, do not rely on it.

Provisioning protocol (plan 3.1):

  1. Derive the deterministic id (section 3), write the grag_refs row (pending).
  2. POST the create with the client-chosen id.
  3. 201sync_status='ready'.
  4. 409already provisioned (a previous attempt succeeded). GET the resource to confirm it matches expectations (name, parent workspace), then sync_status='ready'.
  5. Any other error → failed + last_error; the reconcile sweep retries from step 2 — safe because the id never changes between attempts.
# Create the client workspace — idempotent by client-chosen id (dev tenant shown)
curl -sS -X POST "$GRAG_URL/workspaces/api/v1/workspaces" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: knoll-dev" \
  -H "Content-Type: application/json" \
  -d '{"id": "client-hartmann-maschinenbau-9f3ab2c1",
       "name": "Hartmann Maschinenbau GmbH"}'
# 201 Created  -> grag_refs.sync_status = ready
# 409 Conflict -> already provisioned: GET it, verify, then ready

curl -sS "$GRAG_URL/workspaces/api/v1/workspaces/client-hartmann-maschinenbau-9f3ab2c1" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: knoll-dev"

The same protocol applies to KBs (POST /workspaces/api/v1/workspaces/{ws}/kbs with id: "kb-analysis-<shortid>") and conversations. Conversations MUST be created explicitly before the first chat turn — the chat BFF does not create them; a made-up conversation_id streams fine but silently never persists (plan 5.2, review finding [6]):

curl -sS -X POST "$GRAG_URL/workspaces/api/v1/conversations" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: knoll-dev" \
  -H "Content-Type: application/json" \
  -d '{"id": "conv-file-c41f09aa",
       "kb_id": "kb-analysis-4b0c77de",
       "workspace_id": "client-hartmann-maschinenbau-9f3ab2c1",
       "title": "Frag die Akte — Hartmann Maschinenbau"}'

KG entity writes are idempotent by design: POST /kg-service/api/v1/entities/upsert is an upsert (response reports {inserted, updated}), and the AGE mirror uses MERGE. Re-running the deterministic KG writer (plan 3.6) with the same ids is always safe — that is what the reconcile job exploits.

7. Canonical success-lever slugs & KG entity-id derivation

Source of truth for labels and order: SUCCESS_LEVERS in next-monorepo/apps/web/lib/types.ts (11 success levers of the Knoll methodology, fixed order). Derivation rule: entity_id = "lever:" + extraction_slugify(label) — the platform extraction slugifier, not the section-4 DNS slugifier: NFKC-normalize → lowercase → join whitespace runs with - → strip every char that is not a unicode alphanumeric or - → strip outer - → cap 120 chars (kg-service/src/kg_service/workers/extraction.py::slugify; 07 §4.1). Umlauts survive, and because & is stripped after whitespace joining, "Products & Services" yields a double hyphen. Seeds MUST use this rule so auto-extracted document mentions dedupe onto the seeded entities instead of minting duplicates. The section-4 DNS-label slugifier applies only to workspaces-plane ids (tenant/workspace/KB/conversation), never to KG entity ids. The English label (with &, umlauts) is preserved verbatim in the entity's label field.

#Success lever (label)SlugKG entity id
1MarketPositionmarket-positionlever:market-position
2Strategystrategylever:strategy
3Brandbrandlever:brand
4Competitioncompetitionlever:competition
5ProductsAndServicesproducts--serviceslever:products--services
6Pricingpricinglever:pricing
7Customerscustomerslever:customers
8Salessaleslever:sales
9PlanningAndControllingplanning--controllinglever:planning--controlling
10MarketingImplementationmarketing-implementationlever:marketing-implementation
11Personnelpersonnellever:personnel

This is a closed taxonomy: exactly these 11 ids are seeded once via POST /kg-service/api/v1/entities/upsert (plan 3.5) and referenced by every SCORES / ADDRESSES edge (plan 3.6). New levers do not appear without a methodology change; retired ones are tombstoned (properties.status="inactive"), never deleted (no delete route exists).

Seeding example (kg-service requires X-Workspace-ID; entities are tenant-wide per ADR 0025 Amendment, so seed once via the general workspace):

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": "lever:market-position",
        "entity_type": "SuccessLever",
        "label": "MarketPosition",
        "aliases": [],
        "description": "Success lever 1 of the Knoll methodology: the company's position in the market.",
        "properties": {"order": 1, "status": "active", "source": "knoll-methodik"}
      }]}'
# → {"inserted": 1, "updated": 0}

Service-provider entities follow the same derivation rule (service-provider: + extraction slug of name, e.g. "Digital Wings KG"service-provider:digital-wings-kg07 §4.3; derived once at first sync, persisted in the Knoll DB row, never re-derived; on a rare collision append -2, -3, … per the section-4 collision protocol); Knoll DB remains the master, the KG is a projection (plan 3.5).

8. X-Pipeline-Id — cost-attribution convention

The ledger attributes spend per run via the X-Pipeline-Id request header: ai-gateway's completion routes read it (ai-gateway/src/ai_gateway/api/routes/completion.py:78,122, responses.py:111) and stamp it into every cost entry (pipeline_common/ledger.py:30,131). Per-run totals are then queryable at GET /ledger/api/v1/ledger/totals?pipeline_id=… — note the doubled ledger path segment (Traefik prefix /ledger + internal /api/v1/ledger/totals; review finding [4]). GET /ledger/api/v1/ledger/spend?group_by=service|provider is tenant-level only — the only two groupings that exist; per-analysis attribution is impossible without pipeline ids.

Format (normative):

pipeline_id := "knoll-" <scope> "-" <step> "-" <runid>

scope  := <analysis-shortid>            ; analysis-scoped runs (expert-report pipeline, KIU, …)
        | "agent-" <nn>                ; agent runs without an analysis (nn = AI-agent
                                       ;   number from the ai_agents registry, e.g. "02")
step   := "interview" | "document" | "swot" | "scoring" | "report" | "report"
        | "classification"             ; checklist classification (plan 4.3)
        | "leadscore"                  ; acquisition lead scoring (plan 5.1a)
        | "matching"                   ; service-provider rationale (plan 5.4)
        | "project"                    ; project brief (plan 5.5)
runid  := 8 hex chars = shortid of the ai_runs row

Charset stays within [a-z0-9-], total length ≤ 63 (not enforced by GRAG — kept for log/metric hygiene). Examples:

  • knoll-4b0c77de-scoring-4e21ac03 — expert-report step 4 (Scoring) of analysis 4b0c77de
  • knoll-agent-02-leadscore-b7d2e910 — acquisition lead scoring, no analysis

ai_runs.step<step> token mapping (normative). The well-known ai_runs.step values (06-knoll-db-schema.md §3.12) use underscores, which are illegal in the [a-z0-9-] pipeline-id charset — never embed them directly; map them:

ai_runs.step (06 §3.12)pipeline-id <step> token
interview, document, swot, scoring, report, reportidentical (already charset-clean)
checklist_classificationclassification
lead_scoringleadscore
service_provider_rationalematching
project_briefproject

Rules:

  1. One pipeline id per ai_runs row, stored in ai_runs.pipeline_id at run start. Stamp it on every direct GRAG call the run makes (ai-gateway chat/completions, groundedness score, kg-service writes) — services that don't read it simply ignore it.
  2. Per-analysis rollup is a Knoll-side sum (plan 7.3 / 14-cost-model.md): select all ai_runs.pipeline_id for the analysis, call /ledger/totals once per id, sum. /totals takes exactly one pipeline_id — there is no prefix or wildcard query.
  3. BFF-lane traffic carries no Knoll pipeline id. The chat/upload/ingest BFF routes do not forward X-Pipeline-Id (grep over frontend-next/apps/web/app/api: zero matches) — chat spend lands in tenant-level /spend only.
  4. Ingest jobs use the GRAG-assigned jobId from POST /next/api/ingest/start as their pipeline id (the orchestrator stamps it downstream; the ledger client's own docstring pairs the budget poller with totals(pipeline_id=…), pipeline-common/src/pipeline_common/client/ledger.py:6). Record the jobId in grag_refs (grag_kind='job') and query /totals with it verbatim — do NOT compose a knoll-… id for ingest. TODO-VERIFY: run one live ingest against knoll-dev and confirm GET /ledger/api/v1/ledger?pipeline_id=<jobId> returns the chunking/embedding cost rows for that job (i.e. that the orchestrator's downstream cost entries really carry the jobId as pipeline_id).
  5. ai-gateway's /embed and /rerank routes do not read X-Pipeline-Id (grep over ai-gateway/src/ai_gateway/api/routes: only completion.py / responses.py / ledger.py match) — retrieval/embedding spend inside a run is attributable per tenant and service, not per run. TODO-VERIFY: whether per-run retrieval attribution matters for 14-cost-model.md; if yes, file a platform gap alongside plan 1.14 (small, additive: read the header in the embed/rerank routes).

Query examples:

# Per-run totals (expert-report Scoring step of analysis 4b0c77de)
curl -sS "$GRAG_URL/ledger/api/v1/ledger/totals?pipeline_id=knoll-4b0c77de-scoring-4e21ac03" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT"

# Tenant-level budget display (Settings → AI, plan 6.6)
curl -sS "$GRAG_URL/ledger/api/v1/ledger/spend?group_by=service" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT"

Stamping example (expert-report pipeline step, direct ai-gateway lane):

curl -sS -X POST "$GRAG_URL/ai-gateway/api/v1/chat/completions" \
  -H "Authorization: Bearer $GRAG_API_KEY" \
  -H "X-Tenant-ID: $GRAG_TENANT" \
  -H "X-Workspace-ID: client-hartmann-maschinenbau-9f3ab2c1" \
  -H "X-Pipeline-Id: knoll-4b0c77de-scoring-4e21ac03" \
  -H "Content-Type: application/json" \
  -d '{"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "…"}]}'

(X-Workspace-ID is optional on ai-gateway but enables keyvault workspace-scoping and attribution — send it whenever the run is client-scoped; see 02-grag-api-cookbook.md.)


Quick checklist for reviewers of provisioning PRs

  • Every new id matches its section-3 pattern and passes ^[a-z0-9][a-z0-9-]{0,62}$ (KG entities exempt: one :, extraction-slug charset — umlauts allowed — ≤120 chars).
  • No id in {default, system, canary, general} is created or deleted.
  • Workspaces-plane slugs produced only by the section-4 function (single shared implementation in packages/grag-client); label-derived KG entity slugs only by the extraction-rule function (section 7 / 07 §4.1) — never cross-wired; umlaut and & (double-hyphen) fixtures in the unit tests for both.
  • grag_refs row written before the GRAG call (I1); 409 handled as already-provisioned; no Idempotency-Key header anywhere.
  • No code parses a grag_id (I2); GRAG-assigned documentId/jobId stored verbatim (I3).
  • Every direct GRAG call from an ai_runs step stamps X-Pipeline-Id per section 8.