Subsidiary Corporations
Feature flag: subsidiaryCorporationsEnabled on the gameState singleton (_id: "current"), default ON for fresh worlds via featureFlagDefaults. Fail-closed on any world that never stamped the flag, isSubsidiaryCorporationsEnabled() returns false unless the field is explicitly true. Every subsidiary API route, and every effect below, is gated behind this flag.
A player-owned corporation can control other player-owned corporations as a holding group: one corp (the parent) formalizes >50% voting control of another (the subsidiary), gaining management rights over its CEO seat, funding it directly, and setting a dividend floor. The relationship is always derived, never stored as a parent pointer, a corp is a subsidiary of whoever currently controls its votes, full stop. This companion doc covers that system; see corporations.md for the base corporation model it extends.
Core Model: Derived Control, Not a Stored Edge#
There is no parentCorporationId field anywhere. Two facts combine to define "is this corp a managed subsidiary right now, and if so, of whom":
- Voting control, some corporation currently controls >50% of the target's voting power (
SUBSIDIARY_OWNERSHIP_THRESHOLD_PERCENT = 50incorporateOwnership.ts, kept in sync withSUBSIDIARY_FORMALIZATION_THRESHOLD_PERCENT = 50in the subsidiaries module). Voting power, not raw share count, super-share multipliers are respected viaacquirerOwnershipPercent()/getControllingCorporateParent(). - Formalization,
Corporation.subsidiaryFormalizedAtTurnis set. This is an opt-in marker, not a parent pointer; it just records "yes, someone formalized this."
isFormalizedSubsidiary() requires both: a controlling parent AND the marker. If voting control lapses (parent sells down, dilution, etc.) the marker becomes stale and is cleared automatically by the turn processor's zombie cleanup (see below), there is no manual "unformalize because the numbers moved" step for players.
Unsold shares still sitting in the parent's own open sell orders or listings count toward its control for every derived-control check (loadReservedCorporatePositions / resolveControllingCorporateParent), so listing a controlling block for sale does not silently drop parent powers before the trade fills.
Eligibility#
- Parent eligibility (
isEligibleAsSubsidiaryParent): the corp must not be national/state-owned (countryOwnerIdunset) and must not itself already be a formalized subsidiary, no chaining. A subsidiary cannot itself be a parent. - Subsidiary eligibility (
isEligibleAsSubsidiary): the target must not be national/state-owned. - No self-ownership: a corp cannot formalize itself.
- No ownership cycles:
wouldCreateOwnershipCycle()walks the derived control graph (built fresh from every corp'sgetControllingCorporateParent()on every check) to reject any formalization that would let A control B control A. This is enforced twice, once when a corp attempts to formalize a subsidiary, and once earlier at the point of purchase (corpPurchaseWouldCycle()incycleGuard.ts) so a corporate buyer cannot even acquire majority control of a corp that already controls it. The purchase-time check was added after the formalize-time-only check left a window where the loop existed in the share graph before anyone tried to formalize it. - One-person rule: a subsidiary's CEO must be a different human than the parent's owner, the parent's sitting CEO, or the CEO of any sibling subsidiary of the same parent (
humanBlockedFromSubsidiaryCeo()). This keys on the human behind the seat, notcorp.userId, a caretaker-run parent resolves tocaretakerCeo.underlyingUserId(resolveParentCeoUserId()). NPP-run siblings with no caretaker are skipped entirely (no human operates them, nothing to block).
Formalizing a Subsidiary#
POST /api/corporations/[id]/subsidiary/formalize, target is [id], parentCorporationId in the body.
- Caller must be CEO of the parent (not vacant,
userIdmatches). - Parent voting control of the target must exceed 50%, computed with reserved-holdings adjustment.
- Target must not already be formalized.
- Cycle and eligibility checks above all apply.
- Same-human guard on formalize too: if the target's current CEO is a human and that human is the parent's owner, formalization is refused until a different CEO (player or NPP) is seated on the target.
- On success,
subsidiaryFormalizedAtTurnis set to the current turn on the target only.formalizeSubsidiary()uses an atomicupdateOneguarded bysubsidiaryFormalizedAtTurn: { $exists: false }so a race can't double-formalize.
Releasing a Subsidiary#
POST /api/corporations/[id]/subsidiary/release, dismissCaretaker optional in body.
- Caller must currently be authorized to act as parent (
canActOnCorporationAsParent). - Minimum age:
SUBSIDIARY_MIN_AGE_TURNS = 24turns (≈1 real day) must pass between formalize and release, an anti-flip-flop guard. - Clears
subsidiaryFormalizedAtTurn,parentDividendFloorPct,parentDividendFloorSetByCorpId. - Optionally dismisses an NPP caretaker CEO the parent installed, restoring the underlying human.
Zombie Subsidiary Cleanup (turn processor)#
cleanupZombieSubsidiaries() runs every turn inside processCorporationTurn (gated by the feature flag), after the corp/sector lookups are built:
- For every corp with
subsidiaryFormalizedAtTurnset: if nobody controls >50% anymore, the formalization marker and dividend-floor fields are unset, the managed relationship dissolves on its own, no player action needed. - For every corp with a stale
parentDividendFloorSetByCorpId(the setter no longer controls >50%, but the formalization itself is still valid under a different controller): only the floor is cleared, formalization stays. - Runs as a bulk write; logs the count and records an audit entry (
corp.subsidiary_cleanup).
Authorization: canActOnCorporationAsParent#
Every subsidiary management action (release, capital injection, dividend floor, CEO appointment) shares one authorization check:
true iff:
feature flag is ON, AND
sub.subsidiaryFormalizedAtTurn is set, AND
sub is not national/state-owned, AND
the caller is the sitting (non-vacant) CEO/owner of whoever currently
controls >50% of sub's voting power (recomputed live, reserved holdings included)
Parent authority is recomputed on every call from live share data, it is never cached on userId.
Capital Injection#
POST /api/corporations/[id]/subsidiary/capital-injection, { amount } in the parent's local currency.
- Cap: a single injection cannot exceed 25% of parent liquid capital (
SUBSIDIARY_CAPITAL_INJECTION_MAX_PCT_OF_PARENT_LIQUID = 0.25). - Cooldown:
SUBSIDIARY_CAPITAL_INJECTION_COOLDOWN_TURNS = 24turns per subsidiary. - Forex-aware: parent local → ₳ (anchor) → subsidiary local, using each corp's own FX rate (
getCorpFxRate). - Atomic:
atomicallyDebitCorpLiquidCapital()on the parent first (race-safe balance gate); if the credit to the subsidiary fails for any reason, the debit is refunded. - Two
emitTxledger entries (corp_capital_injection, one negative on the parent, one positive on the subsidiary) plus an audit record (corp.capitalInjection). - Sets
sub.lastCapitalInjectionTurnto the current turn.
Parent Dividend Floor#
POST /api/corporations/[id]/subsidiary/dividend-floor, { floorPct } (0 to MAX_DIVIDEND_RATE).
- Stored as
Corporation.parentDividendFloorPct+parentDividendFloorSetByCorpIdon the subsidiary. activeParentDividendFloorPct()derives the effective floor: returns 0 unless the feature is enabled, both fields are set, a controlling parent currently exists, and that parent's id matches the one who set the floor. Otherwise the floor is silently ignored (and later swept by zombie cleanup).- Folded into the corp's effective dividend rate via a
max(...)with the CEO-set rate at both the per-turn dividend site and the corporation-detail read path, so a parent can force a minimum payout that the subsidiary's own CEO cannot undercut, but never override a CEO who is already paying more.
CEO Appointment on Subsidiaries#
POST /api/corporations/[id]/subsidiary/appoint-ceo, { ceoType: "character" | "npp", characterId?, forcedNppId? }.
- Caller must pass
canActOnCorporationAsParent. - NPP path: reuses the existing caretaker-CEO mechanism (
appointCaretakerCeo), leaves the subsidiary'suserIdintact and setscaretakerCeo, so a human who still nominally "owns" the seat is tracked underneath. - Character path: enforces the one-person rule against the parent owner, the parent's resolved CEO, and every sibling subsidiary's CEO, then reseats: closes the old CEO's tenure record, sets
ceoId/ceoType: "character"/userId, clearsceoVacantand any pending caretaker/appointment fields, opens a new tenure record.
Blocked While a Formalized Subsidiary: Share Issuance#
subsidiaryIssuanceBlockReason() in issuanceGuard.ts blocks all equity issuance (public issuance, self-issuance, going public) on any corp with subsidiaryFormalizedAtTurn set, whenever the feature flag is on. Rationale in-code: any issuance dilutes the parent's stake and could drop it below the >50% control threshold, letting the subsidiary's CEO escape parent oversight. The parent must fund it via capital injection instead, or release it first if dilution is genuinely wanted. No-op when the feature is off or the corp isn't formalized, non-subsidiary issuance is unaffected.
Spin-Off#
POST /api/corporations/[id]/subsidiary/spin-off, { sectorType, name, tickerSymbol?, appointedCeoType, appointedCeoCharacterId?, forcedNppId? }.
Moves one of the parent's sector types into a new, wholly parent-owned private corporation, immediately formalized as a subsidiary of the parent.
- Eligibility: caller is the non-vacant parent CEO; parent must be an eligible subsidiary parent (not national, not itself a subsidiary, no chaining); parent must operate at least one sector of the requested type.
- Cooldown:
SPIN_OFF_COOLDOWN_TURNS = 168turns (≈7 real days) per parent. - Cost:
SPIN_OFF_BASE_COST_ANCHOR = 500,000 ₳base +SPIN_OFF_PER_SECTOR_COST_ANCHOR = 100,000 ₳per sector moved, i.e.spinOffCostAnchor(n) = 500,000 + 100,000 × n, converted to the parent's local currency and debited atomically. The fee is not destroyed, it credits the parent's country treasury (creditTreasuryProceeds), ledgered on both sides (corp_capital_seeddebit,gov_tax_revenuecredit). - New corp: private (
isPrivate: true, no public float at creation), 100% owned by the parent (single shareholder entry:{ corporationId: parent._id, shares: subsidiaryShares }), share count fromgetEraFounderShares(CEO_INITIAL_SHARES, ...), deliberately deflated below the flatCEO_INITIAL_SHARESbase so a spin-off in an early era doesn't open at a modern-sized market cap.subsidiaryFormalizedAtTurnis set immediately at creation;isSpinOff: true,spunOffFromCorpId,spunOffAtTurnare stamped. - CEO: either a human character (subject to the one-person rule, same as
appointSubsidiaryCeo) or an NPP caretaker (pickOrCreateNppCeoForNewCorp). An NPP-run new corp gets the system placeholder user id, never the parent's. - Sector transfer is a REASSIGN, not a merge: the moved
corporateSectorsdocuments keep their own_idand onlycorporationIdis repointed.revenueandcurrentGrowthCostare re-denominated corp-currency → ₳ → new-corp-currency (identity when parent and spin-off share a currency, the normal case for a same-country spin-off). Plant-related fields (capitalStock,buildQueue, construction-in-progress,mothballed,plantsStartTurn) ride along untouched and are explicitly not put through the currency round-trip, those are already ₳-denominated by contract, and re-converting them on a cross-currency move would misstate capex by the FX rate. - Atomic with rollback: on any failure mid-transaction, transferred sectors are moved back to the parent, the new corp document is deleted, and the debited fee is refunded.
- Anchors
parent.lastSpinOffTurnfor the cooldown. - Interacts with merger-review divestiture: if the parent has a
pendingDivestitureorder (a C3 merger remedy), the spin-off does not discharge it by itself, the new corp is still wholly parent-owned, so the group's controlled market share is unchanged.settleDivestitureIfSatisfied()is called best-effort right after the spin-off so the order's status reflects reality immediately; actual discharge requires the parent to later sell the spin-off down below the control threshold.
Ownership Cycle Guard on Purchase#
corpPurchaseWouldCycle() (cycleGuard.ts) is checked when a corporation (not a player character) buys shares in another corporation, at the point of purchase rather than only at formalization time. It reads the full corp cap-table graph (_id, shareholders, totalShares, superShareMultiplier) and reuses the same wouldCreateOwnershipCycle() walk. Refusal message: OWNERSHIP_CYCLE_ERROR.
Holding Groups: Tax Relief, Balance Sheet, Synergies#
A group is the set of corporations connected by formalized subsidiary edges, resolved fresh every turn by resolveFormalizedGroups() in groups/groupMembership.ts. A group edge requires BOTH de-facto control (>50% voting) AND formalization, de-facto control alone is not a group for any of the effects below. Cycles that slip past the guards are handled defensively: the root-resolution walk is bounded by edge count and canonicalizes on the smallest id in any detected cycle so the group doesn't fragment into singletons and silently lose relief.
Group Loss Relief (tax)#
computeGroupRelief() in groups/lossRelief.ts. Each corp is still taxed alone during normal turn processing; relief is applied as a rebate afterward, not a recomputation of the tax figure, arithmetically identical to filing consolidated, but additive rather than invasive to the turn's hot path.
- Same-country only. A loss in one country cannot shelter profit in another. Cross-border intra-group transfer-pricing audits are live in : exposure above ₳5 million can trigger a 40% surcharge. Same-country intra-group pricing is ignored intentionally.
- Pools by
(group root, country)viapoolByGroupAndCountry(). - Requires at least 2 group members, at least one profit and one loss, and some tax actually paid; otherwise no relief.
lossesSurrendered = min(totalLoss, totalProfit).effectiveRateis derived fromtotalTaxPaid / totalProfitfor the pool (never looked up from a rate table), so it automatically reflects whatever mix of state/federal/legal-structure rates actually applied, a pass-through member that paid $0 tax correctly contributes nothing.totalRelief = min(totalTaxPaid, lossesSurrendered × effectiveRate), capped at tax actually collected; relief is a refund, never a payment from the treasury to a group that paid nothing.- Allocated pro-rata among the members who actually paid tax, by share of tax paid; the last payer absorbs the rounding remainder so allocations sum exactly.
Group Balance Sheet (read-only consolidation)#
loadGroupBalanceSheet() in groups/groupBalanceSheet.ts. Consolidates nothing in the engine, every member keeps its own cash, shares, and shareholders. This is purely a display aggregation for a parent corp's page:
- Returns
nullif the corp isn't in a formalized group of 2+. - Per member: liquid capital (anchor ₳), sector revenue (anchor ₳), sector count. Root listed first, then by revenue descending.
- Totals: liquid capital, revenue, sector count, deduplicated industries, deduplicated countries.
- Sector revenue is read in the host country's currency (not the owning corp's), consistent with how sector revenue is generally stored.
Group Synergies (marketing & logistics)#
computeGroupSynergies() in groups/synergies.ts. An explicitly asymmetric model: members are pulled up toward the group's best marketingStrength / logisticsStrength, never dragged down, averaging was rejected because it would make acquiring a weak subsidiary strictly punish a strong parent.
- Convergence rate:
GROUP_SYNERGY_CONVERGENCE = 0.05(5% of the remaining gap closed per turn). - Ordinary ceiling:
GROUP_SYNERGY_MAX_SHARE = 0.6, a member may be lifted to at most 60% of the group leader's strength. - Spin-off ceiling:
SPINOFF_SYNERGY_MAX_SHARE = 0.85for a corp that was spun off from a current member of the same group, withinSPINOFF_BRAND_INHERITANCE_TURNS = 96turns of the spin-off. If the corp it was spun from has since left the group, or the window has expired, the ordinary 0.6 ceiling applies instead, this is what the previously-deadisSpinOff/spunOffFromCorpIdfields (written since spin-off shipped, read by nothing until this landed) are for. - Target for each member =
max(current strength, leader strength × applicable cap), never below where the member already is. - Delta per turn =
(target - current) × 0.05, applied to both marketing and logistics independently. - Sub-0.01 deltas are skipped entirely (not worth a write, wouldn't show in the UI).
- Returns only members that actually move; a settled group writes nothing.
Admin Toggle#
POST /api/admin/corporations/subsidiaries/toggle flips gameState.subsidiaryCorporationsEnabled. UI: .
UI#
SubsidiaryManagementCard.tsx, parent-side management panel (capital injection, dividend floor, CEO appointment, release) shown whencanManageAsParentis true.SubsidiariesOverviewCard.tsx, lists the corp's subsidiaries (from thesubsidiariespayload built incorporationDetail.ts, filtered to >50% voting control) and group balance-sheet summary.FormalizeSubsidiaryModal.tsx, formalize flow, shown whencanFormalizeAsSubsidiaryis true.SpinOffModal.tsx, spin-off flow (canSpinOffrequires the flag on, viewer is the non-vacant CEO, and the corp is eligible as a parent).AppointSubsidiaryCeoModal.tsx, CEO reseat flow (character or NPP).
Collections & Fields#
No new collection. All state lives on the existing corporations documents:
| Field | Meaning |
|---|---|
subsidiaryFormalizedAtTurn?: number |
Presence = formalized; the turn it happened. Cleared automatically if control lapses. |
isSpinOff?: boolean, spunOffFromCorpId?: ObjectId, spunOffAtTurn?: number |
Spin-off provenance; feeds the synergy brand-inheritance ceiling. |
lastSpinOffTurn?: number |
Cooldown anchor on the parent for spin-offs it initiates. |
lastCapitalInjectionTurn?: number |
Per-subsidiary cooldown anchor for capital injections received. |
parentDividendFloorPct?: number, parentDividendFloorSetByCorpId?: ObjectId |
Dividend floor set by the controlling parent; only honored while that parent still controls >50%. |
pendingDivestiture?: PendingDivestiture |
Merger-review remedy; measured against the controlled group, not discharged by a spin-off alone. |
Key Files#
- ,
isSubsidiaryCorporationsEnabled() - , thresholds, cooldowns, spin-off cost formula
- ,
isFormalizedSubsidiary,activeParentDividendFloorPct, eligibility,wouldCreateOwnershipCycle,humanBlockedFromSubsidiaryCeo - ,
canActOnCorporationAsParent - ,
corpPurchaseWouldCycle(purchase-time cycle check) - , blocks equity issuance on formalized subsidiaries
- ,
resolveParentCeoUserId,collectSiblingSubsidiaryCeoUserIds - ,
cleanupZombieSubsidiaries(), run every turn - , shared command result/
fail()helper - ,
releaseSubsidiary.ts,capitalInjection.ts,spinOff.ts,appointSubsidiaryCeo.ts,setParentDividendFloor.ts - ,
resolveFormalizedGroups() - ,
computeGroupRelief(),poolByGroupAndCountry() - ,
loadGroupBalanceSheet() - ,
computeGroupSynergies(),memberShareCap() - ,
SUBSIDIARY_OWNERSHIP_THRESHOLD_PERCENT,getControllingCorporateParent,acquirerOwnershipPercent - ,
controlledGroupIds(), divestiture satisfaction check against the controlled group - , subsidiaries payload, viewer permission flags (
canManageAsParent,canFormalizeAsSubsidiary,canSpinOff) - , subsidiary-related fields on
Corporation - ,
release/route.ts,capital-injection/route.ts,dividend-floor/route.ts,appoint-ceo/route.ts,spin-off/route.ts - , admin feature-flag toggle
- ,
SubsidiariesOverviewCard.tsx,FormalizeSubsidiaryModal.tsx,SpinOffModal.tsx,AppointSubsidiaryCeoModal.tsx