Skip to content

fix(seam): scope the enterprise ImportError arm to the top-level import (#1653) - #1739

Merged
vybe merged 3 commits into
devfrom
fix/ent196-honest-enterprise-load
Jul 23, 2026
Merged

fix(seam): scope the enterprise ImportError arm to the top-level import (#1653)#1739
vybe merged 3 commits into
devfrom
fix/ent196-honest-enterprise-load

Conversation

@dolho

@dolho dolho commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Fixes #1653.

Related to Abilityai/trinity-enterprise#196 (OSS half; the enterprise half — per-module registration isolation — is Abilityai/trinity-enterprise#197).

The bug

The enterprise load block caught bare ImportError and printed:

Trinity Enterprise submodule not present — OSS-only build
(this is normal; enterprise modules are an optional private submodule)

Correct for an OSS clone. But ImportError also covers a mounted submodule whose module failed to import — e.g. importing a name from an OSS table that hasn't landed yet.

Observed live: the submodule was present and correctly mounted, a module failed, and the operator was told "this is normal" while a paid feature was silently absent. Diagnosing it required hand-running register_enterprise() in the container to see the real traceback.

The fix

Narrow the benign branch to its actual signature — a ModuleNotFoundError whose missing module is the enterprise package:

Situation Exception Branch
OSS clone, no submodule ModuleNotFoundError(name="enterprise.backend") benign ✓
Module imports a missing name plain ImportError (not a ModuleNotFoundError) loud
Module needs an uninstalled package ModuleNotFoundError(name="pyotp") loud
Anything else (schema, migration, mount) Exception loud ✓ (unchanged)

The loud handler was already correct and is only factored into _report_enterprise_failure() so both paths share it. No behavior change for a genuine OSS build.

Test-pin update

tests/unit/test_847_entitlement_seam.py statically pinned the literal from .sso import register as register_sso + register_sso(app) in the enterprise __init__.py. The paired enterprise PR registers each module through an isolating helper, so that exact pair no longer exists.

The pin moves to the intent — "the submodule registers sso" — and still accepts the pre-ent#196 form, so this cross-repo static check doesn't break on a behavior-preserving refactor in the other repo.

Verification

New tests/unit/test_ent196_enterprise_load_reporting.py (4 passed, 1 skipped — the skip is the "no submodule" case, which can't be exercised in a checkout that has one). Includes a static guard that fails if a bare except ImportError ever comes back in front of the benign message.

Full OSS unit suite: 4667 passed, 16 skipped, 5 failed. All 5 failures reproduce on a clean origin/dev worktree and are unrelated:

  • test_admin_email_loginAttributeError: module 'bcrypt' has no attribute '__about__' (local bcrypt/passlib version mismatch)
  • test_1474_read_boundary_z::test_schedules_summary_last_run_at_normalized
  • test_1472_schedule_validation::test_accepts_valid[0 4 * * *-Europe/Kiev]

Enterprise-docs guard run locally against docs/, CLAUDE.md, main.py, entitlement_service.py — clean (the new comment describes the generic seam and names no module).

Verified live: with the enterprise submodule mounted and one module genuinely broken, the log now reads Trinity Enterprise registration completed with 1 FAILED module(s): … instead of the reassuring line.

🤖 Generated with Claude Code

dolho and others added 2 commits July 22, 2026 12:01
…l" (ent#196)

The enterprise load block caught bare ImportError and printed

    Trinity Enterprise submodule not present — OSS-only build
    (this is normal; enterprise modules are an optional private submodule)

That is right for an OSS clone. But ImportError also covers a MOUNTED submodule
whose module failed to import — e.g. importing a name from an OSS table that
hasn't landed yet. Observed live: the submodule was present and correctly
mounted, a module failed, and the operator was told "this is normal" while a
paid feature was silently absent. Diagnosing it took a hand-run
register_enterprise() to see the real traceback.

Narrow the benign branch to its actual signature — a ModuleNotFoundError naming
the `enterprise` package itself. A plain ImportError (missing NAME) and a
ModuleNotFoundError for anything else (a missing third-party dep) now fall
through to the existing loud handler, which was already correct and is only
factored out into _report_enterprise_failure so both paths share it.

test_847_entitlement_seam pinned the literal
`from .sso import register as register_sso` in the enterprise __init__.py. The
paired enterprise change registers each module through an isolating helper, so
that pair no longer appears; the pin moves to the intent (the submodule
registers sso) rather than the import style, and still accepts the old form.

Related to Abilityai/trinity-enterprise#196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent#196)

The registration seam is a CLAIM that a feature is present and served. A module
that claims its feature_id and then fails partway through mounting leaves that
claim standing with nothing behind it: advertised by
GET /api/settings/feature-flags, UI rendered by the OSS bundle, every request
404s.

Give the registering side a way to roll its own claim back so a half-registered
module is simply absent — the honest state. Idempotent; withdrawing an
unregistered id is a no-op.

Used by the paired enterprise change, which snapshots the registry around each
module's register() and withdraws whatever it claimed before failing. That
lives on the private side because only it knows the claim boundary; this is the
edition-agnostic primitive.

Related to Abilityai/trinity-enterprise#196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dolho

dolho commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

Extensive testing + one addition

The OSS-only path, verified on a genuinely submodule-less build

The backend image ships without enterprise/ (it's bind-mounted in dev), so I built a real OSS-only tree — src/backend copied minus enterprise/ — and booted it against the same env:

$ docker run --rm --env-file .env -v <oss-tree>:/app <backend-image> \
    sh -c 'python -c "import main"'
Trinity Enterprise submodule not present — OSS-only build (this is normal; …)
IMPORT OK

So the narrowed branch still reports a real OSS clone correctly — the change doesn't regress the case the message exists for.

The loud path was observed live in the opposite direction earlier: with the submodule mounted and Alembic unable to resolve a revision, boot printed Trinity Enterprise registration FAILED — continuing OSS-only: CommandError(...) with a traceback, instead of the reassuring line.

Added: EntitlementService.unregister_module (6bc7f19)

Fault injection against the real register_enterprise (details in Abilityai/trinity-enterprise#197) turned up a third bug: a module that claims its feature_id and then fails leaves a ghost entitlement — advertised by GET /api/settings/feature-flags, UI rendered by the OSS bundle, every call 404s because the router never mounted.

The registering side needs a way to withdraw its own claim, so this adds the edition-agnostic primitive here and the enterprise side uses it (snapshot the registry around each register(), withdraw whatever leaked on failure). Idempotent; withdrawing an unregistered id is a no-op. Nothing in OSS calls it today — it exists for the registering side, same shape as register_module.

3 new tests in test_847_entitlement_seam.py cover withdraw / idempotency / no-collateral-damage.

Suites

  • test_847_entitlement_seam.py + test_ent196_enterprise_load_reporting.py23 passed, 1 skipped (the skip is the no-submodule case, unreachable in a checkout that has one — covered end-to-end by the container test above instead).
  • Enterprise-docs guard re-run against docs/, CLAUDE.md, main.py, entitlement_service.py — clean; the new docstring describes the generic seam and names no module.
  • Full OSS unit suite result is unchanged from the PR body: 4667 passed, 5 pre-existing failures that reproduce on a clean origin/dev worktree.

…rt (#1653)

main.py wrapped the enterprise import AND register_enterprise(app) in one try
whose first arm was `except ImportError`. ModuleNotFoundError subclasses
ImportError, so a real bug raised inside module registration — enterprise
modules lazily import OSS seams in their register() — printed

    Trinity Enterprise submodule not present — OSS-only build (this is normal…)

On a mounted install that is actively false: the diagnostic traceback never ran,
boot did not crash, and the failing module's routes could stay mounted while its
entitlement was never registered, 403-ing forever with nothing in the logs
connecting the two. Reported by @vybe on #1653.

The fix has two parts and both are needed. My earlier pass here narrowed the arm
to ModuleNotFoundError + a check that the missing module IS the enterprise
package. That alone is not sufficient: a module doing `from .missing_sibling
import x` inside register() raises ModuleNotFoundError whose name starts with
`enterprise`, so the name check calls it benign — the very case the issue is
about. So adopt #1653's structural suggestion too: the ImportError arms now
guard ONLY the top-level import, and register_enterprise(app) moves to the else
arm under `except Exception`, which catches everything it can raise.

The name check is kept for the top-level import, where the structure cannot
help: if enterprise/backend/__init__.py needs a third-party package that isn't
installed, that is a real failure wearing the same exception type as an absent
submodule.

Verified live, all four branches:
  absent submodule (real OSS tree, no enterprise dir) -> calm line, boots clean
  missing third-party dep in the top-level import     -> diagnostic
  `from .gone_module import x` inside register()      -> diagnostic
     (ModuleNotFoundError name='enterprise.backend.gone_module' — the trap case)
  the live product_events failure                     -> diagnostic, real cause

CI string assertions unchanged and pinned by a test: build-without-submodule.yml
greps for "Trinity Enterprise submodule not present".

Related to #1653, Abilityai/trinity-enterprise#196

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dolho dolho changed the title fix(seam): stop reporting a real enterprise failure as "this is normal" (ent#196) fix(seam): scope the enterprise ImportError arm to the top-level import (#1653) Jul 22, 2026
@dolho

dolho commented Jul 22, 2026

Copy link
Copy Markdown
Contributor Author

This PR now also fixes #1653 — and #1653's approach found a hole in mine

#1653 (credit @vybe) describes exactly the bug this PR was opened for; I hit it independently while testing trinity-enterprise#196. Reading it, its suggested fix is better than my original one in a case I had wrong, so I've adopted it.

The hole in the name-check-only approach

My first pass kept one try and narrowed the arm to ModuleNotFoundError + "is the missing module the enterprise package?". That mishandles the case the issue is actually about:

scenario exception name-check verdict correct
OSS clone, no submodule ModuleNotFoundError(name='enterprise') benign benign ✓
module imports a missing NAME plain ImportError loud loud ✓
missing third-party dep ModuleNotFoundError(name='pyotp') loud loud ✓
register() imports a missing SIBLING ModuleNotFoundError(name='enterprise.backend.gone') benign ← wrong loud

A module doing from .missing_sibling import x inside register() produces a name whose first segment is enterprise — indistinguishable from an absent submodule by name alone. And lazy imports inside register() are exactly the established pattern the issue cites.

Both parts are needed

Verified live — all four branches

absent submodule (real OSS tree, enterprise/ removed)  -> calm line + IMPORT OK
missing third-party dep in the top-level import        -> diagnostic
from .gone_module import x inside register()           -> diagnostic
   ModuleNotFoundError("No module named 'enterprise.backend.gone_module'")   ← the trap case
the live product_events failure on this instance       -> diagnostic, real cause named

That last one is the bug in the wild: this instance currently has trinity-enterprise#184's telemetry module importing an OSS table whose OSS half (#1721) hasn't merged. Before: "submodule not present — this is normal". After: registration FAILED — ImportError("cannot import name 'product_events' …").

Acceptance criteria (#1653)

  • ModuleNotFoundError inside register_enterprise(app) reported as a bug with a traceback naming the module
  • Genuinely absent submodule still prints the calm line and boots clean — verified on a real OSS tree, not a mock
  • Core boot still survives a broken enterprise module (degrade-don't-crash holds; the live instance is proof)
  • Regression tests for both branches

Tests

tests/unit/test_1653_enterprise_load_reporting.py — 7 passed (renamed from the ent#196 file). The structural test parses main.py with ast, not string search: the surrounding comments mention register_enterprise(app) in prose and a substring check matched those (it did, and failed the first run).

Also pins the CI string contract — build-without-submodule.yml greps for "Trinity Enterprise submodule not present", so a reworded message would break CI silently.

Related tests: 87 passed, 3 skipped.

@github-actions

Copy link
Copy Markdown

⚠️ Nightly unit-suite check skipped — merge conflict against dev.

Resolve by running git merge dev locally and pushing the result. The next nightly run will re-test once the conflict is gone.

@vybe vybe left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validated via /validate-pr: OSS half of the ent#196 seam pair (unregister_module + honest ImportError narrowing), named regression tests, security clean. Enterprise half (ent#197) merged.

@vybe
vybe merged commit 628e203 into dev Jul 23, 2026
22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants