@package-system

Features and access

Feature grants, role defaults, and the deny-by-default visibility predicate.

Features are the access currency of the package system. A package declares the capability classes it provides; project roles receive grants of those feature ids; and every surface a caller can see or invoke — tools, skills, routes, widgets, dock entries, commands, workflow templates — is checked against the caller's granted features through one shared predicate. There is no second authorization vocabulary: if something needs gating, it names a feature.

Declaring features

Features live in the manifest's requires block:

"requires": {
  "providesFeatures": [
    "my-pkg.read",
    { "id": "my-pkg.admin", "title": "Administration", "description": "Manage package settings." }
  ],
  "defaultRoleGrants": {
    "owner":  ["my-pkg.read", "my-pkg.admin"],
    "admin":  ["my-pkg.read", "my-pkg.admin"],
    "member": ["my-pkg.read"],
    "viewer": []
  }
}
  • providesFeatures entries are either a bare id string or a { id, title?, description? } object. The object form adds human-facing metadata for the admin roles UI without changing the id. Duplicate ids are rejected at manifest validation.
  • The id prefix is the tier. Feature ids are two-tier and the tier is the prefix, so the boundary can never drift out of sync with a hand-kept list. platform.* marks a decision that crosses the project boundary — another tenant, a platform-global file, cross-user records, the global credential scope. Every other id, project.* included, acts inside the caller's own session.projectId. The tier is a label, never a gate: hasFeature remains the one access predicate and has no tier branch. What the label governs is defaults — a platform.* id carries no default role grant and reaches nobody until an operator grants it deliberately. Name a capability platform.* only when it genuinely reaches outside the project.
  • defaultRoleGrants maps role names to the feature ids that role receives by default when the package is installed; admins can adjust grants per project afterwards. It deliberately cannot reach the strongest roles. For any package that is not a host builtin, the merge skips every role holding the '*' wildcard and every role at or stronger than the admin priority anchor — so an owner or admin entry in a project-dropped or third-party manifest is silently ignored, and those ids stay grantable only by hand in Admin → Roles. Declare grants for manager, member, viewer and your own custom roles. Enumerating owner is dead weight in every case: the seeded owner role holds the wildcard already.
  • The list of consumers — which tools, skills, commands, and App surfaces a feature actually gates — is never stored on the manifest. It is derived from the loaded definitions at boot, so it can never drift from the code.

The one visibility predicate

Every requires: { features: [...] } declaration is evaluated by a single shared function, meetsRequires(session, requires), built on hasFeature. Surfaces never inline their own copy of the logic.

DeclarationResult
No requires / empty featuresVisible to everyone (ungated)
Caller's grantedFeatures contains '*'Passes every check (the '*' wildcard; the seeded owner role holds it, admin does not)
Multiple features listedEvery listed feature must be granted (AND semantics)
Feature id that no package providesNever granted to any role except '*' holders. It cannot appear in a derived grant list either, so it is wildcard-holder-only by construction
Caller has no grantedFeatures at allDenied

A failing check hides the surface entirely. A gated skill is not injected into the system prompt, does not appear in the packages overview or any catalog, and is never rendered as a locked or disabled entry — a caller who lacks the feature cannot infer that it exists. The same applies to gated tools (model-facing catalog and dispatch), commands, widgets, and dock entries.

Visibility and execution are gated independently. Hiding happens at the advertisement boundary; execution is additionally enforced server-side — a route's declared feature is checked by the dispatcher before the handler runs (see Routes), tool dispatch rejects callers that fail requires.features, and filesystem access goes through URI policies.

Where requires appears

SurfaceFieldNotes
Markdown contributions (PackageFile)requires.featuresAuthored via the flat requiredFeatures: [a, b] frontmatter key — see Contributions
Tools (PackageTool)requires.features, requires.escalationsfeatures gates catalog visibility and dispatch; escalations is catalog metadata for input-dependent checks the handler performs itself
Commands (PackageCommand)requires.featuresSame semantics
Workflow templates (PackageWorkflow)requires.featuresGates every template-listing surface — see Workflow templates
Widgets and dock entriesrequires.featuresGated surfaces are omitted from the workspace snapshot — see App surfaces. A widget may additionally list requires.tools, which is catalog metadata, not a gate
Routesexport const feature = '...'One feature per route file, enforced by the dispatcher before the handler runs
Whole packagerequires.accessFeatureA single feature id that gates the ENTIRE package — see below

For imperative checks inside a handler, the same building blocks are exported from @neuralis/package-system/access:

import { hasFeature, requireFeature, meetsRequires } from '@neuralis/package-system/access';

hasFeature(session, 'my-pkg.read');        // boolean
requireFeature(session, 'my-pkg.admin');   // throws a 403-status error when missing
meetsRequires(session, { features: ['a', 'b'] }); // AND over the list

Base-access feature — gating a whole package

Individual tools, skills, and widgets can each name a feature, but a package can also gate its entire surface with one declaration:

"requires": { "accessFeature": "labs.access" }

A caller who lacks labs.access sees none of the package — no tools, no skills, no widgets, and no entry in the agent's <packages> overview. It is hidden, not shown locked, so its existence can't be inferred. Leaving accessFeature out keeps the package visible to everyone — so packages imported from other ecosystems load unchanged.

This is the clean way to build and test a package in isolation: declare an access feature with no default role grant — until you grant it to a role, only a '*' holder (owner-strength) sees the package. A project admin does not, because the seeded admin role holds an enumerated list rather than the wildcard. Grant it in Admin → Roles to the roles that should test it. A declared access feature is grantable in Admin → Roles — so once you've gated a package, you can grant base access to exactly the roles (or people) that should have it. An administrator can also attach a required feature to an already-installed package from the workspace Packages tab — picking from the project's existing features — a restrict-only override that can tighten visibility but never grant access, so it can never weaken a floor. Such an override-attached feature is also grantable in Admin → Roles: the feature catalog is project-aware, so it surfaces both manifest-declared access features and per-project overrides under their package.

Scope — who a loaded package reaches

Package consumption is governed by three orthogonal axes:

AxisQuestionMechanism
uri-policyWhich paths may the package touch?URI policies
featureWhich roles may use a capability?requires (above)
scopeWhich user/agent is a loaded package advertised to?source scope + canAccessScope

The scope axis matters when a capability is owned by an individual or an agent rather than the whole project. A package contributed by a user-scoped or agent-scoped source loads for the project but is advertised only to that user (or that agent) — its tools, skills, and widgets never reach another member's stream, MCP catalog, or workspace. The same one predicate, canAccessScope, decides both file access and package visibility, evaluated at every advertise boundary. This is what makes per-person and per-agent capabilities possible on a shared, multi-tenant project.

Role priority

Feature grants answer "what can this caller do". Role priority answers an orthogonal question: "what strength of role may this caller hand out". A member who is allowed to create agents must still not be able to mint an admin-strength agent or invite an owner.

Priority is one ordinal number per role, lower = stronger. The five built-in roles anchor the scale and are immutable:

RolePriority
owner1
admin2
manager10
member20
viewer30

Declared priorities are integers in 1..99. The gaps between the anchors are deliberate: an operator can slot a custom role between two built-ins without renumbering the scale.

Custom roles carry their own declared priority in the project's role definitions. A role name is cosmetic — anyone may invent one — but the priority number is enforced. An unknown label with no declared priority resolves to the weakest possible sentinel, so it is always assignable but can never be an escalation.

The assignment rule, implemented once in canAssignRole / validateRoleAssignment: a caller may assign a role only when the target's priority is equal to or weaker than their own (targetPriority >= callerPriority). A caller priority of undefined means a system/internal session with no membership identity, which bypasses the check. The caller's own priority travels on the canonical session as SessionContext.priority (see Sessions).

This single predicate is enforced at every role-handling boundary: project invitations, member role changes, role definition edits, and agent provisioning from package team members.

Deny by default

The whole layer composes into one posture: undeclared means ungated, ungranted means invisible, and unknown means denied. Referencing a feature id that nothing provides is a legitimate authoring technique — it makes a contribution reachable only by a '*' holder, without any extra mechanism. How roles and feature grants are administered across projects is covered in Roles and features.

On this page