Skip to content

Debug PERMISSION_DENIED errors

PortalCLIAPIIntermediate~10 min

Someone can't see a memory they should, or an App's call is refused, and all the error says is PERMISSION_DENIED, Forbidden or MEMORY_ACCESS_DENIED. Find out whether they actually have access, which grant gives it, or that none does. Then find the gate that is blocking them and open it. The first check is a form in the portal. The deeper diagnostics further down are GraphQL queries for an administrator. Most fixes are a portal setting, or a request to the admin who owns the grant.

The error tells you a gate closed; it doesn't tell you which one. Hadron's memory access runs through a four-gate cross-org chain, plus a strict-ownership rule for personal memory. For what each gate is and why it exists, see Memory access. Read that first if the gate names below feel abstract.

First, check their access in the portal

Ask the server directly: does this person have access to this resource, and through which grant?

  1. In the portal, press ⌘K (Ctrl K on Windows and Linux) and type check access. Choose Check access.
  2. In User, enter the person: @handle, hrn:user:<handle>, or a user id. It starts filled in with you.
  3. In Resource, enter the full URN of the memory, node, agent, App, organization or user, for example hrn:mem:acme.com:market, or the id of an AI config.
  4. Choose Check access.

What you get back: their Role, a tick or a cross for each of READ, WRITE, MANAGE and DELETE, and a Grants table. Each row of the table shows a Source, a Role and a Via, naming the grant that confers the access. The answer comes from the same resolver that enforces access, so it is authoritative for what that person's grants allow on that resource. It doesn't see the context of a particular failing call, such as which App it came through or which credentials it used.

What it tells you:

  • Grants listed. They do have access, and the table names where it comes from. If their call still fails, the problem is elsewhere: the wrong App context, a stale token, or a different user than you think. The gate-by-gate walk below explains the rest.
  • "No grants — this user has no access to this resource." They really have no grant. Use the cases below to find the closed gate and open it.

Anyone can check their own access. To check someone else's, you need the right to audit the resource. That means being a platform admin, an ADMIN or OWNER of the organization that owns it, or, for a personal memory, its owner. Without that, the portal says "You're not allowed to audit access to this resource." That is you being blocked, not the person having no access.

An agent connected over MCP can't run this check, so ask in the portal rather than in chat.

The same check from the CLI

hadron access check <user> <memory-urn>

access check returns the server-computed effective capabilities (read/write/manage/delete) plus the grants that confer them — straight from the authoritative effectiveAccess resolver, so it never disagrees with the enforced decision. See Access checks for the full contract.

Two outcomes tell you where to go next:

  • A non-empty grants list — the user does have access, and the SOURCE / VIA columns name the grant. If a call still fails, the problem is elsewhere (wrong App context, a stale token, a different user than you think); the gate-by-gate walk below explains the rest.
  • An empty grants list ("No access") — the user genuinely has no grant. Fall through to the cases below to find the closed gate and open it.

Two caveats:

  • Checking someone else needs audit rights. Checking yourself is always allowed. For another user, access check requires a platform admin, an ADMIN/OWNER of the resource's owning org, or (for a strict-owner personal memory) the memory's principal. If you lack them, the command exits 1 (FORBIDDEN); that is the auditor being blocked, not the subject having no access.
  • It answers for one (user, resource) pair. It does not list every user who can reach a memory — for that use memory member / memory share.

Triage in 30 seconds

Symptom Likely case Jump to
App-keyed call fails; nothing user-related involved. Cross-org install grant inactive. Case 1
Reading a knowledge memory fails; agent and memory are in different orgs. Agent not attached to the memory, or cross-org subscription missing. Case 2
Mutation in the portal — toggling a setting, attaching a memory — denies for a CONTRIBUTOR / READER. Admin-gated mutation called by non-admin. Case 3
Personal-memory call denies even though the user is signed in. AgentSubscription revoked, expired, or wrong-App. Case 4

If none of the above fits, jump to Other gates.

Case 1: Cross-org install grant inactive

Gate: 1 (App ↔ Agent).

Symptom: an App-keyed call from one org to an Agent owned by another org is denied. The same Agent works fine when called from its owning org.

Diagnose

Check whether the calling org has an active AgentOrgGrant for the Agent:

query CheckGrants($orgId: ID!) {
  organization(id: $orgId) {
    name
    agentOrgGrants {
      agent { id urn name }
      isActive
      activatedAt
      revokedAt
    }
  }
}

If the Agent isn't in the list, no grant row exists. If isActive is false or revokedAt is set, the grant is closed.

Fix

Cross-org grants auto-provision when the calling org first calls createApp (with the Agent referenced) or publishAgentImport. Revoking happens explicitly. To restore access:

  • No grant row at all: have the calling org's admin run the install flow (Building an agent, Step 3). The grant gets created on first install.
  • Grant exists but isActive: false: the Agent's owning-org admin revoked it. Reach out to them to re-activate.
  • Same-org call denied at this gate: this shouldn't happen — same-org installs auto-pass Gate 1. If you see it, the App's agent_id is null. Inspect the App and re-create it pointing at the right Agent.

Case 2: Agent not attached to the memory

Gate: 2 (Agent ↔ Memory).

Symptom: the Agent's chat or tool-use returns "memory not found" or denies on read of a knowledge memory the Agent is supposed to use.

Diagnose

List the agent's attached memories from the agent's owning org:

query AgentMemories($orgId: ID!) {
  organization(id: $orgId) {
    agents {
      id name urn
      memoryItems {
        memory { id urn name visibility }
        role
      }
    }
  }
}

If the memory you expect isn't in the agent's memoryItems, the attachment doesn't exist. If it's there but role: read and you were trying to write, see Case 3 and the Gate 4 effective-role rule.

For a cross-org knowledge memory (the Agent in Org B reads a memory published by Org D), also confirm the MemorySubscription:

query MyMemories {
  myMemories {
    id urn name visibility organization { name }
  }
}

If the cross-org memory doesn't show up in myMemories for an org admin who should see it, the subscription is missing or revoked.

Fix

  • No AgentMemoryItem: an admin of the agent's org attaches the memory through the agent detail page (Memory tab → Add memory).
  • Cross-org MemorySubscription missing: the publishing org (Org D) controls who can subscribe. Admins of Org B request a subscription through whatever onboarding the publishing org provides; today this is a manual mutation rather than a self-service UI.

Case 3: Admin-gated mutation called by non-admin

Gate: 4 (effective role).

Symptom: a portal toggle (asset upload, AI config save, visibility change, member promotion) saves with "permission denied" or Forbidden for a member who can read the page.

A subset of mutations require org ADMIN or OWNER of the agent's organization, not just ORG membership. The portal usually hides the controls from non-admins, but a few render the controls and fail on save — and any caller hitting these mutations directly through GraphQL or the SDK will see the same error.

Note: pre-008 there was an AppAgent.role cap that gated some of these. Post-008 it's gone — system memory is universally read-only from any App, and admin gating is enforced at the resolver layer.

Diagnose

Check your role on the relevant org:

query MyOrgs {
  myOrganizations {
    id
    name
    members {
      user { id name }
      role
    }
  }
}

Find your user in members and inspect role. If it's CONTRIBUTOR or READER, you cannot run admin-gated mutations.

Fix

  • An existing OWNER or ADMIN of the org promotes you (the organization's Team tab in the portal).
  • Or: the action is taken by an existing admin on your behalf.

Mutations known to be admin-gated include setAgentAssetUploadEnabled (toggling the asset-upload tool), AI config writes for agent and app surfaces, and most cross-org configuration changes. The error message itself doesn't always say "admin only" — treat any Forbidden on a configuration write as a candidate for this case.

Case 4: Personal memory blocked

Gate: 3 (User ↔ Agent) plus the strict-ownership rule.

Symptom: a chat that worked yesterday now denies. The user is signed in. The Agent is correctly installed in the calling App's org. Nothing about the Agent's setup changed.

There are three sub-shapes here. Diagnose first, then fix.

4a — Subscription revoked, expired, or never activated

query MySubs {
  myAgentSubscriptions {
    agent { id urn name }
    isActive
    activatedAt
    expiresAt
    revokedAt
  }
}

If the agent isn't in the list, no subscription was provisioned — this is normal for userId = "anonymous" callers (no auto-provision on anonymous identity). For an authenticated user, an agent should appear after their first chat.

If isActive: false:

  • revokedAt is set → an admin or the user themselves revoked it. Re-activate by sending a fresh chat (the auto-provision rebinds if revocation isn't sticky in your config) or have an admin of the agent's org re-activate the row.
  • expiresAt is in the past → the subscription has a finite lifetime that lapsed. Re-subscribe.

4b — Cross-App isolation (different App, same Agent)

This is the subtle one. A user's personal memory is keyed on (app_id, user_id), not on agent. If the same Agent is installed in two Apps (say, Juno-on-MicroMentor and Juno-on-CompanyX), each install has its own personal memory. A chat through CompanyX's App cannot read what was written through MicroMentor's App.

Confirm by listing the Apps in the calling org and checking which one is making the call:

query OrgApps($orgId: ID!) {
  apps(orgId: $orgId) {
    id urn name agent { id name }
  }
}

If you see two Apps both pointing at the same Agent, the personal memories are isolated by design. This is not a bug — it's the multi-install isolation property Memory access exists to enforce.

4c — User trying to read someone else's personal memory

The strict-ownership rule says only Memory.userId can read their own personal memory. Not org admins. Not platform owners. Not the Agent's developer. Not anyone.

If you're hitting this trying to support a user, you can't read the data directly. Help them through the issue from their side (authenticated as them), or guide them to extract whatever they need themselves.

Fix

  • 4a: re-subscribe (or have an admin re-activate the AgentSubscription).
  • 4b: not a fix — accept the isolation. If the user actually needs cross-App memory, the architecture has to change (file this as a feature request).
  • 4c: redirect through the user. Hadron's strict ownership is non-negotiable.

Other gates

The four cases above cover the high-frequency failure modes. The Memory access explanation page has a more exhaustive symptom-to-gate table for edge cases — visibility mismatches, role caps via MemorySubscription, and the "App-keyed call without an App in context" debug shape.

If the diagnostic queries above all return clean results and the call still fails, capture the exact error message and the GraphQL operation that triggered it, then file an issue at hadron-memory/hadron-server.