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

RuleConsequence
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 dataKnoll 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 modelAuthorization (incl. Partner-only gates) is enforced 100 % in the Knoll backend against users.role (plan 2.3/7.7)
NamingTables/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
EnumsDomain 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
Keysuuid PKs (gen_random_uuid()) except seeded registries with stable text ids (success_levers.slug, checklist_template.id, ai_agents.id)
TimestampsEvery 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 / scoresEUR 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)
DatesStored as date/timestamptz; German formatting ("14.05.2026", "03/2025") is a render concern only
DeletionHard 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)

EnumValuesSource
engagement_statusDraft, Onboarding, Analysis, CompletedEngagementStatus
kyc_statusPending, Verified, RejectedKycStatus
hgb_size_classMicro, Small, Medium, LargeHgbSizeClass
traffic_lightRed, Yellow, GreenTrafficLight
checklist_statusOpen, Requested, PartiallyReceived, Received, NotAvailableChecklistStatus
milestone_statusPending, InProgress, Completed, DelayedMilestoneStatus
milestone_responsibleClient, Analyst, ExpertMilestone.responsible
contract_typeNDA, AnalyzerMainContract, ProjectExecution, TaxAdvisorConfidentialityWaiverContractType
contract_statusPending, SignedContractStatus
analysis_packageAnalyzerBasic, AnalyzerPlus, AnalyzerWithImplementationAnalysisPackage
project_statusProposal, Offer, InImplementation, CompletedProjectStatus
project_moduleMarketPreparation, MarketDevelopment, DigitalizationProject.module
swot_categoryStrength, Weakness, Opportunity, RiskSwotCategory
question_typeyes-no, free-text, number, multiple-choice, single-choice, scale, tableQuestionType
report_statusProvisional, FinalExpertReport.status
team_roleLeadPartner, Analyst, IUMember, Expert, BackofficeTeamMember.role
user_rolePartner, Analyst, Backoffice, Admin, TesterSettings 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_statusNew, Contacted, Converted, ArchivedLead.status
service_provider_statusApproved, UnderReview, BlockedServiceProvider.status
activity_typequestionnaire, expert_report, contract, project, ai, client, checklistActivity.type
ai_agent_typeAcquisition, Steering, Pipeline, ProjectGeneratorAiAgent.type
ai_agent_statusActive, Ready, InMaintenanceAiAgent.status
calculation_unitPersonDays, Hours, FlatRateCalculationLineItem.unit
detail_levelCompact, Standard, DetailedSettings AI tab
notification_typequestionnaire, expert_report, kyc, milestone, summarySettings Notifications tab
file_sourceupload, generatedplan 2.4 (originals vs. rendered PDFs)
languageGerman, EnglishSettings 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)

EnumValuesUsed by
ai_run_statusqueued, running, awaiting_review, approved, rejected, succeeded, failed, cancelledai_runs (state machine, §3.12)
groundedness_bandgreen, amber, red, unknownai_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_typetenant, workspace, kb, document, conversation, kg_entitygrag_refs
grag_sync_statuspending, synced, failed, superseded, deletedgrag_refs

3. Tables

3.1 Identity & firm

users

ColumnTypeConstraintsNotes
iduuidPK
firm_iduuidFK → firms(id) NOT NULLv1 is single-firm; column keeps multi-firm open
nametextNOT NULLe.g. "Dr. Michael Knoll"
emailtextNOT NULL, UNIQUE (case-insensitive index)Auth.js credentials login id (plan 2.3)
password_hashtextNULLNULL once SSO-only
roleuser_roleNOT NULLPartner gates expert-report approval (plan 5.3/6.4) and fee edits — enforced server-side (plan 7.7)
languagelanguageNOT NULL DEFAULT Germani18n decision pending (plan 6.6)
initialstextNULLfallback: derived from name
activebooleanNOT NULL DEFAULT truedeactivate instead of delete while referenced by audit-relevant rows

firms

ColumnTypeConstraintsNotes
iduuidPKone row in v1
nametextNOT NULL"kbs group – Knoll Beratung & Service"
streettext"Maximilianstraße 35"
postal_codetext
citytext
vat_idtext"DE 214 598 337"
grag_tenant_idtextNOT 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)

ColumnTypeConstraintsNotes
iduuidPK
firm_iduuidFK → firms NOT NULL
labeltextNOT NULL"10–50 Mio. € revenue" (Settings firm tab)
revenue_min_mionumeric(10,2)NULLlower bound in Mio. EUR (NULL = open)
revenue_max_mionumeric(10,2)NULLupper bound (NULL = open, ">100 Mio.")
fee_netnumeric(12,2)NULLNULL = "individuell" (Partner sets analyses.fee manually)
sort_ordersmallintNOT NULLdisplay 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").

ColumnTypeConstraintsNotes
slugtextPK, 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
nametextNOT NULL, UNIQUEexact types.ts label
sort_ordersmallintNOT NULL, UNIQUEfixed order 1–11 (SUCCESS_LEVERS const)
activebooleanNOT NULL DEFAULT truetombstone 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):

#slugname
1market-positionMarketPosition
2strategyStrategy
3brandBrand
4competitionCompetition
5products--servicesProductsAndServices
6pricingPricing
7customersCustomers
8salesSales
9planning--controllingPlanningAndControlling
10marketing-implementationMarketingImplementation
11personnelPersonnel

3.3 clients

clients

ColumnTypeConstraintsNotes
iduuidPKseed maps prototype ids m-01
firm_iduuidFK → firms NOT NULL
company_nametextNOT NULL
legal_formtext
industrytext
citytext
countrytext
website_urltext
founding_yearinteger
revenuenumeric(14,2)annual revenue EUR
employeesinteger
balance_sheet_totalnumeric(14,2)EUR
hgb_size_classhgb_size_class
kyc_statuskyc_statusNOT NULL DEFAULT Pendingtransitions trigger the kyc notification (plan 2.7)
customer_sincedaterendered "MM/YYYY"
notestextNULL

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

ColumnTypeConstraintsNotes
iduuidPK
client_iduuidFK → clients NOT NULL ON DELETE CASCADE
nametextNOT NULL
roletextfree text ("Geschäftsführer")
emailtext
phonetext
positionsmallintNOT NULL DEFAULT 0display order

3.4 analyses

analyses

ColumnTypeConstraintsNotes
iduuidPKseed maps a-01
client_iduuidFK → clients NOT NULL
titletextNOT NULL
packageanalysis_packageNOT NULL
statusengagement_statusNOT NULL DEFAULT Draft
phasesmallintNOT NULL DEFAULT 1, CHECK 1–6process phases A.II.1/A.II.2
start_datedate
deadlinedate
feenumeric(12,2)defaulted from fee_tiers by revenue; edits Partner-only
questionnaire_version_iduuidFK → questionnaire_versions NOT NULLpinned 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)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADE
nametextNOT NULL
target_datedateNOT NULL
actual_datedateNULL
statusmilestone_statusNOT NULL DEFAULT PendingDelayed (or target_date < today while not Completed) fires the milestone notification (plan 2.7)
responsiblemilestone_responsibleNOT NULL
positionsmallintNOT NULL DEFAULT 0

contracts (⚠ review addition — status tracking IS in scope; e-signature is not)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADE
typecontract_typeNOT NULL
statuscontract_statusNOT NULL DEFAULT Pending
signed_atdateNULL
file_iduuidFK → files NULLscanned signed contract, if stored
UNIQUE (analysis_id, type)one row per contract type per analysis

analysis_team (⚠ review addition)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADE
user_iduuidFK → users NULLNULL for externals (experts not in users)
nametextNULLrequired when user_id IS NULL — CHECK (user_id IS NOT NULL OR name IS NOT NULL)
roleteam_roleNOT NULL
initialstextNULLfor externals; internals derive from users
Partial UNIQUE (analysis_id, user_id) WHERE user_id IS NOT NULL

3.5 Questionnaire

questionnaire_versions

ColumnTypeConstraintsNotes
iduuidPK
versionintegerNOT NULL, UNIQUEmonotonically increasing
titletextNOT NULL"Fragenkatalog Analyzer (Anlage C.I.1 + C.I.2)"
definitionjsonbNOT NULLarray 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_fromdateNOT NULL
created_byuuidFK → 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)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADE
question_idtextNOT NULLQuestion.id within the pinned version's definition
answerjsonbNOT NULLtyped by question.type: boolean (yes-no), string (free-text/single-choice), number (number/scale), string[] (multiple-choice), array of row-objects (table)
updated_byuuidFK → users NULL
UNIQUE (analysis_id, question_id)the debounced-autosave upsert target

questionnaire_submissions (submit + re-submit anchor, plan 4.5)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADE
numberintegerNOT NULL; UNIQUE (analysis_id, number)1..n submissions
submitted_attimestamptzNOT NULLfires the questionnaire notification (plan 2.7)
submitted_byuuidFK → users NULL
answers_snapshotjsonbNOT NULLfrozen 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)

ColumnTypeConstraintsNotes
idtextPKstable ids ck-01ck-46, seeded from lib/mock-data/checklist.ts (verified: exactly 46 items in groups A. Fragenkatalog, B.1 Marktposition / UnternehmenB.11 Personal; the ck- prefix matches the mock and 08 §5.2's references)
grouptextNOT NULL
labeltextNOT NULL
positionsmallintNOT NULL
activebooleanNOT NULL DEFAULT trueretire 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)

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL ON DELETE CASCADEall 46 rows created on analysis creation
template_idtextFK → checklist_template NOT NULL
statuschecklist_statusNOT NULL DEFAULT Open
hinttextNULL
suggested_file_iduuidFK → files NULLplan 4.3: classifier suggests which uploaded document satisfies this item
suggested_statuschecklist_statusNULLsuggested target status (typically Received)
suggested_rationaletextNULLLLM rationale for the reviewer
confirmed_byuuidFK → users NULLhuman confirms — no silent auto-set (plan 4.3)
confirmed_attimestamptzNULL
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

ColumnTypeConstraintsNotes
iduuidPK
analysis_iduuidFK → analyses NOT NULL
versionintegerNOT NULL; UNIQUE (analysis_id, version)1..n
statusreport_statusNOT NULL DEFAULT Provisional
created_attimestamptzNOT NULL
overall_scorenumeric(2,1)CHECK 1.0–5.05 = best (per types.ts "ADR-013")
traffic_lighttraffic_lightcomputed in code from overall_score
key_messagesjsonbstring[]
swotjsonb{strengths: string[], weaknesses: string[], opportunities: string[], risks: string[]}
approved_byuuidFK → users NULLPartner role required — QMS human-in-the-loop, enforced server-side (plan 2.3/5.3)
approved_attimestamptzNULLfires the expert_report notification when the draft is ready for review (plan 2.7)
pdf_file_iduuidFK → files NULLrendered report PDF, file store (plan 5.3 step 6, 6.4 export)

report_lever_scores

ColumnTypeConstraintsNotes
expert_report_iduuidFK → expert_reports NOT NULL ON DELETE CASCADE
lever_slugtextFK → success_levers NOT NULL
scorenumeric(2,1)NOT NULL, CHECK 1.0–5.0LLM proposes → deterministic caps in code → stored (plan 5.3 step 4)
traffic_lighttraffic_lightNOT NULLcomputed in code
commenttextrationale
cappedbooleanNOT NULL DEFAULT falsetrue when a deterministic cap lowered the LLM proposal
cap_reasontextNULLe.g. "no business plan → MarketPosition ≤ 1.5"
PK (expert_report_id, lever_slug)exactly 11 rows per expert report

recommendations

ColumnTypeConstraintsNotes
iduuidPK
expert_report_iduuidFK → expert_reports NOT NULL ON DELETE CASCADE
lever_slugtextFK → success_levers NOT NULL
titletextNOT NULL
descriptiontext
swot_categoryswot_categoryNOT NULL
prioritysmallintNOT NULL, CHECK 1–31 = highest
time_horizontext"0–6 Monate"
positionsmallintNOT NULL DEFAULT 0

Expert report versioning strategy (plan 2.1):

  1. Versions are immutable. Every pipeline run (plan 5.3) writes a new expert_reports row with version + 1 plus its child rows; nothing is edited in place after approval.
  2. Approval = Partner sets status='Final' + approved_by/_at. The current expert report of an analysis is the highest-version row with status='Final' (UI falls back to the highest Provisional draft when none is Final).
  3. Corrections after approval → new version (re-run or manual edit path); the old Final stays for audit.
  4. 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, RECOMMENDS edges) are re-upserted idempotently with analysis_id + version in the edge properties (plan 3.6).
  5. The prototype's ExpertReport.pipeline (PipelineStep[]) is not stored — the pipeline panel (plan 6.4) derives it from ai_runs (§3.12).

3.8 service providers

service_providers

ColumnTypeConstraintsNotes
iduuidPKseed maps d-01
slugtextNOT NULL, UNIQUE, CHECK ^[a-z0-9][a-z0-9-]*$KG entity id = service-provider:<slug> (plan 3.5, 03-id-conventions.md)
nametextNOT NULL
categorytextNOT NULL"Webdesign & Development", …
citytext
contact_persontextcontact person name (flat, per types.ts)
emailtexttarget of "Anfrage senden" (plan 2.7)
work_samplesintegerNOT NULL DEFAULT 0joint projects count
overall_gradenumeric(2,1)NULLderived — 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_signedbooleanNOT NULL DEFAULT false
gdpr_checkedbooleanNOT NULL DEFAULT false
statusservice_provider_statusNOT NULL DEFAULT UnderReviewBlocked/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)

ColumnTypeConstraintsNotes
iduuidPK
service_provider_iduuidFK → service_providers NOT NULL ON DELETE CASCADE
project_iduuidFK → projects NULLrating in context of a project
rated_byuuidFK → users NULL
rated_attimestamptzNOT NULL
error_frequencysmallintCHECK 1–6
functionalitysmallintCHECK 1–6
customer_ratingsmallintCHECK 1–6
deadline_compliancesmallintCHECK 1–6
price_positioningsmallintCHECK 1–6
reference_potentialsmallintCHECK 1–6
negotiation_positionsmallintCHECK 1–6
capacitiessmallintCHECK 1–6
commenttext

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

ColumnTypeConstraintsNotes
iduuidPKseed maps p-01
analysis_iduuidFK → analyses NOT NULL
client_iduuidFK → clients NOT NULLdenormalized per types.ts; backend asserts it matches analyses.client_id
recommendation_iduuidFK → recommendations NULLplan 5.5: generated from an approved recommendation; source of the IMPLEMENTS KG edge (plan 3.6)
titletextNOT NULL
moduleproject_moduleNOT NULL
descriptiontext
statusproject_statusNOT NULL DEFAULT Proposal
prioritysmallintNOT NULL, CHECK 1–3
timeframetext"Q3–Q4 2026"
external_totalnumeric(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)

ColumnTypeConstraintsNotes
project_iduuidFK → projects ON DELETE CASCADEPK (project_id, lever_slug)
lever_slugtextFK → success_levers

project_service_provider (m:n — Project.serviceProviderIds)

ColumnTypeConstraintsNotes
project_iduuidFK → projects ON DELETE CASCADEPK (project_id, service_provider_id)
service_provider_iduuidFK → service_providers

calculation_line_items

ColumnTypeConstraintsNotes
iduuidPK
project_iduuidFK → projects NOT NULL ON DELETE CASCADE
positiontextNOT NULLposition label
descriptiontext
unitcalculation_unitNOT NULL
quantitynumeric(10,2)NOT NULL
ratenumeric(12,2)NOT NULLEUR per unit
amountnumeric(12,2)NOT NULL= quantity * rate, computed and written by code (never by the LLM, plan 5.5)
sort_ordersmallintNOT NULL DEFAULT 0

3.10 Acquisition: leads

ColumnTypeConstraintsNotes
iduuidPKseed maps l-…
companytextNOT NULL
industrytext
regiontext
employeesinteger
revenue_classtext
attractivenesssmallintCHECK 1–10
reachabilitysmallintCHECK 1–10
overall_scorenumeric(3,1)derived: 0.6 * attractiveness + 0.4 * reachability, computed in code (plan 5.1a); stored for sorting only
statuslead_statusNOT NULL DEFAULT New
signalsjsonbstring[]
ai_run_iduuidFK → ai_runs NULLthe structured-output extraction run that scored this lead
client_iduuidFK → clients NULLset 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)

ColumnTypeConstraintsNotes
iduuidPK
timestamptimestamptzNOT NULLrendered German ("Heute, 09:12")
typeactivity_typeNOT NULL
texttextNOT NULL
analysis_iduuidFK → analyses NULL ON DELETE SET NULL
client_iduuidFK → clients NULL ON DELETE SET NULL
user_iduuidFK → users NULLNULL for system events
actortextNOT NULLdisplay 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)

ColumnTypeConstraintsNotes
idtextPKstable ids ag-01ag-11, seeded from lib/mock-data/agents.ts
nametextNOT NULL
moduletextNOT NULL"Modul 1 – Akquise", "Modul 3 – KIU", …
typeai_agent_typeNOT NULL
descriptiontext
competenciesjsonbstring[]
statusai_agent_statusNOT NULL DEFAULT Readyoperator-managed flag
configjsonbNOT 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.

ColumnTypeConstraintsNotes
iduuidPK
agent_idtextFK → ai_agents NULL⚠ nullable — ad-hoc runs
analysis_iduuidFK → analyses NULL⚠ nullable — acquisition/KIU runs without analysis (plan 2.1)
expert_report_iduuidFK → expert_reports NULLset for pipeline runs that belong to an expert-report version (plan 5.3)
steptextNOT NULLwell-known values below
statusai_run_statusNOT NULL DEFAULT queuedstate machine below
attemptsmallintNOT NULL DEFAULT 1bounded prompt-JSON + Zod validation retries (plan 1.14 — ai-gateway has ⚠ NO response_format/JSON mode)
modeltextgateway model id actually used (incl. fallback resolution)
pipeline_idtextNULLthe 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_refsjsonbpointers to inputs (analysis id, question ids, document ids, prior run ids) — no bulk payload copies
outputjsonbNULLthe Zod-validated structured result
tokens_inintegerNULLfrom ai-gateway usage.prompt_tokens (CompletionResponse — verified: usage {prompt_tokens, completion_tokens, total_tokens}; no USD in the response)
tokens_outintegerNULLusage.completion_tokens
cost_usdnumeric(10,4)NULLbackfilled from the ledger totals by pipeline_id (the gateway response carries no USD)
groundedness_scorenumeric(4,3)NULLfrom POST /groundedness/api/v1/score (include_nli=true, include_per_sentence=true, skip include_spans — English-only; plan 5.3)
groundedness_bandgroundedness_bandNULLservice band verbatim (green/amber/red/unknown, §2.2)
errortextNULLlast error (gateway status, validation failure, SSE error frame)
reviewed_byuuidFK → users NULLPartner review gate between pipeline steps (plan 5.3)
reviewed_attimestamptzNULL
review_commenttextNULL
started_attimestamptzNULL
finished_attimestamptzNULL

Well-known step values (extensible; keep in one TS constant):

stepFeaturePlan
interview, document, swot, scoring, report, reportthe six expert-report pipeline steps (incl. contradiction detection + follow-up questions in interview)5.3
lead_scoringdeterministic-extraction over the acquisition transcript5.1a
checklist_classificationdocument → 46-item checklist match suggestion4.3
service_provider_rationalerationale text drafting5.4
project_briefproject brief + calculation mapping5.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)

ColumnTypeConstraintsNotes
user_iduuidFK → users ON DELETE CASCADE
keynotification_type
activebooleanNOT 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)

ColumnTypeConstraintsNotes
firm_iduuidPK, FK → firmsone row per firm
standard_modeltextNOT NULLvalidated at write time against GET /ai-gateway/api/v1/models (read-through, never cached durably — plan 6.6)
detail_leveldetail_levelNOT NULL DEFAULT Standardexpert-report verbosity
caps_enabledbooleanNOT NULL DEFAULT truedeterministic score caps toggle (5.3 step 4)
updated_byuuidFK → 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).

ColumnTypeConstraintsNotes
iduuidPK
firm_iduuidFK → firms NOT NULL
client_iduuidFK → clients NULL
analysis_iduuidFK → analyses NULL
checklist_item_iduuidFK → checklist_items NULLchecklist upload linkage (plan 4.1)
expert_report_iduuidFK → expert_reports NULLrendered report PDFs (plan 5.3 step 6)
filenametextNOT NULLoriginal filename — display + download name (never part of the storage key)
mimetextNOT NULL
size_bytesbigintNOT NULL
sha256char(64)NOT NULLintegrity + duplicate detection; indexed
sourcefile_sourceNOT NULLupload vs. generated
storage_keytextNOT NULL, UNIQUEobject-store key, convention below
grag_document_idtextNULLconvenience 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_byuuidFK → users NULL
deleted_attimestamptzNULLset 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).

ColumnTypeConstraintsNotes
iduuidPK
knoll_typetextNOT 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_idtextNOT NULLPK/slug of the owning row (uuid as text; success_levers.slug; service_providers.id)
grag_typegrag_resource_typeNOT NULL
grag_tenant_idtextNOT NULLalways set — grag-client hard-fails on missing tenant (plan 2.5; platform silently falls back to tenant default)
grag_workspace_idtextNULLrequired for kg-service calls (X-Workspace-ID)
grag_kb_idtextNULL
grag_idtextNOT NULLthe resource's own id (workspace id, kb id, document id, conversation id, entity id)
grag_job_idtextNULLlast ingest jobId (docfold/orchestrator) — diagnostic only; job state expires platform-side (status hash / 30-min SSE ceiling)
sync_statusgrag_sync_statusNOT NULL DEFAULT pending
last_synced_attimestamptzNULL
last_errortextNULLlast 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_typeConventionChosen by
tenantknoll (start), later firm-<slug>; dev: knoll-devoperator (plan 1.3)
workspaceclient-<slug>-<shortid> (slugified umlauts: ä→ae …)Knoll backend (plan 3.1)
kbkb-analysis-<shortid>; methodology: kb-methodology in generalKnoll backend (plan 3.1/3.3)
conversationconv-<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
documentserver-assigned by POST /workspaces/api/v1/kbs/{kb}/documents (returned by the upload BFF as documentId) — store verbatimGRAG
kg_entitylever:<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).

ColumnTypeConstraintsNotes
idbigintPK, identity
timestamptimestamptzNOT NULL DEFAULT now()
user_iduuidNULLNULL = system/job
actortextNOT NULLdisplay-name/email snapshot at event time
actiontextNOT NULLdotted 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
entitytextNOT NULLtable/entity name
entity_idtextNOT NULL
detailsjsonbbefore/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):

TableIndexWhy
usersUNIQUE 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_answersUNIQUE (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

DataLives inKnoll accessPlan
Document inventory per KB (name, mime, status queued→processing→indexed|failed|superseded, chunk counts)GRAG workspaces.documentsGET $GRAG_URL/workspaces/api/v1/kbs/{kb}/documents — render live, cache ≤ minutes4.4 ("inventory NOT duplicated — cache only")
Conversation transcripts (messages, sources, trace, groundedness, cost JSONB)GRAG workspaces.conversations / conversation_messagesBFF chat stream + GET /workspaces/api/v1/conversations/{id}/messages5.1a/5.1b/5.2, 09-chat-integration.md
Chunks & vectorsvoyager collection = kb_id (per-KB)retrieval via ai-gateway; never read raw02-grag-api-cookbook.md
KG entities & edgeskg-service (relational kg_entities + AGE; ⚠ edges AGE-only, lossy, no entity delete route)projection only — re-creatable from this schema via the reconcile job3.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 tab1.7/6.6
Spend / budgetledger serviceGET $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 exist1.6/7.3, 14-cost-model.md
Model catalogai-gatewayGET $GRAG_URL/ai-gateway/api/v1/models at Settings render/save time6.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_template ids.
  • 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 sealing questionnaire_versions version 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.