Edition map and named feature guards
Epic 10745 "Close the identified gaps in Jaah CE". Original request (verbatim): "Close the identified gaps in Jaah CE". Settled inputs: PRD (Task 10746) and Architecture (Task 10747). Requirement and AC ids below are the PRD's; section refs (§) are the Architecture's. Read them with task_read on 10746/10747 if you need more than is quoted here.
## Requirement (verbatim)
R2 — Named feature guards and one edition map (PRD, verbatim). Every cloud-only capability (everything CE leaves out today — billing, cloud node launch, node groups, Multi-Node Hosts, NodeLink, MicroVM, GHA runners, knowledge base, project memory, SSO, SCIM, SIEM export, tenant mail, data residency, org transfer, sign-up/users, … — plus R1's enterprise features) has a stable name, and ONE file maps each name to the editions that include it. Editions in the map today: `cloud` and `ce`; the map must accept a further edition (e.g. `onprem`, `enterprise`) by adding a column/entry only. Code guards a feature by its name, not by `edition.IS_CE`.
Operator's words (Q2): "Let's guard each feature with its name and maintain a map of what is included in CE. that will give us a way to create other types of distribution, e.g. for on-premises usage or enterprise, etc. Do a deep research for the best practice" → map scope: "Every cloud-only feature".
R4 (PRD) context: gitleaks over the built tree stays the scan of record; check_tree must keep reporting 0 findings.
## Scope and approach
Child C1 of Architecture §7. Build the seam every other child uses (Architecture §2, §3).
**The edition map, `packages/editions/features.toml`.**
- It holds `editions = ["cloud", "ce"]` and one `[feature.<slug>]` row per cloud-only capability.
- §2.1 rules: the slug matches `^[a-z][a-z0-9_]*$` and names a capability, not a tier. `label` is the only text a person sees. `cloud` is on every row. `paths`, `route_prefixes` and `console_routes` are optional.
- Move EVERY current cloud-only `exclude` path and every `removed_route_prefixes` entry of `tools/community_edition/manifest.toml` into exactly one row.
- Seed list (§3; C1 owns the final classification): billing, hostings, cloud_nodes, node_groups, multi_node_hosts, nodelink, microvm, gha_runners, apt_self_upgrade, tailnet, knowledge_base, support_assistant, site_chat, project_memory, jaah_ai_credit, sso, scim, siem_export, tenant_mail, system_mail, data_residency, org_transfer, multi_org_tenancy, signup, users_invites, oauth_signin, slack, telegram, legal_acceptance, customers, control_plane_view.
- The `sso`, `scim`, `jaah_ai_credit`, `tenant_mail`, `multi_org_tenancy` and `org_transfer` rows may start with only what the manifest already excludes; C2a and C2b add the enterprise module paths.
- If C5 has already merged, move `portfolio/fleet/memory-on.md` from the manifest into the `project_memory` row's `paths`.
**The renderer, `tools/editions/render.py --edition <name> --out <root>`.** It writes the three import-free modules of §2.2, each with the GENERATED header:
- `apps/api/app/edition_features.py`, which also renders `REMOVED_ROUTE_PREFIXES` and replaces `edition_removed_routes.py`.
- `apps/jaahd/edition_features.py`.
- `apps/web/src/lib/editionFeatures.ts`, with the `FeatureName` union, `ENABLED`, `isEnabled`, `LABELS` and `REMOVED_CONSOLE_ROUTES`.
- Commit the `cloud` rendering.
**The `EDITION` constant.**
- Widen its annotation to `str` (TS `string`) in both `edition.py` files and `edition.ts`.
- Add a test asserting `EDITION ∈ editions`.
- Replace the manifest's fixed `[[rewrite]]` EDITION flips with `build_tree.py` writing `[mirror] edition` into those three lines. Keep the exactly-once check.
**`build_tree.py` derivation (§2.4).**
- Read `[mirror] edition`.
- exclude := manifest.exclude ∪ the paths of features the edition lacks.
- `removed_route_prefixes` leaves the manifest.
- Render the three modules for the edition into the tree.
**`check_tree.py`.**
- Honour the §2.3 guard forms in the import check. The body of `enabled("f")` with f ∉ E is never-run, and so is the rest of a block after `if not enabled("f"): return|raise`.
- New `features` check: every `enabled("x")`/`isEnabled('x')` literal names a map feature, and every path of a feature E lacks is absent.
- New orphan check with `[check] entrypoints` (§2.5).
- The `IS_CE`/`TYPE_CHECKING` forms stay. The "no knob" check is untouched.
**`edition_fixture(edition)` helpers** in the api `conftest`, the jaahd tests and web `test/edition.ts`.
**`tools/tests/test_editions.py`, with every §2.5 test:**
- Freshness.
- Each `paths` glob matches ≥1 file, and no two features' globs overlap.
- Every guard literal names a feature.
- Manifest `exclude` entries belong to no feature.
- The api's private cloud-router test pins the `routers_cloud.py` route table against the map's `route_prefixes`, both directions.
- The AC2.3 allowlist test has two sections. `EDITION_WIDE` is final: one entry per site key (file + enclosing function), each with a one-line reason. Pre-list the site key `apps/jaahd/agents/agent_layer.py::place_subagents` there for C5. The stale-entry check covers `PENDING_SWEEP` only, so this pre-listed key passes before C5 lands. `PENDING_SWEEP` holds every current per-feature `IS_CE` site, each tagged with its owning child (C2a/C2b/C3a/C3b/C3c/C3d), and fails on a stale entry.
- A test forbidding a bare `IS_CE` monkeypatch in new tests.
**Docs.**
- Rewrite the manifest comments that call the manifest the single home of exclusions (line 1), and the "carries inert" list, to point at the map.
- Add an "Add an edition" section (≤10 steps) to the mechanism's README. Route it from `tools/community_edition/README.md` so `check_context.py` reachability stays green.
**The AC2.4 dry run goes in the PR body.** Take a temp copy of the map with an `onprem` edition that includes `sso` and `scim`, plus a temp manifest with `[mirror] edition = "onprem"`. It builds with `build_tree.py --manifest <tmp>`. That tree contains `org_sso.py` and the CE tree does not. Nothing is committed.
No call-site rewrite beyond what the tests need: the sweeps are C2a–C3d.
Credentials & reach: (Architecture §10 rows, verbatim)
- Build, check (incl. gitleaks), tooling + app test suites | worktree only; gitleaks at `~/.local/bin/gitleaks` on node h2 | verified (binary present on h2)
- `mirrors.yml` `jaah-ce` hand run, no publish (AC1.3) | `jaah-forge` (task-authenticated) on GitHub `jaah-ai/jaah` | verified (from h2: `api get repos/jaah-ai/jaah/actions/workflows/mirrors.yml` returned the active workflow, and `api get repos/jaah-ai/jaah` reports `permissions.push` and `admin` true — the identity may dispatch; the dispatch itself is C6's write)
- Jaah Deploy watch per child PR | `jaah-forge` read verbs (`pr checks`, `ci log`) | verified (same forge identity and read probe as above)
Out of scope: (PRD §3, verbatim — do NOT touch)
- MUST NOT make `jaah-ai/jaah-ce` public, change repo visibility, or run a `ce-mirror` publish.
- MUST NOT edit LICENSE text or any legal page (legal review is a human item).
- MUST NOT edit `docs/pricing.md` or the marketing site (deferred to the flip; not in this repo).
- MUST NOT add a second secret scanner (trufflehog etc.).
- MUST NOT add an env var, setting, build arg or runtime flag that selects an edition, enables a feature or moves a cap.
- MUST NOT change cloud behaviour or cloud plan entitlements/pricing; the R2 guard rewrite is behaviour-preserving for `cloud`.
- MUST NOT ship `.claude/skills/` (translate, marketing-copy) — named a deferred follow-up below.
- MUST NOT build an on-prem or enterprise edition — only make the map able to describe one.
Architecture boundaries:
- D4, D5: no monorepo file moves. `portfolio/fleet/*.md`, `docs/policies/context-architecture.md`, `.claude/agents/*.md`, hub pointers, `tools/check_context.py`, `tools/portable_standard.py`, `infra/local/Dockerfile.api` and `infra/host/fleet-harness/reconcile.py` stay untouched.
- D1: keep schema, strip logic. The enterprise ORM models (the `models/orgs.py` SSO/SCIM classes, `models/metering.py`, `models/tenant_email.py`) and the squashed baseline migration stay. No edition-forked schema, no new migration.
Done means:
- AC2.1 → verify: open the map file: every feature row names its editions; `cloud` includes every feature.
- AC2.2 (single home) → verify: the manifest's cloud-only `exclude` entries and `removed_route_prefixes` are derived from (or checked against) the map — a test fails if a path/prefix belongs to no feature, or a feature excluded from `ce` has a shipped path. Run the CE tooling tests (`./dc check full` stage that runs them) and observe green; then delete one feature's map row locally and observe the test red. (Tightened: delete two rows, and both must go red. One is a row with `route_prefixes`, red via the `routers_cloud.py` route-table pin. The other is a paths-only row, red via the orphan check.)
- AC2.4 (extensibility) → verify: the README of the mechanism documents, in under ten steps, how to add an edition; the architect's spec includes a dry-run demonstrating a third edition builds with the existing build tool and no code change outside the map and manifest. (Here: the dry-run output is in the PR body.)
- AC2.5 (no knob) → the edition and features remain build-time literals: no env var, setting, build arg or flag turns a feature on (existing CE invariant, PRD §7 of the CE build). Verify: the existing "no knob" check in `check_tree.py` stays green.
- AC2.6 (copy) → every feature name a person sees (upgrade message, docs) is a human label, never the slug. (Here: every row has a `label` written per docs/standards/ui-copy-standards.md — a real product name such as "Single sign-on", not a slug restated in Title Case.)
- Freshness: `python3 tools/editions/render.py --edition cloud --out .` leaves `git status` clean.
- The built CE tree has the same file set as a develop build (diff `find $T -type f | sort`), except that the three rendered edition modules are added and `edition_removed_routes.py` is dropped if folded in. No other path is added or removed.
- `manifest.toml` has no `removed_route_prefixes` key. Its `exclude` holds only non-feature carve-outs (tests, fixtures, tooling, docs, `infra/jaah`, `infra/host`, `apps/site-widget`). Every entry removed from the manifest appears in exactly one map row.
- State in the PR body whether `packages/editions/**` and `tools/editions/**` ship in the tree. If `check_tree.py` reads the map in the public tree, the manifest `include` carries them and the built tree's own `check_tree.py $T` run stays green.
- Quick gate `./dc check quick` green.
- `python3 tools/community_edition/build_tree.py $T && python3 tools/community_edition/check_tree.py $T` exits 0 (AC1.2: import closure intact).
- The PR's Jaah Deploy on GitHub succeeds. If not, fix it and re-merge; leave a cancelled or superseded deploy alone.
Outcome
Built the edition map (packages/editions/features.toml, 31 rows), rendered feature guards for api/jaahd/web, derived CE build + features/orphans checks, AC2.3 allowlist tests. Quick gate green; PR #7169 merged (eff16b06c), Jaah Deploy succeeded. Deferred: splitting the 4 aggregation seams, web orphan check. Memory: none applied.
Artifacts
Activity
- Draft
- Ready
- Runner started
- Runner ended (done)