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 forpersonal-class Memory. The principal (the Memory's owner) creates a row granting a specific grantee areaderorwriterrole on that one Memory. Each (Memory, grantee) pair is a single row.MemoryMember— symmetric team membership forgroup-class Memory. Each member carries areader,writer, orownerrole. At least oneowneralways 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:
- 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. - The App (Sage chatbot frontend) in
wellness.example— a chatbot-type App withagent_id = sage. CallingcreateAppauto-provisions anAgentOrgGrant(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.) - An
AppKeyfor the App so the chatbot frontend can authenticate withhdr_app_…tokens.
Now an end user, Alice, opens the Sage chat for the first time:
- The platform detects no
AgentSubscription(alice, sage)exists yet. Auto-provisions it —activatedAt = now(), no expiry, not revoked. - 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)withMemory.userId = alice,class = 'personal'(visibility isnull— privacy is class-driven). - The Agent's writes into that personal Memory are gated by Alice's
AgentSubscriptionAND byMemory.app_id = sage-app.id. Without the subscription, Sage has zero access. Without the matchingapp_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:
- 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 ofwellness.examplecannot read either user's personal memory through any platform surface (gate 4:personal/privateis 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:
activatedAtis set, ANDrevokedAtis not set, ANDexpiresAtis 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
AgentSubscriptiondenies it; theMemory.app_id = app.idcheck 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.userIdis 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.