Skip to content

Subscriptions and grants

Hadron has two grant entities that are easy to confuse. They govern different things, and confusing them is the most common source of "why can't this caller access that memory?" debugging dead-ends. This page is the disambiguation.

(Older docs called these AppAgent and AgentSubscription. Spec 008 renamed them, dropped the AppAgent join, and introduced AgentOrgGrant as the Org-side license. The 008-amendment in spec-kits #43 reintroduces AppAgent — without the legacy role column — to support multi-Agent Apps. The two-level grant structure below is the current target shape.)

Spec 023 update: AgentSubscription is a license, not a memory grant

Spec 023-app-shape narrows AgentSubscription to a single responsibility: it authorizes the User's license to interact with the Agent — nothing more. Having an active AgentSubscription(user, agent) does not, by itself, grant the User read or write access to any specific Memory.

Per-Memory authorization moves to two dedicated primitives:

  • MemoryShare — asymmetric cross-user grant for personal-class Memory. The principal (the Memory's owner) creates a row granting a specific grantee a reader or writer role on that one Memory. Each (Memory, grantee) pair is a single row.
  • MemoryMember — symmetric team membership for group-class Memory. Each member carries a reader, writer, or owner role. At least one owner always exists; the platform refuses to demote or remove the last one.

The license check still gates the interaction: an Agent acting on behalf of a user whose AgentSubscription is inactive is denied at the license layer, regardless of any MemoryShare or MemoryMember rows. The two-layer separation means an active subscription is necessary but not sufficient on any call where the license check fires — concretely, every personal-class memory operation, since that's the only class whose authorization tuple includes an Agent. For app-class and group-class memory the license check is skipped entirely (those classes have no scoping Agent), and the per-Memory primitive — AppMember or MemoryMember — is the sole gate. For knowledge-class memory, neither the license nor a per-Memory primitive is consulted; the existing class/visibility rules govern.

The schema column shape of AgentSubscription is unchanged; only the semantics narrow. The grant's other roles — authorizing Builders to bundle Agents via AgentImport, authorizing org-level App installations — are unchanged.

The narrowing closes a privacy gap in the spec-008 model: under the old reading, any user with an AgentSubscription to (say) a coaching Agent could have been authorized to read another user's personal-class Memory created in the same Agent's App. Under 023, that read is explicitly governed by MemoryShare — the principal must have created a row naming the other user as a grantee.

The sections below are in transition: they still use the older "subscription grants memory access" phrasing in places, and will be rewritten as the hadron-portal UI and CLI clients catch up. The authoritative reference for the narrowed semantics is the spec-023 predicate in hadron-server (src/lib/auth/resolveMemoryAuthorization.ts), which already enforces the model described above — including the personal-class license gate and the per-Memory authorization primitives. Where this section and the legacy passages below disagree, this section is correct.

The two grants

Org ↔ Agent User ↔ Agent
Entity name AgentOrgGrant AgentSubscription
What it expresses "This org is licensed to install this Agent (and to bundle it as a dep in another Agent the org owns)." "This user is authorized to use this Agent."
Who creates it Auto-provisioned on first contact (e.g., when createApp is called with the Agent's id) The platform, automatically, on the user's first chat with the Agent
Lifecycle Active / expired / revoked — same shape as user subscription Active / expired / revoked — fluctuates with billing, member churn, admin action
What it grants Cross-org gate 1 (App ↔ Agent): a session through this App can resolve the Agent's design + knowledge memories Cross-org gate 3 (User ↔ Agent): the Agent can write to the user's personal-class Memory
Composite key (orgId, agentId) (userId, agentId)
Active-status predicate activatedAt IS NOT NULL AND revokedAt IS NULL AND (expiresAt IS NULL OR expiresAt > now()) Same predicate (the shared isLifecycleActive helper)
Paid? No in v1 — auto-provisions for any installation request. A future spec adds explicit "subscribe to this Agent" UX with billing. No in v1 — auto-provisions on first user-attributed contact. Same future-spec story for billing.

Beyond the two grants, there's a third row that often comes up alongside them: AppMember is the User↔App membership join. It isn't a grant — it's a membership: which users can act as members of this particular install of the Agent, and what role they hold within it (e.g., 'principal', 'guide', 'owner', 'cook', 'eater'). The role strings come from the Agent's installationPolicy.memberRoles and the Agent's installationPolicy.maxMembers caps the count.

How they line up with the access-control gates

The cross-org evaluation chain (see Memory access) checks four gates in order. Two of them consult the grants above:

Gate Predicate (in 008 vocabulary)
1. App ↔ Agent App.agent_id IS NOT NULL AND Agent.visibility allows the App's org. Cross-org installs additionally require an active AgentOrgGrant(App.organization_id, App.agent_id). Same-org installs auto-pass.
2. Agent ↔ Memory AgentMemoryItem(agent, memory) exists, OR memory = agent.systemMemoryId, OR memory.app_id = app.id AND memory.class = 'app'. Cross-org for knowledge: MemorySubscription(orgB, memory) AND any required MemoryLicense valid.
3. User ↔ Agent (personal-class only) Active AgentSubscription(user, app.agent_id) AND Memory.app_id = app.id (the per-App isolation that makes Mike's two installs of Juno see distinct personal memory).
4. Effective role Most restrictive of AgentMemoryItem.role and MemorySubscription.role. personal- and private-class memory is strictly owner-only — even an ADMIN/OWNER cannot read another user's.

Worked example: a chatbot with two end users

The wellness.example organization runs a chatbot called Sage. To make Sage available to end users, the org admin sets up:

  1. The Agent (Sage) in wellness.example — a chatbot-type Agent with a system prompt, a conversation design in its system memory, and a knowledge memory full of therapy-technique reference content.
  2. The App (Sage chatbot frontend) in wellness.example — a chatbot-type App with agent_id = sage. Calling createApp auto-provisions an AgentOrgGrant(wellness.example, sage). (Same org, so the gate-1 check would auto-pass even without the grant row, but the row gets created anyway for symmetry.)
  3. An AppKey for the App so the chatbot frontend can authenticate with hdr_app_… tokens.

Now an end user, Alice, opens the Sage chat for the first time:

  1. The platform detects no AgentSubscription(alice, sage) exists yet. Auto-provisions itactivatedAt = now(), no expiry, not revoked.
  2. Sage starts writing personal-class records for Alice — chat history, extracted facts. The platform creates a personal Memory keyed at (sage-app.id, alice.id) with Memory.userId = alice, class = 'personal' (visibility is null — privacy is class-driven).
  3. The Agent's writes into that personal Memory are gated by Alice's AgentSubscription AND by Memory.app_id = sage-app.id. Without the subscription, Sage has zero access. Without the matching app_id, a different App's session would be denied — even if it deployed the same Sage Agent.

A second end user, Bob, opens the same chat:

  1. Same path. Bob gets his own AgentSubscription(bob, sage) and his own personal Memory at (sage-app.id, bob.id). Sage cannot cross-read between Alice's and Bob's memories. Even an ADMIN of wellness.example cannot read either user's personal memory through any platform surface (gate 4: personal/private is strict ownership).

What system-memory access looks like post-008

The pre-008 AppAgent.role column (OPERATE or DEVELOP) was dropped by spec 008. The 008-amendment in #43 reintroduces the AppAgent join itself but does not restore the role column — the OPERATE/DEVELOP semantics stay gone. The current rule:

  • System memory is universally read-only from any App context. No App can write to its Agent's system memory, regardless of role or grants.
  • Edits to the conversation design happen in the Agent ownership surface (the Agent's owning org's portal view of the Agent), not through any App.

This simplifies the mental model: an App is the deployment; you don't edit the design through a deployment.

What AgentSubscription lifecycle states mean

An AgentSubscription is active for authorization purposes when:

  • activatedAt is set, AND
  • revokedAt is not set, AND
  • expiresAt is not set OR is in the future.
    no row exists
         │ first user-attributed call
    ╔═════════╗
    ║ active  ║   activatedAt = now()
    ╚═════════╝
       │  │
       │  │ admin revokes
       │  │   revokedAt = now()
       │  ▼
       │  ╔═════════╗
       │  ║ revoked ║
       │  ╚═════════╝
       │ expiresAt < now()
    ╔═════════╗
    ║ expired ║   (derived — no row state change)
    ╚═════════╝

Re-subscription clears revokedAt and bumps activatedAt again — the same AgentSubscription row is reused. The user's personal memory of the Agent is still there if it was retained at revocation (see below).

AgentOrgGrant follows the same lifecycle states with the same predicate. The shared helper isLifecycleActive checks both.

What happens to personal memory on revocation

The platform's default is retain by user, deny to Agent:

  • The personal Memory row stays — the user owns it, may want to keep their notes from a class they finished or a service they unsubscribed from.
  • The Agent loses write access immediately (the inactive AgentSubscription denies it; the Memory.app_id = app.id check in gate 3 still passes, but gate 3 also fails on inactive subscription).
  • The user retains read access via direct authenticated GraphQL queries — the access path Memory.userId == ctx.userId is not gated by the AgentSubscription.

Single exception: if the personal Memory is empty (zero nodes) at the moment of revocation, the platform may hard-delete it. This avoids orphan rows piling up from users who explored an Agent and never wrote anything.

Membership churn (008-agent-installation FR-015): when a user leaves an App via removeAppMember, their personal-class Memory at (app_id, user.id) is retained as an orphan. If the user later rejoins the same App, the orphan auto-attaches by composite-key match. If the user joins a different App of the same Agent, the orphan stays put and a fresh personal Memory is created at the new (app_id, user.id) — installs are isolated, not Agent-pivoted.

Cross-org subscriptions

A subscription crosses an organization boundary when the User and the Agent are in different orgs. Concretely: every AgentSubscription whose user is an end-user signing up at hadronmemory.com (rather than a member of the Agent's owning org) is cross-org — the user's personal Memory lives at (app_id, user_id) where the App is in whatever org operates the chatbot, and the Agent (which is in the chatbot operator's org) needs cross-org write permission to land records there.

That cross-org write permission is exactly what the AgentSubscription grants. Without it, no cross-org access. The Agent can't write to a user's personal memory by guessing or asserting — it has to be granted server-side via the subscription row.

The same logic applies to AgentOrgGrant: a chatbot operator who installs a marketplace Agent into their own org needs an active grant for that Agent (cross-org). v1 auto-provisions on first contact; a future spec adds the explicit "subscribe" UX with billing.

Authorization summary

Operation Required
Org installs an Agent (creates an App with agent_id) Active AgentOrgGrant(org, agent) (auto-provisioned on first contact in v1)
App reads Agent's system memory App.agent_id IS NOT NULL
App writes to Agent's system memory Never. System memory is read-only from any App context post-008. Edit through the Agent ownership surface.
App reads/writes its own app-class memory Memory.app_id = app.id
App reads attached knowledge memory AgentMemoryItem(agent, memory) exists; cross-org needs MemorySubscription
App writes to attached knowledge memory Same plus AgentMemoryItem.role = read-write
App writes to a user's personal memory AgentSubscription(user, agent) is active AND Memory.app_id = app.id AND the call is on behalf of user
User reads their own personal memory Always (strict ownership — Memory.userId == ctx.userId)
Org ADMIN reads a user's personal memory Never. personal (and private) memories are strictly owner-only — no admin bypass.
Builder bundles Agent X into Agent Y as a dep Active AgentOrgGrant(Y.organization_id, X) exists

Every check is server-side. Apps cannot self-attest user identity, grant state, or personal-memory access. See Memory access for the full evaluation chain.

What's next

  • Agents and Apps — the class/instance walkthrough that motivates the two-grant structure.
  • Memory access — the full cross-org evaluation chain that ties subscriptions, memory class, and visibility together.
  • Understanding Memory — what the four memory classes actually contain.
  • Architecture — how grants fit into the broader entity hierarchy.