{"slug":"architecture/PROJECTION_MODEL","title":"The Projection Model — Canonical","section":"architecture","sectionLabel":"Architecture","status":"unclassified","type":"document","path":"architecture/PROJECTION_MODEL.mdx","bytes":9110,"description":null,"updated":null,"source":"integratewise/Integratewise-docs-wiki","content":"\n> **Status:** Canonical architecture doctrine\n> **Date:** June 12, 2026\n> **Authority:** Nirmal (Founder)\n> **Companion docs:** `SPINE_MODEL.md` (the truth), `USER_SYSTEM_JOURNEY_BLUEPRINT.md` (layers/stages)\n> **Map:** `docs/CANON.md`. When a doc contradicts this file or its referenced code, the code wins.\n\n**Canonical Reference:** [CANONICAL_PLATFORM_ARCHITECTURE.md](./CANONICAL_PLATFORM_ARCHITECTURE.md)\n\n> This document is downstream of the Canonical Platform Architecture. When this document contradicts the canonical architecture, the canonical architecture wins.\n\n---\n\n## 0. The one-line truth\n\n```text\nA projection is a VIEW, computed from the Spine, for a specific (user × tenant × context).\nThe Spine is the truth; a projection is never a store, never an ETL copy, never a raw tool table.\n```\n\n**Doctrine (enforced in code):** No surface or Workbench component may query raw tables directly.\nEvery read goes through the Projection Layer. See the banner in\n`apps/web/src/lib/projection/index.ts`.\n\n---\n\n## 1. Why projections exist\n\nThe Spine holds one canonical, normalized truth per tenant. But two users never need the same\nslice of it. A CS practitioner, a RevOps manager, and the founder looking at the same account must\neach see a _different, correct_ view — scoped by who they are, what they're entitled to, and how\ndeep they can see across departments.\n\nA projection resolves that slice **deterministically** so the UI is dumb and consistent: the\nsurface renders whatever the projection returns; it never decides scope on its own.\n\n---\n\n## 2. What a projection is computed from\n\n`useProjection()` (`apps/web/src/lib/projection/use-projection.ts`) assembles the slice from six\ndimensions:\n\n| Dimension        | Meaning                                                               | Source                    |\n| ---------------- | --------------------------------------------------------------------- | ------------------------- |\n| **Role**         | owner / admin / manager / practitioner(readonly)                      | profile → `projectRole()` |\n| **Department**   | primary domain (CUSTOMER_SUCCESS, REVOPS, SALES, …, BIZOPS, PERSONAL) | profile.department        |\n| **Industry**     | tenant vertical + subsector                                           | tenant spine config       |\n| **Depth matrix** | how deep this user sees _across_ departments                          | tenant spine config       |\n| **Twin tier**    | AI capability level (no_twin / basic_twin / full_twin)                | tenant spine config       |\n| **Entitlements** | active `tenant_products`                                              | product entitlements      |\n\nThe 12 department domains and their entity types are defined in `DOMAIN_SPINE_CONFIG`\n(`packages/types/src/schema.ts`) — the same config the Spine writer uses. Projection and Spine\nshare one schema source, so a view can never reference a type the Spine doesn't know.\n\n---\n\n## 3. The canonical projection context object\n\nThe projection resolves into one typed result — the single object every surface consumes:\n\n```text\nProjectionResult\n├── user:   UserContext   { userId, role, department, capabilities, entitlements }\n├── tenant: TenantContext { tenantId, industryVertical, subsector, depthMatrix, twinTier }\n├── modules:     ProjectedModule[]      ← nav is a projection of capability, not a static menu\n├── entityTypes: ProjectedEntityType[]  ← which types + which fields (field policy)\n├── metrics:     ProjectedMetric[]      ← which KPIs, how aggregated\n└── canAct / canApprove / canAdmin      ← governance gates surfaced to the UI\n```\n\nDefined in `use-projection.ts`. Navigation is built from it (`projection/nav-builder.ts` →\n`buildNavFromProjection`); module access is gated by it (`projection/module-guard.ts`); field\nvisibility (render / search / AI-readable) is governed by it (`projection/field-policy.ts`).\n\n**Two Views** are enforced at the nav layer: _My Department_ (department modules), _My Self_\n(personal modules). Cross-department visibility is no longer surfaced as a separate org view.\n\n---\n\n## 4. The read path (where projections come from)\n\nSurfaces never hold their own store. They fetch typed projections from the gateway, served\nD1-direct from the Spine partitions (DECISION 21 — no Supabase-backed BFF hop):\n\n```text\nSpine (Neon tenant-level entity model)\n        │  Gateway (Clerk identity · tenant/RBAC · typed contracts)\n        ├── GET /api/v1/projections/morning-context     → returning-user hydration (signals/entities/connectors)\n        ├── GET /api/v1/workspace/*                      → workspace + page projections (SuccessPageProjection, …)\n        ├── GET /api/v1/pipeline/entities[/batch|/counts] → entity grids / counts\n        └── proposals / signals projections              → ProposalOpsProjection, signal views\n```\n\nFrontend hooks: `use-morning-context.ts` / `use-hydration.ts` (hydration), `api-cs.ts` +\n`use-cs-data.ts` (CS page projections), `use-proposals.ts` (ops). Typed projection shapes live in\n`@integratewise/types` (`SuccessPageProjection`, `ProposalOpsProjection`, `MorningContext`).\n\n> ⚠️ Migration note: parts of `useProjection()` still read context via a Supabase-style client\n> (`spineClient.from(...)`). Target: resolve `UserContext`/`TenantContext` through the gateway\n> projection endpoints (D1-direct), consistent with DECISION 22. Tracked in\n> `docs/migrations/DE_SUPABASE_MIGRATION.md`.\n\n---\n\n## 5. The consistency rule (the CUSTOMER_SUCCESS → BizOps fix)\n\nThe observed UX gap — a surface showing `CUSTOMER_SUCCESS` while another resolves it to\n`Business Operations` — is a **context-assembly inconsistency**, not a rendering bug. The rule:\n\n```text\nThere is exactly ONE projection context per (user, tenant) render.\nDepartment/persona is resolved ONCE, canonically, and every surface reads the same object.\n```\n\nRequirements:\n\n1. **Single resolution.** `department` (persona) is resolved once into `UserContext.department`\n   using the canonical domain keys (`DEPARTMENT_TO_CONFIG_KEY`, `packages/types/src/schema.ts`).\n   No surface re-derives or re-labels it locally.\n2. **Canonical labels.** Display names map from the canonical key in one place\n   (`DOMAIN_SPINE_CONFIG`), so `CUSTOMER_SUCCESS` always renders with one label everywhere.\n3. **Depth, not duplication.** Cross-department views come from the **depth matrix**, not from a\n   second persona. A CS user seeing BizOps data is depth-driven visibility into the same Spine,\n   not a different projection identity.\n4. **Default once.** The `bizops` default applies only at the single resolution point, never\n   per-component.\n\nIf a surface needs different data, it requests a different _projection of the same context_ — it\nnever invents its own context.\n\n---\n\n## 6. Invariants\n\n- The Spine is the truth; projections are derived and disposable (cache-friendly, TTL 60–300s).\n- Projections are RBAC-filtered, schema-governed, tenant-scoped — at the projection layer, not JSX.\n- Governance gates (`canAct`/`canApprove`) ride on the projection; the UI cannot grant itself rights.\n- Nav, modules, fields, and metrics are all projections of capability — not static config.\n\n---\n\n## 7. Source-of-truth index (read the code, not a copy)\n\n| Concept                                  | Authoritative source                                                                        |\n| ---------------------------------------- | ------------------------------------------------------------------------------------------- |\n| Projection hook + context types          | `apps/web/src/lib/projection/use-projection.ts`                                             |\n| Role/capability/entitlement projection   | `apps/web/src/lib/projection/rbac-projection.ts`                                            |\n| Nav as projection                        | `apps/web/src/lib/projection/nav-builder.ts`                                                |\n| Module access gate                       | `apps/web/src/lib/projection/module-guard.ts`, `module-resolver.ts`                         |\n| Field-level policy (render/search/AI)    | `apps/web/src/lib/projection/field-policy.ts`                                               |\n| Hydration (returning user)               | `apps/web/src/hooks/use-morning-context.ts`, `use-hydration.ts`                             |\n| Gateway projection endpoints (D1-direct) | `services/gateway/src/workspace-spine.ts`                                                   |\n| Domain config + department mapping       | `packages/types/src/schema.ts` (`DOMAIN_SPINE_CONFIG`, `DEPARTMENT_TO_CONFIG_KEY`)          |\n| Typed projection shapes                  | `@integratewise/types` (`SuccessPageProjection`, `ProposalOpsProjection`, `MorningContext`) |\n\n---\n\n_This file is the canonical reference for projections. Pointer-based by design: update pointers,\nnever fork the lists. The Spine is the truth; everything the user sees is a view of it._\n"}