ADR-044: Workspace navigation
Date: 2026-04-30 Authors: Jean-Francois Meyers Scope: granit-dotnet (
Granit.Workspaces,Granit.Workspaces.Endpoints,Granit.Workspaces.Framework); granit-front (@granit/workspaces-react) Epic: #1506 — Refonte UI Hybride Story: #1523 — ADR Workspace navigation Status: AcceptedNumbering note: the planning conversation reserved ADR-043 for this topic, but PR #1618 shipped ADR-043 (Dashboard push transport) on the same day. This ADR slots in at 044; the remaining Phase 0 ADRs shift accordingly (045 IoC contributor, 046 Activities/Timeline split, 047 EntityView, 048 Cross-module relations, 049 Landing route). Cross-references in the already-merged ADR-040, ADR-041, ADR-042 are amended in the same PR that lands this file.
Context
Section titled “Context”Frappe ships Workspaces as the primary navigation primitive: a hierarchical tree where each workspace exposes its own sidebar of items (DocTypes, dashboards, reports, links). Sub-workspaces nest arbitrarily (Accounting > Invoicing > Sales Invoices). Tenant admins reorder, rename, hide.
Odoo ships Apps as a flatter alternative: a single level of top-tabs, each tab gates a menu tree underneath. Critically, the same model can appear under multiple apps — the customer list shows up under Accounting, Sales, and Contacts, each time with the app’s preset filter (e.g., Accounting hides leads).
Notion uses page hierarchy for navigation; the side peek + canonical URL split between “this page in this context” and “this page anywhere” is one of its strongest UX patterns.
Granit’s planning conversation locked the synthesis: Frappe-style hierarchy + Odoo-style cross-workspace sharing + Notion-style canonical detail routes, all driven by the inversion-of-control contributor pattern (see ADR-045) so that adding a module never requires patching a workspace declared elsewhere.
Decision
Section titled “Decision”1. Hierarchical workspace tree
Section titled “1. Hierarchical workspace tree”WorkspaceDefinition declares a workspace by name with optional sub-workspaces, ordered sections, and items per section:
public sealed class AccountingWorkspaceDefinition : WorkspaceDefinition{ public override string Name => "Granit.Accounting"; public override string DisplayKey => "Workspace:Accounting"; public override string Icon => "calculator"; public override int Order => 100; public override string? PermissionPrefix => "Accounting";
protected override void Configure(WorkspaceBuilder b) { b.SubWorkspace<InvoicingWorkspaceDefinition>(); b.SubWorkspace<PaymentsWorkspaceDefinition>();
b.Section("masters", s => s .Entity<Customer>("active-with-balance", preset: p => p .Filter("HasOpenInvoices", FieldOp.Eq, true) .Sort("-LastInvoiceDate") .Columns("Name", "Email", "OutstandingAmount")) .Entity<Supplier>("default"));
b.Section("dashboards", s => s .Dashboard<FinanceOverviewDashboard>() .Dashboard<CashFlowDashboard>()); }}The discovery endpoint (GET /api/workspaces, story #1554) returns the tree filtered by the user’s permissions.
2. Depth ≤ 4, enforced
Section titled “2. Depth ≤ 4, enforced”A workspace tree may nest at most 4 levels deep (Root > Sub > SubSub > SubSubSub). The cap is enforced by an architecture test in Granit.ArchitectureTests (the AbstractionsPurityTests infrastructure introduced in PR #1603 extends naturally to a WorkspaceDepthTests).
Rationale: deeper trees collapse the UX into a tunnel — Frappe ships ad-hoc workspaces beyond 3 levels and the result is a navigation maze. Four levels accommodate the Granit Framework workspace pattern (Framework > System > Settings > <module item>) and stops there.
3. Cross-workspace entity sharing
Section titled “3. Cross-workspace entity sharing”The same entity may be exposed in N workspaces, each carrying its own preset overlay:
// Granit.Accounting workspace — Customer with overdue-balance presetb.Section("masters", s => s.Entity<Customer>("active-with-balance", preset: p => p.Filter("HasOpenInvoices", FieldOp.Eq, true)));
// Granit.Contacts workspace — Customer with full list, no presetb.Section("contacts", s => s.Entity<Customer>("default"));Same EntityDefinition, same QueryDefinition, two different surfaces.
4. Additive overlay only — security invariant
Section titled “4. Additive overlay only — security invariant”The preset overlay is strictly additive: it can constrain (AND more filters, narrow column visibility) but never relax the entity’s compiled defaults.
- A workspace cannot bypass an entity’s compiled
BaseFilter(e.g., a tenant filter or a soft-delete filter fromGranit.Persistence). - A workspace cannot expose a column the entity hides (
IsVisible: false). - A workspace cannot grant a permission the user doesn’t already have on the entity.
This is the security invariant of the navigation layer. An architecture test (planned with story #1553 in Phase 1) detects attempts to relax — e.g., a preset that removes an entity’s filter clause — and fails the build.
5. Detail routes are workspace-agnostic
Section titled “5. Detail routes are workspace-agnostic”URLs split into two families:
| URL pattern | Sense |
|---|---|
/w/{workspace}/{...sub}/{entity} | Workspace-scoped collection (preset applied) |
/w/{workspace}/{...sub}/{entity}?peek={id} | Side peek (Notion-style drawer) — same context |
/{entity}/{id} | Canonical detail page — workspace-agnostic, sharable, indexable |
Rationale: a deep link to a customer record is a property of the customer, not of the navigation context the user happened to be in. Two users sharing /customer/abc-123 see the same content regardless of which workspace they each landed in. Notion got this right; Granit adopts it as-is.
6. Permissions intersect — defense in depth
Section titled “6. Permissions intersect — defense in depth”Three gates filter every workspace item at render time:
visible = workspace.canRead AND entity.canList -- inherited from EntityDefinition AND (no workspace-item-perm OR has it) -- optional per-item gatePermissions added by this layer:
Workspace.{Name}.Read— see the workspace in the navigation tree (e.g.,Workspace.Granit.Accounting.Read).Workspace.{Name}.Manage— customize the workspace per tenant (Tier B Layer 1, ADR-040, Phase 2).
A user with access to Granit.Accounting but not to Customer sees the workspace in their sidebar — the items they can’t read are filtered out (per defense-in-depth from ADR-040 / story #1549). Conversely, a user with Customer.Read but no access to Granit.Accounting reaches the customer through Granit.Contacts or the canonical /customer/{id} URL.
7. Per-workspace landing dashboard (optional)
Section titled “7. Per-workspace landing dashboard (optional)”A workspace may declare a landing dashboard:
b.LandingDashboard<FinanceOverviewDashboard>();When present, navigating to /w/{workspace} lands directly on that dashboard. When absent, the route shows the section list (sub-workspaces + items grouped by section). The landing dashboard is itself workspace-scoped — its KPIs may reference dashboard-scoped filters (per ADR-038) and inherit the workspace’s preset overlay where applicable.
8. Default landing route — separate concern
Section titled “8. Default landing route — separate concern”The 5-tier resolution of which workspace the user lands on at login (user-sticky > user-pinned > role-default > tenant-default > framework-default) is documented in ADR-049. The role-default tier supports deep-link routes (e.g., /w/accounting/invoicing/customers?view=overdue), not just bare workspace names.
Consequences
Section titled “Consequences”Positive
Section titled “Positive”- A module’s nav surface is added by contribution, not by patching a workspace declared elsewhere — see ADR-045. Adding
Granit.Privacyadds items to the frameworkPrivacyworkspace shell automatically; removing it removes them. No coordination required. - Cross-workspace sharing eliminates the “should this entity live under Accounting or Contacts?” debate — it lives in both, with the right preset for each context.
- The additive-only invariant means workspace customization can never be a security regression.
- Detail-route stability (canonical
/{entity}/{id}) makes Granit URLs first-class for sharing, bookmarking, indexing — three properties Frappe’s deeply nested URLs lack. - Permissions intersect cleanly: the same entity in two workspaces with different presets remains gated by the same
Entity.Readpermission.
Negative / accepted trade-offs
Section titled “Negative / accepted trade-offs”- The depth-≤-4 cap forces flatter trees than Frappe sometimes ships. We accept this as a UX-quality decision; deeper hierarchies become tags or saved views.
- The additive-only rule means a workspace cannot offer “Customer with EXTRA fields the default doesn’t show” without the entity author adding those fields to the compiled
EntityDefinition. This is by design (Tier A ownership of the schema). - Tenant admins (Tier B) can customize workspace layouts (Phase 2 / ADR-040 Layer 1) — reorder sections, hide items, add custom links — but cannot grant access to entities the user lacks read on. The intersection rule is unbreakable.
Cross-references
Section titled “Cross-references”- ADR-040 — Three-tier metadata. Workspaces are Tier A; tenant overrides are Tier B Layer 1.
- ADR-045 — IoC contributor pattern. The mechanism modules use to add items to a workspace declared elsewhere.
- ADR-047 —
EntityView. User-saved views compose with workspace presets in the resolution hierarchy. - ADR-049 — Default landing route. The 5-tier resolver for “where does the user land at login”.
References
Section titled “References”- Frappe Workspaces — research compiled in PR #1599 conversation. Hierarchical, tenant-customizable, but no cross-workspace sharing primitive (apps work around with redirects).
- Odoo Apps + record rules — research compiled in PR #1599 conversation. Cross-app sharing via record rules is powerful but the model-rule-app combinatorics get hard to debug. Granit’s typed preset overlay narrows the surface.
- Notion side peek — release notes 2022-07-20. Canonical URL + drawer pattern adopted in §5 above.
- ISO 27001 A.9.4 (Access control) — the intersection rule (§6) is what makes workspace navigation auditable: the workspace is a UX primitive, not an access primitive.