Unclassified

The Projection Model — Canonical

Status: Canonical architecture doctrine
Date: June 12, 2026
Authority: Nirmal (Founder)
Companion docs: SPINE_MODEL.md (the truth), USER_SYSTEM_JOURNEY_BLUEPRINT.md (layers/stages)
Map: docs/CANON.md. When a doc contradicts this file or its referenced code, the code wins.

Canonical Reference: CANONICAL_PLATFORM_ARCHITECTURE.md

This document is downstream of the Canonical Platform Architecture. When this document contradicts the canonical architecture, the canonical architecture wins.


0. The one-line truth

A projection is a VIEW, computed from the Spine, for a specific (user × tenant × context).
The Spine is the truth; a projection is never a store, never an ETL copy, never a raw tool table.

Doctrine (enforced in code): No surface or Workbench component may query raw tables directly.
Every read goes through the Projection Layer. See the banner in
apps/web/src/lib/projection/index.ts.


1. Why projections exist

The Spine holds one canonical, normalized truth per tenant. But two users never need the same
slice of it. A CS practitioner, a RevOps manager, and the founder looking at the same account must
each see a different, correct view — scoped by who they are, what they're entitled to, and how
deep they can see across departments.

A projection resolves that slice deterministically so the UI is dumb and consistent: the
surface renders whatever the projection returns; it never decides scope on its own.


2. What a projection is computed from

useProjection() (apps/web/src/lib/projection/use-projection.ts) assembles the slice from six
dimensions:

DimensionMeaningSource
Roleowner / admin / manager / practitioner(readonly)profile → projectRole()
Departmentprimary domain (CUSTOMER_SUCCESS, REVOPS, SALES, …, BIZOPS, PERSONAL)profile.department
Industrytenant vertical + subsectortenant spine config
Depth matrixhow deep this user sees across departmentstenant spine config
Twin tierAI capability level (no_twin / basic_twin / full_twin)tenant spine config
Entitlementsactive tenant_productsproduct entitlements

The 12 department domains and their entity types are defined in DOMAIN_SPINE_CONFIG
(packages/types/src/schema.ts) — the same config the Spine writer uses. Projection and Spine
share one schema source, so a view can never reference a type the Spine doesn't know.


3. The canonical projection context object

The projection resolves into one typed result — the single object every surface consumes:

ProjectionResult
├── user:   UserContext   { userId, role, department, capabilities, entitlements }
├── tenant: TenantContext { tenantId, industryVertical, subsector, depthMatrix, twinTier }
├── modules:     ProjectedModule[]      ← nav is a projection of capability, not a static menu
├── entityTypes: ProjectedEntityType[]  ← which types + which fields (field policy)
├── metrics:     ProjectedMetric[]      ← which KPIs, how aggregated
└── canAct / canApprove / canAdmin      ← governance gates surfaced to the UI

Defined in use-projection.ts. Navigation is built from it (projection/nav-builder.ts →
buildNavFromProjection); module access is gated by it (projection/module-guard.ts); field
visibility (render / search / AI-readable) is governed by it (projection/field-policy.ts).

Two Views are enforced at the nav layer: My Department (department modules), My Self
(personal modules). Cross-department visibility is no longer surfaced as a separate org view.


4. The read path (where projections come from)

Surfaces never hold their own store. They fetch typed projections from the gateway, served
D1-direct from the Spine partitions (DECISION 21 — no Supabase-backed BFF hop):

Spine (Neon tenant-level entity model)
        │  Gateway (Clerk identity · tenant/RBAC · typed contracts)
        ├── GET /api/v1/projections/morning-context     → returning-user hydration (signals/entities/connectors)
        ├── GET /api/v1/workspace/*                      → workspace + page projections (SuccessPageProjection, …)
        ├── GET /api/v1/pipeline/entities[/batch|/counts] → entity grids / counts
        └── proposals / signals projections              → ProposalOpsProjection, signal views

Frontend hooks: use-morning-context.ts / use-hydration.ts (hydration), api-cs.ts +
use-cs-data.ts (CS page projections), use-proposals.ts (ops). Typed projection shapes live in
@integratewise/types (SuccessPageProjection, ProposalOpsProjection, MorningContext).

⚠️ Migration note: parts of useProjection() still read context via a Supabase-style client
(spineClient.from(...)). Target: resolve UserContext/TenantContext through the gateway
projection endpoints (D1-direct), consistent with DECISION 22. Tracked in
docs/migrations/DE_SUPABASE_MIGRATION.md.


5. The consistency rule (the CUSTOMER_SUCCESS → BizOps fix)

The observed UX gap — a surface showing CUSTOMER_SUCCESS while another resolves it to
Business Operations — is a context-assembly inconsistency, not a rendering bug. The rule:

There is exactly ONE projection context per (user, tenant) render.
Department/persona is resolved ONCE, canonically, and every surface reads the same object.

Requirements:

  1. Single resolution. department (persona) is resolved once into UserContext.department
    using the canonical domain keys (DEPARTMENT_TO_CONFIG_KEY, packages/types/src/schema.ts).
    No surface re-derives or re-labels it locally.
  2. Canonical labels. Display names map from the canonical key in one place
    (DOMAIN_SPINE_CONFIG), so CUSTOMER_SUCCESS always renders with one label everywhere.
  3. Depth, not duplication. Cross-department views come from the depth matrix, not from a
    second persona. A CS user seeing BizOps data is depth-driven visibility into the same Spine,
    not a different projection identity.
  4. Default once. The bizops default applies only at the single resolution point, never
    per-component.

If a surface needs different data, it requests a different projection of the same context — it
never invents its own context.


6. Invariants

  • The Spine is the truth; projections are derived and disposable (cache-friendly, TTL 60–300s).
  • Projections are RBAC-filtered, schema-governed, tenant-scoped — at the projection layer, not JSX.
  • Governance gates (canAct/canApprove) ride on the projection; the UI cannot grant itself rights.
  • Nav, modules, fields, and metrics are all projections of capability — not static config.

7. Source-of-truth index (read the code, not a copy)

ConceptAuthoritative source
Projection hook + context typesapps/web/src/lib/projection/use-projection.ts
Role/capability/entitlement projectionapps/web/src/lib/projection/rbac-projection.ts
Nav as projectionapps/web/src/lib/projection/nav-builder.ts
Module access gateapps/web/src/lib/projection/module-guard.ts, module-resolver.ts
Field-level policy (render/search/AI)apps/web/src/lib/projection/field-policy.ts
Hydration (returning user)apps/web/src/hooks/use-morning-context.ts, use-hydration.ts
Gateway projection endpoints (D1-direct)services/gateway/src/workspace-spine.ts
Domain config + department mappingpackages/types/src/schema.ts (DOMAIN_SPINE_CONFIG, DEPARTMENT_TO_CONFIG_KEY)
Typed projection shapes@integratewise/types (SuccessPageProjection, ProposalOpsProjection, MorningContext)

This file is the canonical reference for projections. Pointer-based by design: update pointers,
never fork the lists. The Spine is the truth; everything the user sees is a view of it.