06 — Knoll Postgres Schema (Schema v1, plan 2.1)
Purpose. This document specifies the Knoll Analyzer's own Postgres schema — the small
"ordinary data" database that a developer turns into Drizzle models (packages/db, plan 2.1/D8).
It is the source of truth for all business facts and all relationship facts (the GRAG
knowledge graph is only a projection — KG edges are AGE-only, best-effort and non-replayable,
see 07-kg-schema-knoll-advisory.md and 13-platform-gaps-issues.md). Field names and enum
values mirror the prototype types in next-monorepo/apps/web/lib/types.ts verbatim (now
English-identifier) so the mock-data seed (plan 2.2) loads losslessly.
Status / verified against: 2026-07-06 · integration plan tasks/todo.md rev. 2 (plan 2.1,
incl. all ⚠ review corrections) · GRAG repo document-processing-pipelines @ db63a95 ·
prototype apps/web/lib/types.ts, lib/mock-data/*, app/(app)/settings/page.tsx.
Sibling docs: 01-architecture.md (system split), 03-id-conventions.md (id naming
conventions — the grag_refs table contract is normative here, §3.15),
04-provisioning-runbook.md (who creates the GRAG side),
08-gutachten-pipeline-spec.md (ai_runs consumer), 10-document-pipeline.md (files +
upload lane), 12-gdpr-compliance.md (deletion semantics), 14-cost-model.md (pipeline_id
attribution).
1. Scope and design rules
| Rule | Consequence |
|---|---|
| Knoll DB = source of truth for users/roles, clients, analyses, questionnaire answers, checklists, expert reports, service providers, projects, leads, and every relationship between them (plan target architecture) | GRAG holds projections (KG), derived artifacts (chunks/vectors) and conversation transcripts; anything lost there is reconstructable from this schema (reconcile job, plan 3.6) |
| GRAG keeps no binaries (docfold output is markdown; job payloads expire) | Originals + generated PDFs live in the Knoll file store; files is its metadata table (plan 2.4) |
| Cache-only rule for GRAG-owned data | Knoll never persists GRAG document inventories, conversation contents, vectors or KG state — only the ids linking to them (grag_refs) and file binaries it owns itself. See §8 |
| GRAG has no user model | Authorization (incl. Partner-only gates) is enforced 100 % in the Knoll backend against users.role (plan 2.3/7.7) |
| Naming | Tables/columns snake_case English; German domain terms now translated (clients, files, lever_slug). Technical/infrastructure tables (ai_runs, grag_refs, audit_log) use English column names |
| Enums | Domain enums use the exact English string values from types.ts (Postgres enum or text + CHECK; Drizzle pgEnum recommended). A German label map drives the UI. Internal state-machine enums are English |
| Keys | uuid PKs (gen_random_uuid()) except seeded registries with stable text ids (success_levers.slug, checklist_template.id, ai_agents.id) |
| Timestamps | Every table has created_at timestamptz NOT NULL DEFAULT now() and updated_at timestamptz NOT NULL DEFAULT now() — omitted from the column tables below for brevity |
| Money / scores | EUR net as numeric(12,2); lever scores numeric(2,1) CHECK 1.0–5.0 (5 = best); service-provider criteria smallint CHECK 1–6 (6 = best) |
| Dates | Stored as date/timestamptz; German formatting ("14.05.2026", "03/2025") is a render concern only |
| Deletion | Hard delete + audit_log entry (GDPR deletion, plan 7.5); files additionally tracks deleted_at so the object-store sweep is verifiable. GRAG-side deletion is a separate fan-out (plan 3.2/4.4) |
2. Enum definitions
2.1 Domain enums (values verbatim from apps/web/lib/types.ts, English)
| Enum | Values | Source |
|---|---|---|
engagement_status | Draft, Onboarding, Analysis, Completed | EngagementStatus |
kyc_status | Pending, Verified, Rejected | KycStatus |
hgb_size_class | Micro, Small, Medium, Large | HgbSizeClass |
traffic_light | Red, Yellow, Green | TrafficLight |
checklist_status | Open, Requested, PartiallyReceived, Received, NotAvailable | ChecklistStatus |
milestone_status | Pending, InProgress, Completed, Delayed | MilestoneStatus |
milestone_responsible | Client, Analyst, Expert | Milestone.responsible |
contract_type | NDA, AnalyzerMainContract, ProjectExecution, TaxAdvisorConfidentialityWaiver | ContractType |
contract_status | Pending, Signed | ContractStatus |
analysis_package | AnalyzerBasic, AnalyzerPlus, AnalyzerWithImplementation | AnalysisPackage |
project_status | Proposal, Offer, InImplementation, Completed | ProjectStatus |
project_module | MarketPreparation, MarketDevelopment, Digitalization | Project.module |
swot_category | Strength, Weakness, Opportunity, Risk | SwotCategory |
question_type | yes-no, free-text, number, multiple-choice, single-choice, scale, table | QuestionType |
report_status | Provisional, Final | ExpertReport.status |
team_role | LeadPartner, Analyst, IUMember, Expert, Backoffice | TeamMember.role |
user_role | Partner, Analyst, Backoffice, Admin, Tester | Settings Profile tab + plan 2.3 (⚠ review: NOT "Berater/Assistenz"; Admin added for ops). Partner gates expert-report approval + fee edits. Tester = pilot role WITHOUT any AI access: manual workflow only, every AI surface blurred in the UI and rejected server-side (apps/web/lib/auth/ai-access.ts); not a manager role, so it cannot change roles (its own included) |
lead_status | New, Contacted, Converted, Archived | Lead.status |
service_provider_status | Approved, UnderReview, Blocked | ServiceProvider.status |
activity_type | questionnaire, expert_report, contract, project, ai, client, checklist | Activity.type |
ai_agent_type | Acquisition, Steering, Pipeline, ProjectGenerator | AiAgent.type |
ai_agent_status | Active, Ready, InMaintenance | AiAgent.status |
calculation_unit | PersonDays, Hours, FlatRate | CalculationLineItem.unit |
detail_level | Compact, Standard, Detailed | Settings AI tab |
notification_type | questionnaire, expert_report, kyc, milestone, summary | Settings Notifications tab |
file_source | upload, generated | plan 2.4 (originals vs. rendered PDFs) |
language | German, English | Settings Profile tab — ⚠ keeping English is an open i18n decision (plan 6.6); the column ships either way |
Analysis.phase is not an enum: smallint CHECK (phase BETWEEN 1 AND 6) — the six
process phases (PHASES in types.ts) are a UI constant, not data.
2.2 Technical enums (English, Knoll-internal)
| Enum | Values | Used by |
|---|---|---|
ai_run_status | queued, running, awaiting_review, approved, rejected, succeeded, failed, cancelled | ai_runs (state machine, §3.12) |
groundedness_band | green, amber, red, unknown | ai_runs — the POST /groundedness/api/v1/score service vocabulary (verified: groundedness/src/groundedness/api/schemas/responses.py Band), persisted verbatim by the expert-report pipeline's direct /score calls (08 §4). ⚠ The chat SSE's high/medium/low is a BFF-only re-mapping that never reaches ai_runs (02 §F.2) — do not conflate the two |
grag_resource_type | tenant, workspace, kb, document, conversation, kg_entity | grag_refs |
grag_sync_status | pending, synced, failed, superseded, deleted | grag_refs |
3. Tables
3.1 Identity & firm
users
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
firm_id | uuid | FK → firms(id) NOT NULL | v1 is single-firm; column keeps multi-firm open |
name | text | NOT NULL | e.g. "Dr. Michael Knoll" |
email | text | NOT NULL, UNIQUE (case-insensitive index) | Auth.js credentials login id (plan 2.3) |
password_hash | text | NULL | NULL once SSO-only |
role | user_role | NOT NULL | Partner gates expert-report approval (plan 5.3/6.4) and fee edits — enforced server-side (plan 7.7) |
language | language | NOT NULL DEFAULT German | i18n decision pending (plan 6.6) |
initials | text | NULL | fallback: derived from name |
active | boolean | NOT NULL DEFAULT true | deactivate instead of delete while referenced by audit-relevant rows |
firms
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | one row in v1 |
name | text | NOT NULL | "kbs group – Knoll Beratung & Service" |
street | text | "Maximilianstraße 35" | |
postal_code | text | ||
city | text | ||
vat_id | text | "DE 214 598 337" | |
grag_tenant_id | text | NOT NULL, UNIQUE, CHECK matches ^[a-z0-9][a-z0-9-]{0,62}$ | knoll (prod) / knoll-dev (dev), per 03-id-conventions.md; must equal the tenant in every grag_refs row of this firm |
fee_tiers (drives analysis pricing, plan 2.1)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
firm_id | uuid | FK → firms NOT NULL | |
label | text | NOT NULL | "10–50 Mio. € revenue" (Settings firm tab) |
revenue_min_mio | numeric(10,2) | NULL | lower bound in Mio. EUR (NULL = open) |
revenue_max_mio | numeric(10,2) | NULL | upper bound (NULL = open, ">100 Mio.") |
fee_net | numeric(12,2) | NULL | NULL = "individuell" (Partner sets analyses.fee manually) |
sort_order | smallint | NOT NULL | display order |
Seed (from the prototype Settings page): 10–50 Mio. → 15.000 €; 50–100 Mio. → 30.000 €;
100 Mio. → NULL (individuell). Edits are Partner-only ("Anpassungen sind Partnern vorbehalten"). TODO-VERIFY: whether the pricing model keys the tier on revenue only or also employees/balance_sheet_total (HGB class) — check Knoll-Analyzer-Docs pricing model before wiring the default-fee picker.
3.2 Taxonomy master: success_levers
Master of the closed 11-lever taxonomy. Knoll DB is the master; the GRAG KG entity dictionary
is a projection seeded via POST /kg-service/api/v1/entities/upsert (plan 3.5). kg-service has
no entity delete route — deactivation here tombstones the KG projection
(properties.status="inactive").
| Column | Type | Constraints | Notes |
|---|---|---|---|
slug | text | PK, CHECK ^[a-z0-9][a-z0-9-]*$ | KG entity id = lever:<slug> (03-id-conventions.md); slug derived with the extraction slugifier (07-kg-schema-knoll-advisory.md §4.1) — see seed note below |
name | text | NOT NULL, UNIQUE | exact types.ts label |
sort_order | smallint | NOT NULL, UNIQUE | fixed order 1–11 (SUCCESS_LEVERS const) |
active | boolean | NOT NULL DEFAULT true | tombstone mirror for the KG projection |
Seed (order + slugs; derived with the extraction slugifier — NFKC → lowercase →
whitespace→- → strip remaining non-alphanumerics, kg-service/src/kg_service/workers/
extraction.py::slugify, 07-kg-schema-knoll-advisory.md §4.1. Quirk: & is stripped after
whitespace joining, so "Products & Services" → products--services with a
double hyphen — intentional, byte-identical with the 07 §4.2 upsert payload so document
mentions dedupe onto the seeded entities):
| # | slug | name |
|---|---|---|
| 1 | market-position | MarketPosition |
| 2 | strategy | Strategy |
| 3 | brand | Brand |
| 4 | competition | Competition |
| 5 | products--services | ProductsAndServices |
| 6 | pricing | Pricing |
| 7 | customers | Customers |
| 8 | sales | Sales |
| 9 | planning--controlling | PlanningAndControlling |
| 10 | marketing-implementation | MarketingImplementation |
| 11 | personnel | Personnel |
3.3 clients
clients
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | seed maps prototype ids m-01… |
firm_id | uuid | FK → firms NOT NULL | |
company_name | text | NOT NULL | |
legal_form | text | ||
industry | text | ||
city | text | ||
country | text | ||
website_url | text | ||
founding_year | integer | ||
revenue | numeric(14,2) | annual revenue EUR | |
employees | integer | ||
balance_sheet_total | numeric(14,2) | EUR | |
hgb_size_class | hgb_size_class | ||
kyc_status | kyc_status | NOT NULL DEFAULT Pending | transitions trigger the kyc notification (plan 2.7) |
customer_since | date | rendered "MM/YYYY" | |
notes | text | NULL |
GRAG side: one workspace client-<slug>-<shortid> per client, provisioned per plan 3.1 and
tracked in grag_refs (knoll_type='client', grag_type='workspace').
contacts
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
client_id | uuid | FK → clients NOT NULL ON DELETE CASCADE | |
name | text | NOT NULL | |
role | text | free text ("Geschäftsführer") | |
email | text | ||
phone | text | ||
position | smallint | NOT NULL DEFAULT 0 | display order |
3.4 analyses
analyses
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | seed maps a-01… |
client_id | uuid | FK → clients NOT NULL | |
title | text | NOT NULL | |
package | analysis_package | NOT NULL | |
status | engagement_status | NOT NULL DEFAULT Draft | |
phase | smallint | NOT NULL DEFAULT 1, CHECK 1–6 | process phases A.II.1/A.II.2 |
start_date | date | ||
deadline | date | ||
fee | numeric(12,2) | defaulted from fee_tiers by revenue; edits Partner-only | |
questionnaire_version_id | uuid | FK → questionnaire_versions NOT NULL | pinned at creation; answers reference question ids of this version |
Deliberately not stored: questionnaire_progress / checklist_progress
(derive from questionnaire_answers vs. the pinned definition and from checklist_items
statuses), expert_report_id (derive: latest expert_reports version per §6), pipeline state
(lives in ai_runs).
GRAG side: one KB kb-analysis-<shortid> (= voyager collection) in the client workspace;
⚠ no GRAG project layer (plan 0.1/3.1). Tracked in grag_refs.
milestones (⚠ review addition, plan 2.1)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | |
name | text | NOT NULL | |
target_date | date | NOT NULL | |
actual_date | date | NULL | |
status | milestone_status | NOT NULL DEFAULT Pending | Delayed (or target_date < today while not Completed) fires the milestone notification (plan 2.7) |
responsible | milestone_responsible | NOT NULL | |
position | smallint | NOT NULL DEFAULT 0 |
contracts (⚠ review addition — status tracking IS in scope; e-signature is not)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | |
type | contract_type | NOT NULL | |
status | contract_status | NOT NULL DEFAULT Pending | |
signed_at | date | NULL | |
file_id | uuid | FK → files NULL | scanned signed contract, if stored |
UNIQUE (analysis_id, type) | one row per contract type per analysis |
analysis_team (⚠ review addition)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | |
user_id | uuid | FK → users NULL | NULL for externals (experts not in users) |
name | text | NULL | required when user_id IS NULL — CHECK (user_id IS NOT NULL OR name IS NOT NULL) |
role | team_role | NOT NULL | |
initials | text | NULL | for externals; internals derive from users |
Partial UNIQUE (analysis_id, user_id) WHERE user_id IS NOT NULL |
3.5 Questionnaire
questionnaire_versions
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
version | integer | NOT NULL, UNIQUE | monotonically increasing |
title | text | NOT NULL | "Fragenkatalog Analyzer (Anlage C.I.1 + C.I.2)" |
definition | jsonb | NOT NULL | array of 16 QuestionnaireChapter objects, shape exactly as types.ts: {nr: "1"…"11" | "IT.0"…"IT.4", title, description, lever?, attachments: string[], questions: [{id, text, type: question_type, options?, columns?, hint?}]} |
valid_from | date | NOT NULL | |
created_by | uuid | FK → users NULL |
Versioning strategy: versions are immutable once referenced by any analysis. A changed
questionnaire = new row with version + 1; running analyses keep their pinned version
(analyses.questionnaire_version_id), new analyses pick the latest. v1 seed = the 16-chapter
definition from lib/mock-data/questionnaire.ts (verified: 16 chapters; sourced from Anlage
C.I.1 — Fragenkatalog Analyzer/Kundenfragebogen, 11 main chapters — plus Anlage C.I.2 —
Fragebogen IT / IST-Aufnahme). TODO-VERIFY: diff the mock definition against Anlage C.I.1
(raw/Anlage_C.I.1_Kundenfragebogen.md) + C.I.2 (raw/Anlage_C.I.2_Ergaenzung_Fragebogen_IT.md)
in Knoll-Analyzer-Docs/ before sealing version 1 — ⚠ NOT Anlage A.I.2.2, which is the
KIU initial assessment (industry assessment/opportunities/market participants/project proposals), a
different artifact.
questionnaire_answers (autosave-friendly, plan 6.2)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | |
question_id | text | NOT NULL | Question.id within the pinned version's definition |
answer | jsonb | NOT NULL | typed by question.type: boolean (yes-no), string (free-text/single-choice), number (number/scale), string[] (multiple-choice), array of row-objects (table) |
updated_by | uuid | FK → users NULL | |
UNIQUE (analysis_id, question_id) | the debounced-autosave upsert target |
questionnaire_submissions (submit + re-submit anchor, plan 4.5)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | |
number | integer | NOT NULL; UNIQUE (analysis_id, number) | 1..n submissions |
submitted_at | timestamptz | NOT NULL | fires the questionnaire notification (plan 2.7) |
submitted_by | uuid | FK → users NULL | |
answers_snapshot | jsonb | NOT NULL | frozen copy of all answers at submit — audit trail + render source for the markdown document |
On submit: render answers_snapshot to markdown → ingest into the analysis-KB → run the
deterministic KG upserts (plan 3.6). The resulting GRAG document id goes to grag_refs
(knoll_type='questionnaire_submission'). ⚠ Re-submit (plan 4.5): new row, re-render,
POST /workspaces/api/v1/documents/{oldId}/supersede the previous doc, re-run the idempotent
KG upserts; the old grag_refs row flips to sync_status='superseded'.
3.6 Checklist (C.I.3, 46 items)
checklist_template (seeded template)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PK | stable ids ck-01…ck-46, seeded from lib/mock-data/checklist.ts (verified: exactly 46 items in groups A. Fragenkatalog, B.1 Marktposition / Unternehmen … B.11 Personal; the ck- prefix matches the mock and 08 §5.2's references) |
group | text | NOT NULL | |
label | text | NOT NULL | |
position | smallint | NOT NULL | |
active | boolean | NOT NULL DEFAULT true | retire items without breaking old analyses |
TODO-VERIFY: diff the 46 mock items against the original Anlage C.I.3 in
Knoll-Analyzer-Docs/ before freezing the seed ids.
checklist_items (per-analysis status)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL ON DELETE CASCADE | all 46 rows created on analysis creation |
template_id | text | FK → checklist_template NOT NULL | |
status | checklist_status | NOT NULL DEFAULT Open | |
hint | text | NULL | |
suggested_file_id | uuid | FK → files NULL | plan 4.3: classifier suggests which uploaded document satisfies this item |
suggested_status | checklist_status | NULL | suggested target status (typically Received) |
suggested_rationale | text | NULL | LLM rationale for the reviewer |
confirmed_by | uuid | FK → users NULL | human confirms — no silent auto-set (plan 4.3) |
confirmed_at | timestamptz | NULL | |
UNIQUE (analysis_id, template_id) |
The deterministic scoring rule "no business plan → MarketPosition ≤ 1.5" is code, not data
(plan 4.3 / 5.3 step 4): it reads the business-plan item's status and caps the
market-position score before persisting to report_lever_scores.
3.7 Expert reports (versioned)
expert_reports
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
analysis_id | uuid | FK → analyses NOT NULL | |
version | integer | NOT NULL; UNIQUE (analysis_id, version) | 1..n |
status | report_status | NOT NULL DEFAULT Provisional | |
created_at | timestamptz | NOT NULL | |
overall_score | numeric(2,1) | CHECK 1.0–5.0 | 5 = best (per types.ts "ADR-013") |
traffic_light | traffic_light | computed in code from overall_score | |
key_messages | jsonb | string[] | |
swot | jsonb | {strengths: string[], weaknesses: string[], opportunities: string[], risks: string[]} | |
approved_by | uuid | FK → users NULL | Partner role required — QMS human-in-the-loop, enforced server-side (plan 2.3/5.3) |
approved_at | timestamptz | NULL | fires the expert_report notification when the draft is ready for review (plan 2.7) |
pdf_file_id | uuid | FK → files NULL | rendered report PDF, file store (plan 5.3 step 6, 6.4 export) |
report_lever_scores
| Column | Type | Constraints | Notes |
|---|---|---|---|
expert_report_id | uuid | FK → expert_reports NOT NULL ON DELETE CASCADE | |
lever_slug | text | FK → success_levers NOT NULL | |
score | numeric(2,1) | NOT NULL, CHECK 1.0–5.0 | LLM proposes → deterministic caps in code → stored (plan 5.3 step 4) |
traffic_light | traffic_light | NOT NULL | computed in code |
comment | text | rationale | |
capped | boolean | NOT NULL DEFAULT false | true when a deterministic cap lowered the LLM proposal |
cap_reason | text | NULL | e.g. "no business plan → MarketPosition ≤ 1.5" |
PK (expert_report_id, lever_slug) | exactly 11 rows per expert report |
recommendations
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
expert_report_id | uuid | FK → expert_reports NOT NULL ON DELETE CASCADE | |
lever_slug | text | FK → success_levers NOT NULL | |
title | text | NOT NULL | |
description | text | ||
swot_category | swot_category | NOT NULL | |
priority | smallint | NOT NULL, CHECK 1–3 | 1 = highest |
time_horizon | text | "0–6 Monate" | |
position | smallint | NOT NULL DEFAULT 0 |
Expert report versioning strategy (plan 2.1):
- Versions are immutable. Every pipeline run (plan 5.3) writes a new
expert_reportsrow withversion + 1plus its child rows; nothing is edited in place after approval. - Approval = Partner sets
status='Final'+approved_by/_at. The current expert report of an analysis is the highest-versionrow withstatus='Final'(UI falls back to the highestProvisionaldraft when none is Final). - Corrections after approval → new version (re-run or manual edit path); the old Final stays for audit.
- GRAG projections follow the version: the rendered PDF is ingested into the analysis-KB, and
a new approval supersedes the previous expert-report document there
(
POST /workspaces/api/v1/documents/{id}/supersede, plan 4.4); KG facts (SCORES,RECOMMENDSedges) are re-upserted idempotently withanalysis_id+ version in the edge properties (plan 3.6). - The prototype's
ExpertReport.pipeline(PipelineStep[]) is not stored — the pipeline panel (plan 6.4) derives it fromai_runs(§3.12).
3.8 service providers
service_providers
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | seed maps d-01… |
slug | text | NOT NULL, UNIQUE, CHECK ^[a-z0-9][a-z0-9-]*$ | KG entity id = service-provider:<slug> (plan 3.5, 03-id-conventions.md) |
name | text | NOT NULL | |
category | text | NOT NULL | "Webdesign & Development", … |
city | text | ||
contact_person | text | contact person name (flat, per types.ts) | |
email | text | target of "Anfrage senden" (plan 2.7) | |
work_samples | integer | NOT NULL DEFAULT 0 | joint projects count |
overall_grade | numeric(2,1) | NULL | derived — recomputed in code from service_provider_ratings; D.III.2's 0.35/0.35/0.25 weights only roll error_frequency/functionality/customer_rating into the quality sub-score — the cross-criteria formula is a Knoll code decision (see note below). Stored only for list sorting |
nda_signed | boolean | NOT NULL DEFAULT false | |
gdpr_checked | boolean | NOT NULL DEFAULT false | |
status | service_provider_status | NOT NULL DEFAULT UnderReview | Blocked/removed → KG tombstone properties.status="inactive" (kg-service has no delete route, plan 3.5) — consumers must filter |
service_provider_ratings (rating matrix D.III.2, scale 1–6, 6 = best)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
service_provider_id | uuid | FK → service_providers NOT NULL ON DELETE CASCADE | |
project_id | uuid | FK → projects NULL | rating in context of a project |
rated_by | uuid | FK → users NULL | |
rated_at | timestamptz | NOT NULL | |
error_frequency | smallint | CHECK 1–6 | |
functionality | smallint | CHECK 1–6 | |
customer_rating | smallint | CHECK 1–6 | |
deadline_compliance | smallint | CHECK 1–6 | |
price_positioning | smallint | CHECK 1–6 | |
reference_potential | smallint | CHECK 1–6 | |
negotiation_position | smallint | CHECK 1–6 | |
capacities | smallint | CHECK 1–6 | |
comment | text |
Ranking is deterministic in code (plan 5.4); the LLM only drafts the rationale text.
RESOLVED (was TODO-VERIFY; read from the Anlage D.III.2 xlsx): the 0.35/0.35/0.25 weighting
belongs to the "Matrix zur Qualitätsbewertung" only — it rolls the three quality
sub-criteria (error frequency 0.35, functionality 0.35, customer rating 0.25) into the
single main criterion "Qualität Dienstleistung". The remaining main criteria (number of
work samples — an absolute count on service_providers.work_samples, not a 1–6 grade —
deadline compliance, price positioning, reference potential, negotiation position,
capacities) carry no weighting in the source. The cross-criteria aggregation to
overall_grade is therefore undefined in D.III.2 and is an explicit Knoll design decision —
document the chosen formula in code and here before implementing.
3.9 projects (Project Generator)
projects
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | seed maps p-01… |
analysis_id | uuid | FK → analyses NOT NULL | |
client_id | uuid | FK → clients NOT NULL | denormalized per types.ts; backend asserts it matches analyses.client_id |
recommendation_id | uuid | FK → recommendations NULL | plan 5.5: generated from an approved recommendation; source of the IMPLEMENTS KG edge (plan 3.6) |
title | text | NOT NULL | |
module | project_module | NOT NULL | |
description | text | ||
status | project_status | NOT NULL DEFAULT Proposal | |
priority | smallint | NOT NULL, CHECK 1–3 | |
timeframe | text | "Q3–Q4 2026" | |
external_total | numeric(12,2) | NULL |
internal_total is derived (sum of calculation_line_items.amount) — computed in code
(plan 5.5: "amounts computed in code"), not stored.
project_lever (m:n — Project.lever is an array in types.ts)
| Column | Type | Constraints | Notes |
|---|---|---|---|
project_id | uuid | FK → projects ON DELETE CASCADE | PK (project_id, lever_slug) |
lever_slug | text | FK → success_levers |
project_service_provider (m:n — Project.serviceProviderIds)
| Column | Type | Constraints | Notes |
|---|---|---|---|
project_id | uuid | FK → projects ON DELETE CASCADE | PK (project_id, service_provider_id) |
service_provider_id | uuid | FK → service_providers |
calculation_line_items
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
project_id | uuid | FK → projects NOT NULL ON DELETE CASCADE | |
position | text | NOT NULL | position label |
description | text | ||
unit | calculation_unit | NOT NULL | |
quantity | numeric(10,2) | NOT NULL | |
rate | numeric(12,2) | NOT NULL | EUR per unit |
amount | numeric(12,2) | NOT NULL | = quantity * rate, computed and written by code (never by the LLM, plan 5.5) |
sort_order | smallint | NOT NULL DEFAULT 0 |
3.10 Acquisition: leads
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | seed maps l-… |
company | text | NOT NULL | |
industry | text | ||
region | text | ||
employees | integer | ||
revenue_class | text | ||
attractiveness | smallint | CHECK 1–10 | |
reachability | smallint | CHECK 1–10 | |
overall_score | numeric(3,1) | derived: 0.6 * attractiveness + 0.4 * reachability, computed in code (plan 5.1a); stored for sorting only | |
status | lead_status | NOT NULL DEFAULT New | |
signals | jsonb | string[] | |
ai_run_id | uuid | FK → ai_runs NULL | the structured-output extraction run that scored this lead |
client_id | uuid | FK → clients NULL | set on Converted (lead → client conversion) |
The acquisition chat transcript itself lives in GRAG (conversation on kb-methodology, plan 5.1a) —
linked via grag_refs (knoll_type='lead', grag_type='conversation'). ag-01 lead research
and ag-03 outreach are explicitly deferred (plan 5.8) — no tables for them in v1.
3.11 activities (dashboard feed + weekly digest source, plan 2.7)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
timestamp | timestamptz | NOT NULL | rendered German ("Heute, 09:12") |
type | activity_type | NOT NULL | |
text | text | NOT NULL | |
analysis_id | uuid | FK → analyses NULL ON DELETE SET NULL | |
client_id | uuid | FK → clients NULL ON DELETE SET NULL | |
user_id | uuid | FK → users NULL | NULL for system events |
actor | text | NOT NULL | display name; "AI-Pipeline" for system events (matches mock user) |
3.12 AI: ai_agents and ai_runs
ai_agents (registry — feeds the /ai-agents pages, plan 2.1/6.1)
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | text | PK | stable ids ag-01…ag-11, seeded from lib/mock-data/agents.ts |
name | text | NOT NULL | |
module | text | NOT NULL | "Modul 1 – Akquise", "Modul 3 – KIU", … |
type | ai_agent_type | NOT NULL | |
description | text | ||
competencies | jsonb | string[] | |
status | ai_agent_status | NOT NULL DEFAULT Ready | operator-managed flag |
config | jsonb | NOT NULL DEFAULT {} | prompt/config ref: {system_prompt_version, model?, top_k?, kb?} — the prompt text itself is versioned in the repo, not the DB |
last_run / total_runs from the prototype are derived from ai_runs
(⚠ review: MAX(started_at) / COUNT(*) per agent_id) — not stored.
ai_runs (LLM state machine, ⚠ nullable analysis_id + agent_id per review)
One row per single-shot LLM/gateway run the Knoll backend orchestrates (expert-report pipeline
steps, lead scoring, checklist classification, project-brief drafting). Chat turns through the
BFF (plan D3) are not ai_runs — GRAG persists those conversations itself.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
agent_id | text | FK → ai_agents NULL | ⚠ nullable — ad-hoc runs |
analysis_id | uuid | FK → analyses NULL | ⚠ nullable — acquisition/KIU runs without analysis (plan 2.1) |
expert_report_id | uuid | FK → expert_reports NULL | set for pipeline runs that belong to an expert-report version (plan 5.3) |
step | text | NOT NULL | well-known values below |
status | ai_run_status | NOT NULL DEFAULT queued | state machine below |
attempt | smallint | NOT NULL DEFAULT 1 | bounded prompt-JSON + Zod validation retries (plan 1.14 — ai-gateway has ⚠ NO response_format/JSON mode) |
model | text | gateway model id actually used (incl. fallback resolution) | |
pipeline_id | text | NULL | the value sent as X-Pipeline-Id; per-run spend = GET /ledger/api/v1/ledger/totals?pipeline_id=… (⚠ corrected path; plan 1.6/7.3, 14-cost-model.md). Naming convention in 03-id-conventions.md |
input_refs | jsonb | pointers to inputs (analysis id, question ids, document ids, prior run ids) — no bulk payload copies | |
output | jsonb | NULL | the Zod-validated structured result |
tokens_in | integer | NULL | from ai-gateway usage.prompt_tokens (CompletionResponse — verified: usage {prompt_tokens, completion_tokens, total_tokens}; no USD in the response) |
tokens_out | integer | NULL | usage.completion_tokens |
cost_usd | numeric(10,4) | NULL | backfilled from the ledger totals by pipeline_id (the gateway response carries no USD) |
groundedness_score | numeric(4,3) | NULL | from POST /groundedness/api/v1/score (include_nli=true, include_per_sentence=true, skip include_spans — English-only; plan 5.3) |
groundedness_band | groundedness_band | NULL | service band verbatim (green/amber/red/unknown, §2.2) |
error | text | NULL | last error (gateway status, validation failure, SSE error frame) |
reviewed_by | uuid | FK → users NULL | Partner review gate between pipeline steps (plan 5.3) |
reviewed_at | timestamptz | NULL | |
review_comment | text | NULL | |
started_at | timestamptz | NULL | |
finished_at | timestamptz | NULL |
Well-known step values (extensible; keep in one TS constant):
step | Feature | Plan |
|---|---|---|
interview, document, swot, scoring, report, report | the six expert-report pipeline steps (incl. contradiction detection + follow-up questions in interview) | 5.3 |
lead_scoring | deterministic-extraction over the acquisition transcript | 5.1a |
checklist_classification | document → 46-item checklist match suggestion | 4.3 |
service_provider_rationale | rationale text drafting | 5.4 |
project_brief | project brief + calculation mapping | 5.5 |
State machine:
queued ──► running ──► succeeded (ungated runs: 4.3, 5.1a, 5.4, 5.5)
│
├──────► awaiting_review ──► approved (gated pipeline steps, plan 5.3:
│ │ Partner review between steps)
│ └──────────► rejected (→ new run row, attempt reset)
├──────► failed (gateway error / validation retries
│ exhausted per plan 1.14)
└──────► cancelled (user/operator abort)
Transitions are Knoll-backend-only; approved/rejected additionally require
users.role = 'Partner' and write audit_log. A rejected or failed run is never
mutated back to queued — retry = new row (same step, attempt restarts; the old row
keeps the failure evidence).
3.13 Settings & notifications
notification_preferences (plan 2.1/2.7, Settings tab 3)
| Column | Type | Constraints | Notes |
|---|---|---|---|
user_id | uuid | FK → users ON DELETE CASCADE | |
key | notification_type | ||
active | boolean | NOT NULL | |
PK (user_id, key) |
Absence of a row = prototype default: questionnaire/expert_report/kyc/milestone true,
summary (weekly Monday digest) false. The email layer (plan 2.7) reads this
table before every send.
knoll_ai_settings (plan 2.1, Settings AI tab — consumed by 5.3)
| Column | Type | Constraints | Notes |
|---|---|---|---|
firm_id | uuid | PK, FK → firms | one row per firm |
standard_model | text | NOT NULL | validated at write time against GET /ai-gateway/api/v1/models (read-through, never cached durably — plan 6.6) |
detail_level | detail_level | NOT NULL DEFAULT Standard | expert-report verbosity |
caps_enabled | boolean | NOT NULL DEFAULT true | deterministic score caps toggle (5.3 step 4) |
updated_by | uuid | FK → users NULL |
The "Human-in-the-loop approval" toggle from the prototype is deliberately not a column —
it is QMS-mandatory and hard-coded in the 5.3 pipeline ("kann nicht deaktiviert werden").
GRAG-side tenant settings (chat.default_model, chunking.default_*, …) stay in GRAG's
settings store and are read through GET/PUT $GRAG_URL/workspaces/api/v1/settings/{key}
(plan 1.7/6.6) — not duplicated here.
3.14 files — file-store metadata (plan 2.4)
GRAG keeps no binaries. Every original upload is persisted to the Knoll file store before it is forwarded to the BFF upload lane (plan 4.1), and every generated PDF (expert report, offer) lands here too. This table is the metadata index; the bytes live in an S3-compatible bucket (or Postgres bytea at pilot scale, D8).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
firm_id | uuid | FK → firms NOT NULL | |
client_id | uuid | FK → clients NULL | |
analysis_id | uuid | FK → analyses NULL | |
checklist_item_id | uuid | FK → checklist_items NULL | checklist upload linkage (plan 4.1) |
expert_report_id | uuid | FK → expert_reports NULL | rendered report PDFs (plan 5.3 step 6) |
filename | text | NOT NULL | original filename — display + download name (never part of the storage key) |
mime | text | NOT NULL | |
size_bytes | bigint | NOT NULL | |
sha256 | char(64) | NOT NULL | integrity + duplicate detection; indexed |
source | file_source | NOT NULL | upload vs. generated |
storage_key | text | NOT NULL, UNIQUE | object-store key, convention below |
grag_document_id | text | NULL | convenience mirror of the live grag_refs row (grag_type='document'); NULL for files never ingested (e.g. signed contracts, generated offer PDFs not sent to GRAG) |
uploaded_by | uuid | FK → users NULL | |
deleted_at | timestamptz | NULL | set when the object is removed from the store (GDPR sweep verification, plan 7.5) |
Storage-key convention (deterministic, prefix-deletable):
client/{client_id}/analysis/{analysis_id}/{file_id} # analysis-scoped files
client/{client_id}/{file_id} # client-scoped, no analysis
firm/{firm_id}/{file_id} # firm-level files
Never embed filename in the key (umlauts, collisions) — it lives only in this table.
The per-client/per-analysis prefixes make GDPR deletion a prefix delete + row hard-delete +
audit_log entry (12-gdpr-compliance.md, plan 7.5). Invariant checked by the sync job:
files.grag_document_id equals the grag_id of the single live grag_refs row for this
file, or both are NULL.
3.15 grag_refs — the Knoll ↔ GRAG id ledger
One row per GRAG resource that a Knoll entity owns. This table is the normative
grag_refs contract — column names, knoll_type value list, grag_sync_status lifecycle
and uniqueness as defined below. Id naming conventions remain authoritative in
03-id-conventions.md, but every other restatement of this table is superseded by this
section and must reference it verbatim: 03 §5's variant (knoll_type/grag_kind/
payload_sha256, creating/ready/deleting states, job/schema kinds),
10-document-pipeline.md §2/§6's ingest statuses and 11-resilience-and-errors.md §5.3's
status list. Two consequences: ingest jobIds are recorded in grag_job_id on the owning
row (diagnostic only) — never as separate grag_refs rows; and GRAG-side document ingest
substates (converting, indexing, indexed, …) live in the GRAG document inventory and
are read live per §6 — never stored in sync_status. Written by the provisioning module
(plan 3.1), the upload lane (plan 4.1), chat setup (plan 5.2) and the KG seeder (plan 3.5).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | uuid | PK | |
knoll_type | text | NOT NULL, CHECK in (firm, client, analysis, methodology, file, questionnaire_submission, expert_report, lead, analysis_chat, success_lever, service_provider) | which Knoll entity owns the resource |
knoll_id | text | NOT NULL | PK/slug of the owning row (uuid as text; success_levers.slug; service_providers.id) |
grag_type | grag_resource_type | NOT NULL | |
grag_tenant_id | text | NOT NULL | always set — grag-client hard-fails on missing tenant (plan 2.5; platform silently falls back to tenant default) |
grag_workspace_id | text | NULL | required for kg-service calls (X-Workspace-ID) |
grag_kb_id | text | NULL | |
grag_id | text | NOT NULL | the resource's own id (workspace id, kb id, document id, conversation id, entity id) |
grag_job_id | text | NULL | last ingest jobId (docfold/orchestrator) — diagnostic only; job state expires platform-side (status hash / 30-min SSE ceiling) |
sync_status | grag_sync_status | NOT NULL DEFAULT pending | |
last_synced_at | timestamptz | NULL | |
last_error | text | NULL | last provisioning/ingest/upsert error (plan 2.1) |
Partial UNIQUE (knoll_type, knoll_id, grag_type) WHERE sync_status IN (pending,synced) | exactly one live ref per pair; superseded/deleted rows remain as history |
ID conventions recorded here (summary — 03-id-conventions.md is authoritative; all
workspaces-plane ids must match ^[a-z0-9][a-z0-9-]{0,62}$, verified at
workspaces/src/workspaces/schemas.py:39):
grag_type | Convention | Chosen by |
|---|---|---|
tenant | knoll (start), later firm-<slug>; dev: knoll-dev | operator (plan 1.3) |
workspace | client-<slug>-<shortid> (slugified umlauts: ä→ae …) | Knoll backend (plan 3.1) |
kb | kb-analysis-<shortid>; methodology: kb-methodology in general | Knoll backend (plan 3.1/3.3) |
conversation | conv-<purpose>-<shortid> — client-chosen, no underscores (DNS-label); ⚠ must be created via POST /workspaces/api/v1/conversations BEFORE first chat turn — the BFF does NOT create conversations, a made-up id streams fine but never persists (plan 5.2) | Knoll backend |
document | server-assigned by POST /workspaces/api/v1/kbs/{kb}/documents (returned by the upload BFF as documentId) — store verbatim | GRAG |
kg_entity | lever:<slug>, service-provider:<slug> (colon ids — kg-service only, not DNS-label constrained) | Knoll backend (plan 3.5) |
Idempotency: deterministic client-chosen ids + treat HTTP 409 as already-provisioned.
⚠ There is no Idempotency-Key support anywhere in the workspaces service (review-verified;
the mention in workspaces/CLAUDE.md has no implementation) — do not send the header
(plan 3.1).
Example — provisioning a KB-chat conversation (plan 5.2) and recording it:
curl -sS -X POST "$GRAG_URL/workspaces/api/v1/conversations" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT" \
-H "Content-Type: application/json" \
-d '{
"id": "conv-file-7f3k2-0001",
"kb_id": "kb-analysis-7f3k2",
"workspace_id": "client-hartmann-a1b2",
"title": "Frag die Akte – Hartmann Maschinenbau"
}'
# 200 → INSERT INTO grag_refs (knoll_type, knoll_id, grag_type, grag_tenant_id,
# grag_workspace_id, grag_kb_id, grag_id, sync_status, last_synced_at)
# VALUES ('analysis_chat', '<analysis-uuid>', 'conversation', 'knoll',
# 'client-hartmann-a1b2', 'kb-analysis-7f3k2', 'conv-file-7f3k2-0001',
# 'synced', now());
# 409 → already provisioned: verify the existing grag_refs row, do not error.
(Dev examples use -H "X-Tenant-ID: knoll-dev".)
3.16 audit_log
Append-only. No FKs — rows must survive entity deletion (accountability after GDPR
deletion); personal data inside details must follow 12-gdpr-compliance.md
(pseudonymize/minimize).
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | bigint | PK, identity | |
timestamp | timestamptz | NOT NULL DEFAULT now() | |
user_id | uuid | NULL | NULL = system/job |
actor | text | NOT NULL | display-name/email snapshot at event time |
action | text | NOT NULL | dotted verbs, e.g. expert_report.approve, expert_report.reject, fee.update, role.update, file.delete, client.delete, grag.provision, grag.purge, kg.upsert, ai_run.approve |
entity | text | NOT NULL | table/entity name |
entity_id | text | NOT NULL | |
details | jsonb | before/after diff, request context |
Minimum audited events (QMS + plan 7.5/7.7): every expert-report approval/rejection, every fee/fee-tier change, role changes, GDPR data export/deletion executions, GRAG provisioning/de-provisioning, deterministic KG writes (plan 3.6), file deletions.
4. Relations overview
firms ─┬─ users ──┬─ notification_preferences
│ └─ (approvals / reviews / uploads via FKs)
├─ fee_tiers
├─ knoll_ai_settings (1:1)
└─ clients ─┬─ contacts
├─ files (also ← analyses/checklist_items/expert_reports)
└─ analyses ─┬─ milestones
├─ contracts ──────────────► files
├─ analysis_team ───────────► users
├─ questionnaire_answers (version via
├─ questionnaire_submissions analyses.questionnaire_version_id
│ → questionnaire_versions)
├─ checklist_items ────────► checklist_template, files
├─ expert_reports ─┬─ report_lever_scores ─► success_levers
│ ├─ recommendations ─────► success_levers
│ └─ pdf ──────────────────► files
├─ projects ─┬─ calculation_line_items
│ ├─ project_lever ──────────► success_levers
│ ├─ project_service_provider ─► service_providers
│ └─ recommendation_id ───────► recommendations
└─ ai_runs (nullable analysis_id/agent_id/expert_report_id)
service_providers ── service_provider_ratings (← users, ← projects)
leads ──► ai_runs, clients (conversion)
ai_agents ──► ai_runs
activities ──► analyses?, clients?, users?
grag_refs (knoll_type + knoll_id → any owning row; no hard FK by design)
audit_log (no FKs, append-only)
grag_refs.knoll_id is deliberately a soft reference (text, no FK): it must outlive the
owning row long enough for the GRAG-side purge fan-out (plan 3.2/7.5) to complete, then the
row flips to sync_status='deleted' and may be hard-deleted after the retention window.
5. Indexes worth defining
Beyond PKs and the UNIQUE constraints listed above (Drizzle: define in the table builders):
| Table | Index | Why |
|---|---|---|
users | UNIQUE lower(email) | login lookup |
clients | (firm_id, company_name) | list + search |
analyses | (client_id), (status, phase) | client detail; dashboard filters |
milestones | (analysis_id), (status, target_date) | "milestone delayed" scan (plan 2.7) |
questionnaire_answers | UNIQUE (analysis_id, question_id) | autosave upsert (already listed; it is the hot path) |
checklist_items | (analysis_id, status) | progress computation |
expert_reports | (analysis_id, version DESC) | "current expert report" lookup |
recommendations | (expert_report_id), (lever_slug) | render + KG reconcile (plan 3.6) |
projects | (analysis_id), (recommendation_id) | IMPLEMENTS projection + analysis detail |
leads | (status, overall_score DESC) | funnel view |
activities | (timestamp DESC), (analysis_id, timestamp DESC) | feed + weekly digest |
ai_runs | (analysis_id, started_at DESC), (agent_id, started_at DESC), (expert_report_id, step), (pipeline_id), (status) partial WHERE status IN ('queued','running','awaiting_review') | pipeline panel (6.4), agent stats (6.1), ledger reconciliation (7.3), work queue |
files | (analysis_id), (checklist_item_id), (sha256), (grag_document_id) | downloads, dedupe, GRAG lookups |
grag_refs | (grag_id), (grag_type, grag_id), (knoll_type, knoll_id), (sync_status) partial WHERE sync_status IN ('pending','failed') | reverse lookup from GRAG webhooks/SSE payloads; reconcile job (plan 3.6) work list |
audit_log | (timestamp DESC), (entity, entity_id) | review + data export |
service_provider_ratings | (service_provider_id, rated_at DESC) | overall_grade recompute |
6. What is deliberately NOT stored here (GRAG-owned) — and the cache-only rule
| Data | Lives in | Knoll access | Plan |
|---|---|---|---|
Document inventory per KB (name, mime, status queued→processing→indexed|failed|superseded, chunk counts) | GRAG workspaces.documents | GET $GRAG_URL/workspaces/api/v1/kbs/{kb}/documents — render live, cache ≤ minutes | 4.4 ("inventory NOT duplicated — cache only") |
| Conversation transcripts (messages, sources, trace, groundedness, cost JSONB) | GRAG workspaces.conversations / conversation_messages | BFF chat stream + GET /workspaces/api/v1/conversations/{id}/messages | 5.1a/5.1b/5.2, 09-chat-integration.md |
| Chunks & vectors | voyager collection = kb_id (per-KB) | retrieval via ai-gateway; never read raw | 02-grag-api-cookbook.md |
| KG entities & edges | kg-service (relational kg_entities + AGE; ⚠ edges AGE-only, lossy, no entity delete route) | projection only — re-creatable from this schema via the reconcile job | 3.5/3.6, 07-kg-schema-knoll-advisory.md |
GRAG tenant settings (chat.default_model, chunking.default_*, …) | GRAG settings store (ADR 0030) | read-through in Settings AI tab | 1.7/6.6 |
| Spend / budget | ledger service | ⚠ GET $GRAG_URL/ledger/api/v1/ledger/spend?group_by=service|provider (tenant budget) and GET $GRAG_URL/ledger/api/v1/ledger/totals?pipeline_id=… (per-run) — only those two groupings exist | 1.6/7.3, 14-cost-model.md |
| Model catalog | ai-gateway | GET $GRAG_URL/ai-gateway/api/v1/models at Settings render/save time | 6.6 |
Cache-only rule. Knoll MAY cache GRAG reads (in-memory/Redis, short TTL) for UI latency,
but: (1) no GRAG payload is ever the basis of a durable business-state write — the only
durable Knoll-side copies are ids in grag_refs, the files.grag_document_id mirror, and
binaries in the Knoll file store; (2) any state shown as authoritative (document status,
conversation history, spend) is re-fetched, not replayed from cache; (3) staleness is resolved
by re-fetch + the idempotent reconcile job (plan 3.6), never by writing cached values back.
Example of the canonical cache-only read (document status for the checklist tab, plan 6.3):
curl -sS "$GRAG_URL/workspaces/api/v1/kbs/kb-analysis-7f3k2/documents" \
-H "Authorization: Bearer $GRAG_API_KEY" \
-H "X-Tenant-ID: $GRAG_TENANT"
# → {"items":[{"id":"…","name":"Jahresabschluss-2025.pdf","status":"indexed", …}]}
# Join items[].id against grag_refs.grag_id / files.grag_document_id for display;
# do NOT persist the payload.
Also NOT here: end-user identity on the GRAG side (GRAG has no user model — plan
resource-mapping table); the X-Pipeline-Id ledger rows themselves (ledger is authoritative,
ai_runs.cost_usd is a backfilled convenience copy); the Methodenhandbuch corpus
(kb-methodology, plan 3.3 — Knoll stores only its grag_refs row).
7. Open TODO-VERIFY items
- RESOLVED (was TODO-VERIFY): rating matrix D.III.2 — the 0.35/0.35/0.25 weighting
applies only to the quality matrix (error frequency 0.35 / functionality 0.35 /
customer rating 0.25 → main criterion "Qualität Dienstleistung"); the other main criteria
carry no weighting in the xlsx. Remaining OPEN DECISION (not a verify item): the
cross-criteria aggregation formula for
service_providers.overall_grade(§3.8). - TODO-VERIFY: checklist C.I.3 — the mock seed has exactly 46 items (groups A, B.1–B.11);
diff wording/grouping against the original Anlage before freezing
checklist_templateids. - TODO-VERIFY: questionnaire v1 — diff the 16-chapter mock definition (
lib/mock-data/questionnaire.ts) against Anlage C.I.1 + C.I.2 (raw/Anlage_C.I.1_Kundenfragebogen.md,raw/Anlage_C.I.2_Ergaenzung_Fragebogen_IT.md) before sealingquestionnaire_versionsversion 1 (⚠ not A.I.2.2 — that is the KIU initial assessment, a different artifact). - TODO-VERIFY: fee-tier semantics — prototype shows revenue-keyed tiers (10–50 → 15.000 €, 50–100 → 30.000 €, >100 → individuell); confirm against the pricing model whether employees/balance_sheet_total (HGB class) co-determine the tier.