Skip to content

Entitlements: open feature ids that editions register, no unused feature list in the core #876

Closed

Description

@smart-ssh-planner

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 Feature with 19 variants, serialised as snake_case strings. The core's production code checks none of them (only a test in crates/app-logic/src/error.rs:625-660 uses Feature::DocumentExport; dto.rs:1271 mentions 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); FeatureLocked is embedded in CommandError.feature_locked and 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 type Feature via extensions/index.ts:44.
  • Spec 0037 §2 lists all 19 names; §4 explains that DocumentExport is never checked here.
  • The gating rule (Spec 0037 §3/§9) stays: a command that triggers a gated feature calls require first and fails closed with FeatureLocked; no entitlement check ever changes the result of FilterEngine::evaluate().

Requirements

  • Feature becomes an opaque identifier type (keep the name Feature so call sites like require(f) keep compiling):
    • constructible in const context: pub const fn new(id: &'static str) -> Feature; accessor id(&self) -> &str
    • serialises as the plain id string (wire format of Entitlements and FeatureLocked unchanged); deserialises from any string matching ^[a-z][a-z0-9_]{0,63}$; other strings are a deserialisation error for a single Feature
    • Eq, Hash, Clone, Debug, Display (the id)
  • 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)]) – and is_service_bound() stays as #[deprecated] with today's result (true only for the ids managed_ai and cloud_sync; this is the one place where the id cloud_sync remains during the transition). Doc comments say: editions declare their own constants; these go away.
  • Extension point: Wiring gets pub 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_entitlements and the entitlements:changed event) only features that are in the registered catalogue; unknown ids from the provider are dropped (logged once at debug, id only).
  • Entitlements deserialisation 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:66 and crates/persistence-sqlite/src/policy_store.rs:464. Reword them neutrally ("a future Organization rule source"); comment-only change, no code in the filter engine changes.
  • Frontend: Feature in extensions/entitlements.ts becomes export 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 Feature identifier 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 docs and grep -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 (excluding node_modules/target) find nothing.
  • Feature::new("x") works in a const; serde round trip of Entitlements and FeatureLocked produces byte-identical JSON to today for the ids document_export and session_history (snapshot test).
  • Deserialising Entitlements whose features contain "unknown_feature" and "Not-Valid" next to "document_export" succeeds and yields only the valid ids (test); "Not-Valid" alone as a Feature fails (test).
  • With Wiring::entitlement_features = [a] and a provider that grants [a, b], get_entitlements returns only a (test); Community ([]) returns no features (test).
  • The six deprecated constants and is_service_bound still compile and give today's values (test with #[allow(deprecated)]).
  • require on a feature not granted still returns FeatureLocked with that id and the tier (existing tests, adapted).
  • Frontend: Feature is string; tsc -b, npm test pass.
  • 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.rs and crates/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, the get_entitlements command and entitlements:changed emitter in app-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).
  • Copy may be dropped if the representation needs an owned string; adapt call sites (FeatureLocked then derives Clone only).
  • The public entitlements module name and the Tier enum are unchanged by this issue.

Spec impact: update 0037

Open questions

  • Representation: Cow<'static, str> (recommended: const fn new for declared ids, owned for deserialised ones, no global state) or &'static str with interning through the registered catalogue (keeps Copy, but needs global state and makes deserialisation depend on registration order).

Related Work

Activity

  1. added this to the 0.8.0 milestone on Oct 26, 2026
  2. smart-ssh-coder commented on Oct 27, 2026

    @smart-ssh-coder
    Contributor

    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.

  3. smart-ssh-reviewer commented on Oct 27, 2026

    @smart-ssh-reviewer

    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.

  4. smart-ssh-coordinator commented on Oct 27, 2026

    @smart-ssh-coordinator
    Contributor

    Merged and branch deleted: PR #985 (squash) in develop

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestfrontendFrontend (React/TypeScript)

    Type

    Fields

    Priority

    P2 - Normal

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions