Skip to content

Open App Local-Development Linking Specification

Status: Accepted 2026-07-31 · normative companion to the LTS process document (PR #3241) Mechanism basis: hand-spike proven 2026-07-26 · decision record: docs/decisions/2026-07-30-openapp-local-dev-architecture.md Conformance language: MUST / SHOULD / MAY are used per RFC 2119. A conforming implementation of the mj dev workspace tool and the mj doctor checks satisfies every MUST here.

One sentence: cross-repo Open App development on MemberJunction works by generating an ephemeral pnpm workspace at the member repos’ common parent directory that redirects module resolution to local source for selected repos and to the registry for everything else — committing nothing to any repo, asserting a single physical copy of every shared dependency, and proving publishability separately.


This document specifies how local checkouts of first-party repositories are linked for cross-repo development; it does not specify how an Open App is registered so a running host will load it.

The two are orthogonal, and stating that plainly is the load-bearing decision of this spec:

Axiom (linking ⊥ registration). Linking redirects module resolution so that a consumer resolves a dependency from a sibling’s local source instead of the registry. Registration makes an installed or linked app’s packages load inside a running MJAPI/Explorer. Installation wires the host once (schema, migrations, the dynamicPackages entries a host reads at boot); the workspace only redirects resolution. Neither substitutes for the other, and neither MUST assume the other has happened. A workspace can link an app whose packages are not registered (they resolve but do not load); a host can register an app whose packages are not linked (they load from the registry).

Linking is the subject of this specification and of the linking MVP. Registration is phase 2; §6 specifies only the constraints registration inherits from this axiom, so the two halves compose rather than entangle.

In scope: the generated-files contract (§2), resolution semantics (§3), the workspace lifecycle (§4), the requirements the tooling enforces (§5), the future composition of mj app install --branch (§6), the integration surface for external tooling (§7), and the mj doctor checks (§9).

Terminology. A member is a repository selected into a workspace; its packages are linked from local source. A non-member is any package resolved from the registry. A producer repo is a member contributing library packages that siblings consume. The workspace tool is mj dev workspace. The era platform manifest is eras.*.platform in release-lines.json (PR #3241 §4.1) — the single source of exact platform pins across the ecosystem.


The workspace tool generates a fixed, small set of files at the members’ common parent directory; the tool is their only writer, and none of them is ever committed to any repository.

The contract is exactly the files below, all written to the common parent (e.g. ~/code/bluecypress/), all removed at teardown. Nothing inside any member repo changes to make linking work — this is the “nothing committed” property, satisfied by geography rather than by discipline.

File (at the common parent)Contents the tool MUST generate
pnpm-workspace.yamlThe packages: list of member globs; linkWorkspacePackages: true; the onlyBuiltDependencies build-scripts allowlist. Producer repos contribute packages/* only, never apps/* — every member names its apps mj_api/mj_explorer, so contributing apps/* from more than one member collides.
.npmrcTransitional pnpm settings the workspace needs to run against repos still pinned to npm: package-manager-strict=false (members pin packageManager: npm@…) and auto-install-peers=true. Caution: auto-install-peers=true turns peer RANGES into install instructions — each consumer’s unanchored peer range is resolved to the newest satisfying version, which is how a workspace floats part of the Angular family ahead of the rest (two physical copies, the exact census failure §5.1 forbids). The peer-declaration doctrine (§5.1a) is what makes this setting safe. strict-peer-dependencies=true is the standing posture as of 2026-08-07 (the family conforms to §5.1a: MJ#3580 + the fix/angular-anchors PRs, all merged). Registry-resolved MJ copies lag source fixes, so the workspace root package.json carries a scoped pnpm.peerDependencyRules bridge block: allowedVersions: { "nunjucks>chokidar": "5" } (nunjucks’s optional watch-mode peer; no MJ consumer uses nunjucks watch — permanent) and ignoreMissing: ["axios"] plus scoped <sdk>>zod: "^3.24" allowances for @modelcontextprotocol/sdk, zod-to-json-schema, openai, and @anthropic-ai/sdk (bridges for registry-lag: older published @memberjunction copies in the tree still declare zod 3.24 / omit axios; MJ source is fixed on next — remove each rule at the first family pin-rev past the fixed release). It also carries the transitional public-hoist-pattern block (§2.1).
package.jsonPrivate, "private": true; devDependencies = the union of every member’s root devDependencies, so root-level build tooling (e.g. tsc-alias) is on every member’s script PATH via pnpm’s root-.bin mechanism.
pnpm-lock.yamlThe workspace’s own lockfile, generated by the install. It is never committed anywhere and never replaces any member’s committed lockfile.
.env.workspace (planned, phase 2)Per-workspace registration entries, referenced from the tracked mj.config.cjs and loaded per-workspace at the call site (dotenvx). Specified in §6; not part of the linking MVP.

The workspace tool MUST be the only writer of these files. External tooling MUST NOT write them (§7). No MJ tooling MUST ever write through a node_modules path into any of these trees — under pnpm that writes into the machine-wide content-addressable store and corrupts unrelated projects.

2.1 The transitional hoist block has a named sunset

Section titled “2.1 The transitional hoist block has a named sunset”

The generated .npmrc MAY carry a public-hoist-pattern block enumerating the packages Angular’s dev server externalizes to app context (measured at ~69 packages in the hand-spike). This block is transitional debt, owned by the generated file, with a named sunset: at each member’s real conversion to pnpm the app declares those dependencies explicitly, and the corresponding hoist lines are removed. The block MUST be enumerated (never a broad wildcard), and every line MUST be traceable to a package the dev server actually externalizes. Broad hoisting MUST NOT be introduced; the resolution model this program exists to fix is hoisting.

Discrepancy flagged for review. The shareable summary (openapp-workspace-spec.html §02) places link-workspace-packages and the build-scripts allowlist in .npmrc. The proven runbook places both in pnpm-workspace.yaml (pnpm 10 reads them there). This spec follows the runbook. Reviewers should confirm the summary is corrected to match.

2.2 turbo.json is not part of the contract

Section titled “2.2 turbo.json is not part of the contract”

The hand-spike copied a turbo.json to the parent so turbo would infer the workspace root there. The evidence shows this is transitional — it works only until members commit their own pnpm configuration and dies thereafter. turbo.json is therefore not a member of the normative generated-files contract; the workspace tool SHOULD NOT depend on generating it, and turbo caching is an optimization layered on top of workspace resolution, not a requirement of it.


Members link from local source; non-members resolve from the registry; partial linking — some apps local, the rest from the registry — is the normal case, not a degraded one.

linkWorkspacePackages: true links a package to a member only when that member’s local version satisfies the consuming manifest’s declared range. This produces three behaviors the tooling MUST honor:

  1. Members link. A consumer’s dependency on a producer package resolves to the producer’s local checkout (a symlink into local source), so an edit in the producer is visible through the consumer’s own node_modules with no publish and no copy step.
  2. Non-members resolve from the registry. Any package whose repo is not a workspace member resolves from registry.npmjs.org the ordinary way — including @memberjunction/* at whatever era version each member pins.
  3. Partial linking is expected. A workspace routinely links a subset of the dependency graph while the rest resolves from the registry. A member consuming a linked producer and a registry-only sibling in the same install is the designed-for case, not an edge case.

3.1 Exact pins against the era platform manifest

Section titled “3.1 Exact pins against the era platform manifest”

Platform versions across the ecosystem are pinned exact, sourced from the era platform manifest. Because a member links only when its local version satisfies the consumer’s declared specifier, an exact pin links only to a member at that identical version:

  • A range specifier (e.g. a producer consumed at ^5.31.1, local 5.32.0) satisfies → links.
  • An exact specifier (e.g. a sibling consumed at exact 1.1.1, local 1.2.0) does not satisfy → that dependency resolves from the registry even though the sibling is present as a member.

The exact-1.1.1-vs-local-1.2.0 case is the canonical partial-linking outcome: presence as a member is necessary but not sufficient; version satisfaction decides.

Where the tooling decides whether a member can satisfy a sibling’s platform range — and in the corresponding mj doctor check — the comparison MUST be era-based, not raw semver.

The reason is prerelease exclusion. Under standard semver, a prerelease version (an Edge checkout, e.g. 6.1.0-next.3) does not satisfy an ordinary range (e.g. ^6.1.0 or >=5.30.0), because a prerelease only matches a comparator sharing its exact major.minor.patch tuple. A raw-semver check would therefore refuse to link two members that are on the same era but where one is a prerelease Edge checkout — the common cross-repo development case. Era-based comparison treats two checkouts on the same era as compatible, so an Edge member links to a certified-range sibling within that era.

The current CheckMJVersionCompatibility in @memberjunction/open-app-engine (packages/OpenApp/Engine/src/dependency/version-checker.ts) uses raw semver. Era-aware comparison is therefore a required addition for the linking/doctor path, consistent with PR #3241’s rule that link and doctor version checks compare eras, not semver ranges.


A workspace has four operations — create, verify, reset, teardown — and one hard invariant: one checkout equals one workspace at a time.

  • Create. The developer selects members; the tool generates the §2 files and runs a single install pass. Producer repos contribute packages/* only (§2). Install is one resolution pass covering every member (~1 minute cold in the hand-spike, 16 projects in 53 s).
  • Verify. The tool re-runs the single-copy census (R1, §5.1) and the resolution checks. Verification is a read-only assertion over the installed tree; it MUST NOT mutate any repo or generated file.
  • Reset. mj dev workspace reset MUST recover a clean workspace from every enumerated half-state in one command — a stray member-local node_modules, a rewritten member lockfile, a mid-session branch switch. Reset re-establishes the invariants of a fresh create without requiring teardown.
  • Teardown. The tool removes every generated file and every generated node_modules. After teardown every repo is exactly as its git history says — no residue, no committed linking state. Teardown is the proof of the “nothing committed” property.

One checkout = one workspace. A given checkout MUST belong to at most one workspace at a time. Parallel-era work (maintaining a 5.x line while developing on 6.x) uses separate git worktrees, each with its own workspace; pnpm’s hard-linked store makes per-worktree installs cheap. Whether the tool manages worktrees is deferred (a later decision); v1 MUST document the worktree recipe and MUST NOT build speculative worktree-lifecycle machinery.


Three requirements — a single-copy census, a split-brain sentinel with guaranteed reset, and a canary-install certification gate — are the reason linked development is safe to certify from.

Workspace generation MUST assert a clean census: exactly one physical copy of each singleton-sensitive package — @angular/* (framework), rxjs, zone.js, @memberjunction/global, and @memberjunction/core. More than one physical variant of any of these MUST be a loud failure at generation, because two copies of Angular or a duplicated MJ singleton is the browser-physics failure (NG0203) this whole architecture exists to prevent. The identical census MUST stand as a permanent mj doctor check, so the one-time measurement becomes a standing invariant.

5.1a — the peer-declaration doctrine that keeps the census clean

Section titled “5.1a — the peer-declaration doctrine that keeps the census clean”

The census is the invariant; this is the declaration discipline that produces it. Mixed pinning regimes are the failure mode: an exact pin in one place plus an open range in another lets the resolver float one member of a lockstep family (e.g. @angular/router) past its exact-pinned siblings — the historical ERESOLVE class — while under auto-install-peers an unanchored range floats a second whole copy in silently. Two rules prevent both:

  1. Libraries declare framework peers as RANGES, never exact"@angular/core": "^21.2.22" (caret at the era platform pin). A peer declaration is a compatibility claim, not an install instruction; an exact peer pin falsely vetoes every other in-range build and turns each Angular security patch into a full republish of the family. This binds MJ’s ng-* packages (MJ#3580) and Open App library packages equally.
  2. Every package that consumes @angular/* anchors the concrete version — exact "21.2.22" entries in its own devDependencies (or committees-style at the repo root). The anchor is what the resolver actually installs; peers then resolve against it instead of floating. Same consumers-declare rule this program already adopted for vitest/vite, applied to runtime singletons — all anchors at ONE version, so the census still sees a single physical copy.

Installed versions are therefore decided only at anchors and the era platform manifest; peer declarations only state tolerance. Proven 2026-08-07 in the workspace harness: strict-mode @angular peer errors 502 → 0, single 21.1.3 census, test sweep byte-identical.

The census counts physical copies, not references. A benign footnote the tooling SHOULD tolerate: a second copy of a platform package confined entirely to a dev-only CLI subtree that no consumed library resolves is not a violation, provided every consumed library resolves the single shared copy. The check MUST attribute any extra copy before passing, not merely count.

A stray npm install inside a member produces a member-local node_modules and a rewritten package-lock.json — a split brain, where some resolution comes from the workspace and some from npm’s own hoisted tree. The tooling MUST detect this mechanically (an npm-authored node_modules carries markers pnpm never creates) and MUST surface it as a loud mj doctor error that names the fix. mj dev workspace reset (§4) MUST clear the condition in one command. Both the sentinel and reset are v1-mandatory, not later hardening: the failure they prevent — silent stale or split resolution — is precisely the disease this program cures.

5.3 R3 — publishability proof (canary-install gate)

Section titled “5.3 R3 — publishability proof (canary-install gate)”

Linked development is deliberately more forgiving than a real install: strict pnpm already fails undeclared imports loudly inside the workspace, but publish-shape defects (tarball contents, files/exports fields, workspace:/catalog: specifier rewriting) remain invisible until a stranger installs. Certification therefore MUST include a canary-install gate, run in CI (ephemeral registry, torn down), independent of any dev machine:

  1. Each release candidate MUST publish to and install clean from an ephemeral registry into a bare directory.
  2. The built Explorer bundle MUST contain each app’s class-registration key strings — verified by an @RegisterClass key-string grep of the built bundle. A linked bundle can assemble while silently omitting an app’s registrations; the grep is what linked mode cannot see and the canary catches.

The daily loop never runs this gate; it is B’s permanent job description, not a developer ritual. The class-registration keys the grep targets are the same ones the manifest generator resolves via its unbounded node_modules walk-up and fs.realpathSync (packages/CodeGenLib/src/Manifest/GenerateClassRegistrationsManifest.ts:231, :240), which is verified to survive both pnpm store layouts and the parent-workspace layout.


6. mj app install --branch (future) — a composition, never new linking internals

Section titled “6. mj app install --branch (future) — a composition, never new linking internals”

When mj app install gains a --branch mode for an unpublished dependency, it MUST be built as a composition of two existing halves — install-and-register, and link — and MUST NOT grow its own linking machinery.

mj app install --branch <app>@<branch> names an Open App dependency that is not yet published. Its two halves are already-owned building blocks:

  • The install/register half clones and builds the target, registers it (schema, migrations, and the dynamicPackages entries a host reads at boot), and runs migrations — using the existing @memberjunction/open-app-engine functions (CreateAppSchema/DropAppSchema, RunAppMigrations, AddAppPackages/RemoveAppPackages, AddServerDynamicPackages/AddClientDynamicPackages), executed against the target checkout’s own engine version, not the caller’s.
  • The linking half delegates entirely to the workspace mechanism in this spec. It MUST NOT reimplement resolution, symlinking, or the generated-files contract.

This is the axiom of §1 enforced in tooling: install wires the host, the workspace redirects resolution, and --branch is the seam that composes them.

6.1 Phase-2 registration inherits one rule from the axiom

Section titled “6.1 Phase-2 registration inherits one rule from the axiom”

Registration enters the program after the linking MVP is proven, under one fixed design rule: dev-mode registration MUST NOT dirty a tracked file. Registration entries ride environment variables referenced from the tracked mj.config.cjs (whose idiom is already process.env, e.g. packages/MJAPI/mj.config.cjs), loaded per-workspace via dotenvx from the generated .env.workspace (§2). Installed (tracked) and dev-linked (env) entries compose — e.g. a host’s dynamicPackages.server is the concatenation of the tracked list and a JSON.parse(process.env.MJ_DEV_SERVER_PACKAGES ?? '[]') tail — so a mixed instance loads both. The host already reads dynamicPackages.server[] and invokes each entry’s StartupExport by name (packages/ServerBootstrap/src/index.ts:139, :159); the env design changes where the entries come from, not how the host consumes them.

Because a bare mj codegen run outside the loaded environment silently sees no dev registration, generated run-wrappers and a mj doctor sentinel (§9) that detect the workspace-present-but-env-absent state are v1-mandatory companions of the registration phase. Note that an overlay-file approach (a separate .mjrc.cjs) was evaluated and rejected: config loading is not centralized — the canonical loader (packages/Config/src/config-loader.ts:95) orders mj.config.cjs first while several raw cosmiconfig call sites use default ordering where .mjrc.cjs wins — so overlay visibility differs per consumer and per directory. The env-var direction is ordering-independent: the refs evaluate inside whichever file any consumer resolves.


7. Integration surface for external tooling

Section titled “7. Integration surface for external tooling”

Tooling that builds on this mechanism integrates against exactly two surfaces — the generated-files contract and the exported open-app-engine functions — and there are no others.

  1. The generated-files contract (§2) at the common parent, with the workspace tool as its only writer. External tooling MAY read these files; it MUST NOT write them and MUST NOT depend on files outside the §2 set.
  2. The exported @memberjunction/open-app-engine functions — the same install/registration building blocks mj app install composes (§6), executed against the target checkout’s own engine version.

There MUST be no tool-specific hooks, side channels, injected package-manager plugins, or invisible manifest mutation. A workflow that needs something these two surfaces do not provide is an ADR requirements conversation, not a license to write into repos, manifests, or the generated files directly. This constraint is what keeps the mechanism auditable: “what actually runs” is answerable from two places.


This program wires source resolution and (in phase 2) registration; everything else belongs to the surfaces that already own it.

The following are explicitly out of scope and MUST NOT be absorbed into the workspace tool:

  • Database provisioning — creating or configuring the database an instance runs against.
  • Instance lifecycle management — spinning up, tearing down, or port-managing running MJ instances.
  • App schema and migration execution — running an app’s migrations or CodeGen. These stay under explicit developer control via existing commands; the workspace loop deliberately excludes the database.

These belong to @memberjunction/open-app-engine (schema/migration functions) and to instance-management tooling, invoked explicitly — never as a hidden side effect of creating or resetting a workspace.


9. mj doctor checks catalog (consolidated)

Section titled “9. mj doctor checks catalog (consolidated)”

mj doctor is where the workspace’s invariants become standing, machine-checked rules rather than one-time measurements.

A conforming mj doctor MUST implement the following. Each names its failure mode.

CheckBehaviorPhase
Single-copy census (R1)More than one physical copy of @angular/*, rxjs, zone.js, @memberjunction/global, or @memberjunction/corefail (loud), after attributing any extra copy (§5.1).v1
Split-brain sentinel (R2)A member-local node_modules/package-lock.json while a workspace is active → fail, naming the fix (mj dev workspace reset).v1
dynamicPackages entries resolveEvery package named in a host’s dynamicPackages.server[]/client[] MUST resolve to an importable package; an unresolvable entry → fail (the host would log “not found” at boot).v1
Zero-registration warningA workspace that links app packages but registers none → warn, not fail — a services-only package (no registered classes) is legitimate; the warning exists to catch the accidental zero, not to forbid the deliberate one.v1
Workspace-present / env-absent sentinelA workspace is present but the registration environment (.env.workspace via dotenvx) is not loaded → warn/fail per severity: a bare mj codegen here would silently see no dev registration (§6.1).phase 2
Registration provenance printoutmj doctor MUST print the resolved registration set with provenance — which entries came from the tracked mj.config.cjs and which from the loaded environment — so a developer can see why a package is or is not loading.phase 2

Related standing check (era alignment, PR #3241): mj doctor SHOULD also verify MJ’s own ng-* peer ranges against the era platform manifest, in both directions (consumer pins vs manifest, and MJ’s published peer ranges vs manifest). This is the same era-comparison discipline §3.2 requires of the link decision.


  • plans/openapp-workspace-spec.html — the shareable architecture summary this document makes normative.
  • plans/openapp-workspace-hand-spike-runbook.md — the proven procedure and results ledger (hand-spike, 2026-07-26).
  • PR #3241 — the LTS process document that references this spec; source of the era platform manifest (eras.*.platform).
  • Issue #3273 — the original mj app link symlink proposal this architecture supersedes.
  • @memberjunction/open-app-engine (packages/OpenApp/Engine/) — the exported install/registration building blocks (§6, §7).
  • packages/ServerBootstrap/src/index.ts — the host’s dynamicPackages loader (§6.1).
  • packages/Config/src/config-loader.ts — cosmiconfig searchPlaces ordering (§6.1).
  • packages/CodeGenLib/src/Manifest/GenerateClassRegistrationsManifest.ts — the class-registration manifest generator (§5.3).