parse-stack-next 4.5.0 → 5.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. checksums.yaml +4 -4
  2. data/.bundle/config +2 -0
  3. data/.env.sample +17 -3
  4. data/.github/workflows/codeql.yml +44 -0
  5. data/.github/workflows/docs.yml +39 -0
  6. data/.github/workflows/release.yml +32 -0
  7. data/.github/workflows/ruby.yml +8 -6
  8. data/.gitignore +4 -0
  9. data/.vscode/settings.json +3 -0
  10. data/CHANGELOG.md +305 -72
  11. data/Gemfile.lock +10 -3
  12. data/LICENSE.txt +1 -1
  13. data/README.md +190 -219
  14. data/Rakefile +1 -1
  15. data/SECURITY.md +30 -0
  16. data/assets/parse-stack-next-avatar.png +0 -0
  17. data/assets/parse-stack-next-avatar.svg +37 -0
  18. data/assets/parse-stack-next-banner.png +0 -0
  19. data/assets/parse-stack-next-banner.svg +45 -0
  20. data/assets/parse-stack-next-social-preview.png +0 -0
  21. data/docs/atlas_vector_search_guide.md +511 -0
  22. data/docs/client_sdk_guide.md +1320 -0
  23. data/docs/mcp_guide.md +225 -104
  24. data/docs/mongodb_direct_guide.md +21 -4
  25. data/docs/usage_guide.md +585 -0
  26. data/examples/transaction_example.rb +28 -28
  27. data/lib/parse/acl_scope.rb +2 -2
  28. data/lib/parse/agent/mcp_rack_app.rb +184 -16
  29. data/lib/parse/agent/metadata_dsl.rb +16 -16
  30. data/lib/parse/agent/pipeline_validator.rb +28 -1
  31. data/lib/parse/agent/prompts.rb +5 -5
  32. data/lib/parse/agent/tools.rb +287 -14
  33. data/lib/parse/agent.rb +209 -12
  34. data/lib/parse/api/analytics.rb +27 -5
  35. data/lib/parse/api/files.rb +6 -2
  36. data/lib/parse/api/push.rb +21 -4
  37. data/lib/parse/api/server.rb +59 -0
  38. data/lib/parse/api/users.rb +26 -2
  39. data/lib/parse/atlas_search/index_manager.rb +84 -0
  40. data/lib/parse/atlas_search.rb +37 -9
  41. data/lib/parse/cache/pool.rb +88 -0
  42. data/lib/parse/cache/redis.rb +249 -0
  43. data/lib/parse/client/body_builder.rb +94 -0
  44. data/lib/parse/client/caching.rb +109 -9
  45. data/lib/parse/client/response.rb +27 -0
  46. data/lib/parse/client.rb +74 -3
  47. data/lib/parse/console.rb +203 -0
  48. data/lib/parse/embeddings/cohere.rb +484 -0
  49. data/lib/parse/embeddings/fixture.rb +130 -0
  50. data/lib/parse/embeddings/jina.rb +454 -0
  51. data/lib/parse/embeddings/local_http.rb +492 -0
  52. data/lib/parse/embeddings/openai.rb +520 -0
  53. data/lib/parse/embeddings/provider.rb +264 -0
  54. data/lib/parse/embeddings/qwen.rb +431 -0
  55. data/lib/parse/embeddings/voyage.rb +550 -0
  56. data/lib/parse/embeddings.rb +225 -0
  57. data/lib/parse/graphql/scalars.rb +53 -0
  58. data/lib/parse/graphql/type_generator.rb +264 -0
  59. data/lib/parse/graphql.rb +48 -0
  60. data/lib/parse/live_query/client.rb +24 -5
  61. data/lib/parse/live_query/subscription.rb +17 -6
  62. data/lib/parse/live_query.rb +9 -4
  63. data/lib/parse/model/associations/collection_proxy.rb +2 -2
  64. data/lib/parse/model/associations/has_many.rb +32 -1
  65. data/lib/parse/model/associations/has_one.rb +17 -0
  66. data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
  67. data/lib/parse/model/classes/user.rb +307 -11
  68. data/lib/parse/model/clp.rb +1 -1
  69. data/lib/parse/model/core/create_lock.rb +14 -2
  70. data/lib/parse/model/core/embed_managed.rb +296 -0
  71. data/lib/parse/model/core/fetching.rb +4 -4
  72. data/lib/parse/model/core/indexing.rb +53 -14
  73. data/lib/parse/model/core/parse_reference.rb +3 -3
  74. data/lib/parse/model/core/properties.rb +70 -1
  75. data/lib/parse/model/core/querying.rb +57 -1
  76. data/lib/parse/model/core/vector_searchable.rb +285 -0
  77. data/lib/parse/model/file.rb +16 -4
  78. data/lib/parse/model/model.rb +26 -10
  79. data/lib/parse/model/object.rb +63 -6
  80. data/lib/parse/model/pointer.rb +16 -2
  81. data/lib/parse/model/shortnames.rb +2 -0
  82. data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
  83. data/lib/parse/model/vector.rb +102 -0
  84. data/lib/parse/mongodb.rb +90 -8
  85. data/lib/parse/pipeline_security.rb +59 -2
  86. data/lib/parse/query/constraints.rb +16 -14
  87. data/lib/parse/query/ordering.rb +1 -1
  88. data/lib/parse/query.rb +137 -64
  89. data/lib/parse/stack/generators/templates/model.erb +2 -2
  90. data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
  91. data/lib/parse/stack/generators/templates/model_role.rb +1 -1
  92. data/lib/parse/stack/generators/templates/model_session.rb +1 -1
  93. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  94. data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
  95. data/lib/parse/stack/version.rb +1 -1
  96. data/lib/parse/stack.rb +375 -73
  97. data/lib/parse/two_factor_auth/user_extension.rb +5 -2
  98. data/lib/parse/vector_search.rb +341 -0
  99. data/parse-stack-next.gemspec +10 -9
  100. data/scripts/docker/docker-compose.test.yml +18 -0
  101. data/scripts/start-parse.sh +6 -0
  102. data/scripts/vector_prototype/create_vector_index.js +105 -0
  103. data/scripts/vector_prototype/fetch_embeddings.py +241 -0
  104. data/scripts/vector_prototype/fixture_manifest.json +9 -0
  105. data/scripts/vector_prototype/query_prototype.rb +84 -0
  106. data/scripts/vector_prototype/run.sh +34 -0
  107. metadata +77 -5
  108. data/parse-stack.png +0 -0
data/docs/mcp_guide.md CHANGED
@@ -285,7 +285,7 @@ Cooperative cancellation lets clients abort an in-flight long-running tool call.
285
285
 
286
286
  2. **SSE client disconnect.** When the underlying TCP connection closes (browser tab closed, network drop), Rack calls `SSEBody#close`, which trips the same cancellation token.
287
287
 
288
- **Identity binding (required for `notifications/cancelled`).** The cancelling request **must** carry the same `X-MCP-Session-Id` header as the original request. The header is sanitized into `agent.correlation_id` and used as half of the registry key (the JSON-RPC `requestId` is the other half). Cancellation without a matching `X-MCP-Session-Id` is a silent no-op — this prevents an attacker who guesses sequential JSON-RPC ids from cancelling other clients' in-flight requests. Failures (no session id, no matching entry, mismatched session id) all return `202` so the response shape is not a probe oracle.
288
+ **Identity binding (required for `notifications/cancelled`).** The cancelling request **must** carry the same `Mcp-Session-Id` header as the original request. The header is sanitized into `agent.correlation_id` and used as half of the registry key (the JSON-RPC `requestId` is the other half). Cancellation without a matching `Mcp-Session-Id` is a silent no-op — this prevents an attacker who guesses sequential JSON-RPC ids from cancelling other clients' in-flight requests. Failures (no session id, no matching entry, mismatched session id) all return `202` so the response shape is not a probe oracle.
289
289
 
290
290
  **Cooperative checkpoints.** Cancellation is observed at safe points inside tool execution, not by forcibly killing the dispatcher thread. The two checkpoints built into `Parse::Agent#execute` are:
291
291
 
@@ -319,7 +319,7 @@ The stream still emits the `response` SSE event before closing so clients do not
319
319
 
320
320
  **Scope and limitations.**
321
321
  - The cancellation registry is per `MCPRackApp` instance. Cancellation does not span multiple mount points within a process, nor multiple processes in a clustered deployment.
322
- - Clients that do not set `X-MCP-Session-Id` lose cancellation but keep every other MCP feature.
322
+ - Clients that do not set `Mcp-Session-Id` lose cancellation but keep every other MCP feature.
323
323
  - The standalone WEBrick-backed `MCPServer` does not support streaming and therefore does not support cancellation; calls return a single buffered response with no opportunity to interrupt.
324
324
 
325
325
  ---
@@ -544,14 +544,14 @@ Register before the `MCPRackApp` or `MCPServer` starts handling requests. Regist
544
544
 
545
545
  ```ruby
546
546
  Parse::Agent::Tools.register(
547
- name: :breakdown_captures,
548
- description: "Count captures grouped by user/project/team/org with optional date window",
547
+ name: :breakdown_posts,
548
+ description: "Count posts grouped by user/project/workspace/tenant with optional date window",
549
549
  parameters: {
550
550
  type: "object",
551
551
  properties: {
552
552
  group_by: {
553
553
  type: "string",
554
- enum: ["user", "project", "team", "org"],
554
+ enum: ["user", "project", "workspace", "tenant"],
555
555
  description: "Dimension to group by"
556
556
  },
557
557
  since: {
@@ -609,7 +609,7 @@ original_call = Parse::Agent::MCPDispatcher.method(:call)
609
609
  module CustomDispatch
610
610
  def self.call(body:, agent:, logger: nil)
611
611
  if body.dig("method") == "tools/call" &&
612
- body.dig("params", "name") == "breakdown_captures"
612
+ body.dig("params", "name") == "breakdown_posts"
613
613
  # handle it here, return { status: 200, body: jsonrpc_result }
614
614
  else
615
615
  original_call.call(body: body, agent: agent, logger: logger)
@@ -961,9 +961,9 @@ support.describe_for("Ticket")
961
961
  # class_name: "Ticket",
962
962
  # accessible: :permitted,
963
963
  # agent_fields: [:subject, :status, :created_at, ...],
964
- # agent_canonical_filter: { "isDraft" => { "$ne" => true } },
964
+ # agent_canonical_filter: { "draft" => { "$ne" => true } },
965
965
  # per_agent_filter: { archived: false }, # composed: per-class AND :default
966
- # tenant_scope: { field: :org_id, value: "acme" },
966
+ # tenant_scope: { field: :tenant_id, value: "acme" },
967
967
  # large_fields: [:body_html],
968
968
  # agent_methods: ["archive", "reopen"], # tier-filtered to what this agent can call
969
969
  # }
@@ -1432,13 +1432,13 @@ Register before the `MCPRackApp` or `MCPServer` starts handling requests. Regist
1432
1432
  ```ruby
1433
1433
  Parse::Agent::Prompts.register(
1434
1434
  name: "team_health",
1435
- description: "Summary of team activity in the last 30 days",
1435
+ description: "Summary of workspace activity in the last 30 days",
1436
1436
  arguments: [
1437
- { "name" => "team_id", "description" => "Parse objectId of the team", "required" => true }
1437
+ { "name" => "team_id", "description" => "Parse objectId of the workspace", "required" => true }
1438
1438
  ],
1439
1439
  renderer: ->(args) {
1440
1440
  since = (Time.now - 30 * 86400).utc.iso8601
1441
- "Show activity for team #{args["team_id"]} since #{since}. " \
1441
+ "Show activity for workspace #{args["team_id"]} since #{since}. " \
1442
1442
  "Use count_objects and query_class to report events, members, and recent changes."
1443
1443
  }
1444
1444
  )
@@ -1453,8 +1453,8 @@ A renderer lambda may return either:
1453
1453
  # Hash form — overrides description per render
1454
1454
  renderer: ->(args) {
1455
1455
  {
1456
- description: "Team #{args["team_id"]} health report",
1457
- text: "Analyze team #{args["team_id"]} activity since #{Time.now - 30 * 86400}."
1456
+ description: "Workspace #{args["team_id"]} health report",
1457
+ text: "Analyze workspace #{args["team_id"]} activity since #{Time.now - 30 * 86400}."
1458
1458
  }
1459
1459
  }
1460
1460
  ```
@@ -1585,7 +1585,11 @@ The `tools/call` response for this tool ships with both forms:
1585
1585
 
1586
1586
  Per MCP 2025-06-18 expectations, clients should prefer `structuredContent` over parsing `content` text. The text content is unchanged from prior versions so legacy clients keep working unmodified.
1587
1587
 
1588
- **Scope.** Only tools registered via `Tools.register(..., output_schema:)` opt into structured output. Built-in tools (`query_class`, `aggregate`, `get_object`, etc.) retain text-only output for now opting them in is a follow-on item that would require declaring schemas for every existing tool. The `output_schema:` parameter is optional; tools registered without it produce the same wire shape they did in 4.1.
1588
+ **Built-in tool coverage (v5.0+).** Eleven built-in tools now declare `outputSchema` and emit `structuredContent` automatically: `count_objects`, `get_object`, `get_objects`, `get_sample_objects`, `distinct`, `group_by`, `group_by_date`, `list_tools`, `get_all_schemas`, `get_schema`, and `query_class`. The dispatcher mirrors each tool's result `data` Hash into `structuredContent` in addition to the existing text `content` array. `query_class` declares a permissive superset envelope (single `type: "object"` root, as MCP requires) that admits both the default JSON row shape (`{class_name, result_count, pagination, results, ...}`) and the `format: "csv" | "markdown" | "table"` text shape (`{class_name, format, headers, row_count, output}`) — clients disambiguate via the presence of `format`.
1589
+
1590
+ **Remaining text-only built-ins.** `aggregate`, `export_data`, `atlas_text_search`, `atlas_autocomplete`, `atlas_faceted_search`, `explain_query`, and `call_method` continue to emit text-only output. `explain_query` mirrors MongoDB's version-dependent explain shape and `call_method` returns application-defined values, so both may stay text-only indefinitely; the Atlas + aggregate tools will opt in as their envelope shapes stabilize.
1591
+
1592
+ **Custom tools.** The `output_schema:` parameter on `Tools.register` remains optional; tools registered without it produce the same text-only wire shape they did in 4.1.
1589
1593
 
1590
1594
  ### Batch pointer resolution: `get_objects`
1591
1595
 
@@ -1595,7 +1599,7 @@ When you need to dereference multiple pointers, use `get_objects(class_name:, id
1595
1599
  result = agent.execute(:get_objects,
1596
1600
  class_name: "User",
1597
1601
  ids: ["abc123", "def456", "xyz789"],
1598
- include: ["team"] # optional pointer fields to resolve
1602
+ include: ["workspace"] # optional pointer fields to resolve
1599
1603
  )
1600
1604
  # result[:data] =>
1601
1605
  # {
@@ -1632,7 +1636,7 @@ When a tool fails inside `Parse::Agent#execute`, the failure envelope returned t
1632
1636
  For `:access_denied` refusals, the envelope additionally carries a `details:` block populated from `Parse::Agent::AccessDenied#to_details`. It lets consumers branch on the specific refusal reason — and, when applicable, auto-rewrite the failing request — without parsing the prose `error:` message:
1633
1637
 
1634
1638
  ```ruby
1635
- agent.execute(:aggregate, class_name: "Capture",
1639
+ agent.execute(:aggregate, class_name: "Post",
1636
1640
  pipeline: [{ "$group" => { "_id" => "$_p_author", "n" => { "$sum" => 1 } } }]
1637
1641
  )
1638
1642
  # => {
@@ -1834,7 +1838,11 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
1834
1838
 
1835
1839
  **Conversation correlation across multi-tool sessions.** Without correlation, individual tool-call events have no link between them — a Datadog dashboard sees "user X did query_class" and "user X did get_object" as independent points, with no way to know they belong to the same LLM turn. The dispatcher threads an optional correlation id through to every notification:
1836
1840
 
1837
- - **Header path (recommended for hosted MCP):** the client sends `X-MCP-Session-Id: <opaque-id>` on every request in the conversation. `MCPRackApp` reads the header, sanitizes the value (charset `[A-Za-z0-9._-]`, max 128 chars — anything else is silently dropped to prevent log injection), and sets `agent.correlation_id` unless the factory has already supplied one. Notifications fired during that request carry the value as `payload[:correlation_id]`.
1841
+ - **Header path (recommended for hosted MCP):** the client sends `Mcp-Session-Id: <opaque-id>` on every request in the conversation (the MCP 2025-06-18 Streamable HTTP spec-canonical name). `MCPRackApp` reads the header, sanitizes the value (charset `[A-Za-z0-9._-]`, max 128 chars — anything else is silently dropped to prevent log injection), and sets `agent.correlation_id` unless the factory has already supplied one. Notifications fired during that request carry the value as `payload[:correlation_id]`.
1842
+
1843
+ **Server-assigned on `initialize`:** when the client omits the header on the `initialize` request, `MCPRackApp` generates a UUID, binds it to `agent.correlation_id`, and returns it in the `Mcp-Session-Id` response header. Clients echo that id on subsequent requests. A client-supplied `Mcp-Session-Id` on `initialize` is echoed back unchanged; a factory-bound `correlation_id` always wins over both. Only the `initialize` response carries the header — non-init responses don't, so the id is never leaked on every reply. The SDK does not maintain a server-side session store: the id is best-effort correlation only (audit threading + cancellation routing), and a subsequent request carrying an "unknown" id is NOT refused.
1844
+
1845
+ **Session termination via `DELETE /`:** a `DELETE` carrying `Mcp-Session-Id` cancels every in-flight request registered under that correlation id and returns `204 No Content`. The header value is sanitized with the same regex as the request setter; missing or invalid values return `400`. The DELETE handler runs before the agent factory, so teardown traffic cannot force per-request agent construction.
1838
1846
 
1839
1847
  - **Factory path (for application-bound sessions):** application code that already has an internal session identifier can override the client-supplied header by setting it inside the agent factory:
1840
1848
 
@@ -1853,7 +1861,7 @@ Every tool call dispatched through `Agent#execute` fires the `"parse.agent.tool_
1853
1861
 
1854
1862
  When unset (no header, no factory assignment), `payload[:correlation_id]` is omitted entirely — the key does not appear in the payload hash.
1855
1863
 
1856
- The same `X-MCP-Session-Id` header is **required** for cooperative cancellation via `notifications/cancelled` — see the Cancellation section. Clients that thread the header through every request in a conversation get both correlated audit logs and cancellation; clients that don't lose both but keep every other MCP feature.
1864
+ The same `Mcp-Session-Id` header is **required** for cooperative cancellation via `notifications/cancelled` — see the Cancellation section. Clients that thread the header through every request in a conversation get both correlated audit logs and cancellation; clients that don't lose both but keep every other MCP feature.
1857
1865
 
1858
1866
  **Cancellation notification asymmetry.** A tool cancelled BEFORE it runs (via `agent.cancelled?` at the dispatcher's first checkpoint) does not fire `parse.agent.tool_call` — the tool never executed, so there is nothing to instrument. This matches how rate-limit and permission refusals are surfaced. A tool cancelled AFTER it returns (second checkpoint, "client cancelled while the tool's I/O was running") DOES fire the notification with `success: false, error_code: :cancelled`. Subscribers that count cancellations should expect the second shape; pre-run cancellations are visible to operators only via the wire response.
1859
1867
 
@@ -2030,18 +2038,18 @@ Two additive keyword arguments (v4.2.1) narrow the response without changing the
2030
2038
 
2031
2039
  ```ruby
2032
2040
  # Pull only a known subset (exact match)
2033
- agent.execute(:get_all_schemas, names: %w[Capture Project Team])
2034
- # => { custom: [{ name: "Capture", ... }, { name: "Project", ... }, { name: "Team", ... }], ... }
2041
+ agent.execute(:get_all_schemas, names: %w[Post Project Workspace])
2042
+ # => { custom: [{ name: "Post", ... }, { name: "Project", ... }, { name: "Workspace", ... }], ... }
2035
2043
 
2036
2044
  # Pull every class whose name starts with a prefix (case-sensitive)
2037
- agent.execute(:get_all_schemas, prefix: "Capture")
2038
- # => { custom: [{ name: "Capture", ... }, { name: "CaptureRevision", ... }], ... }
2045
+ agent.execute(:get_all_schemas, prefix: "Post")
2046
+ # => { custom: [{ name: "Post", ... }, { name: "PostRevision", ... }], ... }
2039
2047
 
2040
2048
  # Compose as intersection
2041
2049
  agent.execute(:get_all_schemas,
2042
- names: %w[Capture CaptureRevision Project],
2043
- prefix: "Capture")
2044
- # => only Capture + CaptureRevision (the names that ALSO match the prefix)
2050
+ names: %w[Post PostRevision Project],
2051
+ prefix: "Post")
2052
+ # => only Post + PostRevision (the names that ALSO match the prefix)
2045
2053
  ```
2046
2054
 
2047
2055
  Both arguments default to nil (no filter, current behavior). An empty `names: []` array or empty `prefix: ""` string is also a no-op. Comparison is case-sensitive for exact match and prefix.
@@ -2091,11 +2099,11 @@ Aggregate results expose Parse pointer fields in their Parse-on-Mongo storage fo
2091
2099
  **Default-on compaction.** Every `aggregate` response is run through a compaction pass that rewrites `_p_<field>` keys to `<field>` and strips the `<ClassName>$` prefix from each value. The envelope picks up a top-level `pointer_classes:` map preserving the class information:
2092
2100
 
2093
2101
  ```ruby
2094
- agent.execute(:aggregate, class_name: "Capture",
2095
- pipeline: [{ "$match" => { "isRemoved" => { "$ne" => true } } }, { "$project" => { "_p_author" => 1 } }]
2102
+ agent.execute(:aggregate, class_name: "Post",
2103
+ pipeline: [{ "$match" => { "archived" => { "$ne" => true } } }, { "$project" => { "_p_author" => 1 } }]
2096
2104
  )
2097
2105
  # => {
2098
- # class_name: "Capture",
2106
+ # class_name: "Post",
2099
2107
  # result_count: 3,
2100
2108
  # results: [
2101
2109
  # { "objectId" => "row1", "author" => "alice1" },
@@ -2111,7 +2119,7 @@ agent.execute(:aggregate, class_name: "Capture",
2111
2119
  **Opting out.** Pass `compact_pointers: false` to receive raw Parse-on-Mongo shapes. Consumers that parse `<ClassName>$<objectId>` strings directly should either set the flag to `false` or migrate to consuming the bare objectId and the `pointer_classes` envelope map.
2112
2120
 
2113
2121
  ```ruby
2114
- agent.execute(:aggregate, class_name: "Capture",
2122
+ agent.execute(:aggregate, class_name: "Post",
2115
2123
  pipeline: [...],
2116
2124
  compact_pointers: false)
2117
2125
  # Response keys back to raw _p_author: "_User$alice1" form; no pointer_classes
@@ -2124,10 +2132,10 @@ The pipeline access-policy walker that enforces a class's `agent_fields` allowli
2124
2132
  Schema-replacing stages (`$project`, `$group`, `$bucket`, `$bucketAuto`, `$replaceRoot`, `$replaceWith`, `$facet`, `$sortByCount`, `$count`) drop the source set; downstream stages can only reference the newly-introduced fields. This unblocks the canonical "group → filter → sort → limit" pattern that previously failed because synthetic accumulator outputs (`contributor_count`, `total_sum`) were checked against the source class's `agent_fields` allowlist and refused as `:field_denied`.
2125
2133
 
2126
2134
  ```ruby
2127
- # Capture has agent_fields :only, [:objectId, :_p_author, :status]
2135
+ # Post has agent_fields :only, [:objectId, :_p_author, :status]
2128
2136
  # total_sum is NOT in agent_fields — but it's introduced by $group, so the
2129
2137
  # downstream $match/$sort can reference it without a denial.
2130
- agent.execute(:aggregate, class_name: "Capture", pipeline: [
2138
+ agent.execute(:aggregate, class_name: "Post", pipeline: [
2131
2139
  { "$group" => { "_id" => "$status",
2132
2140
  "total_sum" => { "$sum" => "$amount" } } },
2133
2141
  { "$match" => { "total_sum" => { "$gt" => 100 } } },
@@ -2151,10 +2159,10 @@ All three are `:readonly` and inherit the same access-control gates as `aggregat
2151
2159
  Group records by a field and apply an aggregation:
2152
2160
 
2153
2161
  ```ruby
2154
- agent.execute(:group_by, class_name: "Capture", field: "lastAction",
2162
+ agent.execute(:group_by, class_name: "Post", field: "lastAction",
2155
2163
  operation: "count")
2156
2164
  # => { success: true, data: {
2157
- # class_name: "Capture", field: "lastAction", operation: "count",
2165
+ # class_name: "Post", field: "lastAction", operation: "count",
2158
2166
  # group_count: 4, limit: 200,
2159
2167
  # groups: [
2160
2168
  # { key: "submitted", value: 142 },
@@ -2170,7 +2178,7 @@ agent.execute(:group_by, class_name: "Capture", field: "lastAction",
2170
2178
  **Pointer auto-detection.** When the local Parse model declares the field as `:pointer`, the handler emits `$_p_<field>` in the pipeline and strips the `<ClassName>$` prefix from the response keys, surfacing the class once in `pointer_class:`:
2171
2179
 
2172
2180
  ```ruby
2173
- agent.execute(:group_by, class_name: "Capture", field: "author")
2181
+ agent.execute(:group_by, class_name: "Post", field: "author")
2174
2182
  # => { ..., pointer_class: "_User",
2175
2183
  # groups: [{ key: "abc123", value: 47 }, { key: "def456", value: 31 }, ...] }
2176
2184
  ```
@@ -2180,7 +2188,7 @@ Call `get_objects(class_name: "_User", ids: ["abc123", "def456"])` to resolve th
2180
2188
  **Array flattening.** Pass `flatten_arrays: true` to `$unwind` the field before grouping so individual array elements are counted:
2181
2189
 
2182
2190
  ```ruby
2183
- agent.execute(:group_by, class_name: "Capture", field: "tags", flatten_arrays: true)
2191
+ agent.execute(:group_by, class_name: "Post", field: "tags", flatten_arrays: true)
2184
2192
  # Each tag is counted once per row containing it.
2185
2193
  ```
2186
2194
 
@@ -2200,11 +2208,11 @@ agent.execute(:group_by, class_name: "Order", field: "customerId",
2200
2208
  Bucket records by a date field at an interval and aggregate. Same operation set as `group_by`, plus `interval:` and `timezone:`:
2201
2209
 
2202
2210
  ```ruby
2203
- agent.execute(:group_by_date, class_name: "Capture",
2211
+ agent.execute(:group_by_date, class_name: "Post",
2204
2212
  field: "createdAt", interval: "day",
2205
2213
  timezone: "America/New_York")
2206
2214
  # => { success: true, data: {
2207
- # class_name: "Capture", field: "createdAt", interval: "day",
2215
+ # class_name: "Post", field: "createdAt", interval: "day",
2208
2216
  # operation: "count", timezone: "America/New_York", sort: "key_asc",
2209
2217
  # groups: [
2210
2218
  # { key: "2024-11-24", value: 47 },
@@ -2227,10 +2235,10 @@ agent.execute(:group_by_date, class_name: "Capture",
2227
2235
  Return the distinct values of a field, optionally filtered:
2228
2236
 
2229
2237
  ```ruby
2230
- agent.execute(:distinct, class_name: "Asset", field: "mediaFormat",
2231
- where: { "isRemoved" => { "$ne" => true } })
2238
+ agent.execute(:distinct, class_name: "Document", field: "mediaFormat",
2239
+ where: { "archived" => { "$ne" => true } })
2232
2240
  # => { success: true, data: {
2233
- # class_name: "Asset", field: "mediaFormat",
2241
+ # class_name: "Document", field: "mediaFormat",
2234
2242
  # count: 3, values: ["video", "image", "audio"]
2235
2243
  # } }
2236
2244
  ```
@@ -2238,8 +2246,8 @@ agent.execute(:distinct, class_name: "Asset", field: "mediaFormat",
2238
2246
  **Pointer fields.** When the field is a pointer, the values come back stripped of the `<ClassName>$` prefix and `pointer_class:` carries the class:
2239
2247
 
2240
2248
  ```ruby
2241
- agent.execute(:distinct, class_name: "Asset", field: "authorTeam")
2242
- # => { ..., pointer_class: "Team",
2249
+ agent.execute(:distinct, class_name: "Document", field: "authorWorkspace")
2250
+ # => { ..., pointer_class: "Workspace",
2243
2251
  # values: ["alphaTeam", "betaTeam", "gammaTeam"] }
2244
2252
  ```
2245
2253
 
@@ -2256,12 +2264,12 @@ All three tools accept `dry_run: true`, which returns the constructed MongoDB pi
2256
2264
  - Letting a power-user LLM mutate the pipeline (add a `$lookup`, change the `$sort`) before re-issuing through `aggregate`.
2257
2265
 
2258
2266
  ```ruby
2259
- agent.execute(:group_by, class_name: "Capture", field: "author",
2267
+ agent.execute(:group_by, class_name: "Post", field: "author",
2260
2268
  operation: "sum", value_field: "elapsedMs",
2261
2269
  sort: "value_desc", limit: 10, dry_run: true)
2262
2270
  # => { success: true, data: {
2263
2271
  # dry_run: true,
2264
- # class_name: "Capture",
2272
+ # class_name: "Post",
2265
2273
  # parameters: { field: "author", operation: "sum", value_field: "elapsedMs",
2266
2274
  # sort: "value_desc", limit: 10 },
2267
2275
  # pipeline: [
@@ -2467,7 +2475,7 @@ Parse::Agent.rack_app(logger: Rails.logger) { |env| ... }
2467
2475
 
2468
2476
  **Sub-agent auth-scope inheritance and permissions clamp (v4.2).** When a tool handler constructs a sub-agent with `Parse::Agent.new(parent: agent, ...)`, the sub inherits `session_token` and `tenant_id` from the parent unless explicitly overridden. Without this inheritance, a session-token parent would silently produce a master-key sub-agent — the constructor default `session_token: nil` resolves to master-key mode — escalating privilege through the very kwarg meant to close sub-agent footguns. Explicit overrides still work (`Parse::Agent.new(parent: agent, session_token: nil)` produces a master-key sub if that is genuinely what the handler wants), but the default is fail-safe inheritance. `permissions:` is NOT inherited and defaults to `:readonly`, but the constructor enforces a clamp: an explicit `permissions:` override on a sub-agent is accepted only if `≤ parent.permissions`, otherwise `ArgumentError` is raised at construction. The clamp is the structural guarantee that a delegation chain cannot escape the parent's tier through sub-agent construction. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42) for the full inheritance table.
2469
2477
 
2470
- **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction (or defers to per-call REST if the server is unreachable). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role membership via `Parse::Role.all_for_user` — no token round-trip, the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping — no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
2478
+ **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction (or defers to per-call REST if the server is unreachable). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user` — no token round-trip, the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping — no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
2471
2479
 
2472
2480
  **ACL composition on the mongo-direct aggregate path (v4.4.0).** When `aggregate` routes through `Parse::MongoDB.aggregate` (the default when `Parse::MongoDB.enabled?` is true), the agent layer derives the auth posture from the agent instance and forwards it to ACLScope — session-tokened / acl_user / acl_role agents get the same row-level `_rperm` `$match` injection regardless of identity mode; master-key agents pass `master: true` (the agent's class/field/tenant/canonical-filter gates are the security boundary for that posture). The posture is built in `Parse::Agent#acl_scope_kwargs`, not from tool-call JSON arguments; LLM-supplied `master:`, `session_token:`, `acl_user:`, or `acl_role:` kwargs are silently swallowed by the tool signature's `**_kwargs` catchall and never reach `Parse::MongoDB.aggregate`. An LLM cannot escalate from a scoped posture to master-key by injecting `master: true` into the tool arguments.
2473
2481
 
@@ -2483,6 +2491,119 @@ The corollary: a session-tokened or `acl_user`-scoped agent calling `aggregate`
2483
2491
 
2484
2492
  ---
2485
2493
 
2494
+ ## Client Mode — Session-Token-Only Agents (v5.0)
2495
+
2496
+ `Parse::Agent` automatically enters *client mode* when its underlying `Parse::Client` carries **no `master_key`** AND the constructor was given a **non-empty `session_token:`**. Client mode is a *posture*, not a separate class — the same `Parse::Agent` instance answers `agent.client_mode? => true` and applies a tighter dispatch ceiling to itself. The two complementary postures:
2497
+
2498
+ | Posture | `master_key` configured? | `session_token:` supplied? | Dispatch surface | ACL/CLP enforced by |
2499
+ |---------------|:------------------------:|:--------------------------:|------------------|---------------------|
2500
+ | **client mode** | no | yes (required) | session-token REST allowlist | Parse Server (native) |
2501
+ | **master-key** | yes | optional (scopes if set) | full tool catalog | SDK (`ACLScope` + `CLPScope` on mongo-direct) |
2502
+
2503
+ A `Parse::Client` with no `master_key` AND no `session_token:` is **not** client mode — it is the legacy no-master construction that still emits the `[Parse::Agent:SECURITY]` banner. It's preserved for back-compat with test harnesses that drive the SDK without auth.
2504
+
2505
+ ### Detection and surface ceiling
2506
+
2507
+ ```ruby
2508
+ Parse.setup(server_url: "...", application_id: "...", api_key: "...") # no master_key
2509
+ agent = Parse::Agent.new(session_token: user.session_token)
2510
+
2511
+ agent.client_mode? # => true
2512
+ agent.allow_mutations? # => false (default in client mode)
2513
+ ```
2514
+
2515
+ The client-mode dispatch ceiling is a small allowlist; every other built-in tool is refused at the boundary:
2516
+
2517
+ - **Read tools (always allowed):** `list_tools`, `get_object`, `get_objects`, `query_class`, `count_objects`, `get_sample_objects`
2518
+ - **Mutation tools (gated by `allow_mutations:`):** `create_object`, `update_object`, `delete_object`
2519
+ - **Refused at the ceiling:** `aggregate`, `atlas_text_search`, `atlas_autocomplete`, `atlas_faceted_search`, `find_similar`, `group_by`, `group_by_date`, `distinct`, `explain_query`, `export_data`, `get_all_schemas`, `get_schema`, `create_class`, `delete_class`, `call_method`, and every registered custom tool whose `register(...)` call did not pass `client_safe: true`.
2520
+
2521
+ The refused tools all require either the application master key (REST `/aggregate`, `/schemas`) or a direct MongoDB connection (atlas-search, mongo-direct queries) — neither of which a client-mode agent has. Refusing at the dispatch ceiling rather than at first REST call gives the LLM an immediate `:access_denied` error envelope it can recover from, instead of a 403 from Parse Server somewhere downstream.
2522
+
2523
+ ### `allow_mutations:` — per-agent write gate
2524
+
2525
+ ```ruby
2526
+ # Default: client-mode agents are read-only
2527
+ reader = Parse::Agent.new(session_token: user.session_token)
2528
+ reader.execute(:create_object, class_name: "Post", fields: { title: "x" })
2529
+ # => { success: false, error_code: :access_denied,
2530
+ # error: "Raw mutation tool 'create_object' is disabled. Pass allow_mutations: true to enable." }
2531
+
2532
+ # Opt in per agent
2533
+ writer = Parse::Agent.new(session_token: user.session_token, allow_mutations: true)
2534
+ writer.execute(:create_object, class_name: "Post", fields: { title: "x" }) # → posts to /classes/Post with the session token
2535
+ ```
2536
+
2537
+ The gate AND-composes with the existing `PARSE_AGENT_ALLOW_WRITE_TOOLS` and `PARSE_AGENT_ALLOW_RAW_CRUD` env vars — both env vars and `allow_mutations: true` must agree before `create_object` / `update_object` / `delete_object` dispatch. In master-key mode `allow_mutations:` defaults to `true` so existing master-key agents continue to use the env vars alone (back-compat). Explicit `allow_mutations: false` on a master-key agent disables raw CRUD for that agent even when the env vars are set.
2538
+
2539
+ ### `acl_user:` / `acl_role:` are refused on no-master clients
2540
+
2541
+ ```ruby
2542
+ Parse::Agent.new(acl_user: some_user_pointer)
2543
+ # => ArgumentError: acl_user:/acl_role: require a Parse::Client with a master_key
2544
+ # configured. The current client has no master_key. Use session_token: to bind
2545
+ # a per-user identity instead, or configure a master-key client for scoped
2546
+ # aggregations.
2547
+ ```
2548
+
2549
+ Both `acl_user:` and `acl_role:` are SDK-side constructor assertions — the SDK *asserts* "act as this user" or "act as this role" and then enforces the resulting `_rperm` filter itself, on a mongo-direct query path. Without a master key the SDK cannot reach that path, and Parse Server's REST surface has no "act as user-pointer" or "act as role" affordance, so honoring them would silently downgrade to anonymous. The constructor fails fast and points the caller at `session_token:` (the only verified identity model available to a no-master client).
2550
+
2551
+ ### `client_safe:` — eligibility flag for custom tools
2552
+
2553
+ ```ruby
2554
+ Parse::Agent::Tools.register(
2555
+ name: :my_read_helper,
2556
+ description: "Compute something from session-scoped data",
2557
+ parameters: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
2558
+ permission: :readonly,
2559
+ client_safe: true, # opt-in; default is false (master-key only)
2560
+ handler: ->(args, agent:) {
2561
+ # IMPORTANT: thread agent.request_opts (NOT just session_token:) so the
2562
+ # request also carries use_master_key: false. In a deployment where the
2563
+ # process-default Parse::Client carries a master key, omitting
2564
+ # use_master_key: false here would silently escalate to master-key
2565
+ # posture and bypass Parse Server's session-token authorization.
2566
+ agent.client.fetch_object("MyClass", args[:id], **agent.request_opts)
2567
+ },
2568
+ )
2569
+ ```
2570
+
2571
+ Custom tools default to master-key-only — a registered tool is refused at the client-mode dispatch ceiling unless its author explicitly declared `client_safe: true`. The flag is an eligibility assertion from the tool author: *"this handler does not touch the master key, does not call mongo-direct aggregates, and is safe for a session-token-only agent."* The companion predicate `Parse::Agent::Tools.client_safe?(name)` reports the resolved eligibility of any built-in or registered tool.
2572
+
2573
+ **Canonical handler pattern: `**agent.request_opts`.** Always splat `agent.request_opts` into the underlying `Parse::Client` call rather than threading `session_token: agent.session_token` alone. `request_opts` sets both `session_token:` and `use_master_key: false` (and raises `Parse::ACLScope::ACLRequired` for scoped postures that REST cannot honor). The `session_token:`-alone pattern works only when the process has no master key configured anywhere — the safer pattern works in every deployment.
2574
+
2575
+ ### Sub-agent inheritance
2576
+
2577
+ ```ruby
2578
+ parent = Parse::Agent.new(session_token: user.session_token, allow_mutations: true)
2579
+ child = Parse::Agent.new(parent: parent)
2580
+ child.client_mode? # => true (inherits the parent's client + session_token)
2581
+ child.allow_mutations? # => true (inherits the parent's gate)
2582
+
2583
+ narrower = Parse::Agent.new(parent: parent, allow_mutations: false)
2584
+ narrower.allow_mutations? # => false (sub may narrow)
2585
+
2586
+ Parse::Agent.new(parent: reader_without_mutations, allow_mutations: true)
2587
+ # => ArgumentError: sub-agent cannot widen parent's allow_mutations gate
2588
+ ```
2589
+
2590
+ The `allow_mutations:` gate composes with the existing sub-agent subset rules (`permissions:` clamp, `tools:` narrowing, `classes:` allowlist intersection) — a sub-agent may narrow but never widen, including the mutation gate.
2591
+
2592
+ ### Refusal message shape (operator-distinguishable)
2593
+
2594
+ Four different refusal reasons each produce a distinct `:error_code` and message shape so SOC tooling can branch on them without parsing prose. Messages below are paraphrased for table width — the actual messages in `lib/parse/agent.rb` are longer; the column shows the opening clause and key tokens.
2595
+
2596
+ | Refusal | Opening clause / key token | `:error_code` | Carries class name? |
2597
+ |---------|-----------------------------|---------------|----------------------|
2598
+ | Operator `tools:` filter | `"Tool 'X' is not enabled for this agent instance (excluded by the configured tools: filter)."` | `:tool_filtered` | No |
2599
+ | Mutation gate | `"Raw mutation tool 'create_object' is disabled for this client-mode agent. Construct the agent with allow_mutations: true …"` | `:access_denied` | No |
2600
+ | Mode ceiling | `"Tool 'aggregate' is not available to client-mode agents. …"` | `:access_denied` | No |
2601
+ | `agent_hidden` class | `"Class 'StudentSSN' is not accessible to this agent"` | `:access_denied` | Yes (the class name in the request) |
2602
+
2603
+ **Resolution order at dispatch:** operator filter ▷ mutation gate ▷ mode ceiling ▷ in-tool class gate. Operator-filter precedence is deliberate — when a tool is excluded by both the operator's `tools: { except: [...] }` AND the mutation gate (or the mode ceiling), the operator-filter message wins so the operator looks at the right knob first. The mode-ceiling message names the tool, not the class — even when the request would have hit an `agent_hidden` class, the ceiling fires first for a refused tool, so the LLM does not learn anything about the class. For tools that pass the ceiling (e.g. `query_class`) the in-tool `assert_class_accessible!` runs next and the `agent_hidden` message echoes the class name supplied by the caller.
2604
+
2605
+ ---
2606
+
2486
2607
  ## `agent_hidden` — Per-Class Agent-Surface Denial
2487
2608
 
2488
2609
  `agent_hidden` is a model-level DSL declaration that blocks all agent access to a Parse class. It is the strongest access-restriction primitive in the DSL — stronger than `agent_fields` (which trims visible fields) and unrelated to `agent_visible` (which is an opt-in filter for the relation diagram, not an access restriction).
@@ -2679,18 +2800,18 @@ Two options on `property` carry per-field metadata to an LLM through `get_schema
2679
2800
  ### Declaration
2680
2801
 
2681
2802
  ```ruby
2682
- class Membership < Parse::Object
2683
- parse_class "Membership"
2803
+ class Subscription < Parse::Object
2804
+ parse_class "Subscription"
2684
2805
 
2685
2806
  property :title, :string,
2686
- _description: "Display title for this membership grant"
2807
+ _description: "Display title for this subscription grant"
2687
2808
 
2688
2809
  property :grant, :string,
2689
- _description: "Scope of the membership grant",
2810
+ _description: "Scope of the subscription grant",
2690
2811
  _enum: {
2691
- team: "Member of a team within the org",
2692
- project: "Member of a single project under a team",
2693
- organization: "Member of the org as a whole",
2812
+ workspace: "Member of a workspace within the tenant",
2813
+ project: "Member of a single project under a workspace",
2814
+ tenant: "Member of the tenant as a whole",
2694
2815
  }
2695
2816
 
2696
2817
  property :account_level, :string,
@@ -2702,27 +2823,27 @@ class Membership < Parse::Object
2702
2823
  end
2703
2824
  ```
2704
2825
 
2705
- `_description:` takes a single string. `_enum:` takes a Hash mapping each allowed value (Symbol or String) to a per-value description. Value keys are stringified at declaration time to match the wire-format shape an LLM will see in query constraints (the schema always reports `value: "team"`, never `value: :team`).
2826
+ `_description:` takes a single string. `_enum:` takes a Hash mapping each allowed value (Symbol or String) to a per-value description. Value keys are stringified at declaration time to match the wire-format shape an LLM will see in query constraints (the schema always reports `value: "workspace"`, never `value: :workspace`).
2706
2827
 
2707
2828
  ### Surface in `get_schema`
2708
2829
 
2709
2830
  Both annotations show up per-field in the `fields[]` array:
2710
2831
 
2711
2832
  ```ruby
2712
- agent.execute(:get_schema, class_name: "Membership")
2833
+ agent.execute(:get_schema, class_name: "Subscription")
2713
2834
  # => {
2714
2835
  # success: true,
2715
2836
  # data: {
2716
- # class_name: "Membership",
2837
+ # class_name: "Subscription",
2717
2838
  # fields: [
2718
2839
  # { name: "title", type: "string", required: false,
2719
- # description: "Display title for this membership grant" },
2840
+ # description: "Display title for this subscription grant" },
2720
2841
  # { name: "grant", type: "string", required: false,
2721
- # description: "Scope of the membership grant",
2842
+ # description: "Scope of the subscription grant",
2722
2843
  # allowed_values: [
2723
- # { "value" => "team", "description" => "Member of a team within the org" },
2724
- # { "value" => "project", "description" => "Member of a single project under a team" },
2725
- # { "value" => "organization", "description" => "Member of the org as a whole" }
2844
+ # { "value" => "workspace", "description" => "Member of a workspace within the tenant" },
2845
+ # { "value" => "project", "description" => "Member of a single project under a workspace" },
2846
+ # { "value" => "tenant", "description" => "Member of the tenant as a whole" }
2726
2847
  # ] },
2727
2848
  # { name: "accountLevel", type: "string", required: false,
2728
2849
  # allowed_values: [...] },
@@ -2778,11 +2899,11 @@ The gem doesn't raise on the declaration — keeping `_enum:` on string-typed pr
2778
2899
  Pointer columns are stored on disk as `"ClassName$objectId"`. A `where:` constraint that passes a bare objectId without the surrounding Pointer shape matches nothing, and an LLM seeing `type: "Pointer"` alone has no signal about which value shapes are accepted. The schema formatter auto-emits a `query_hint:` on every Pointer field describing the SDK-accepted shapes inline, so the LLM doesn't have to query a sample row or guess.
2779
2900
 
2780
2901
  ```ruby
2781
- agent.execute(:get_schema, class_name: "Capture")
2902
+ agent.execute(:get_schema, class_name: "Post")
2782
2903
  # => {
2783
2904
  # success: true,
2784
2905
  # data: {
2785
- # class_name: "Capture",
2906
+ # class_name: "Post",
2786
2907
  # fields: [
2787
2908
  # { name: "author", type: "Pointer", required: true,
2788
2909
  # target_class: "_User",
@@ -2800,7 +2921,7 @@ agent.execute(:get_schema, class_name: "Capture")
2800
2921
  **Hidden-target collapse.** When the target class is registered as `agent_hidden` (the LLM is not allowed to know it exists), `target_class:` is suppressed and `query_hint:` collapses the class name to a `<targetClass>` placeholder so the hint still describes the shape without leaking the target's identity:
2801
2922
 
2802
2923
  ```ruby
2803
- # Membership.belongs_to :user, class_name: "_User"
2924
+ # Subscription.belongs_to :user, class_name: "_User"
2804
2925
  # and _User is agent_hidden in this agent's posture
2805
2926
  # => { name: "user", type: "Pointer",
2806
2927
  # query_hint: 'Pointer to <targetClass>. Equality: { "user" => "<objectId>" } ' \
@@ -2819,7 +2940,7 @@ The hint mirrors the shapes the SDK actually normalizes through `convert_constra
2819
2940
 
2820
2941
  ### The bug it fixes
2821
2942
 
2822
- The reported reproducer: a `query_class(class_name: "Membership", keys: ["user", "title", "active", "createdAt"], include: ["user"])` against a 6-row Membership query. The included `_User` records carried full S3 presigned image URLs (~600 chars each on two columns), a 17-entry `teams[]` pointer array, an `organizations[]` array, and 13 other fields per row. The user objects accounted for ~85% of the response payload, while the LLM only ever consumed `firstName`/`lastName`/`email`/`lastActiveAt`/`internalTag` — maybe 5% of the materialized user.
2943
+ The reported reproducer: a `query_class(class_name: "Subscription", keys: ["user", "title", "active", "createdAt"], include: ["user"])` against a 6-row Subscription query. The included `_User` records carried full S3 presigned image URLs (~600 chars each on two columns), a 17-entry `workspaces[]` pointer array, an `tenants[]` array, and 13 other fields per row. The user objects accounted for ~85% of the response payload, while the LLM only ever consumed `firstName`/`lastName`/`email`/`lastActiveAt`/`category` — maybe 5% of the materialized user.
2823
2944
 
2824
2945
  `keys:` on the parent class trimmed the parent rows correctly, but Parse Server returned the included user untouched because no dotted-path projection was specified for the join. `agent_join_fields` is the developer-friendly way to declare the projection once at the model layer instead of per-call.
2825
2946
 
@@ -2830,7 +2951,7 @@ class Parse::User
2830
2951
  # Direct-query allowlist — the upper bound on what an agent ever sees
2831
2952
  # from _User on a `query_class("_User", ...)` call.
2832
2953
  agent_fields :first_name, :last_name, :email, :icon_image, :source_image,
2833
- :teams, :organizations, :last_active_at, :internal_tag
2954
+ :workspaces, :tenants, :last_active_at, :category
2834
2955
 
2835
2956
  # Heavy fields — stripped from any join even without an agent_join_fields
2836
2957
  # declaration (see "Resolution order" below).
@@ -2839,7 +2960,7 @@ class Parse::User
2839
2960
  # Narrower projection used when _User shows up as a join target. The agent
2840
2961
  # gets these fields automatically when another class's query includes :user
2841
2962
  # — no per-call dotted-path keys needed.
2842
- agent_join_fields :first_name, :last_name, :email, :last_active_at, :internal_tag
2963
+ agent_join_fields :first_name, :last_name, :email, :last_active_at, :category
2843
2964
  end
2844
2965
  ```
2845
2966
 
@@ -2883,14 +3004,14 @@ Pass any `<pointer>.*` dotted path in `keys:` and auto-projection is suppressed
2883
3004
  ```ruby
2884
3005
  # Auto-projection fires (bare pointer in keys + include)
2885
3006
  agent.execute(:query_class,
2886
- class_name: "Membership",
3007
+ class_name: "Subscription",
2887
3008
  keys: ["user", "title"],
2888
3009
  include: ["user"])
2889
- # => wire keys: "user,title,user.firstName,user.lastName,user.email,user.internalTag,user.objectId,user.createdAt,user.updatedAt"
3010
+ # => wire keys: "user,title,user.firstName,user.lastName,user.email,user.category,user.objectId,user.createdAt,user.updatedAt"
2890
3011
 
2891
3012
  # Auto-projection SUPPRESSED (caller passed user.* dotted path)
2892
3013
  agent.execute(:query_class,
2893
- class_name: "Membership",
3014
+ class_name: "Subscription",
2894
3015
  keys: ["user.iconImage", "title"],
2895
3016
  include: ["user"])
2896
3017
  # => wire keys: "user.iconImage,title" (no auto-expansion)
@@ -2900,7 +3021,7 @@ The auto-projection also doesn't fire when:
2900
3021
 
2901
3022
  - `keys:` is absent entirely (caller chose full-row mode).
2902
3023
  - The bare pointer name is NOT in `keys:` (caller didn't ask for the pointer at the parent level either — Parse Server wouldn't return it).
2903
- - The include is multi-hop (`include: ["user.team"]`) — only one-hop targets get auto-projected; deeper hops materialize fully. Keeps the rewrite bounded and avoids walking the full RelationGraph at query time.
3024
+ - The include is multi-hop (`include: ["user.workspace"]`) — only one-hop targets get auto-projected; deeper hops materialize fully. Keeps the rewrite bounded and avoids walking the full RelationGraph at query time.
2904
3025
 
2905
3026
  ### Response envelope: `truncated_include_fields`
2906
3027
 
@@ -2908,16 +3029,16 @@ When auto-projection fires, `query_class`, `get_object`, and `get_objects` add a
2908
3029
 
2909
3030
  ```ruby
2910
3031
  agent.execute(:query_class,
2911
- class_name: "Membership",
3032
+ class_name: "Subscription",
2912
3033
  keys: ["user", "title", "active"],
2913
3034
  include: ["user"],
2914
3035
  limit: 10)
2915
3036
  # => {
2916
- # class_name: "Membership",
3037
+ # class_name: "Subscription",
2917
3038
  # result_count: 10,
2918
3039
  # results: [...],
2919
3040
  # truncated_include_fields: {
2920
- # "user" => ["iconImage", "sourceImage", "teams", "organizations"]
3041
+ # "user" => ["iconImage", "sourceImage", "workspaces", "tenants"]
2921
3042
  # }
2922
3043
  # }
2923
3044
  ```
@@ -2939,11 +3060,11 @@ If the join-relevant fields ARE the same as the direct-query fields (common for
2939
3060
  Both `agent_fields` and `agent_join_fields` are echoed as top-level keys on the `get_schema` response when declared. The allowlist is already enforced by stripping non-allowed fields from the response, but enforcement-by-omission left consumers guessing what they could write in `keys:` — the explicit echo closes that gap:
2940
3061
 
2941
3062
  ```ruby
2942
- agent.execute(:get_schema, class_name: "Membership")
3063
+ agent.execute(:get_schema, class_name: "Subscription")
2943
3064
  # => {
2944
3065
  # success: true,
2945
3066
  # data: {
2946
- # class_name: "Membership",
3067
+ # class_name: "Subscription",
2947
3068
  # type: "custom",
2948
3069
  # fields: [...], # already trimmed to the allowlist
2949
3070
  # agent_fields: ["user", "title", "active", "grant", "accountLevel"],
@@ -3035,13 +3156,13 @@ class Order < Parse::Object
3035
3156
  property :total, :float
3036
3157
  property :status, :string
3037
3158
 
3038
- # Every read tool now filters by org_id = agent.tenant_id automatically.
3039
- agent_tenant_scope :org_id, from: ->(agent) { agent.tenant_id }
3159
+ # Every read tool now filters by tenant_id = agent.tenant_id automatically.
3160
+ agent_tenant_scope :tenant_id, from: ->(agent) { agent.tenant_id }
3040
3161
  end
3041
3162
  ```
3042
3163
 
3043
3164
  Two arguments:
3044
- - `field` (Symbol or String) — the Parse field to scope on (e.g., `:org_id`, `:account_id`, `:tenant`).
3165
+ - `field` (Symbol or String) — the Parse field to scope on (e.g., `:tenant_id`, `:account_id`, `:workspace_id`).
3045
3166
  - `from:` (Proc / lambda) — a callable receiving the agent instance and returning the scope value to filter by. Return `nil` to signal "this agent has no tenant binding" — the call is then refused unless a bypass declaration covers the agent.
3046
3167
 
3047
3168
  ### Setting the agent's tenant binding
@@ -3110,22 +3231,22 @@ The proper fix (recursive scope injection into sub-pipelines) is tracked as a fo
3110
3231
 
3111
3232
  ## `agent_canonical_filter` — Per-Class "Valid State" Predicate
3112
3233
 
3113
- Many Parse classes have a "live records" subset that every legitimate read should respect — soft-delete columns (`isRemoved`), publication flags (`onTimeline`), validity windows, tombstone markers, etc. Without a mechanism that codifies this subset, an LLM that drops to raw `aggregate` for a question `query_class` couldn't answer will silently include rows the application would have hidden, producing counts that disagree with the rest of the system.
3234
+ Many Parse classes have a "live records" subset that every legitimate read should respect — soft-delete columns (`archived`), publication flags (`published`), validity windows, tombstone markers, etc. Without a mechanism that codifies this subset, an LLM that drops to raw `aggregate` for a question `query_class` couldn't answer will silently include rows the application would have hidden, producing counts that disagree with the rest of the system.
3114
3235
 
3115
3236
  `agent_canonical_filter` declares the predicate ONCE on the model class. Every read tool the agent exposes applies it BY DEFAULT to every call, and `get_schema` surfaces it so callers that opt out can reproduce the predicate manually.
3116
3237
 
3117
3238
  ### Declaration
3118
3239
 
3119
3240
  ```ruby
3120
- class Capture < Parse::Object
3241
+ class Post < Parse::Object
3121
3242
  property :title, :string
3122
- property :isRemoved, :boolean
3123
- property :onTimeline, :boolean
3243
+ property :archived, :boolean
3244
+ property :published, :boolean
3124
3245
 
3125
3246
  # MongoDB-style match expression. Same shape that query_class's `where:`
3126
3247
  # accepts. Keys are stringified at declaration time.
3127
- agent_canonical_filter "isRemoved" => { "$ne" => true },
3128
- "onTimeline" => true
3248
+ agent_canonical_filter "archived" => { "$ne" => true },
3249
+ "published" => true
3129
3250
  end
3130
3251
  ```
3131
3252
 
@@ -3142,13 +3263,13 @@ The canonical filter is applied across every read surface the agent exposes:
3142
3263
  - **`get_sample_objects`** — included in the sample's effective `where:` so sample rows are drawn from the same subset as a normal query.
3143
3264
  - **`export_via_query`** and **`export_via_aggregate`** (the two backends behind `export_data`) — applied so an export is never a path to soft-deleted or otherwise excluded rows that the conversational tools hide.
3144
3265
 
3145
- ID-based reads (`get_object`, `get_objects`) intentionally do NOT apply the canonical filter. The caller named a specific objectId and is asking for that exact row; redacting it because it failed a `isRemoved => { "$ne" => true }` predicate would surprise legitimate callers fetching a soft-deleted record by ID for audit or restoration. Hidden-class refusal still applies — `agent_hidden` is the access boundary; `agent_canonical_filter` is a default predicate.
3266
+ ID-based reads (`get_object`, `get_objects`) intentionally do NOT apply the canonical filter. The caller named a specific objectId and is asking for that exact row; redacting it because it failed a `archived => { "$ne" => true }` predicate would surprise legitimate callers fetching a soft-deleted record by ID for audit or restoration. Hidden-class refusal still applies — `agent_hidden` is the access boundary; `agent_canonical_filter` is a default predicate.
3146
3267
 
3147
3268
  ### Per-call opt-out
3148
3269
 
3149
3270
  ```ruby
3150
- # Count all captures, including soft-deleted ones
3151
- agent.execute(:count_objects, class_name: "Capture",
3271
+ # Count all posts, including soft-deleted ones
3272
+ agent.execute(:count_objects, class_name: "Post",
3152
3273
  apply_canonical_filter: false)
3153
3274
  ```
3154
3275
 
@@ -3159,14 +3280,14 @@ agent.execute(:count_objects, class_name: "Capture",
3159
3280
  When a class declares `agent_canonical_filter`, `get_schema(class_name)` surfaces it as `canonical_filter:` so a caller that opts out can reproduce the predicate in its own `where:`:
3160
3281
 
3161
3282
  ```ruby
3162
- agent.execute(:get_schema, class_name: "Capture")
3283
+ agent.execute(:get_schema, class_name: "Post")
3163
3284
  # => {
3164
3285
  # success: true,
3165
3286
  # data: {
3166
- # class_name: "Capture",
3287
+ # class_name: "Post",
3167
3288
  # type: "custom",
3168
3289
  # fields: [...],
3169
- # canonical_filter: { "isRemoved" => { "$ne" => true }, "onTimeline" => true },
3290
+ # canonical_filter: { "archived" => { "$ne" => true }, "published" => true },
3170
3291
  # ...
3171
3292
  # }
3172
3293
  # }
@@ -3175,8 +3296,8 @@ agent.execute(:get_schema, class_name: "Capture")
3175
3296
  ### Programmatic lookup
3176
3297
 
3177
3298
  ```ruby
3178
- Parse::Agent::MetadataRegistry.canonical_filter("Capture")
3179
- # => { "isRemoved" => { "$ne" => true }, "onTimeline" => true }
3299
+ Parse::Agent::MetadataRegistry.canonical_filter("Post")
3300
+ # => { "archived" => { "$ne" => true }, "published" => true }
3180
3301
  Parse::Agent::MetadataRegistry.canonical_filter("ClassWithoutFilter")
3181
3302
  # => nil
3182
3303
  ```
@@ -3397,16 +3518,16 @@ audit = Parse::Agent.audit_metadata
3397
3518
  # => {
3398
3519
  # classes_audited: 28,
3399
3520
  # visible_classes_declared: true, # opt-in mode vs back-compat fallback
3400
- # missing_class_descriptions: ["ProjectUsage", "CaptureSnapshot"],
3521
+ # missing_class_descriptions: ["PostMetric", "PostSnapshot"],
3401
3522
  # missing_field_descriptions: {
3402
- # "Capture" => [:internal_tag, :base_status, ...],
3403
- # "Membership" => [:grant, :active]
3523
+ # "Post" => [:category, :status, ...],
3524
+ # "Subscription" => [:grant, :active]
3404
3525
  # },
3405
3526
  # unresolvable_allowlist_entries: {
3406
- # "ProjectStage" => [:statys] # likely typo of :status
3527
+ # "PostStatus" => [:statys] # likely typo of :status
3407
3528
  # },
3408
3529
  # canonical_filter_summary: {
3409
- # "Capture" => { "isRemoved" => { "$ne" => true }, "onTimeline" => true }
3530
+ # "Post" => { "archived" => { "$ne" => true }, "published" => true }
3410
3531
  # }
3411
3532
  # }
3412
3533
 
@@ -3437,20 +3558,20 @@ Parse::Agent::MetadataAudit.print_summary
3437
3558
  # Classes audited: 28 (agent_visible mode)
3438
3559
  #
3439
3560
  # Missing class descriptions (2):
3440
- # - ProjectUsage
3441
- # - CaptureSnapshot
3561
+ # - PostMetric
3562
+ # - PostSnapshot
3442
3563
  #
3443
3564
  # Missing field descriptions (7 across 2 classes):
3444
- # Capture (5):
3445
- # internal_tag, base_status, is_removed, on_timeline, author
3446
- # Membership (2):
3565
+ # Post (5):
3566
+ # category, status, archived, published, author
3567
+ # Subscription (2):
3447
3568
  # grant, active
3448
3569
  #
3449
3570
  # Unresolvable allowlist entries:
3450
- # ProjectStage: statys
3571
+ # PostStatus: statys
3451
3572
  #
3452
3573
  # Canonical filters declared (1):
3453
- # Capture: {"isRemoved" => {"$ne" => true}, "onTimeline" => true}
3574
+ # Post: {"archived" => {"$ne" => true}, "published" => true}
3454
3575
  ```
3455
3576
 
3456
3577
  `print_summary` writes to `$stdout` by default; pass `io:` to redirect. Returns the same hash that `audit_metadata` returns, so a Rake task can both display and process the findings in one call.