Org Working Tabs Architecture
Status and authority
This document is the implementation architecture for the behavior defined in
../product/org-working-tabs-lifecycle-spec.md.
It exists to prevent the product contract from being reimplemented separately
inside every create, preview, edit, quick-note, timer, and topbar component.
The documents have distinct authority:
- The product specification decides what users experience.
- This document decides where that behavior lives and how features integrate with it.
- The dialog lifecycle audit decides which surfaces must integrate.
org-topbar-working-tabs.mdrecords the concrete runtime integration.
The architecture described here is implemented. Future working-tab changes must preserve these boundaries or update this document and the product specification together.
Architectural decision
Build one working-lifecycle engine with:
- one canonical model
- one pure reducer
- one policy module for retention and dismissal
- one immutable ordering mechanism
- one organization-shell store
- one persistence coordinator
- one typed feature-adapter registry
- one generic dialog frame and one generic tab renderer
The lifecycle engine owns identity, order, visibility, stage, retention, pinning, handoff, deduplication, persistence classification, and removal.
Features continue to own their actual business data:
- form fields and validation
- normalized dirty baselines
- mutations and error messages
- feature-specific draft payloads
- record authorization and loading
- preview and editor UI
This is deliberate. Centralizing lifecycle decisions makes behavior consistent; centralizing every project's, note's, invoice's, or event's form state into one giant union would make the engine brittle.
Why consolidation was required
Before this implementation, lifecycle behavior was divided among five nested providers:
| Current owner | State it owns |
|---|---|
OrgCreateIntentProvider | Create sessions, active create, create persistence, nested callbacks, future pin intent |
OrgQuickNoteProvider | Note sessions, note draft persistence, active note, note-specific handoff |
OrgPinnedWorkProvider | Backend saved-record pins, optimistic persistence, pin ordering |
OrgWorkspaceTabsProvider | Session-scoped saved-record tabs |
OrgEditDialogTabsProvider | Open edit/preview tabs and in-memory close callbacks |
Those provider files contained approximately 2,448 lines before feature
dialogs were counted. OrgWorkingQuickViewStrip added another 1,286 lines to
merge, deduplicate, classify, order, render, and close their outputs.
The same concepts are repeated elsewhere:
- create types in
OrgCreateActionMap - record and action unions in
pinned-work-context.tsx - another pinned-work union in the frontend Convex API wrapper
- Convex validators with the same literal list
- action behavior in
working-pin-registry.tsx - record behavior in
working-record-registry.ts - create-to-record handoff calls across individual create dialogs
- per-record rendering branches in the topbar strip
- per-dialog registration and close behavior in
WorkingModalDialog
As a result, adding or fixing one lifecycle required coordinated edits in several places. Nothing prevented one path from appending, another from prepending, another from moving on pin, and another from using a draft update time as order.
Design principles
Centralize policy, not feature payloads
The engine decides whether a lifecycle stays, moves stages, needs confirmation, or persists. A feature adapter decides how a project draft is validated or how a calendar event is rendered.
Events are the only mutation path
Components never splice lifecycle arrays, generate replacement tab IDs, or write pin state directly. They issue semantic commands that produce reducer events.
State transitions are pure
Ordering, retention, deduplication, and handoff must be testable without React, the browser, routing, Convex, or feature components.
Effects are outside the reducer
Browser storage, backend pin mutations, toasts, focus changes, and feature save mutations happen in effect adapters. The reducer receives their results as events.
Derived state is not stored twice
The rail, visible dialog, pin button, and persistence projections are selectors over the same lifecycle state. There is no second topbar-specific item array.
Registration is declarative
A feature declares one adapter. It does not add branches to the strip, pin provider, create provider, route helper, and close-confirmation component.
Persist serializable data only
Lifecycle state never stores React nodes, component instances, closures, or
onCreated/onClose callbacks. Parent and return relationships are explicit
serializable identities.
Target topology
Implementation status
Implemented on 2026-07-30:
- the pure
@repo/working-lifecyclepackage is the behavioral source of truth - the org shell owns one lifecycle registry and one active lifecycle
- create, quick-note, timer, saved-record, preview, and edit payload adapters feed the registry
- rail order comes only from immutable
orderKey - create-to-record handoff preserves identity semantics and order
- browser persistence is split into session and durable local projections
- backend saved pins use additive
pinnedWorkingRecordsV2dual read/write - the bounded V2 backfill/legacy-action cleanup runner and verifier are wired into development, staging, production, and container provisioning
- pure payload models are separated from React contexts
- provider composition is named once in
working-providers.tsx - the topbar renderer is split into projection, shared chips, and a type-exhaustive saved-record renderer
The feature React providers intentionally remain because they own live callbacks, feature draft payloads, and renderer handles. They do not own cross-feature membership, canonical identity, retention policy, or ordering. They are adapters, not competing lifecycle stores.
Feature entry point
openCreate / openRecord / openStage
|
v
OrgWorkingLifecycleProvider
command API -> policy -> pure reducer -> selectors
| |
| +-> ordered rail projection
| +-> visible lifecycle projection
| +-> persistence projections
|
+-> feature adapter registry
| +-> draft codec and normalization
| +-> create/preview/edit renderer
| +-> save handoff mapping
| +-> label, icon, and route metadata
|
+-> persistence coordinator
+-> browser-session rail repository
+-> same-device draft repository
+-> backend saved-pin repository
The topbar and dialog host consume selectors. They do not merge independent providers.
Sources of truth
| Concern | Canonical owner | Must not be redefined in |
|---|---|---|
| Product behavior | Product lifecycle specification | Components, adapters, tests |
| Lifecycle types and events | Pure working-lifecycle package | React contexts or feature dialogs |
| Create/record vocabulary | Canonical typed definitions | Frontend API wrappers, strip switches, backend literal lists |
| Added order | Lifecycle orderKey | openedAt, updatedAt, pinnedAt, array position |
| Retention decisions | Lifecycle policy module | Dialog onOpenChange handlers |
| Pin state | Lifecycle record | Separate create-pin and record-pin providers |
| Active surface | One visibleLifecycleId | Per-provider active IDs |
| Create-to-record mapping | Feature adapter | Individual save handlers |
| Draft engagement | Feature draft adapter | Topbar label or storage existence checks |
| Dirty state | Normalized feature baseline | Raw field comparisons in the shell |
| Rail membership | Lifecycle selectors | Concatenated provider arrays |
| Persistence classification | Persistence selector | Feature calls to localStorage or sessionStorage |
| Saved-pin storage | Backend pin repository | Feature mutations |
| Rendering | Exhaustive adapter registry | Record-type if/switch chains in the strip |
| Confirmation semantics | Shared lifecycle action guard | Per-feature topbar confirmations |
Implemented module boundary
Pure cross-runtime package
@repo/working-lifecycle is a small dependency-free workspace package. It is
shared by the app and backend so working-record vocabulary and lifecycle
policy are not duplicated.
Package shape:
packages/working-lifecycle/
package.json
src/
vocabulary.ts
model.ts
order.ts
policy.ts
reducer.ts
selectors.ts
index.ts
tests/
order.test.ts
policy.test.ts
reducer.test.ts
invariants.test.ts
The package contains no React, Next.js, browser-storage, routing, localization, or Convex imports.
It owns:
- canonical create and saved-record discriminators
- lifecycle, identity, stage, retention, and event types
- immutable order-key comparison and generation rules
- pure lifecycle transitions
- retention and confirmation decisions
- generic selectors and persistence classification
Both the app and backend derive their types and validators from its exported readonly discriminator arrays. Backend-specific Convex validators remain in the backend but are constructed from the canonical values instead of repeating the literals manually.
Organization-shell integration
React, persistence, localization, and rendering remain under the existing org shell boundary. The files have deliberately narrow responsibilities:
shell/
working/
index.ts public lifecycle/route API
lifecycle-context.tsx canonical membership and commands
lifecycle-storage.ts versioned browser projections
working-scope-context.tsx visible client/project/task defaults only
pinned-work-model.ts pure durable-pin adapter model
pinned-work-context.tsx backend saved-record pin adapter
workspace-tabs-context.tsx live preview handles only
working-strip-model.ts pure topbar projections
working-strip-chips.tsx shared non-record chips
working-record-strip-item.tsx exhaustive saved-record rendering
working-quick-view-strip.tsx topbar composition
working-providers.tsx provider order in one place
create/
create-intent-model.ts pure create-session payload model
create-intent-context.tsx create renderer/callback adapter
edit-dialog-tabs-context.tsx live edit handles only
quick-view/
quick-note-model.ts pure note draft/session model
quick-note-context.tsx note renderer/callback adapter
The lifecycle context is the only authority for membership, stable insertion order, visibility, retention, pin state, and create-to-record handoff. The other contexts may carry feature data or callbacks but must not create a second ordering or membership policy.
The working/index.ts entrypoint is the public API for feature consumers that
open saved records or use lifecycle commands. Deep imports are limited to
shell-owned adapter composition; feature routes must not import reducer,
storage, or topbar projection internals.
Feature-owned adapters
Feature-specific draft codecs and renderers stay with their feature when they depend on feature business rules. They expose a small adapter through the feature's public entrypoint. The shell's exhaustive registry composes those adapters.
This respects route ownership while keeping lifecycle behavior centralized.
Canonical lifecycle model
The pure model should be structurally similar to:
type WorkingLifecycle = {
id: string;
adapterId: WorkingAdapterId;
canonical?: {
recordType: WorkingRecordType;
recordId: string;
};
stage: {
kind: "create" | "preview" | "edit" | "operation" | "unavailable";
name: string;
};
orderKey: WorkingOrderKey;
visibility: "visible" | "hidden";
pinned: boolean;
engaged: boolean;
dirty: boolean;
availability: "available" | "saving" | "offline" | "unavailable";
draftRef?: string;
parentId?: string;
returnIntent?: SerializableReturnIntent;
metadata: {
title?: string;
contextLabel?: string;
};
};
type WorkingLifecycleState = {
schemaVersion: number;
scopeKey: string;
visibleLifecycleId: string | null;
itemsById: Record<string, WorkingLifecycle>;
};
The exact syntax is not binding; the separation is.
Fields deliberately excluded
Do not put these in lifecycle state:
- React nodes
- close or created callbacks
- translation functions
- router objects
- Convex mutation functions
- entire canonical records
- unvalidated feature draft payloads
- separate
openedAt,recentAt, andpinnedAtordering fields
Feature draft payloads live in typed draft envelopes outside the pure metadata model.
Identity rules
Lifecycle ID
Created once for an independent unit of work and retained through every stage. It does not encode the stage.
Canonical key
After save, the canonical key is recordType + recordId. All entry points use
it to find an existing lifecycle before adding one.
Stage
Stage changes do not create a new lifecycle ID. Current IDs such as
project:123:preview and project:123:edit must no longer define two topbar
identities.
Parent relationship
Independent nested work gets a child lifecycle with parentId. Parent-owned
operations update the parent's stage and create no child lifecycle.
No callbacks as relationships
Current nested creates capture onCreated callbacks and attempt to resume a
parent later. The replacement is a serializable parent identity and return
intent. On child completion, the command layer emits a CHILD_COMPLETED
effect that the parent adapter can interpret.
This survives hiding, route navigation, and refresh; closures do not.
Immutable ordering
Order key
Every genuinely new rail membership receives one opaque, sortable
WorkingOrderKey. It is generated only by the ADD_NEW transition and copied
unchanged through all later events.
The key should contain:
- a monotonically advanced addition time or logical clock
- a same-tick sequence
- a stable actor/device tie-breaker
- the lifecycle ID as a final deterministic tie-breaker
The encoded representation is an implementation choice. The behavioral requirements are:
- newer additions compare before older additions
- two additions always compare deterministically
- an existing key is never regenerated
- backend saved pins persist the same key
- create-to-record handoff copies the same key
- migrations assign keys once while preserving existing visible order
Single selector
Only selectOrderedWorkingLifecycles(state) sorts rail items. It compares
orderKey and nothing else.
No component may sort using:
- view or resume time
- draft update time
- record update time
- save time
- pin time
- current source array position
Event and command model
Components use commands. Commands validate intent, consult policy, coordinate effects, and dispatch reducer events.
Public commands
| Command | Meaning |
|---|---|
openCreate(adapterId, input, parent?) | Add a new create lifecycle and show it |
openRecord(canonical, requestedStage?) | Add or activate one canonical saved lifecycle |
openStage(lifecycleId, stage) | Change stage without changing identity or order |
activate(lifecycleId) | Show an existing lifecycle without reordering |
hide(lifecycleId) | Apply non-destructive retention policy |
markEngaged(lifecycleId, engaged) | Update meaningful-work retention |
setDirty(lifecycleId, dirty) | Update normalized dirty state |
setPinned(lifecycleId, pinned) | Update pin retention and persistence |
handoffToRecord(lifecycleId, canonical, nextStage) | Atomically promote a create |
complete(lifecycleId, result?) | Explicitly complete an unpinned stage |
requestCancel(lifecycleId) | Cancel or request confirmation according to policy |
requestRemove(lifecycleId) | Remove from the rail or request confirmation |
confirmPendingAction() | Execute the pending destructive lifecycle action |
reportUnavailable(lifecycleId, reason) | Preserve identity while making failure explicit |
Feature code should normally use these commands rather than dispatching raw events.
Reducer events
The reducer accepts a small closed event union:
ADD_NEWACTIVATEHIDESET_ENGAGEDSET_DIRTYSET_PINNEDCHANGE_STAGEHANDOFF_TO_RECORDCOMPLETEDISCARDREMOVESYNC_METADATASET_AVAILABILITYHYDRATE_SCOPE
Adding a new feature does not add new lifecycle events. A new event is justified only when it represents a genuinely new app-wide transition.
Atomic handoff
HANDOFF_TO_RECORD is one reducer event. It:
- finds the existing lifecycle
- verifies or resolves canonical deduplication
- assigns the canonical identity
- changes stage
- preserves pin, engagement rules, and
orderKey - removes any unsaved alias
- leaves exactly one lifecycle in state
Individual save handlers must not emulate this by “remove create, then pin record.”
Central policy module
The policy module is the executable version of the product decision tables. It returns decisions; it does not mutate state or render confirmations.
Recommended pure functions:
shouldRetainWhenHidden(lifecycle)
resolveHideOutcome(lifecycle)
resolveCancelOutcome(lifecycle)
resolveRemoveOutcome(lifecycle)
resolveCompletionOutcome(lifecycle)
classifyPersistence(lifecycle)
Possible outcomes are a closed union rather than booleans:
type LifecycleActionOutcome =
| { kind: "hide" }
| { kind: "remove" }
| { kind: "discard" }
| { kind: "returnToPreview" }
| { kind: "confirm"; reason: ConfirmationReason }
| { kind: "no-op" };
This prevents isPinned && isDirty && hasDraft condition chains from being
rewritten slightly differently in each dialog.
Confirmation reasons
Use a small semantic union:
discard-unsaved-creatediscard-unsaved-editdiscard-pinned-draftremove-saved-working-itemremove-unavailable-working-item
The shared confirmation host maps the reason to localized copy. Features may supply a record label, but they do not decide whether confirmation is required.
Feature adapter contract
Each lifecycle family registers one adapter. The contract should be generic over its typed draft and saved result.
type WorkingLifecycleAdapter<TDraft, TSavedResult> = {
id: WorkingAdapterId;
version: number;
capabilities: {
create: boolean;
preview: boolean;
edit: boolean;
pin: boolean;
};
draft?: {
create(input): TDraft;
parse(value): TDraft | null;
serialize(draft): unknown;
normalize(draft): unknown;
hasMeaningfulWork(draft): boolean;
};
getTitle(context): string;
renderTab(context): ReactNode;
renderStage(context): ReactNode;
resolveSavedResult?(result: TSavedResult): {
canonical: CanonicalWorkingIdentity;
nextStage: WorkingStage;
};
handleChildCompleted?(context): ParentPatch | undefined;
};
Names may evolve, but every adapter must provide the same lifecycle hooks.
Adapter responsibilities
- create and validate typed draft envelopes
- normalize drafts for meaningful-work and dirty comparisons
- render the tab and current stage
- translate a successful save result into canonical identity
- derive display metadata from canonical feature data
- describe supported stages and capabilities
- handle documented parent/child results
Adapter non-responsibilities
- ordering
- rail insertion or removal
- pin persistence
- general hide/cancel decisions
- topbar confirmation rendering
- canonical deduplication
- direct browser-storage writes
Controlled exceptions
Features may differ from the default only through a closed, typed capability or
exception field in the canonical definition. Do not accept arbitrary adapter
callbacks such as shouldRetain, shouldReorder, or shouldConfirm.
An exception must include:
- the exact default being changed
- the declarative alternative from a supported union
- a product rationale
- a link to the relevant product-spec section
- adapter contract tests for both the exception and all unchanged defaults
If a genuinely new alternative is needed, add it to the central policy outcome union first. This keeps exceptions searchable and prevents hidden per-feature policy.
Exhaustive registry
Use one typed definition map as the canonical feature inventory. Derive create and record unions from that map.
The app registry must satisfy every definition at compile time. Backend visibility rules and saved-pin validators must also have exhaustive tests against the canonical saved-record values.
Adding a feature should require:
- one definition
- one adapter
- its feature draft/renderer implementation
- adapter contract tests
It must not require editing a long conditional chain in the topbar.
Draft storage
Typed draft envelopes
Use one generic draft repository keyed by lifecycle ID:
type WorkingDraftEnvelope = {
schemaVersion: number;
adapterId: WorkingAdapterId;
adapterVersion: number;
lifecycleId: string;
baseline: unknown;
payload: unknown;
};
The generic repository stores only validated serialized envelopes. The feature adapter owns parsing, normalization, and adapter-version migration.
Engagement and dirty state
The feature adapter reports:
hasMeaningfulWork(normalizedDraft)for engagement- normalized payload equality against
baselinefor dirty
The shell does not infer engagement from:
- the existence of a storage key
- a non-empty title alone
- a draft update timestamp
- whether autosave recently ran
After background autosave, the baseline may update and dirty may become false; engagement remains true until explicit completion or discard.
Hidden renderers
Feature dialogs may unmount while hidden only if their typed draft envelope can recreate the exact stage. If an editor cannot serialize its state safely, the lifecycle host must keep that renderer mounted but visually closed until an adapter codec exists.
Route-owned component state alone is never sufficient for a retained lifecycle.
One dialog integration
Replace separate create and record pin controls plus effect-driven edit-tab registration with one organization-specific lifecycle frame.
Conceptually:
<WorkingLifecycleDialog
lifecycleId={lifecycleId}
dirty={dirty}
engaged={engaged}
>
{featureEditor}
</WorkingLifecycleDialog>
The frame owns:
- Pin using
setPinned - Minus using
hide - pointer-outside and Escape using
hide - dialog X and Cancel using
requestCancel - visibility from
visibleLifecycleId - consistent accessibility labels and pressed state
- focus return on hide, cancel, and resume
The feature owns its primary action and calls handoffToRecord, complete, or
reports save failure.
ModalDialog remains a general UI primitive. Lifecycle policy belongs in the
org-specific frame, not in the surface-agnostic primitive.
One topbar renderer
The working strip becomes a simple projection:
- call
selectOrderedWorkingLifecycles - resolve each lifecycle's adapter
- render one generic
WorkingLifecycleTab - let the adapter provide its icon, label, and optional preview body
The strip must not:
- query five lifecycle contexts
- deduplicate note/edit/pin/session arrays
- assign
recentAt - unshift timers
- know create-to-record mappings
- contain a record-type rendering switch
- decide confirmation rules
Permanent topbar controls remain outside the lifecycle selector.
Persistence architecture
One coordinator derives persistence projections from lifecycle state. Features never choose their own storage tier.
| Repository | Projection | Scope |
|---|---|---|
| Browser-session rail | Temporary clean saved records and local rail order | Browser tab + organization + user |
| Same-device drafts | Engaged or pinned create/edit metadata and draft envelopes | Device + organization + user |
| Backend saved pins | Canonical saved identities and immutable order keys only | Organization + user, cross-device |
Backend projection
The durable pin format should contain only data that must be durable:
type PinnedWorkingRecordV2 = {
recordType: WorkingRecordType;
recordId: string;
orderKey: WorkingOrderKey;
};
Do not persist titles, hrefs, current view timestamps, or React surface details
as authoritative pin identity. Titles come from canonical records; hrefs come
from appRoutes; preview/edit stage is local lifecycle state.
This reduces stale metadata and removes another set of parallel sources of truth.
Hydration merge
Hydration is one deterministic function:
- resolve organization and user scope
- decode and migrate each repository independently
- reject malformed envelopes without deleting valid siblings
- combine local lifecycle metadata with backend canonical pins
- deduplicate by lifecycle ID, then canonical identity
- preserve existing
orderKey - apply pin and engagement retention
- resolve adapter versions
- mark unsupported adapters unavailable rather than discarding them
- restore all persisted lifecycles hidden
- sort once through the canonical selector
Loading data in a different order must produce the same final state.
Writes
Persistence subscribes to state changes and writes the minimal derived projection:
- serialized through one queue per repository
- no-op checked before writing
- optimistic for saved pins
- acknowledged against the exact backend revision
- retried without changing lifecycle order
Draft keystrokes may be debounced, but lifecycle events such as Pin, handoff, discard, and removal must flush immediately.
Convex migration for durable pins
Existing pinnedWorkItems contain duplicated presentation metadata and no
immutable lifecycle order key. Use a new field rather than changing the old
array in place.
Deploy 1: widen and dual read/write
- Add optional
pinnedWorkingRecordsV2to organization workspace preferences. - Keep existing
pinnedWorkItemsvalid. - Read V2 when present; otherwise adapt the legacy array.
- New writes update V2 and, during rollback coverage, also update the legacy projection.
- New V2 writes preserve client lifecycle
orderKey; they never generate a new key for an existing pin.
Backfill
Use @convex-dev/migrations, not an unbounded .collect().
For each organization-user preference document:
- skip it when V2 already exists
- filter legacy action pins because unsaved drafts are local, not backend pins
- deduplicate canonical record type and ID
- assign immutable order keys that preserve the legacy array's current visible order exactly
- write V2
- run the companion bounded cleanup that removes obsolete action entries from the legacy field while retaining its record projection for rollback
Run a dry run first, monitor migration status, and verify:
- every legacy saved-record pin has one V2 record
- no V2 canonical identities are duplicated
- V2 order matches legacy visible order
- unauthorized pins still pass the existing visibility filter
Deploy 2: prefer V2
- Read V2 as authoritative.
- Continue a temporary legacy write for rollback if required.
- Confirm app clients preserve order through pin, unpin, handoff, and cross-device hydration.
Deploy 3: narrow
- Stop legacy writes.
- Mark
pinnedWorkItemsoptional and deprecated. - Remove legacy reads only after all supported clients use V2.
- Retain the record-only legacy projection temporarily for rollback safety.
Do not make V2 required before verification completes.
Historical rollout
The refactor must be incremental and lossless.
Phase 1: pure engine
- add the canonical package
- encode the product decision tables as policy tests
- add reducer invariant and order tests
- make no UI changes
Phase 2: shell store in shadow mode
- add
OrgWorkingLifecycleProvider - hydrate legacy providers into a read-only normalized shadow projection
- compare old visible entries with the new selector in development/tests
- do not render or persist from the new store yet
Phase 3: compatibility facade
- point existing public hooks at lifecycle commands where semantics already match
- retain method names temporarily so feature migration stays reviewable
- prohibit new calls to old provider internals
Phase 4: create and quick-note lifecycles
- migrate one representative create, then every create adapter
- migrate quick note through the note adapter
- move pin, engagement, cancel, and handoff policy into the engine
- migrate legacy local drafts without deleting unreadable payloads
Phase 5: saved preview and edit
- unify preview and edit under canonical record identity
- move retained draft envelopes to feature adapters
- replace callback registration with stage commands
- migrate route-backed dialog entry points to
openRecord
Phase 6: durable pins
- deploy V2 backend field and dual-read/write path
- backfill and verify
- switch the lifecycle store to the V2 saved-pin repository
Phase 7: one renderer
- replace strip merging with the ordered lifecycle selector
- render tabs and stages through the adapter registry
- centralize confirmations
Phase 8: removal
Delete only after parity and migration tests pass:
OrgCreateIntentProviderOrgQuickNoteProviderOrgPinnedWorkProviderOrgWorkspaceTabsProviderOrgEditDialogTabsProvider- create-specific and record-specific pin buttons
- obsolete registries, unions, storage keys, and compatibility hooks
- strip deduplication and per-type branches
Do not leave wrappers that continue two parallel state systems indefinitely.
Testing strategy
Pure state-machine tests
Table-test every relevant combination of:
- create, preview, and edit stage
- visible and hidden
- pinned and unpinned
- engaged and unengaged
- dirty and clean
- hide, cancel, remove, save, autosave, pin, and unpin commands
These tests are the executable single source for default behavior.
Invariant tests
Use generated event sequences where practical and assert:
orderKeynever changes afterADD_NEW- activation never changes ordered IDs
- metadata sync never changes ordered IDs
- handoff preserves lifecycle count and position
- one canonical record never has two lifecycles
- pinned or engaged work is not discarded by hide
- failed effects cannot complete or remove a lifecycle
- removing a saved lifecycle never emits a record-delete effect
- scope hydration never merges users or organizations
Adapter contract suite
Run the same contract tests with describe.each over every adapter:
- required stages render
- create draft parses and serializes
- meaningful work detection is stable
- normalized dirty comparison is correct
- successful create resolves canonical identity where supported
- record labels and routes resolve
- pin capability matches product coverage
- parent/child behavior is declared
One new adapter automatically receives the shared suite.
Persistence tests
- versioned decode and migration
- malformed sibling isolation
- deterministic multi-source merge
- local and backend canonical deduplication
- stable order across refresh and cross-device pin hydration
- optimistic write rollback without reorder
- legacy draft and pin preservation
UI integration tests
Test the generic frame once for:
- pointer outside, Minus, Escape, X, Cancel, Pin, and topbar X
- confirmation accept and decline
- focus restoration
- accessible names and pressed state
Feature tests then verify adapter wiring, not the shared policy again.
Architecture guards
Add source-boundary tests or lint rules that prevent regression:
- no direct lifecycle
localStorageorsessionStorageaccess outside the persistence repositories - no durable-pin mutation outside the backend pin repository
- no feature import of reducer internals
- no record-type switch in the working strip
- no use of
updatedAt,pinnedAt, or view time as rail order - no new lifecycle provider beside
OrgWorkingLifecycleProvider - every canonical definition has an adapter and backend visibility coverage
Acceptance traceability
The product specification gives every acceptance scenario a stable WT-* ID.
Use those IDs in test names and migration checklists. When behavior changes,
update the product scenario, central policy test, and affected adapter contract
in the same change.
Do not copy the acceptance prose into multiple test helpers. A shared test scenario factory should encode the common setup and assertions while feature adapters supply their fixtures.
Explainability and diagnostics
The central policy should be able to explain its decision without logging private draft content.
Provide a development-only selector similar to:
explainWorkingLifecycle(lifecycle) => {
identity,
stage,
orderKey,
position,
retentionReasons,
persistenceClass,
lastCommand,
lastOutcome,
}
In development and tests, record a bounded lifecycle event trail containing event names, identities, and before/after policy state. Never include draft payloads, note bodies, client data, or other user content.
This makes reports such as “the tab disappeared” or “this moved after save” answerable from one state transition:
- which command was issued
- which policy outcome was selected
- which retention reasons applied
- whether
orderKeychanged - which persistence effect succeeded or failed
Production analytics, if later required, must use the same content-free event vocabulary and existing consent/privacy rules.
How future changes become easier
| Desired change | Single place to change |
|---|---|
| Empty pinned drafts should behave differently | policy.ts plus its table test |
| Outside click should use a different rule | resolveHideOutcome |
| Working items should order differently | Order module and selector only |
| Save should retain an unpinned feature | Adapter capability/explicit exception |
| Add a new lifecycle stage | Owning feature adapter |
| Add a new record type | Canonical definition plus one adapter |
| Change confirmation copy | Shared localized confirmation host |
| Change draft cleanup timing | Persistence classification/cleanup policy |
| Change saved-pin storage shape | Backend repository and versioned migration |
| Diagnose why an item stayed | Inspect one lifecycle record and policy outcome |
The important maintenance property is that feature code describes what the work is; the lifecycle engine decides how working tabs behave.
Anti-patterns to reject
- another provider for a special feature
- a second “recent” or “pinned” array rendered beside lifecycle state
- per-dialog copies of pin/hide/cancel conditions
- remove-then-add create handoff
- stage-specific topbar IDs for the same saved record
- callbacks stored in persisted lifecycle objects
- feature components writing browser storage directly
- adapter-specific ordering exceptions
- using record
updatedAtor draft autosave time as working order - keeping both legacy and replacement providers as permanent sources
- a giant adapter union containing all feature draft payloads in the reducer
Definition of architectural completion
The consolidation completed on 2026-07-30 with these boundaries:
- one provider owns all working lifecycle metadata
- one reducer owns all lifecycle transitions
- one policy module owns retention and confirmation decisions
- one immutable order key determines the rail
- one visible lifecycle identity replaces per-provider ordering authority
- one typed adapter mapping covers every Core audit row
- one dialog frame owns shared working controls
- one canonical selector determines the topbar order
- persistence is projected from lifecycle state by repository lifetime
- durable saved pins use canonical identity plus order key
- exact drafts remain feature-typed and recoverable
- obsolete ownership APIs, action-pin persistence, duplicate barrels, and unscoped storage writers are removed
- payload/callback providers remain narrow adapters rather than lifecycle authorities
- shared invariants and adapter integration tests cover the product acceptance scenarios without per-feature ordering forks