Shell Domain
Shell owns the account of each person (User), their groups (Group), its Roles (Role: the
profiles workspace.member ⊂ workspace.admin and the atomic user.reader, user.writer,
group.reader, group.writer, directory.provisioner), the Views an account without a profile
lands on (view_access), the instance Namespaces and their aliases, and one application View, its
entrypoint. All authority comes from Kernel Roles, held directly or through groups by Kernel
extends_with; a directory may provide accounts and groups. The design, its laws and its
amendments are in docs/design/accounts-and-roles.md;
each law's enforcement and test are in docs/accounts/laws.tsv, each
scenario's evidence in docs/accounts/scenarios.tsv.
The model before Roles is kept for its callers under legacy/ folders, listed in
legacy/LEDGER.md by the method of docs/legacy.md; it
is described in docs/legacy/shell-domain-before-roles.md.
Contracts
| Method | Called on | Admitted | Input | Output |
|---|---|---|---|---|
User.create | static | user.writer | { email, name, givenName?, familyName?, attributes? } | { user, created } |
User.invite | static | user.writer | { user, profile?: 'member' | 'admin' } | { invitationId, user, state } |
User.update | the account | user.writer | { name?, email?, givenName?, familyName?, icon?, attributes? } | {} |
User.updateProfile | the account | its holder | { name?, icon? } | {} |
User.saveSettings | the account | the account itself only | { set?, unset? } | { settings } |
User.deactivate, User.reactivate, User.delete | the account | user.writer | {} | {} |
User.provision | static | directory.provisioner | { directory, externalId, email, name, givenName?, familyName?, active } | { user, created, adopted } |
User.deprovision | static | directory.provisioner | { directory, externalId } | {} |
Group.create | static | group.writer | { name, description?, icon?, attributes? } | { group } |
Group.update, Group.delete | the group | group.writer | { name?, description?, icon?, attributes? }, {} | {} |
Group.assignMember, Group.unassignMember | static | group.writer, directory.provisioner | { member, group } (@NodeId paths) | {} |
Group.provision | static | directory.provisioner | { directory, externalId, name } | { group, created } |
Group.deprovision | static | directory.provisioner | { directory, externalId } | {} |
disconnectDirectory | function | directory.provisioner | { directory } | { users, groups } |
WorkspaceSettings.save | /:shell.astrale.ai:core.settings | workspace.admin | { set?, unset? } | { settings } |
Refusals (reason.code, family): USER_EMAIL_INVALID (VALIDATION_ERROR); USER_NOT_FOUND,
GROUP_NOT_FOUND (NOT_FOUND); USER_EMAIL_TAKEN, USER_DIRECTORY_MANAGED,
USER_ADMINISTRATOR_PROTECTED, USER_LAST_ADMINISTRATOR, USER_ACTIVE, USER_SUSPENDED,
PROFILE_NOT_HELD, GROUP_DIRECTORY_MANAGED, GROUP_NOT_DIRECTORY_MANAGED,
GROUP_ROLE_PROTECTED (details { role }), GROUP_MEMBERSHIP_CYCLE, DIRECTORY_CONFLICT
(details { user }), ACCOUNT_CHANGED, GROUP_CHANGED (CONFLICT); the membership
methods keep INVALID_GROUP_MEMBERSHIP_REFERENCE, GROUP_MEMBERSHIP_ENDPOINTS_UNAVAILABLE,
GROUP_MEMBERSHIP_CONFLICT, GROUP_MEMBERSHIP_UNCONFIRMED, and invite INVITATION_CONFLICT,
INVITATION_REJECTED, and the settings methods SETTINGS_TOO_MANY, SETTINGS_NAME_REPEATED
(VALIDATION_ERROR), SETTINGS_NOT_FOUND (NOT_FOUND), SETTINGS_CHANGED (CONFLICT). The SDK has no FORBIDDEN business family: a law that forbids a change for
the state of an account, a group or its caller answers CONFLICT.
User and Group declare no write rule: only their methods write them, with Shell's authority, after
the laws the Kernel cannot state (U1–U6, G2–G4). A Domain that needs accounts asks user.writer
(and user.reader) in its release for itself, requirements({ roles: [{ role: S.core.nodes['user.writer'], members: [requirements.domain] }] }),
links its record to the account by an Edge of its own, and tests its own Roles with its own
hasRole Policy. Every write keeps frozen = suspended or directoryActive === false (U2): the
Kernel refuses to authenticate a frozen account.
Settings
Settings are one JSON object of at most 64 names, which the applications choose and validate
themselves (the GUI stores dock, wallpaper, appearance, locale…): a new setting needs no
Shell release. UserSettings holds one person's own, linked to its account by user_settings and
created at its first change; only the account itself reads and writes them, no Role reads another
person's, and they are deleted with the account. WorkspaceSettings, the one Core Node
/:shell.astrale.ai:core.settings, holds the workspace's defaults and customization: everyone
reads it, an administrator saves it. A change merges into the stored object: set writes names,
unset takes them off, on the revision it was read at; when another window wrote first, Shell reads
and merges again, so neither erases the other.
Invitation
User.invite grants the profile (extends_with to profile.<name>), then asks Admin to send the
invitation through Shell's enrollment Integration, which carries { user, email, profile, requestId, requestedBy }: the email is the account's own. Its Provider mints an invocation-bound Shell proof
for its configured HTTPS ingress audience. A returned pending invitation does not prove email
delivery or completed registration. Admin binds the first sign-in of the verified email to the
account (U8).
Release
domain.ts declares Roles and scopes. workspace.admin grants every Shell Role and the grantable
Kernel Roles (R2); the bundle member ⊂ admin and the atomic Roles are release members (AM1);
member holds Kernel writer, admin Kernel admin; no profile holds Kernel reader, which admin
grants to whoever only opens its Views (AM10); Shell itself holds
Kernel authority.reader, graph.reader and graph.writer. Installing it requires an installer
that grants every Role it binds: Root.
View access
A View given to an Identity decides where it lands, never what it reads. view_access runs
from any Identity — an account, a group, a Role — to one Kernel View; each end detaches with its
Node. An admin writes it by a Mutation; an admin, or the Edge's audience and its members, read it.
A visitor's landing is its own Query (@astrale-os/shell): workspace.member opens the workspace; anyone else, reading
with Kernel reader, the Views it holds.
Namespaces and aliases
An alias names a Node. It grants nothing. A name designates at most one Node in its
Namespace, for every author: alias declares the Kernel key unique: [['source', 'slug']] in its
first release, so the Kernel refuses a taken name on the final state of any Mutation, Shell's or an
administrator's own mutate. Edge identity gives a Node at most one alias per Namespace. An alias
is a Path, /:shell.astrale.ai:core.views~"shell.astrale.ai:class.alias"(roadmap), and reaches its
Node only for a reader who may read that Node. Its target endpoint accepts any Node, a Class Shell
does not own, so the alias detaches when its Node is deleted; the Namespace stays.
The Core Namespace views holds the names opened at /v/<name>. Namespace and alias declare
read: true and traverse: true: a reader lists and follows the aliases whose Node it may read,
and an alias toward any other Node does not exist for it. Shell, through its own alias Edges,
finds the Node of every name, even one it does not read.
link names any Node its target Path reaches, whatever Domain owns it: an alias grants nothing,
and each reader sees only the aliases toward Nodes it may read. Offering only Nodes the
administrator sees is the interface's courtesy, not a server guarantee; any writer already learns
from a Mutation's locator whether a Path reaches a Node.
| Method | Calls | Refusals |
|---|---|---|
| link | One Mutation with Shell's authority; its locator resolves target | INVALID_NAME, TARGET_NOT_FOUND, NAME_TAKEN, TARGET_ALREADY_ALIASED |
| unlink | One read of the alias Path with Shell's authority, then one Mutation | INVALID_NAME, NAME_NOT_FOUND |
| rename | Same; renaming a name to itself writes nothing | INVALID_NAME, NAME_NOT_FOUND, NAME_TAKEN |
A name is an Edge slug in lower case (INVALID_NAME). That rule is Shell's: an administrator
writing through mutate bypasses it, never the key. Nothing guards a name's uniqueness but the key.
unlink and rename change the alias only while it still carries the name they read, so a
concurrent rename answers NAME_NOT_FOUND instead of changing another name. Shell maps the Kernel's
sealed refusals:
| Kernel refusal of the Mutation | link | unlink | rename |
|---|---|---|---|
MUTATION_POST_STATE_INVALID, rule duplicate-key | NAME_TAKEN | propagates | NAME_TAKEN |
MUTATION_PRECONDITION_FAILED (the alias Edge exists, is gone, or carries another name) | TARGET_ALREADY_ALIASED | NAME_NOT_FOUND | NAME_NOT_FOUND |
MUTATION_LOCATOR_INVALID (the target Path reaches no Node, or the target was deleted) | TARGET_NOT_FOUND | NAME_NOT_FOUND | NAME_NOT_FOUND |
Every other failure propagates. workspace.admin holders call the three methods, and write alias
Edges by their own Mutations too.
Versions
The Domain's version is the version of domain/package.json, which Release Please writes; it
also writes it to the root package.json, the Project's, which astrale-domain publish reads. One
number names the npm package @astrale-domains/shell and the registry Publication
shell.astrale.ai@<version>, so an instance installs a published release by number:
astrale domain install shell.astrale.ai@<version> -i <instance> --as <instance>-root
shell.astrale.ai@<major>.<minor> installs the highest stable version of that line. The Domain
releases stable versions from 0.1.1 on; 0.1.0, the first Publication, names the 0.1.0-beta.51
release.
Source and qualification
Each declaration, Query and Mutation has its own semantic file. Layer roots are facades; functions own orchestration, integrations/providers own external effects, views/ui own the directory surface. Runtime has one default export and binds every handler. The Dataset is referenced lazily by the root astrale.config.ts.
Domain authors import Schema from @astrale-domains/shell; platform workers use the explicit /domain and /runtime exports. Creation/registration, installation authority, invitation delivery, runtime replay and browser rendering are separate qualification boundaries. The redesigned Admin composition, published Register cohort and real invitation/instance-create journeys remain release gates; this source README is not evidence that those gates passed.
Publication boundary
@astrale-domains/shell is an executable platform package and a deliberate exception to the normal
Domain publication product. It publishes the remote Shell Domain definition and runtime needed by platform
Worker deployments through the explicit ./domain and ./runtime subpaths, while its root remains
Schema-only. Its build therefore compiles the explicit roots in tsconfig.package.json and applies
Shell-owned normalization and checks to the emitted platform closure. Source-only # aliases are
rewritten to relative JS and declaration references so consumer path mappings cannot capture private Shell
types. Tarball qualification then verifies the exact exports, isolated subpaths, absence of source
maps, and an install using only public npm dependencies.
The generic astrale-domain package command remains Schema-only and exposes only the Domain root plus
package metadata. This Shell exception must not be copied to ordinary product Domains or used to
weaken that SDK contract. Deployment consumers must use the explicit executable subpaths so a linked
schema import cannot traverse Shell's private application or runtime implementation.
Source, package, and deterministic test qualification remain distinct from installed-instance, released-package, browser, and live-credential evidence. Shell does not broker authentication tokens; standard Domain token exchange is owned by Protocol, Server, and SDK.