Skip to content

Multi-User/Agent Support for single AD4M nodes (1/3)#629

Merged
lucksus merged 127 commits intodevfrom
multi-user
Jan 5, 2026
Merged

Multi-User/Agent Support for single AD4M nodes (1/3)#629
lucksus merged 127 commits intodevfrom
multi-user

Conversation

@lucksus
Copy link
Copy Markdown
Member

@lucksus lucksus commented Oct 22, 2025

Summary

This PR implements comprehensive multi-user support across the AD4M stack, enabling multiple users to be hosted on a single executor instance with proper isolation, authentication, and routing.

This is the first of (likely) 3 PRs. Email sending for user registration and app-level capability token requests will follow quickly after in a 2nd, and CAL compliant key handling is pushed out to the 3nd change set. The intention is to use this as an MVP of this approach to gather performance data of ad4m running many users to assess feasibility of this federated approach.

Motivation

Enable AD4M to function as a multi-tenant platform where a single executor can securely host multiple users, each with their own identity, perspectives, and neighbourhoods, while maintaining proper isolation and access control.

-> Users are not required to run their AD4M node on their own hardware.
-> Community admins or app developers can easily offer to host AD4M instances for users.
-> Maintaining ability for users to run completely local and p2p and seamlessly switch over

Changes

🔐 User Management & Authentication

  • Added user creation and login via new GraphQL mutations (runtimeCreateUser, runtimeLoginUser)
  • Implemented JWT-based authentication with user email in token sub field
  • Created database schema for user management (new users table)
  • Per-user agent data, DIDs, and signing keys stored and managed separately

👥 Agent Context System

  • Introduced AgentContext to track user_email and is_main_agent status
  • Refactored all agent operations (signing, DID resolution, expression creation) to be context-aware
  • Each managed user receives their own key pair and DID
  • Added helper functions: did_for_context(), sign_for_context(), create_signed_expression() with context

🏘️ Perspective & Neighbourhood Ownership

  • Breaking Change: Migrated from single owner_did to owners array in perspectives
  • Implemented multi-owner perspective support
  • Added neighbourhood ownership tracking (add_owner_to_neighbourhood, get_neighbourhood_owners)
  • Enhanced access management for shared executor environments

📡 Signal Routing

  • Implemented RoutedSignalPayload structure with recipient_did field
  • Local signal routing ensures proper isolation between locally-hosted agents
  • Holochain zomes updated to track and expose all local user DIDs via get_agents_did_keys()
  • Signal filtering now includes recipient DID in filter key

🔗 Language Updates

  • Updated p-diff-sync and p-diff-sync-socket-signaling for multi-user signal routing
  • LinksAdapter now creates DID links for all local users during sync
  • Languages query getAllLocalUserDIDs() for proper signal delivery

🌐 Ad4mConnect

  • Added connectMultiUser() method for multi-user connection mode
  • New configuration options: multiUser, backendUrl, userEmail, userPassword
  • Automatic user creation/login and token management

Testing

  • Multi-user, multi-node tests
  • Local signal routing isolation tests
  • Managed user perspective sharing tests
  • User management database tests
  • Multi-agent Holochain bootstrap tests

Checklist

  • Core user management (create/login users)
  • JWT authentication with user context
  • Per-user agent data, DIDs, and signing keys
  • Multi-owner perspective support
  • Local signal routing and isolation
  • Multi-DID awareness in Holochain zomes
  • Language updates (p-diff-sync, p-diff-sync-socket-signaling)
  • Ad4mConnect multi-user connection mode
  • Local multi-user, multi-node tests
  • Config-based activation/deactivation of multi-user feature
  • Launcher advanced settings UI for enabling/disabling multi-user mode
  • P2P neighbourhood signal verification across multiple nodes (remote testing)
  • Ad4mConnect UI for registration/login with remote/app-configured nodes

🔮 Future Work

follow-up PR

  • CAL-compliant key derivation for hosted/managed users
  • Key revocation mechanism for managed users
  • Iteration on Ad4mConnect UI for hosted users
  • More configuration options on email system to enable app developers to host their own ad4m multi-user instance

Migration Notes

  • Breaking Change: The owner_did field in perspectives has been migrated to an owners array
  • All local DIDs are now published to the agent language for multi-user awareness
  • Signal filtering includes recipient DID in filter key for proper isolation

Future Considerations

💡 CAL Compliance

The current implementation uses locally-generated keys for managed users. A follow-up PR will address CAL (Cryptographic Autonomy License) compliant key derivation and revocation mechanisms to ensure proper security standards and user key sovereignty for hosted/managed users.

Related Issues

Screenshots/Demos

Summary by CodeRabbit

  • New Features

    • Multi-user mode: email/password accounts, per-user login, per-user DIDs and signing, admin toggle, per-user perspective ownership, owner-scoped events, and targeted telepresence routing; UI flows for remote/TLS connections, signup/login, and a Users admin page.
  • Tests

    • New comprehensive multi-user integration and demo test suites; CI job added for multi-user tests.
  • Chores

    • Updated ignore entries for test/dev artifacts.

✏️ Tip: You can customize this high-level summary in your review settings.


Note

Enables hosting multiple users on a single executor with isolation and targeted routing.

  • Languages/Holochain: p-diff-sync zome now takes my_did in commit/sync; adds RoutedSignalPayload and recipient-aware signal handling; DID↔pubkey links per user; get_others/DID queries updated for multi-user.
  • Executor/Core APIs: New GraphQL: runtimeCreateUser, runtimeLoginUser, runtimeMultiUserEnabled/Set, runtimeListUsers; adds UserCreationResult; perspectives now expose owners; telepresence callbacks carry optional recipientDid.
  • Agent/Capabilities: Agent service supports per-user context (didForUser, per-user signing, list local DIDs); JWT sub set to user email; new runtime.user_management capabilities; throttled last-seen tracking.
  • Ad4mConnect/UI: Adds multi-user connection flow (connectMultiUser), remote URL detection, login/signup UI, connection overview; websocket subprotocol fix.
  • CLI/Config: New --enable_multi_user and TLS --tls_port; integration test job integration-tests-multi-user-simple; shared CI setup command; minor .gitignore updates.

Written by Cursor Bugbot for commit 6a6032f. This will update automatically on new commits. Configure here.

return AGENT.didForUser(this.#currentUserContext);
}
// Return cached main agent DID, or fallback to calling AGENT.did()
return this.#cachedMainAgentDid || AGENT.did();
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous getter returns Promise for user context DID

The did getter returns string but when #currentUserContext is set, it calls AGENT.didForUser() which according to agent_extension.d.ts returns Promise<string>. This causes the getter to return a Promise object instead of a string, breaking all code that uses agentService.did synchronously (e.g., string comparisons in LanguageController.ts at lines 291 and 340 will compare against [object Promise]).

Additional Locations (1)

Fix in Cursor Fix in Web

if (this.#currentUserContext) {
// @ts-ignore - New function from Rust
return AGENT.createSignedExpressionForUser(this.#currentUserContext, data);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous method returns Promise for user signed expressions

The createSignedExpression method returns Expression but when #currentUserContext is set, it calls AGENT.createSignedExpressionForUser() which returns Promise<Expression>. Callers like LanguageController.expressionCreate() at line 1012 use this synchronously with Literal.from(expr), which will receive a Promise object instead of an Expression, causing runtime errors when creating expressions in multi-user mode.

Additional Locations (1)

Fix in Cursor Fix in Web

if (this.#currentUserContext) {
// @ts-ignore - New function from Rust
return AGENT.agentForUser(this.#currentUserContext);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous getter returns Promise for user agent

The agent getter returns Agent but when #currentUserContext is set, it calls AGENT.agentForUser() which returns Promise<Agent>. This causes the getter to return a Promise object instead of an Agent, breaking any code that accesses agentService.agent properties synchronously in multi-user mode.

Additional Locations (1)

Fix in Cursor Fix in Web

getAllLocalUserDIDs(): string[] {
// @ts-ignore - Rust implementation includes main agent + managed users
return AGENT.getAllLocalUserDIDs();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Synchronous method returns Promise for local user DIDs

The getAllLocalUserDIDs method returns string[] but calls AGENT.getAllLocalUserDIDs() which returns Promise<string[]>. This means all callers receive a Promise instead of an array. While linksAdapter.ts line 71 uses await, the method signature doesn't indicate it's async, causing type confusion and potential runtime issues when iterating over the "array".

Additional Locations (1)

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Fix all issues with AI Agents 🤖
In @rust-executor/src/db.rs:
- Around line 2206-2233: The argon2 errors in hash_password and verify_password
are incorrectly wrapped as rusqlite::Error::ToSqlConversionFailure; update both
mapping closures to produce AnyError (e.g., use anyhow!("Failed to hash
password: {}", e) or e.into()) instead of the rusqlite wrapper so cryptographic
failures are represented as authentication/domain errors; replace the map_err
for Argon2::hash_password in hash_password and for PasswordHash::new in
verify_password accordingly.

In @rust-executor/src/graphql/graphql_types.rs:
- Around line 622-651: The UserInfo GraphQL type currently exposes last_seen as
Option<f64>, but the codebase uses the DateTime scalar (ISO-8601 string) for
timestamps; change UserInfo.last_seen from Option<f64> to the existing DateTime
scalar type (or Option<String> if DateTime is an alias) and update both From
impls (impl From<crate::types::User> for UserInfo and impl
From<crate::types::UserInfo> for UserInfo) to convert the source timestamp into
an ISO-8601 string/DateTime value (e.g., using chrono::Utc to format a
seconds/epoch value to rfc3339) instead of casting to f64 so GraphQL and JS
clients get consistent string-backed timestamps.

In @rust-executor/src/types.rs:
- Around line 523-543: The User struct currently derives Debug which will print
the sensitive password_hash; remove Debug from #[derive(...)] on the User
definition or replace it by implementing a custom impl std::fmt::Debug for User
that formats username, did, and last_seen but replaces password_hash with a
constant like "***redacted***"; ensure the UserInfo struct can keep Debug as-is
and update any uses that relied on the auto-derived Debug for User to use the
new manual Debug or alternative logging to avoid leaking the hash.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust-executor/src/graphql/subscription_resolvers.rs (1)

227-384: Link subscriptions ignore the uuid argument and filter only by owner DID.

In perspective_link_added/removed/updated:

  • The uuid argument is used only for an initial can_access_perspective check.
  • The actual subscription filter is derived solely from get_agent_did_filter (owner DID).
  • The payload types (PerspectiveLinkWithOwner, PerspectiveLinkUpdatedWithOwner) expose perspective_uuid, but their GetFilter implementations return only owner.

This means a subscription like perspective_link_added(uuid: "P1") will receive link events for all perspectives owned by that user, and the GraphQL client cannot distinguish which perspective an event belongs to (since only the link is exposed via GetValue). That’s a functional regression and can corrupt client state.

Consider changing the filter to include both perspective and owner, e.g.:

  • Make GetFilter for the wrapper types return format!("{}|{}", self.perspective_uuid, self.owner).
  • Compute the subscription filter similarly: Some(format!("{}|{}", uuid, did)).

This preserves multi-user isolation and the original per-perspective contract of these subscriptions.

🧹 Nitpick comments (3)
rust-executor/src/db.rs (3)

683-778: Owners persistence for perspective_handle is sound but JSON error handling could be clearer.

Serializing owners with unwrap_or_default and deserializing with and_then(|json| serde_json::from_str(&json).ok()) gives a safe fallback, but it silently drops malformed JSON in owners.

If you want stricter behavior, consider logging JSON parse failures (or propagating them) instead of returning None so unexpected schema corruption is visible.


780-823: add_owner_to_neighbourhood silently no‑ops when neighbourhood is missing.

When no row matches shared_url, current_owners becomes [] and the UPDATE affects 0 rows, but the function still returns Ok(()). That makes it hard to detect calls with an invalid/neighbourhood URL.

Consider returning an error (or at least logging) when UPDATE ... WHERE shared_url = ?2 modifies 0 rows so callers can distinguish “owner added” from “no such neighbourhood”.


3291-3605: User/owners/last_seen tests provide good coverage; consider adding a negative case for add_owner_to_neighbourhood.

The new tests thoroughly exercise:

  • User creation, duplicate handling, and password verification (including complex/unicode passwords).
  • User table existence/schema.
  • Neighbourhood owners addition and de‑duplication.
  • last_seen tracking and user listing order.

You might add a small test asserting behavior when calling add_owner_to_neighbourhood with a non‑existent shared_url (currently a silent no‑op) to lock in the intended semantics.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 2a6db86 and bfa18ea.

📒 Files selected for processing (4)
  • rust-executor/src/db.rs
  • rust-executor/src/graphql/graphql_types.rs
  • rust-executor/src/graphql/subscription_resolvers.rs
  • rust-executor/src/types.rs
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2025-09-24T14:26:44.307Z
Learnt from: lucksus
Repo: coasys/ad4m PR: 628
File: tests/js/tests/prolog-and-literals.test.ts:2726-2729
Timestamp: 2025-09-24T14:26:44.307Z
Learning: In AD4M, perspectives have names but are uniquely identified by UUIDs. Creating a new perspective with the same name in consecutive calls will create a fresh perspective each time, so there's no risk of state leakage between tests based on perspective names.

Applied to files:

  • rust-executor/src/db.rs
🧬 Code graph analysis (3)
rust-executor/src/graphql/subscription_resolvers.rs (3)
rust-executor/src/graphql/query_resolvers.rs (4)
  • agent (64-101)
  • perspective (440-464)
  • perspectives (552-599)
  • can_access_perspective (20-60)
rust-executor/src/agent/mod.rs (3)
  • did_for_context (103-119)
  • did (143-145)
  • new (256-271)
rust-executor/src/agent/capabilities/mod.rs (1)
  • user_email_from_token (88-108)
rust-executor/src/graphql/graphql_types.rs (2)
rust-executor/src/db.rs (1)
  • new (64-277)
rust-executor/src/graphql/query_resolvers.rs (1)
  • perspective (440-464)
rust-executor/src/db.rs (2)
rust-executor/src/types.rs (1)
  • to_string (460-464)
rust-executor/src/graphql/graphql_types.rs (7)
  • default (1191-1193)
  • default (1209-1211)
  • new (528-543)
  • new (1197-1205)
  • new (1215-1229)
  • get_owners (519-521)
  • is_owned_by (498-503)
🪛 Gitleaks (8.30.0)
rust-executor/src/db.rs

[high] 3298-3298: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3347-3347: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3351-3351: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3425-3425: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3430-3430: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3435-3435: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (15)
rust-executor/src/db.rs (3)

240-275: Schema additions and owners migration look consistent.

users/settings tables and the owners column plus owner_didowners migration are wired coherently and tolerate existing schemas via ignored ALTER TABLE errors. This aligns with the new multi-user model without obvious migration hazards.


2310-2339: Settings and multi‑user toggle helpers are straightforward and cohesive.

get_setting/set_setting plus get_multi_user_enabled/set_multi_user_enabled are minimal and make the feature flag easy to query from other layers. Returning false when the setting is absent gives a predictable default.


2235-2301: NULLS LAST is supported in the bundled SQLite version—no change needed.

SQLite has supported ORDER BY ... NULLS LAST since version 3.30.0 (2019-10-04). The project uses rusqlite 0.34.0, which bundles SQLite 3.49.1, so the syntax in list_users() is valid and portable.

The separation of concerns between get_user_internal and get_user/list_users remains a solid design choice for credential handling.

rust-executor/src/graphql/graphql_types.rs (5)

473-566: PerspectiveHandle.owners and helper methods match the new ownership model.

The added owners: Option<Vec<String>> plus helpers (add_owner, is_owned_by, is_unowned, get_primary_owner, get_owners, is_neighbourhood) give a clear, backwards‑compatible API:

  • Unowned = owners missing or empty.
  • Ownership checks are centralized and reused elsewhere (can_access_perspective).
  • Constructors default owners to None unless explicitly set.

This structure looks consistent with the DB schema changes.


661-668: UserStatistics choice of DateTime is consistent with the rest of the schema.

Using the existing DateTime scalar for last_seen here (vs. numeric in UserInfo) is fine as long as the resolver normalizes the internal i64 timestamp into a UTC DateTime consistently.


670-695: Neighbourhood signal and link owner wrappers align with new filtering.

  • NeighbourhoodSignalFilter’s composite filter "perspective_uuid|recipient_did" matches the new helper in subscription_resolvers, preserving backwards compatibility when recipient is None.
  • PerspectiveLinkWithOwner and PerspectiveLinkUpdatedWithOwner expose owner for filtering while keeping the GraphQL payload limited to link data via GetValue.

These types are the right building blocks; the main concern is how filters are computed on the subscription side (see comments in subscription_resolvers.rs).


1010-1052: Owner-wrapped perspective subscription types are well-structured.

PerspectiveWithOwner and PerspectiveRemovedWithOwner cleanly embed the owner DID into the payload while exposing only the handle/uuid via GetValue, which is ideal for internal filtering and external API stability.


58-75: The user_did field in AuthInfoInput is currently unused and not passed to authorization logic.

While user_did exists as an optional field in the GraphQL input, it is explicitly discarded during the From<AuthInfoInput> for AuthInfo conversion and never reaches any downstream authentication or authorization code. The AuthInfo struct (which is used by request_capability() and permit_capability()) has no user_did field at all.

If this field was intended for future use, clarify its purpose. If it's not needed, consider removing it to avoid confusion.

Likely an incorrect or invalid review comment.

rust-executor/src/graphql/subscription_resolvers.rs (7)

25-71: DID filter helper correctly enforces user vs. main-agent behavior.

get_agent_did_filter cleanly separates:

  • Main agent: optional DID filter (or None if not initialized).
  • User agents: must have a resolvable DID or the subscription fails.

This is the right shape to avoid leaking data to user contexts without a valid DID.


73-107: Neighbourhood signal filter helper addresses prior leak concerns.

get_neighbourhood_signal_filter now:

  • Uses perspective_uuid|did for user contexts and errors on DID resolution failure.
  • Falls back to perspective-only filtering only for the main agent (and only when no DID is available).

Combined with NeighbourhoodSignalFilter::get_filter, this closes the earlier “fallback to perspective-only for users” issue.


170-202: neighbourhood_signal subscription now safely enforces per-agent filters.

Using get_neighbourhood_signal_filter and aborting the subscription on error ensures:

  • Users only see signals addressed to their DID for the specified perspective.
  • The main agent retains backwards-compatible behavior.

The debug logging of perspective and filter is also helpful for diagnosing routing issues.


204-225: perspective_added now respects per-owner isolation.

Switching to PerspectiveWithOwner and filtering via get_agent_did_filter ensures subscribers only see perspective-add events for perspectives they own (or the main agent’s view), which matches the multi-user design.


386-411: perspective_removed is correctly owner-scoped via DID filter.

Using PerspectiveRemovedWithOwner plus get_agent_did_filter ensures only the owning agent (or main agent, as appropriate) receives remove events. Logging the filter aids debugging.


413-457: perspective_sync_state_change enforces access but not per-owner filtering.

You now:

  • Check access via can_access_perspective before subscribing.
  • Filter only by uuid (PerspectiveStateFilter’s filter is the perspective UUID).

This means all authorized owners of a shared perspective see the same sync-state events, which is probably intended. If you ever need per-owner sync state, you’d need an owner-aware wrapper similar to the link subscriptions.


459-480: perspective_updated follows the same owner-based filtering as perspective_added.

Using PerspectiveWithOwner with get_agent_did_filter aligns updated events with the multi-user ownership model and avoids cross-user leaks.

Comment on lines +622 to +651
// Public UserInfo struct for GraphQL - only contains non-sensitive fields
// Note: last_seen uses f64 for GraphQL compatibility (GraphQL Int is limited to i32)
// f64 can safely represent integer timestamps up to 2^53 without precision loss
#[derive(GraphQLObject, Default, Debug, Deserialize, Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct UserInfo {
pub username: String,
pub did: String,
pub last_seen: Option<f64>,
}

impl From<crate::types::User> for UserInfo {
fn from(user: crate::types::User) -> Self {
UserInfo {
username: user.username,
did: user.did,
last_seen: user.last_seen.map(|ts| ts as f64),
}
}
}

impl From<crate::types::UserInfo> for UserInfo {
fn from(user: crate::types::UserInfo) -> Self {
UserInfo {
username: user.username,
did: user.did,
last_seen: user.last_seen.map(|ts| ts as f64),
}
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and read the specified lines
head -n 660 rust-executor/src/graphql/graphql_types.rs | tail -n 60

Repository: coasys/ad4m

Length of output: 1793


🏁 Script executed:

# Search for any custom scalar definitions or timestamp handling in the codebase
rg -i "scalar|timestamp|bigint" rust-executor/src/graphql/ -A 3

Repository: coasys/ad4m

Length of output: 3481


🏁 Script executed:

# Check for juniper or coasys_juniper dependency and version
fd -e toml -e cargo | xargs grep -l "juniper\|coasys_juniper" 2>/dev/null | head -5

Repository: coasys/ad4m

Length of output: 79


🌐 Web query:

GraphQL best practices representing 64-bit Unix timestamps juniper rust custom scalar

💡 Result:

Short guidance + options and a small Juniper pattern.

Key constraints

  • GraphQL transport is JSON; JavaScript Numbers are only safe to 53 bits—so exposing raw 64-bit integers as JSON numbers can lose precision in many clients. [1][2].
  • Juniper has built-in chrono/date types and supports custom scalars via #[derive(GraphQLScalar)] or #[graphql_scalar]. You can also implement ScalarValue to add u64/i64 storage. [3][4][5].

Recommended approaches (pick one)

  1. ISO 8601 DateTime string (recommended for interoperability)
  • Represent instants as RFC3339/ISO-8601 strings (e.g. 2023-01-02T03:04:05Z). Safe, human-readable, well-supported by GraphQL clients and the-guild scalars. Good for most APIs. [6][1]
  1. Integer epoch as string (preserves full 64-bit value)
  • Serialize the 64-bit unix timestamp (seconds or milliseconds) as a GraphQL String scalar. Clients parse into their preferred int type. Avoids JS-Number precision issues. Async-GraphQL documents this pattern and Juniper custom scalars can do the same. [4][2]
  1. Custom 64-bit scalar (if you want a typed scalar name like Timestamp)
  • Implement a GraphQL custom scalar that serializes to/from a string (or to a JSON number only for non-JS clients). In Juniper you can:
    • Newtype + #[derive(juniper::GraphQLScalar)] with #[graphql(transparent)] for simple delegation; or
    • Use #[graphql_scalar] to implement custom parsing/serialization and validation; or
    • Extend ScalarValue if you need server-side storage of i64/u64 in the GraphQL value system. See Juniper docs/examples. [3][5][8]

Practical Juniper pattern (serialize epoch as string)

  • Use a newtype wrapper around i64/chrono::DateTime and implement GraphQL scalar that:
    • to_output: outputs Value::String(self.0.to_string() or self.0.to_rfc3339())
    • from_input: accept Value::String and parse to i64 or DateTime
      This preserves 64-bit precision for JS clients and keeps typing in schema (scalar Timestamp). See Juniper scalars docs and async-graphql example of string-backed 64-bit scalar. [3][4]

Which unit: seconds vs milliseconds

  • Pick and document one (commonly milliseconds in JS ecosystems, seconds in some systems). Always state the unit in the scalar description to avoid confusion. [6][2]

Summary recommendation

  • Default to RFC3339 DateTime when you want human-readable instants and broad client convenience.
  • Use a string-backed 64-bit scalar (scalar name Timestamp) if you must expose raw epoch values and retain full 64-bit precision for clients. Implement it in Juniper as a custom scalar/newtype per the docs. [6][3][4]

References
[1] Juniper Scalars docs (custom scalars & chrono support).
[2] GraphQL.js custom scalars guidance (when to use, serialization concerns; note JS Number precision).
[3] Juniper Scalars (book) — newtype and #[graphql_scalar] usage.
[4] Async-GraphQL example: string-backed 64-bit integer scalar.
[5] Juniper ScalarValue / implementing 64-bit storage.

If you want, I can produce a minimal Juniper #[graphql_scalar] code example for the string-backed 64-bit Timestamp (seconds or ms)—which unit do you prefer?


Consider aligning with the codebase's established timestamp pattern.

While f64 is safe for representing Unix timestamps in Rust, the codebase already uses ISO-8601 DateTime strings for other timestamp fields (e.g., PerspectiveExpression, LinkStatusUpdate). A custom GraphQL scalar wrapping a string—similar to the DateTime scalar already defined in this file—would be more consistent and better follows GraphQL best practices for 64-bit values. Using f64 doesn't actually prevent precision loss in JavaScript clients (both have a 2^53-bit safe integer limit); string-backed timestamps avoid this concern entirely.

🤖 Prompt for AI Agents
In @rust-executor/src/graphql/graphql_types.rs around lines 622-651, The
UserInfo GraphQL type currently exposes last_seen as Option<f64>, but the
codebase uses the DateTime scalar (ISO-8601 string) for timestamps; change
UserInfo.last_seen from Option<f64> to the existing DateTime scalar type (or
Option<String> if DateTime is an alias) and update both From impls (impl
From<crate::types::User> for UserInfo and impl From<crate::types::UserInfo> for
UserInfo) to convert the source timestamp into an ISO-8601 string/DateTime value
(e.g., using chrono::Utc to format a seconds/epoch value to rfc3339) instead of
casting to f64 so GraphQL and JS clients get consistent string-backed
timestamps.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Fix all issues with AI Agents 🤖
In @rust-executor/src/db.rs:
- Around line 1704-1726: The export SELECT is missing the owners column and thus
exported objects lack owners; update the query string ".prepare(\"SELECT uuid,
name, neighbourhood, shared_url, state FROM perspective_handle\")" to include
owners, and modify the export routine that builds the JSON objects to add the
owners field (serialized the same way the import expects—an array/JSON string of
owner strings) so the import code that reads owners_json into perspective_handle
works correctly.
- Around line 780-822: The add_owner_to_neighbourhood currently treats a missing
perspective as an empty owners list and silently succeeds; change it to first
verify the perspective exists and return an error if not found: when fetching
owners in add_owner_to_neighbourhood (the SELECT on perspective_handle),
propagate the query error instead of using unwrap_or_default and map a
QueryReturnedNoRows into a clear Ad4mDbResult error (or explicitly check
existence with a COUNT/EXISTS query), then proceed to update; additionally,
after executing the UPDATE, check the affected row count and return an error if
zero so callers know the owner was not added.
♻️ Duplicate comments (1)
bootstrap-languages/p-diff-sync/index.ts (1)

50-74: Fix per-instance routing: validate recipient matches THIS instance's DID.

The current validation checks if recipientDid is any local user on the node (line 57), rather than checking if it matches this specific instance's agent DID. Each language instance is created for a specific user, so it should only accept signals addressed to that user.

Impact: If the node hosts users A and B, the language instance for user A will incorrectly accept and process signals intended for user B, because localUserDIDs.includes(recipientDid) returns true for both A and B.

Fix: Use strict equality to ensure this instance only processes its own signals:

-      if (payload.recipient_did) {
-        // Check if this signal is for THIS specific user (agent.did)
-        // Each language instance is created for a specific user
-
-        const recipientDid = payload.recipient_did;
-        const localUserDIDs = await agent.getAllLocalUserDIDs();
-        
-        if (! localUserDIDs.includes(recipientDid)) {
-          console.error(`[p-diff-sync] Received Signal not for user on this node. Recipient is ${payload.recipient_did}. All local user DIDs: ${localUserDIDs.join(', ')}`);
-          return; // Not for this user
-        }
+      if (payload.recipient_did) {
+        const recipientDid = payload.recipient_did as string;
+        
+        if (recipientDid !== agent.did) {
+          console.warn(`[p-diff-sync] Routed signal not for this language instance (have ${agent.did}, got ${recipientDid})`);
+          return;
+        }

Note: This matches the guidance in the previous review comment for lines 49-74.

🧹 Nitpick comments (2)
executor/src/agent_extension.d.ts (1)

62-89: Consider adding JSDoc for consistency.

The new multi-user methods are well-typed and consistent with the existing interface pattern. All past review concerns have been successfully addressed (synchronous return types, proper typing).

However, didForUser has comprehensive JSDoc documentation while the other new methods lack it. For consistency and improved developer experience, consider adding similar documentation to:

  • createSignedExpressionForUser (error conditions, what data types are accepted)
  • agentForUser (behavior when user doesn't exist)
  • listUserEmails (when it returns empty arrays)
  • getAllLocalUserDIDs (when it returns empty arrays)
📝 Example JSDoc pattern
/**
 * Creates a signed expression for a specific user.
 * 
 * @param userEmail - The email address of the user
 * @param data - The data to include in the expression
 * @returns A signed Expression object
 * @throws {TypeError} If userEmail is not a non-empty string
 * @throws {Error} If no key is found for the given user email
 */
createSignedExpressionForUser: (userEmail: string, data: unknown) => Expression;
bootstrap-languages/p-diff-sync/index.ts (1)

70-72: Avoid blocking: invoke callbacks concurrently.

Awaiting callbacks serially means a slow callback blocks all subsequent callbacks from starting. Consider invoking them concurrently (e.g., Promise.allSettled) or as fire-and-forget to improve responsiveness.

-        for (const callback of telepresenceAdapter.signalCallbacks) {
-          await callback(perspectiveExpression, recipientDid);
-        }
+        await Promise.allSettled(
+          telepresenceAdapter.signalCallbacks.map(cb => cb(perspectiveExpression, recipientDid))
+        );

Apply the same pattern to the broadcast path (lines 77-79).

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bfa18ea and 624970c.

📒 Files selected for processing (4)
  • bootstrap-languages/p-diff-sync/index.ts
  • executor/src/agent_extension.d.ts
  • rust-executor/src/db.rs
  • rust-executor/src/types.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • rust-executor/src/types.rs
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-03T15:16:29.623Z
Learnt from: lucksus
Repo: coasys/ad4m PR: 619
File: rust-executor/src/perspectives/perspective_instance.rs:639-641
Timestamp: 2025-09-03T15:16:29.623Z
Learning: In the AD4M codebase, database operations like `add_many_links` during critical synchronization processes (such as `diff_from_link_language`) should use `.expect()` rather than error logging, as failures indicate severe system issues where fail-fast behavior is preferred over continuing with potentially corrupted state.

Applied to files:

  • rust-executor/src/db.rs
📚 Learning: 2025-09-24T14:26:44.307Z
Learnt from: lucksus
Repo: coasys/ad4m PR: 628
File: tests/js/tests/prolog-and-literals.test.ts:2726-2729
Timestamp: 2025-09-24T14:26:44.307Z
Learning: In AD4M, perspectives have names but are uniquely identified by UUIDs. Creating a new perspective with the same name in consecutive calls will create a fresh perspective each time, so there's no risk of state leakage between tests based on perspective names.

Applied to files:

  • rust-executor/src/db.rs
🧬 Code graph analysis (2)
bootstrap-languages/p-diff-sync/index.ts (7)
executor/src/core/agent/AgentService.ts (1)
  • agent (79-85)
rust-executor/src/graphql/query_resolvers.rs (1)
  • agent (64-101)
bootstrap-languages/p-diff-sync/linksAdapter.ts (1)
  • LinkAdapter (15-333)
bootstrap-languages/centralized-p-diff-sync/linksAdapter.ts (1)
  • LinkAdapter (8-292)
bootstrap-languages/centralized-p-diff-sync/telepresenceAdapter.ts (1)
  • TelepresenceAdapterImplementation (5-90)
bootstrap-languages/p-diff-sync/telepresenceAdapter.ts (1)
  • TelepresenceAdapterImplementation (4-44)
bootstrap-languages/p-diff-sync/happ.js (2)
  • BUNDLE (3-3)
  • BUNDLE (3-3)
rust-executor/src/db.rs (1)
rust-executor/src/graphql/graphql_types.rs (4)
  • new (528-543)
  • new (1197-1205)
  • new (1215-1229)
  • is_owned_by (498-503)
🪛 Gitleaks (8.30.0)
rust-executor/src/db.rs

[high] 3289-3289: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3338-3338: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3342-3342: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3416-3416: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3421-3421: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3426-3426: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot
🔇 Additional comments (5)
executor/src/agent_extension.d.ts (1)

1-1: LGTM! Import expanded to support multi-user methods.

The addition of Agent to the import is necessary for the new agentForUser return type and aligns with the multi-user support objectives.

bootstrap-languages/p-diff-sync/index.ts (2)

17-18: LGTM! Observability improvements for multi-user debugging.

The logging additions provide clear visibility into which agent context each language instance is operating under, which is valuable for troubleshooting multi-user signal routing.

Also applies to: 22-23, 83-83


26-37: LGTM! DNA registration correctly expanded for multi-user support.

The additional zome calls (render, commit, fast_forward_signal, get_others, add_active_agent_link) align with the multi-user feature requirements described in the PR objectives.

rust-executor/src/db.rs (2)

2206-2224: LGTM! Password hashing implementation is secure.

The password helpers correctly use Argon2 with random salts and proper error handling. The error mapping now uses anyhow! instead of the incorrect rusqlite::Error wrapper that was flagged in previous reviews. This is idiomatic Rust error handling for the Ad4mDbResult<T> = Result<T, AnyError> type alias.


2227-2299: LGTM! User management properly separates internal and public APIs.

The user management implementation addresses all security concerns from previous reviews:

  • Password hash exposure eliminated: Public APIs (get_user, list_users) return UserInfo without password_hash
  • Internal function for verification: get_user_internal is private and only used by verify_user_password
  • Timestamp overflow fixed: Uses i64 instead of i32 for last_seen
  • Proper hashing: Passwords are hashed before storage in add_user

The separation between User (internal with password_hash) and UserInfo (public without password_hash) follows the principle of least privilege.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
rust-executor/src/db.rs (1)

846-857: Consider distinguishing "not found" from database errors.

The current implementation uses unwrap_or_default() on the query result, which converts both "perspective not found" and real database errors (connection issues, corruption, etc.) into an empty vector. This can make debugging harder if an actual database problem occurs.

Since the function returns Ad4mDbResult, callers expect to be notified of errors. Consider either:

  • Propagating all errors and letting callers handle "not found" explicitly, or
  • Using .optional()? to distinguish QueryReturnedNoRows from other errors
🔎 Proposed approach
 pub fn get_neighbourhood_owners(&self, neighbourhood_url: &str) -> Ad4mDbResult<Vec<String>> {
     let owners: Vec<String> = self
         .conn
         .prepare("SELECT owners FROM perspective_handle WHERE shared_url = ?1")?
         .query_row([neighbourhood_url], |row| {
             let owners_json: String = row.get(0)?;
-            Ok(serde_json::from_str(&owners_json).unwrap_or_default())
+            serde_json::from_str(&owners_json).map_err(|e| {
+                rusqlite::Error::FromSqlConversionFailure(
+                    0,
+                    rusqlite::types::Type::Text,
+                    Box::new(e),
+                )
+            })
         })
-        .unwrap_or_default();
+        .optional()?  // Propagate errors, convert None to empty vec
+        .unwrap_or_default();
 
     Ok(owners)
 }

This way, real database errors are propagated while "not found" still returns an empty vec.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 624970c and 6a6032f.

📒 Files selected for processing (1)
  • rust-executor/src/db.rs
🧰 Additional context used
🧠 Learnings (2)
📚 Learning: 2025-09-03T15:16:29.623Z
Learnt from: lucksus
Repo: coasys/ad4m PR: 619
File: rust-executor/src/perspectives/perspective_instance.rs:639-641
Timestamp: 2025-09-03T15:16:29.623Z
Learning: In the AD4M codebase, database operations like `add_many_links` during critical synchronization processes (such as `diff_from_link_language`) should use `.expect()` rather than error logging, as failures indicate severe system issues where fail-fast behavior is preferred over continuing with potentially corrupted state.

Applied to files:

  • rust-executor/src/db.rs
📚 Learning: 2025-09-24T14:26:44.307Z
Learnt from: lucksus
Repo: coasys/ad4m PR: 628
File: tests/js/tests/prolog-and-literals.test.ts:2726-2729
Timestamp: 2025-09-24T14:26:44.307Z
Learning: In AD4M, perspectives have names but are uniquely identified by UUIDs. Creating a new perspective with the same name in consecutive calls will create a fresh perspective each time, so there's no risk of state leakage between tests based on perspective names.

Applied to files:

  • rust-executor/src/db.rs
🧬 Code graph analysis (1)
rust-executor/src/db.rs (1)
rust-executor/src/graphql/graphql_types.rs (7)
  • new (528-543)
  • new (1197-1205)
  • new (1215-1229)
  • default (1191-1193)
  • default (1209-1211)
  • get_owners (519-521)
  • is_owned_by (498-503)
🪛 Gitleaks (8.30.0)
rust-executor/src/db.rs

[high] 3329-3329: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3378-3378: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3382-3382: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3456-3456: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3461-3461: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)


[high] 3466-3466: Detected a Generic API Key, potentially exposing access to various services and sensitive operations.

(generic-api-key)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: Cursor Bugbot

@lucksus lucksus merged commit c193ed8 into dev Jan 5, 2026
5 checks passed
@coderabbitai coderabbitai bot mentioned this pull request Jan 14, 2026
lucksus added a commit that referenced this pull request Feb 18, 2026
Adds 21 entries (Fixed ×11, Added ×5, Changed ×5) covering PRs #629#680
merged after the 0.11.1 release. CI-only PRs (#656, #657, #659) omitted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants