Skip to main content

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 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:

  1. one canonical model
  2. one pure reducer
  3. one policy module for retention and dismissal
  4. one immutable ordering mechanism
  5. one organization-shell store
  6. one persistence coordinator
  7. one typed feature-adapter registry
  8. 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 ownerState it owns
OrgCreateIntentProviderCreate sessions, active create, create persistence, nested callbacks, future pin intent
OrgQuickNoteProviderNote sessions, note draft persistence, active note, note-specific handoff
OrgPinnedWorkProviderBackend saved-record pins, optimistic persistence, pin ordering
OrgWorkspaceTabsProviderSession-scoped saved-record tabs
OrgEditDialogTabsProviderOpen 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-lifecycle package 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 pinnedWorkingRecordsV2 dual 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

ConcernCanonical ownerMust not be redefined in
Product behaviorProduct lifecycle specificationComponents, adapters, tests
Lifecycle types and eventsPure working-lifecycle packageReact contexts or feature dialogs
Create/record vocabularyCanonical typed definitionsFrontend API wrappers, strip switches, backend literal lists
Added orderLifecycle orderKeyopenedAt, updatedAt, pinnedAt, array position
Retention decisionsLifecycle policy moduleDialog onOpenChange handlers
Pin stateLifecycle recordSeparate create-pin and record-pin providers
Active surfaceOne visibleLifecycleIdPer-provider active IDs
Create-to-record mappingFeature adapterIndividual save handlers
Draft engagementFeature draft adapterTopbar label or storage existence checks
Dirty stateNormalized feature baselineRaw field comparisons in the shell
Rail membershipLifecycle selectorsConcatenated provider arrays
Persistence classificationPersistence selectorFeature calls to localStorage or sessionStorage
Saved-pin storageBackend pin repositoryFeature mutations
RenderingExhaustive adapter registryRecord-type if/switch chains in the strip
Confirmation semanticsShared lifecycle action guardPer-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, and pinnedAt ordering 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

CommandMeaning
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_NEW
  • ACTIVATE
  • HIDE
  • SET_ENGAGED
  • SET_DIRTY
  • SET_PINNED
  • CHANGE_STAGE
  • HANDOFF_TO_RECORD
  • COMPLETE
  • DISCARD
  • REMOVE
  • SYNC_METADATA
  • SET_AVAILABILITY
  • HYDRATE_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:

  1. finds the existing lifecycle
  2. verifies or resolves canonical deduplication
  3. assigns the canonical identity
  4. changes stage
  5. preserves pin, engagement rules, and orderKey
  6. removes any unsaved alias
  7. 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-create
  • discard-unsaved-edit
  • discard-pinned-draft
  • remove-saved-working-item
  • remove-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:

  1. one definition
  2. one adapter
  3. its feature draft/renderer implementation
  4. 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 baseline for 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:

  1. call selectOrderedWorkingLifecycles
  2. resolve each lifecycle's adapter
  3. render one generic WorkingLifecycleTab
  4. 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.

RepositoryProjectionScope
Browser-session railTemporary clean saved records and local rail orderBrowser tab + organization + user
Same-device draftsEngaged or pinned create/edit metadata and draft envelopesDevice + organization + user
Backend saved pinsCanonical saved identities and immutable order keys onlyOrganization + 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:

  1. resolve organization and user scope
  2. decode and migrate each repository independently
  3. reject malformed envelopes without deleting valid siblings
  4. combine local lifecycle metadata with backend canonical pins
  5. deduplicate by lifecycle ID, then canonical identity
  6. preserve existing orderKey
  7. apply pin and engagement retention
  8. resolve adapter versions
  9. mark unsupported adapters unavailable rather than discarding them
  10. restore all persisted lifecycles hidden
  11. 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

  1. Add optional pinnedWorkingRecordsV2 to organization workspace preferences.
  2. Keep existing pinnedWorkItems valid.
  3. Read V2 when present; otherwise adapt the legacy array.
  4. New writes update V2 and, during rollback coverage, also update the legacy projection.
  5. 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:

  1. skip it when V2 already exists
  2. filter legacy action pins because unsaved drafts are local, not backend pins
  3. deduplicate canonical record type and ID
  4. assign immutable order keys that preserve the legacy array's current visible order exactly
  5. write V2
  6. 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

  1. Read V2 as authoritative.
  2. Continue a temporary legacy write for rollback if required.
  3. Confirm app clients preserve order through pin, unpin, handoff, and cross-device hydration.

Deploy 3: narrow

  1. Stop legacy writes.
  2. Mark pinnedWorkItems optional and deprecated.
  3. Remove legacy reads only after all supported clients use V2.
  4. 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:

  • OrgCreateIntentProvider
  • OrgQuickNoteProvider
  • OrgPinnedWorkProvider
  • OrgWorkspaceTabsProvider
  • OrgEditDialogTabsProvider
  • 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:

  • orderKey never changes after ADD_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 localStorage or sessionStorage access 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 orderKey changed
  • 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 changeSingle place to change
Empty pinned drafts should behave differentlypolicy.ts plus its table test
Outside click should use a different ruleresolveHideOutcome
Working items should order differentlyOrder module and selector only
Save should retain an unpinned featureAdapter capability/explicit exception
Add a new lifecycle stageOwning feature adapter
Add a new record typeCanonical definition plus one adapter
Change confirmation copyShared localized confirmation host
Change draft cleanup timingPersistence classification/cleanup policy
Change saved-pin storage shapeBackend repository and versioned migration
Diagnose why an item stayedInspect 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 updatedAt or 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