Repository navigation
Entitlements: open feature ids that editions register, no unused feature list in the core #876
Copy link
Copy link
Labels
enhancementNew feature or requestNew feature or requestfrontendFrontend (React/TypeScript)Frontend (React/TypeScript)
Milestone
Description
Goal
The core no longer carries a fixed list of entitlement features it never checks. Feature becomes an open, string-backed identifier: editions and integrations declare and register their own entitlement features through Wiring, and entitlement data that names features the running edition does not know is accepted and simply ignored.
Context
crates/core/src/entitlements.rs:19-47:enum Featurewith 19 variants, serialised assnake_casestrings. The core's production code checks none of them (only a test incrates/app-logic/src/error.rs:625-660usesFeature::DocumentExport;dto.rs:1271mentions it in a comment). 13 variants are referenced nowhere at all.Feature::is_service_bound(entitlements.rs:49-87) classifies features for a policy the core never applies itself.Entitlements { features: HashSet<Feature>, … },has,require→FeatureLocked { feature, tier }(:96-160);FeatureLockedis embedded inCommandError.feature_lockedand serialised to the frontend as{ "feature": "<snake_case>", "tier": "<tier>" }.- Frontend mirror
apps/smart-ssh-community/frontend/src/extensions/entitlements.ts:6-25: a hand-copied union of the 19 strings; exported as typeFeatureviaextensions/index.ts:44. - Spec 0037 §2 lists all 19 names; §4 explains that
DocumentExportis never checked here. - The gating rule (Spec 0037 §3/§9) stays: a command that triggers a gated feature calls
requirefirst and fails closed withFeatureLocked; no entitlement check ever changes the result ofFilterEngine::evaluate().
Requirements
Featurebecomes an opaque identifier type (keep the nameFeatureso call sites likerequire(f)keep compiling):- constructible in
constcontext:pub const fn new(id: &'static str) -> Feature; accessorid(&self) -> &str - serialises as the plain id string (wire format of
EntitlementsandFeatureLockedunchanged); deserialises from any string matching^[a-z][a-z0-9_]{0,63}$; other strings are a deserialisation error for a singleFeature Eq,Hash,Clone,Debug,Display(the id)
- constructible in
- The core declares no product features. The 13 unused variants are removed:
SharedInventory,SharedNotes,CuratedRulePacks,OrgPolicy,MultiServerActions,OrgAiPolicy,TeamAgents,ActivityReport,SessionHandover,CloudSync,AuditExport,Sso,SelfHosted. - Transition (additive for existing extension code, removed in a later release): the six ids that extensions use today stay available as
#[deprecated]associated constants with their current spelling –Feature::DocumentExport,Feature::SessionHistory,Feature::ServerEnvironment,Feature::ChatAttachments,Feature::SelfHostedSync,Feature::ManagedAi(#[allow(non_upper_case_globals)]) – andis_service_bound()stays as#[deprecated]with today's result (true only for the idsmanaged_aiandcloud_sync; this is the one place where the idcloud_syncremains during the transition). Doc comments say: editions declare their own constants; these go away. - Extension point:
Wiringgetspub entitlement_features: Vec<Feature>– the edition's catalogue of features it gates (Community: empty). At startup the core logs and skips duplicates. The core passes to the frontend (get_entitlementsand theentitlements:changedevent) only features that are in the registered catalogue; unknown ids from the provider are dropped (logged once atdebug, id only). Entitlementsdeserialisation tolerates unknown or malformed feature entries: they are skipped, the rest of the value is kept (custom deserialiser for the set, or a wrapper).- Two code comments name a future feature module by its variant name:
crates/core/src/filter/engine.rs:66andcrates/persistence-sqlite/src/policy_store.rs:464. Reword them neutrally ("a futureOrganizationrule source"); comment-only change, no code in the filter engine changes. - Frontend:
Featureinextensions/entitlements.tsbecomesexport type Feature = string;(doc comment: ids are defined by the edition); the 19-literal union is removed. - Spec 0037 §2 and §4: the core defines the
Featureidentifier type and the gating rule, no feature list; editions/integrations register their own entitlement features; unknown features in entitlement data are ignored. New ADR records the decision (decided by maintainer): the public core only contains feature ids it checks itself; editions register their own.
Acceptance Criteria
-
grep -rnE "SharedInventory|SharedNotes|CuratedRulePacks|OrgPolicy|MultiServerActions|OrgAiPolicy|TeamAgents|ActivityReport|SessionHandover|CloudSync|AuditExport|\bSso\b|SelfHosted\b" crates apps docsandgrep -rnE "shared_inventory|shared_notes|curated_rule_packs|org_policy|multi_server_actions|org_ai_policy|team_agents|activity_report|session_handover|audit_export|\bsso\b|\bself_hosted\b" crates apps docs(excludingnode_modules/target) find nothing. -
Feature::new("x")works in aconst; serde round trip ofEntitlementsandFeatureLockedproduces byte-identical JSON to today for the idsdocument_exportandsession_history(snapshot test). - Deserialising
Entitlementswhosefeaturescontain"unknown_feature"and"Not-Valid"next to"document_export"succeeds and yields only the valid ids (test);"Not-Valid"alone as aFeaturefails (test). - With
Wiring::entitlement_features = [a]and a provider that grants[a, b],get_entitlementsreturns onlya(test); Community ([]) returns no features (test). - The six deprecated constants and
is_service_boundstill compile and give today's values (test with#[allow(deprecated)]). -
requireon a feature not granted still returnsFeatureLockedwith that id and the tier (existing tests, adapted). - Frontend:
Featureisstring;tsc -b,npm testpass. - Spec 0037 and the new ADR updated as described.
- Relevant automated tests pass (
cargo test --workspace, clippy with-D warnings,npm run lint).
Technical Notes
- Files:
crates/core/src/entitlements.rs,crates/core/src/filter/engine.rsandcrates/persistence-sqlite/src/policy_store.rs(comments only),crates/app-logic/src/error.rs(tests at:625-660),crates/app-logic/src/dto.rs:1271(comment),crates/app-shell/src/wiring.rs, theget_entitlementscommand andentitlements:changedemitter inapp-shell,apps/smart-ssh-community/frontend/src/extensions/entitlements.ts,docs/specs/0037-entitlements-editionen.md,docs/adr/<next>-*.md. - Gating stays closed-fail and never touches
FilterEngine::evaluate()(.claude/skills/smart-ssh-coder/references/security-invariants.md). Copymay be dropped if the representation needs an owned string; adapt call sites (FeatureLockedthen derivesCloneonly).- The public
entitlementsmodule name and theTierenum are unchanged by this issue.
Spec impact: update 0037
Open questions
- Representation:
Cow<'static, str>(recommended:const fn newfor declared ids, owned for deserialised ones, no global state) or&'static strwith interning through the registered catalogue (keepsCopy, but needs global state and makes deserialisation depend on registration order).
Related Work
Metadata
Metadata
Assignees
Labels
enhancementNew feature or requestNew feature or requestfrontendFrontend (React/TypeScript)Frontend (React/TypeScript)
Type
Fields
Priority
P2 - Normal
Activity
feat(core): open entitlement feature ids registered by editions (#876)
refactor(app-shell): shorten entitlement watcher comment (#876)
smart-ssh-coder commentedon Oct 27, 2026
Implementation ready for review. PR #985. Feature is now an open string id (Cow), the 13 unused variants are removed, six deprecated constants and is_service_bound are kept, Wiring::entitlement_features filters what the frontend sees, and unknown ids are skipped on deserialisation. Spec 0037 and ADR 0153 are updated. CI is green on PR #985.
smart-ssh-reviewer commentedon Oct 27, 2026
Review passed – moving on to merge. Approved. Feature is now an open string id, the unused variants are removed, Wiring::entitlement_features filters what the frontend sees, and unknown ids are skipped on deserialisation. Spec 0037 and ADR 0153 are updated. I checked the counter-proof and the local tests, and CI is green.
feat(core): open entitlement feature ids that editions register (#985)
smart-ssh-coordinator commentedon Oct 27, 2026
Merged and branch deleted: PR #985 (squash) in develop