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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.env.sample +17 -3
- data/.github/workflows/codeql.yml +44 -0
- data/.github/workflows/docs.yml +39 -0
- data/.github/workflows/release.yml +32 -0
- data/.github/workflows/ruby.yml +8 -6
- data/.gitignore +4 -0
- data/.vscode/settings.json +3 -0
- data/CHANGELOG.md +305 -72
- data/Gemfile.lock +10 -3
- data/LICENSE.txt +1 -1
- data/README.md +190 -219
- data/Rakefile +1 -1
- data/SECURITY.md +30 -0
- data/assets/parse-stack-next-avatar.png +0 -0
- data/assets/parse-stack-next-avatar.svg +37 -0
- data/assets/parse-stack-next-banner.png +0 -0
- data/assets/parse-stack-next-banner.svg +45 -0
- data/assets/parse-stack-next-social-preview.png +0 -0
- data/docs/atlas_vector_search_guide.md +511 -0
- data/docs/client_sdk_guide.md +1320 -0
- data/docs/mcp_guide.md +225 -104
- data/docs/mongodb_direct_guide.md +21 -4
- data/docs/usage_guide.md +585 -0
- data/examples/transaction_example.rb +28 -28
- data/lib/parse/acl_scope.rb +2 -2
- data/lib/parse/agent/mcp_rack_app.rb +184 -16
- data/lib/parse/agent/metadata_dsl.rb +16 -16
- data/lib/parse/agent/pipeline_validator.rb +28 -1
- data/lib/parse/agent/prompts.rb +5 -5
- data/lib/parse/agent/tools.rb +287 -14
- data/lib/parse/agent.rb +209 -12
- data/lib/parse/api/analytics.rb +27 -5
- data/lib/parse/api/files.rb +6 -2
- data/lib/parse/api/push.rb +21 -4
- data/lib/parse/api/server.rb +59 -0
- data/lib/parse/api/users.rb +26 -2
- data/lib/parse/atlas_search/index_manager.rb +84 -0
- data/lib/parse/atlas_search.rb +37 -9
- data/lib/parse/cache/pool.rb +88 -0
- data/lib/parse/cache/redis.rb +249 -0
- data/lib/parse/client/body_builder.rb +94 -0
- data/lib/parse/client/caching.rb +109 -9
- data/lib/parse/client/response.rb +27 -0
- data/lib/parse/client.rb +74 -3
- data/lib/parse/console.rb +203 -0
- data/lib/parse/embeddings/cohere.rb +484 -0
- data/lib/parse/embeddings/fixture.rb +130 -0
- data/lib/parse/embeddings/jina.rb +454 -0
- data/lib/parse/embeddings/local_http.rb +492 -0
- data/lib/parse/embeddings/openai.rb +520 -0
- data/lib/parse/embeddings/provider.rb +264 -0
- data/lib/parse/embeddings/qwen.rb +431 -0
- data/lib/parse/embeddings/voyage.rb +550 -0
- data/lib/parse/embeddings.rb +225 -0
- data/lib/parse/graphql/scalars.rb +53 -0
- data/lib/parse/graphql/type_generator.rb +264 -0
- data/lib/parse/graphql.rb +48 -0
- data/lib/parse/live_query/client.rb +24 -5
- data/lib/parse/live_query/subscription.rb +17 -6
- data/lib/parse/live_query.rb +9 -4
- data/lib/parse/model/associations/collection_proxy.rb +2 -2
- data/lib/parse/model/associations/has_many.rb +32 -1
- data/lib/parse/model/associations/has_one.rb +17 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +3 -3
- data/lib/parse/model/classes/user.rb +307 -11
- data/lib/parse/model/clp.rb +1 -1
- data/lib/parse/model/core/create_lock.rb +14 -2
- data/lib/parse/model/core/embed_managed.rb +296 -0
- data/lib/parse/model/core/fetching.rb +4 -4
- data/lib/parse/model/core/indexing.rb +53 -14
- data/lib/parse/model/core/parse_reference.rb +3 -3
- data/lib/parse/model/core/properties.rb +70 -1
- data/lib/parse/model/core/querying.rb +57 -1
- data/lib/parse/model/core/vector_searchable.rb +285 -0
- data/lib/parse/model/file.rb +16 -4
- data/lib/parse/model/model.rb +26 -10
- data/lib/parse/model/object.rb +63 -6
- data/lib/parse/model/pointer.rb +16 -2
- data/lib/parse/model/shortnames.rb +2 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +3 -3
- data/lib/parse/model/vector.rb +102 -0
- data/lib/parse/mongodb.rb +90 -8
- data/lib/parse/pipeline_security.rb +59 -2
- data/lib/parse/query/constraints.rb +16 -14
- data/lib/parse/query/ordering.rb +1 -1
- data/lib/parse/query.rb +137 -64
- data/lib/parse/stack/generators/templates/model.erb +2 -2
- data/lib/parse/stack/generators/templates/model_installation.rb +1 -1
- data/lib/parse/stack/generators/templates/model_role.rb +1 -1
- data/lib/parse/stack/generators/templates/model_session.rb +1 -1
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/generators/templates/webhooks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +375 -73
- data/lib/parse/two_factor_auth/user_extension.rb +5 -2
- data/lib/parse/vector_search.rb +341 -0
- data/parse-stack-next.gemspec +10 -9
- data/scripts/docker/docker-compose.test.yml +18 -0
- data/scripts/start-parse.sh +6 -0
- data/scripts/vector_prototype/create_vector_index.js +105 -0
- data/scripts/vector_prototype/fetch_embeddings.py +241 -0
- data/scripts/vector_prototype/fixture_manifest.json +9 -0
- data/scripts/vector_prototype/query_prototype.rb +84 -0
- data/scripts/vector_prototype/run.sh +34 -0
- metadata +77 -5
- 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 `
|
|
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 `
|
|
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: :
|
|
548
|
-
description: "Count
|
|
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", "
|
|
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") == "
|
|
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: { "
|
|
964
|
+
# agent_canonical_filter: { "draft" => { "$ne" => true } },
|
|
965
965
|
# per_agent_filter: { archived: false }, # composed: per-class AND :default
|
|
966
|
-
# tenant_scope: { field: :
|
|
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
|
|
1435
|
+
description: "Summary of workspace activity in the last 30 days",
|
|
1436
1436
|
arguments: [
|
|
1437
|
-
{ "name" => "team_id", "description" => "Parse objectId of the
|
|
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
|
|
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: "
|
|
1457
|
-
text: "Analyze
|
|
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
|
-
**
|
|
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: ["
|
|
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: "
|
|
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 `
|
|
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 `
|
|
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[
|
|
2034
|
-
# => { custom: [{ name: "
|
|
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: "
|
|
2038
|
-
# => { custom: [{ name: "
|
|
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[
|
|
2043
|
-
prefix: "
|
|
2044
|
-
# => only
|
|
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: "
|
|
2095
|
-
pipeline: [{ "$match" => { "
|
|
2102
|
+
agent.execute(:aggregate, class_name: "Post",
|
|
2103
|
+
pipeline: [{ "$match" => { "archived" => { "$ne" => true } } }, { "$project" => { "_p_author" => 1 } }]
|
|
2096
2104
|
)
|
|
2097
2105
|
# => {
|
|
2098
|
-
# class_name: "
|
|
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: "
|
|
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
|
-
#
|
|
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: "
|
|
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: "
|
|
2162
|
+
agent.execute(:group_by, class_name: "Post", field: "lastAction",
|
|
2155
2163
|
operation: "count")
|
|
2156
2164
|
# => { success: true, data: {
|
|
2157
|
-
# class_name: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
2231
|
-
where: { "
|
|
2238
|
+
agent.execute(:distinct, class_name: "Document", field: "mediaFormat",
|
|
2239
|
+
where: { "archived" => { "$ne" => true } })
|
|
2232
2240
|
# => { success: true, data: {
|
|
2233
|
-
# class_name: "
|
|
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: "
|
|
2242
|
-
# => { ..., pointer_class: "
|
|
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: "
|
|
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: "
|
|
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
|
|
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
|
|
2683
|
-
parse_class "
|
|
2803
|
+
class Subscription < Parse::Object
|
|
2804
|
+
parse_class "Subscription"
|
|
2684
2805
|
|
|
2685
2806
|
property :title, :string,
|
|
2686
|
-
_description: "Display title for this
|
|
2807
|
+
_description: "Display title for this subscription grant"
|
|
2687
2808
|
|
|
2688
2809
|
property :grant, :string,
|
|
2689
|
-
_description: "Scope of the
|
|
2810
|
+
_description: "Scope of the subscription grant",
|
|
2690
2811
|
_enum: {
|
|
2691
|
-
|
|
2692
|
-
project: "Member of a single project under a
|
|
2693
|
-
|
|
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: "
|
|
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: "
|
|
2833
|
+
agent.execute(:get_schema, class_name: "Subscription")
|
|
2713
2834
|
# => {
|
|
2714
2835
|
# success: true,
|
|
2715
2836
|
# data: {
|
|
2716
|
-
# class_name: "
|
|
2837
|
+
# class_name: "Subscription",
|
|
2717
2838
|
# fields: [
|
|
2718
2839
|
# { name: "title", type: "string", required: false,
|
|
2719
|
-
# description: "Display title for this
|
|
2840
|
+
# description: "Display title for this subscription grant" },
|
|
2720
2841
|
# { name: "grant", type: "string", required: false,
|
|
2721
|
-
# description: "Scope of the
|
|
2842
|
+
# description: "Scope of the subscription grant",
|
|
2722
2843
|
# allowed_values: [
|
|
2723
|
-
# { "value" => "
|
|
2724
|
-
# { "value" => "project",
|
|
2725
|
-
# { "value" => "
|
|
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: "
|
|
2902
|
+
agent.execute(:get_schema, class_name: "Post")
|
|
2782
2903
|
# => {
|
|
2783
2904
|
# success: true,
|
|
2784
2905
|
# data: {
|
|
2785
|
-
# class_name: "
|
|
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
|
-
#
|
|
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: "
|
|
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
|
-
:
|
|
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, :
|
|
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: "
|
|
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.
|
|
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: "
|
|
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.
|
|
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: "
|
|
3032
|
+
class_name: "Subscription",
|
|
2912
3033
|
keys: ["user", "title", "active"],
|
|
2913
3034
|
include: ["user"],
|
|
2914
3035
|
limit: 10)
|
|
2915
3036
|
# => {
|
|
2916
|
-
# class_name: "
|
|
3037
|
+
# class_name: "Subscription",
|
|
2917
3038
|
# result_count: 10,
|
|
2918
3039
|
# results: [...],
|
|
2919
3040
|
# truncated_include_fields: {
|
|
2920
|
-
# "user" => ["iconImage", "sourceImage", "
|
|
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: "
|
|
3063
|
+
agent.execute(:get_schema, class_name: "Subscription")
|
|
2943
3064
|
# => {
|
|
2944
3065
|
# success: true,
|
|
2945
3066
|
# data: {
|
|
2946
|
-
# class_name: "
|
|
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
|
|
3039
|
-
agent_tenant_scope :
|
|
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., `:
|
|
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 (`
|
|
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
|
|
3241
|
+
class Post < Parse::Object
|
|
3121
3242
|
property :title, :string
|
|
3122
|
-
property :
|
|
3123
|
-
property :
|
|
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 "
|
|
3128
|
-
"
|
|
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 `
|
|
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
|
|
3151
|
-
agent.execute(:count_objects, class_name: "
|
|
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: "
|
|
3283
|
+
agent.execute(:get_schema, class_name: "Post")
|
|
3163
3284
|
# => {
|
|
3164
3285
|
# success: true,
|
|
3165
3286
|
# data: {
|
|
3166
|
-
# class_name: "
|
|
3287
|
+
# class_name: "Post",
|
|
3167
3288
|
# type: "custom",
|
|
3168
3289
|
# fields: [...],
|
|
3169
|
-
# canonical_filter: { "
|
|
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("
|
|
3179
|
-
# => { "
|
|
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: ["
|
|
3521
|
+
# missing_class_descriptions: ["PostMetric", "PostSnapshot"],
|
|
3401
3522
|
# missing_field_descriptions: {
|
|
3402
|
-
# "
|
|
3403
|
-
# "
|
|
3523
|
+
# "Post" => [:category, :status, ...],
|
|
3524
|
+
# "Subscription" => [:grant, :active]
|
|
3404
3525
|
# },
|
|
3405
3526
|
# unresolvable_allowlist_entries: {
|
|
3406
|
-
# "
|
|
3527
|
+
# "PostStatus" => [:statys] # likely typo of :status
|
|
3407
3528
|
# },
|
|
3408
3529
|
# canonical_filter_summary: {
|
|
3409
|
-
# "
|
|
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
|
-
# -
|
|
3441
|
-
# -
|
|
3561
|
+
# - PostMetric
|
|
3562
|
+
# - PostSnapshot
|
|
3442
3563
|
#
|
|
3443
3564
|
# Missing field descriptions (7 across 2 classes):
|
|
3444
|
-
#
|
|
3445
|
-
#
|
|
3446
|
-
#
|
|
3565
|
+
# Post (5):
|
|
3566
|
+
# category, status, archived, published, author
|
|
3567
|
+
# Subscription (2):
|
|
3447
3568
|
# grant, active
|
|
3448
3569
|
#
|
|
3449
3570
|
# Unresolvable allowlist entries:
|
|
3450
|
-
#
|
|
3571
|
+
# PostStatus: statys
|
|
3451
3572
|
#
|
|
3452
3573
|
# Canonical filters declared (1):
|
|
3453
|
-
#
|
|
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.
|