Design Decisions
This document records the key architectural decisions made in the Lens project, their rationale, and the alternatives that were considered. Each decision is presented with its context, the choice made, and why.
1. Server-Centric Architecture
Section titled “1. Server-Centric Architecture”Decision: The backend assembles complete views. The frontend is a thin rendering layer that never calls Jira APIs directly.
Context: Lens needs to display hierarchical Jira data with potentially 1,000+ issues per lens. Users expect sub-second load times. Jira Cloud imposes strict API rate limits (65,000 points/hour in Tier 1, shared globally across all installations).
Alternatives considered:
- Client-heavy SPA — Frontend fetches Jira data directly via
@forge/bridgerequestJira(). Each lens open would cost hundreds of API points. At scale, the global rate limit would be exhausted within minutes. - Hybrid — Frontend handles some data assembly, backend handles cache. Adds complexity and still risks rate limit issues.
Why server-centric:
- Rate limit conservation. The issue cache means opening a lens costs zero Jira API points. Data is served from Forge SQL.
- Permission filtering at the data layer. The backend can filter out issues the user cannot access before the data ever reaches the frontend, preventing information leakage.
- Single payload. One resolver call returns the entire view. No waterfall of API calls from the frontend.
- Simpler frontend. The frontend receives pre-assembled rows and renders them. No complex data joining or caching logic in the browser.
Tradeoff: Backend resolver calls have a 25-second timeout. Very large lenses (approaching the 1,000-issue limit) must be assembled efficiently. Complex operations are pushed to async consumers with 15-minute timeouts.
2. Forge SQL Over Forge Storage for Structured Data
Section titled “2. Forge SQL Over Forge Storage for Structured Data”Decision: Use Forge SQL (MySQL-compatible) for all structured data: lenses, hierarchy, views, permissions, issue cache. Use Forge KVS only for lightweight key-value data (user prefs, rate limit counters, schema version).
Context: Forge offers two storage options: Forge SQL (relational, MySQL-compatible, 1 GiB limit) and Forge Storage/KVS (key-value, 512 KB per value). Lens stores hierarchical tree data with parent-child relationships, ordering, and cross-table joins.
Alternatives considered:
- KVS-only — Store entire hierarchy as a JSON blob in KVS. Simple for small structures, but breaks down at scale: no partial reads, no concurrent writes, 512 KB value limit.
- KVS with indexing — Use multiple KVS keys with manual indexing. Reinvents a database poorly.
Why Forge SQL:
- Relational data model. Hierarchy nodes have parent-child relationships, ordering constraints, and foreign key references. SQL expresses these naturally.
- Efficient queries.
SELECT * FROM hierarchy_nodes WHERE lens_id = ? ORDER BY depth, positionis a single indexed query. The equivalent in KVS would require loading all data and sorting client-side. - Concurrent writes. Multiple users editing the same lens need transactional guarantees. SQL provides row-level locking.
- Issue cache joins. Enriching hierarchy nodes with issue data is a batch SELECT by ID list, not a per-row KVS lookup.
- 1 GiB is plenty. A moderate deployment (10 lenses, 1,000 issues each) uses ~10 MB. Even at 100x scale, storage is well within limits.
Tradeoff: Forge SQL has a 150 DML/second rate limit. Bulk operations (sync agent execution creating hundreds of nodes) must batch writes carefully.
3. Event-Driven Cache Instead of Direct Jira API Reads
Section titled “3. Event-Driven Cache Instead of Direct Jira API Reads”Decision: Maintain a local copy of Jira issue data in the issue_cache table, kept fresh by free product event triggers. Users read from cache, never from Jira API on lens open.
Context: Jira Cloud’s rate limit system (enforced March 2, 2026) charges points for every API call. A single GET /issue/{key} costs 2 points. A JQL search with 100 results costs 101 points. The Tier 1 global pool is 65,000 points/hour — shared across ALL installations of the app.
Alternatives considered:
- Direct reads — Fetch issue data from Jira API every time a user opens a lens. At 1,000 issues per lens, opening a single lens could cost 2,000+ points. With multiple users and multiple lenses, the global budget would be exhausted in minutes.
- Frontend caching — Cache in browser localStorage/IndexedDB. Doesn’t share across users, doesn’t survive tab closes, and still requires expensive API calls to populate.
Why event-driven cache:
- Product events are free.
avi:jira:created/updated/deleted:issuetriggers cost zero API points. The cache stays fresh automatically. - One-time cost. The only API points spent are: 2 points per issue event (to fetch the full issue for caching) and the initial JQL search when a sync agent runs. Once cached, all subsequent reads are free.
- Sub-second loads. Reading from Forge SQL is orders of magnitude faster than calling Jira API for each issue.
- Scales with installations. As the app grows, cache events scale linearly with actual Jira changes, not with the number of lens opens.
Tradeoff: The cache can be briefly stale (seconds to minutes). A known platform bug (ECO-395) means avi:jira:updated:issue does NOT fire for custom workflow notification transitions, potentially causing staleness of up to 60 minutes. The three-layer auto-sync model (staleness gate, event push, count-check safety net) mitigates this.
4. No Live Sync in MVP (Refresh-on-Open Model)
Section titled “4. No Live Sync in MVP (Refresh-on-Open Model)”Decision: MVP uses a refresh-on-open model. Forge Realtime is wired for post-sync notifications, but multi-user collaborative editing with live conflict resolution is deferred.
Context: The leading competitor has real-time multi-user editing. Forge Realtime (pub/sub) is available but in Preview status with known limitations (60 messages/channel/sec, 32 KB payload limit).
Alternatives considered:
- Full CRDTs — Implement conflict-free replicated data types for concurrent editing. Extremely complex, and Forge’s 25-second function timeout makes server-side CRDT merging challenging.
- Operational Transform — Like Google Docs-style collaboration. Even more complex than CRDTs, and not a natural fit for hierarchical tree data.
Why refresh-on-open:
- Ship faster. PPM users typically work alone on their lens. Multi-user concurrent editing is a nice-to-have, not a blocker.
- Event-driven auto-sync fills the gap. When someone edits an issue in Jira, the cache updates within seconds. The next time any user opens the lens, they see fresh data.
- Forge Realtime is Preview. Building critical features on Preview-status APIs risks breaking changes.
- Incremental path. The Realtime infrastructure is already in place for sync-completion notifications. Extending it to field-level updates is an incremental step, not a rewrite.
Current state: Forge Realtime is used for sync agent completion notifications and field update broadcasts. The useRealtime hook in the frontend subscribes to lens channels. Full collaborative editing is a post-MVP feature.
5. No Redux/Zustand (useState Sufficient)
Section titled “5. No Redux/Zustand (useState Sufficient)”Decision: All state management uses React’s built-in useState, useCallback, and useMemo. No external state library.
Context: State management libraries like Redux or Zustand are common in complex React apps. Lens has significant UI complexity: grid state, Gantt state, toolbar state, inspector panel state, multiple editors.
Alternatives considered:
- Redux — Global store with actions and reducers. Adds boilerplate, dev dependencies, and a learning curve.
- Zustand — Lightweight alternative. Simpler than Redux but still adds a dependency.
- React Context — Used sparingly for cross-cutting concerns (feature flags, Rovo, i18n), but not for component data state.
Why plain useState:
- LensView owns all data. The component hierarchy has a clear owner:
LensViewholds the nodes, views, and sync agents. Children receive props and callbacks. There is no “shared state” problem because the data state lives in one place. - AG Grid manages its own state. The grid handles column visibility, sorting, row expansion, cell editing, and selection internally. The React layer reacts to AG Grid callbacks rather than managing this state.
- No cross-route state. When navigating from lens list to lens view, the view loads fresh data. There is no state that needs to persist across route transitions.
- Simpler debugging. With useState, the state is local and inspectable. No middleware, no action dispatch logs to trace.
- Forge iframe constraints. Redux DevTools and other debugging tools cannot easily reach inside the Forge cross-origin iframe anyway.
Tradeoff: As the component tree grows, prop-drilling becomes verbose. This is managed by keeping the hierarchy shallow and using callback props. If the app evolves to need cross-route state or multiple data sources writing to the same state, a state library may become warranted.
6. AG Grid (Enterprise Features)
Section titled “6. AG Grid (Enterprise Features)”Decision: Use AG Grid as the core rendering engine for the hierarchical data grid.
Context: Lens needs a spreadsheet-like grid with: hierarchical tree data with expand/collapse, inline cell editing, column drag-and-drop reorder, column grouping, keyboard navigation, and performance at 1,000+ rows.
Alternatives considered:
- Custom table implementation — Full control, but reimplementing tree data, virtual scrolling, column resizing, and inline editing is months of work.
- React Table (TanStack Table) — Headless, so all rendering is custom. Provides data logic but no UI. Would still require building the entire grid UI.
- Atlaskit DynamicTable — Atlassian’s own table component. Lacks tree data support, inline editing, and the enterprise features needed.
Why AG Grid:
- Tree data out of the box. AG Grid supports hierarchical data with expand/collapse, arbitrary depth, and parent-child relationships.
- Inline editing. Built-in cell editing with customizable editors (text, dropdown, date picker, etc.).
- Performance. Virtual row rendering handles 1,000+ rows without DOM bloat.
- Column management. Drag-to-reorder, resize, show/hide, pinning, and column groups.
- Extensibility. Custom cell renderers, cell editors, and header components integrate cleanly.
Tradeoffs and workarounds:
- AG Grid’s JS-based hover system (
suppressRowHoverHighlight) had to be disabled in favor of CSS:hoverbecause it produced phantom highlights in empty viewport space. cellEditorPopup: truedoes not work in Forge iframes (overflow clipping + wrong positioning). All editors render inline.stopEditingWhenCellsLoseFocushad to be set tofalsebecause focus tracking breaks across the Forge cross-origin iframe boundary. Manual click-outside handling was implemented instead.
7. Position Gap=100 Strategy for Tree Ordering
Section titled “7. Position Gap=100 Strategy for Tree Ordering”Decision: Use integer positions with gaps of 100 between siblings (100, 200, 300, …) to allow insertions without renumbering.
Context: Users drag-and-drop nodes to reorder them frequently. The hierarchy is stored in Forge SQL with a position column for sibling ordering.
Alternatives considered:
- Fractional positions — Use floating-point numbers (e.g., 1.5 between 1 and 2). Precision degrades over many insertions.
- Linked list — Each node stores a
next_idpointer. Reordering requires updating two nodes. Reading order requires traversal. - Array-based — Store children as an ordered JSON array on the parent. Simple but requires rewriting the entire array on any move.
Why gap=100:
- Insert without renumber. Inserting between positions 200 and 300 uses position 250. No other rows need updating.
- Simple SQL.
ORDER BY position ASCgives correct sibling order. - Move is O(1). Update one row’s position. No cascading updates.
- Renumber only when gaps exhausted. After many insertions in the same spot (100+ between two siblings), a renumber pass redistributes gaps. This is rare in practice.
8. “Lens” Terminology
Section titled “8. “Lens” Terminology”Decision: The codebase uses “lens” terminology exclusively. The product was rebranded from “Structure” to “Lens” to differentiate from the competitor.
Context: The original codebase and the competitor both use “structure” as the primary term. Continuing to use “structure” would cause market confusion and potential trademark issues.
Rules:
- No
structurefallbacks, aliases, or backwards-compatibility shims anywhere in new code - No
payload?.structureId ?? payload?.lensIdpatterns - No
_structurere-exports or aliases - If
structureappears outside of migration DDL strings, it is a bug - Legacy URL
/structure/:idredirects to/lens/:idviaStructureRedirectcomponent
Migration history: Database column names were migrated from structure_id to lens_id in schema migration v6. The structures table was renamed to lenses. All resolver names and API payloads use “lens.”
9. CaaS Manifest Byte Limit Workaround
Section titled “9. CaaS Manifest Byte Limit Workaround”Decision: Use a “slim manifest” convention with minimized field values to fit within the ~257KB CaaS byte limit.
Context: Forge’s CaaS (Container-as-a-Service) manifest conversion has a byte limit on the converted internal format (ECO-1310). The practical ceiling is approximately 36 modules.action entries with the slim format. Lens currently has 35 actions using ~22,850 bytes.
The slim manifest convention:
- Function keys:
f1-fw,g1-g2(2-char identifiers) - Module/trigger/consumer keys: 2-3 char abbreviations
- Action descriptions:
.(single dot — required by lint but not displayed) - Input titles:
x(single char — required by lint but not displayed) - No action-level
title:field (optional, removed) required: falseomitted (defaults to false)
Additionally, 6 dispatcher actions consolidate multiple logical actions into a single manifest entry:
lensCruddispatches to 3 sub-actions via_actionparameterhierMetadispatches to 10 sub-actionsviewPermdispatches to 5 sub-actionsjiraUtildispatches to 3 sub-actionsr2gdispatches to 19 read-only actionsr2r5dispatches to 14 destructive actions
Monitoring: scripts/check-manifest-limits.js verifies the manifest stays under the byte budget. Run before adding any new action.
10. Fail-Open Permission Strategy
Section titled “10. Fail-Open Permission Strategy”Decision: If the Jira API is unreachable during the BROWSE permission check, assume all issues are visible to the user.
Context: When assembling a lens view, the backend verifies that the user can BROWSE each issue in Jira. This requires a Jira API call (api.asUser() JQL check). If Jira is experiencing an outage or the rate limit is exhausted, this call may fail.
Alternatives considered:
- Fail-closed — If the Jira API is unreachable, return an empty view. This is “safer” from a permission standpoint but makes the app completely unusable during any Jira disruption.
- Cached permissions only — Only use KVS-cached permission results. But the cache has a 30-minute TTL, and fresh users have no cache.
Why fail-open:
- Usability over strictness. A Jira outage should not prevent users from accessing their own lenses. The lens owner has already granted access at the lens level.
- Forge SQL isolation. Cross-tenant data leakage is impossible because each installation has its own database. The fail-open path cannot leak data to other organizations.
- Bounded risk. The worst case is a user within the same Jira site seeing an issue they don’t have BROWSE permission on. This is a privacy concern within the same organization, not a security breach across tenants.
- Temporary. The fail-open state only lasts until the Jira API recovers. The next lens open with a working API will enforce permissions correctly and update the cache.
11. Adjacency List Over Nested Set for Hierarchy
Section titled “11. Adjacency List Over Nested Set for Hierarchy”Decision: Use the adjacency list model (parent_id pointer) for the hierarchy, not nested set (left/right bounds).
Context: The hierarchy tree needs to support frequent moves (drag-and-drop reorder, indent/outdent). Both adjacency list and nested set are standard SQL tree representations.
Comparison:
| Operation | Adjacency List | Nested Set |
|---|---|---|
| Move a node | Update 1 row (O(1)) | Recalculate bounds for entire affected subtree (O(n)) |
| Get children of node | WHERE parent_id = ? | WHERE left > parent.left AND right < parent.right |
| Get all descendants | Recursive CTE or app-level recursion | Single range query (fast) |
| Insert node | Set parent_id and position | Shift all bounds to the right (O(n)) |
Why adjacency list:
- Moves are the most frequent operation in Lens. Users constantly drag-and-drop to reorder and restructure. Adjacency list makes moves O(1).
- The
depthcolumn is denormalized to avoid recursive parent traversal during rendering. - Full subtree queries are not common enough to justify the nested set’s write complexity.
12. Schema Migration System Design
Section titled “12. Schema Migration System Design”Decision: Use a sequential migration system with KVS-based version tracking and distributed lease for concurrency control.
Context: Forge functions can cold-start concurrently. Multiple invocations may try to run migrations simultaneously. The schema must evolve safely across versions.
How it works:
ensureSchema()runs on every Forge cold-start- Reads
CURRENT_SCHEMA_VERSIONfrom KVS - Acquires a distributed lease (KVS-based, 120s TTL) to prevent concurrent migration runs
- Runs sequential migrations from current version to target version
- Fresh installs skip migrations and run CREATE TABLE statements directly
safeDDL()wraps ALTER TABLE in try/catch for idempotency on cold-start re-runs
Why this design:
- Forge has no built-in migration framework. The app must manage schema evolution.
- Concurrent cold-starts are a real scenario (multiple users accessing the app simultaneously).
- The KVS lease is simple and reliable. No external coordination service needed.
- Sequential migrations are easy to reason about and debug.
13. Internationalization with react-intl
Section titled “13. Internationalization with react-intl”Decision: Use react-intl (FormatJS) with ICU MessageFormat for all user-visible strings. Support 11 locales.
Context: Atlassian Marketplace apps serve a global audience. The competitor has localization support. Jira itself is available in many languages.
Supported locales: English (source of truth), Spanish, French, German, Japanese, Portuguese (Brazil), Chinese (Simplified), Korean, Italian, Russian, Dutch.
Key design choices:
- English (
en.ts) is the source of truth. All keys must exist here first. - Non-English bundles merge over English (
{ ...en, ...locale }), so missing keys fall back to English. - Locale detection via
navigator.languagewith mapping to supported locales. - ICU plural syntax for proper pluralization (Russian requires 3 forms).
- Global mock in
setupTests.tsreturns English strings so existing test assertions work.
Summary Table
Section titled “Summary Table”| # | Decision | Key Driver |
|---|---|---|
| 1 | Server-centric architecture | Jira API rate limits (65K pts/hr) |
| 2 | Forge SQL for structured data | Relational queries, joins, concurrent writes |
| 3 | Event-driven issue cache | Free product events, zero-cost lens opens |
| 4 | Refresh-on-open (no live sync MVP) | Ship speed, Forge Realtime in Preview |
| 5 | No Redux/Zustand | LensView owns all state, AG Grid manages its own |
| 6 | AG Grid Enterprise | Tree data, inline editing, virtual scrolling |
| 7 | Position gap=100 | O(1) moves without renumbering |
| 8 | ”Lens” terminology | Product differentiation from competitor |
| 9 | Slim manifest convention | CaaS byte limit (~257KB) |
| 10 | Fail-open BROWSE check | Usability during Jira outages |
| 11 | Adjacency list hierarchy | O(1) drag-and-drop moves |
| 12 | Sequential migrations + KVS lease | Concurrent cold-start safety |
| 13 | react-intl (11 locales) | Global Marketplace audience |