Module: Parse::Agent::MetadataRegistry

Extended by:
MetadataRegistry
Included in:
MetadataRegistry
Defined in:
lib/parse/agent/metadata_registry.rb

Overview

Registry module that enriches server schemas with local model metadata. Merges class descriptions, property descriptions, and agent-allowed methods from registered Parse::Object models into the schema data returned by the agent.

Examples:

Enriching a schema

server_schema = { "className" => "Song", "fields" => { ... } }
enriched = MetadataRegistry.enriched_schema("Song", server_schema)
# enriched now includes :description and :agent_methods if defined

Constant Summary collapse

ALWAYS_KEEP_FIELDS =

Fields that always pass through the agent_fields allowlist filter. These carry semantic meaning the LLM needs even when not explicitly listed as analytics-relevant.

%w[objectId createdAt updatedAt].freeze
NOISY_FIELD_METADATA =

Per-field metadata keys that bloat the agent schema response without helping analytics queries. Dropped before the schema reaches the LLM.

%w[indexed].freeze

Instance Method Summary collapse

Instance Method Details

#accessible?(class_name) ⇒ Boolean

Check whether a class name is accessible to agent tools. Inverse of #hidden?. Use at tool-dispatch time to refuse access before any query hits Parse Server.

Parameters:

Returns:

  • (Boolean)


206
207
208
# File 'lib/parse/agent/metadata_registry.rb', line 206

def accessible?(class_name)
  !hidden?(class_name)
end

#agent_methods(class_name, agent_permission: :readonly) ⇒ Hash<Symbol, Hash>

Get agent methods for a Parse class filtered by permission.

Parameters:

  • class_name (String)

    the Parse class name

  • agent_permission (Symbol) (defaults to: :readonly)

    the agent's permission level

Returns:



529
530
531
532
533
# File 'lib/parse/agent/metadata_registry.rb', line 529

def agent_methods(class_name, agent_permission: :readonly)
  klass = find_model_class(class_name)
  return {} unless klass&.respond_to?(:agent_methods_for)
  klass.agent_methods_for(agent_permission)
end

#allow_collscan?(class_name) ⇒ Boolean

Check whether COLLSCANs are explicitly permitted for the given class. Returns true when the model declares agent_allow_collscan true, false otherwise (including when no model class is registered).

Parameters:

  • class_name (String)

    the Parse class name

Returns:

  • (Boolean)


550
551
552
553
554
# File 'lib/parse/agent/metadata_registry.rb', line 550

def allow_collscan?(class_name)
  klass = find_model_class(class_name)
  return false unless klass&.respond_to?(:agent_allow_collscan?)
  klass.agent_allow_collscan?
end

#any_tenant_scope?Boolean

Returns true if any class declares agent_tenant_scope.

Returns:

  • (Boolean)

    true if any class declares agent_tenant_scope.



630
631
632
# File 'lib/parse/agent/metadata_registry.rb', line 630

def any_tenant_scope?
  @tenant_scope_mutex.synchronize { !@tenant_scope_rules.empty? }
end

#canonical_filter(class_name) ⇒ Hash?

Look up the canonical "valid state" filter declared via agent_canonical_filter on the model class. Returns nil when no filter is declared.

Parameters:

  • class_name (String)

    the Parse class name

Returns:

  • (Hash, nil)

    a String-keyed where-style hash, or nil



562
563
564
565
566
# File 'lib/parse/agent/metadata_registry.rb', line 562

def canonical_filter(class_name)
  klass = find_model_class(class_name)
  return nil unless klass&.respond_to?(:agent_canonical_filter_for_apply)
  klass.agent_canonical_filter_for_apply
end

#class_description(class_name) ⇒ String?

Get the class description for a Parse class if registered.

Parameters:

  • class_name (String)

    the Parse class name

Returns:

  • (String, nil)

    the description or nil



509
510
511
512
# File 'lib/parse/agent/metadata_registry.rb', line 509

def class_description(class_name)
  klass = find_model_class(class_name)
  klass&.respond_to?(:agent_description) ? klass.agent_description : nil
end

#enriched_schema(class_name, server_schema, agent_permission: :readonly, edges: nil) ⇒ Hash

Enrich a server schema with local model metadata.

Parameters:

  • class_name (String)

    the Parse class name

  • server_schema (Hash)

    the schema from Parse Server

  • agent_permission (Symbol) (defaults to: :readonly)

    the agent's permission level for method filtering

  • edges (Array<Hash>, nil) (defaults to: nil)

    pre-built relation edges from RelationGraph#build. When omitted, edges are built on demand for this single class; pass a pre-built array when enriching many schemas in a row to avoid the N+1 traversal.

Returns:

  • (Hash)

    the enriched schema



261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
# File 'lib/parse/agent/metadata_registry.rb', line 261

def enriched_schema(class_name, server_schema, agent_permission: :readonly, edges: nil)
  klass = find_model_class(class_name)
  return server_schema unless klass&.respond_to?(:has_agent_metadata?) && klass.has_agent_metadata?

  schema = deep_dup(server_schema)

  # Add class description
  if klass.agent_description
    schema["description"] = klass.agent_description
  end

  # Add class-level analytics usage hint (distinct from description)
  if klass.respond_to?(:agent_usage) && klass.agent_usage
    schema["usage"] = klass.agent_usage
  end

  # Enrich fields with property descriptions
  if schema["fields"] && klass.property_descriptions.any?
    schema["fields"] = enrich_fields(schema["fields"], klass)
  end

  # Filter fields to the declared allowlist (plus always-on system fields).
  # When no allowlist is declared, leave the field set alone.
  # Delegates to field_allowlist so allowlist symbols declared as Ruby
  # property names (snake_case) are normalized to the wire-format column
  # names (camelCase or explicit `field:` alias) before comparing against
  # Parse Server's schema keys. Without this normalization a model with
  # `agent_fields :device_type` filters against `"device_type"`, but the
  # server schema carries `"deviceType"` and the field is silently
  # stripped.
  if schema["fields"] && (allowed = field_allowlist(class_name))
    schema["fields"] = schema["fields"].select { |name, _| allowed.include?(name) }
  end

  # Strip noisy per-field metadata regardless of allowlist
  if schema["fields"]
    schema["fields"] = schema["fields"].transform_values do |config|
      next config unless config.is_a?(Hash)
      cleaned = config.reject { |k, _| NOISY_FIELD_METADATA.include?(k) }
      # Drop defaultValue if it's effectively empty (nil/empty string carry no signal)
      cleaned = cleaned.reject { |k, v| k == "defaultValue" && (v.nil? || v == "") }
      cleaned
    end
  end

  # Add agent-allowed methods (filtered by permission)
  available_methods = klass.agent_methods_for(agent_permission)
  if available_methods.any?
    schema["agent_methods"] = format_methods(available_methods)
  end

  # Surface the canonical "valid state" filter so an LLM that opts
  # out via `apply_canonical_filter: false` on a query can
  # reproduce the same predicate manually. The filter is applied
  # BY DEFAULT on `query_class`/`count_objects`/`aggregate`.
  canonical = klass.respond_to?(:agent_canonical_filter_for_apply) ?
    klass.agent_canonical_filter_for_apply : nil
  if canonical && canonical.any?
    schema["canonical_filter"] = canonical.dup
  end

  # Echo the wire-format `agent_fields` allowlist explicitly. The
  # registry already enforces the allowlist by stripping non-allowed
  # fields from `schema["fields"]`, but enforcement-by-omission left
  # an LLM guessing what it could write in `keys:` and led to
  # repeated refusals on storage-form column names (`_p_author`,
  # etc.). Listing the wire names alongside the trimmed fields hash
  # closes that gap. `ALWAYS_KEEP_FIELDS` (objectId/createdAt/
  # updatedAt) is filtered out — those are always available and
  # would only noise up the echo.
  allowed = field_allowlist(class_name)
  if allowed && (allowed - ALWAYS_KEEP_FIELDS).any?
    schema["agent_fields"] = (allowed - ALWAYS_KEEP_FIELDS)
  end

  # Echo the narrower join projection (wire-format) when declared.
  # Tells the LLM "when I'm included as a pointer on another class's
  # query, you'll see these fields and nothing else" so it can plan
  # the include path without a follow-up `get_schema`.
  join_proj = join_projection_fields(class_name)
  if join_proj && (join_proj[:project] - ALWAYS_KEEP_FIELDS).any?
    schema["agent_join_fields"] = (join_proj[:project] - ALWAYS_KEEP_FIELDS)
  end

  # Embed this class's relationship edges (incoming/outgoing) so the LLM
  # sees pointer/relation context alongside fields. Keeps each schema
  # response self-contained without the cost of the full graph.
  per_class = Parse::Agent::RelationGraph.edges_for(class_name, edges)
  if per_class[:outgoing].any? || per_class[:incoming].any?
    schema["relations"] = {
      "outgoing" => per_class[:outgoing].map { |e| edge_summary(e) },
      "incoming" => per_class[:incoming].map { |e| edge_summary(e) },
    }
  end

  schema
end

#enriched_schemas(server_schemas, agent_permission: :readonly) ⇒ Array<Hash>

Enrich multiple schemas at once. Builds the relation graph exactly once and threads it through each per-schema enrichment so the combined call is O(classes) rather than O(classes^2).

Parameters:

  • server_schemas (Array<Hash>)

    schemas from Parse Server

  • agent_permission (Symbol) (defaults to: :readonly)

    the agent's permission level

Returns:



498
499
500
501
502
503
# File 'lib/parse/agent/metadata_registry.rb', line 498

def enriched_schemas(server_schemas, agent_permission: :readonly)
  edges = Parse::Agent::RelationGraph.build
  server_schemas.map do |schema|
    enriched_schema(schema["className"], schema, agent_permission: agent_permission, edges: edges)
  end
end

#field_allowlist(class_name) ⇒ Array<String>?

Resolve the agent_fields allowlist for a Parse class name. Returns an array of field-name strings including the always-keep system fields, or nil when the model has no allowlist declared (callers should treat nil as "no filtering — return everything").

Parameters:

  • class_name (String)

    the Parse class name

Returns:



366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
# File 'lib/parse/agent/metadata_registry.rb', line 366

def field_allowlist(class_name)
  klass = find_model_class(class_name)
  return nil unless klass&.respond_to?(:agent_field_allowlist)
  allowlist = klass.agent_field_allowlist
  return nil if allowlist.empty?
  # Translate each allowlist entry to its wire-format column name.
  # Priority: the class's field_map (Ruby symbol -> wire symbol) so
  # explicit `field:` aliases (`property :external_id, field: "ExtId"`)
  # resolve to the actual column. Fallback: `String#columnize` so plain
  # snake_case Ruby names (`:device_type` -> `"deviceType"`) match
  # Parse Server's lowerCamelCase wire format. Without this translation
  # the allowlist filter was case-sensitive against snake_case strings
  # and silently stripped legitimate camelCase columns from schema
  # enrichment, `keys:` projection, and pipeline policy enforcement.
  fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
  resolved = allowlist.map do |name|
    mapped = fmap[name.to_sym]
    # When field_map carries an explicit wire name (e.g. a `property
    # :external_id, field: :ExternalReferenceCode` alias), use it
    # verbatim — columnize would lowercase the first character and
    # break the alias. Without a mapping, columnize the Ruby symbol
    # to convert snake_case to lowerCamelCase wire format.
    mapped ? mapped.to_s : name.to_s.columnize
  end
  # Defense-in-depth: refuse to surface Parse Server internal columns
  # (`_hashed_password`, `_session_token`, `_rperm`/`_wperm`, etc.) on
  # the agent surface, regardless of whether a developer accidentally
  # mapped a `property :pw, field: :_hashed_password` and listed it in
  # `agent_fields`. The columnize fallback already strips the leading
  # underscore for snake_case entries; this drop targets the wire-name
  # path that bypasses columnize.
  resolved.reject! { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
  resolved | ALWAYS_KEEP_FIELDS
end

#filter_visible_schemas(schemas) ⇒ Array<Hash>

Filter schemas to only include visible classes. If no classes are marked visible, returns all schemas.

Parameters:

  • schemas (Array<Hash>)

    schemas from Parse Server

Returns:



235
236
237
238
239
240
# File 'lib/parse/agent/metadata_registry.rb', line 235

def filter_visible_schemas(schemas)
  return schemas unless has_visible_classes?

  visible_names = visible_class_names
  schemas.select { |s| visible_names.include?(s["className"]) }
end

#finalize_join_projection(project, dropped, source) ⇒ Object

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.



484
485
486
487
488
489
# File 'lib/parse/agent/metadata_registry.rb', line 484

def finalize_join_projection(project, dropped, source)
  project = (project | ALWAYS_KEEP_FIELDS)
  project.reject! { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
  dropped = dropped.reject { |wire| Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST.include?(wire) }
  { project: project, dropped: dropped, source: source }
end

#has_metadata?(class_name) ⇒ Boolean

Check if a model class has agent metadata.

Parameters:

  • class_name (String)

    the Parse class name

Returns:

  • (Boolean)


539
540
541
542
# File 'lib/parse/agent/metadata_registry.rb', line 539

def has_metadata?(class_name)
  klass = find_model_class(class_name)
  klass&.respond_to?(:has_agent_metadata?) && klass.has_agent_metadata?
end

#has_visible_classes?Boolean

Check if any classes are registered as visible.

Returns:

  • (Boolean)


226
227
228
# File 'lib/parse/agent/metadata_registry.rb', line 226

def has_visible_classes?
  @visible_mutex.synchronize { @visible_classes.any? }
end

#hidden?(class_name) ⇒ Boolean

Check whether a class name is denied to agent tools.

An LLM writing aggregations against Parse-on-Mongo will naturally type system classes by their alias form ("User", "Role", "Installation", "Session") even though the canonical parse_class is the _-prefixed form ("_User", etc.). Similarly, a class declared with parse_class "Foo" lives in the registry as "Foo" but a caller might pass the Ruby class name.

#hidden_name_variants_for expands each registered hidden class to every form a caller might submit; this predicate is a pure string match against that expanded set. Closes the oracle where an LLM could write $lookup: { from: "User" } and bypass an agent_hidden-on-Parse::User because the registry only knew "_User".

Parameters:

Returns:

  • (Boolean)


143
144
145
146
# File 'lib/parse/agent/metadata_registry.rb', line 143

def hidden?(class_name)
  return false if class_name.nil?
  hidden_name_set.include?(class_name.to_s)
end

#hidden_class_namesArray<String>

Class names (Parse class names) that are hidden from every agent tool.

Returns:



119
120
121
122
123
# File 'lib/parse/agent/metadata_registry.rb', line 119

def hidden_class_names
  @hidden_mutex.synchronize { @hidden_classes.dup }.map do |klass|
    klass.respond_to?(:parse_class) ? klass.parse_class : klass.name
  end
end

#hidden_exception_for(class_name) ⇒ Symbol?

Look up the per-class hidden-exception scope (:master_key or nil) for a Parse class name. Returns nil when the class is not hidden at all OR when it is hidden with no exception. Caller must compare against the agent's auth context to decide whether the exception applies.

Parameters:

Returns:



105
106
107
108
109
110
111
112
113
114
115
# File 'lib/parse/agent/metadata_registry.rb', line 105

def hidden_exception_for(class_name)
  return nil if class_name.nil?
  target = class_name.to_s
  @hidden_mutex.synchronize do
    @hidden_classes.each do |klass|
      next unless hidden_name_variants_for(klass).include?(target)
      return @hidden_exceptions[klass]
    end
  end
  nil
end

#hidden_name_setArray<String>

All hidden-class name variants a caller might submit. Includes the canonical parse_class, the un-prefixed alias when parse_class starts with _ (system-class form), and the Ruby class name when it differs from parse_class (parse_class "Foo" override). The hidden_name_variants_for helper MUST NOT take @hidden_mutex — it's called from inside the synchronize block here, and recursive locking would deadlock.

Returns:



156
157
158
159
160
# File 'lib/parse/agent/metadata_registry.rb', line 156

def hidden_name_set
  @hidden_mutex.synchronize do
    @hidden_classes.flat_map { |klass| hidden_name_variants_for(klass) }.uniq
  end
end

#hidden_name_variants_for(klass) ⇒ Array<String>

Compute the set of names a caller might use to reference klass.

Variants emitted:

  • parse_class (canonical, always).
  • parse_class stripped of a leading _ (system-class alias form; e.g. _User -> User).
  • Ruby class name when it differs from parse_class.

Known limitation — collision direction is safe but technically over-broad. If application code declares one class with parse_class "_Foo" and also a separate class with parse_class "Foo", hiding the _Foo class implicitly causes hidden?("Foo") to return true as well, refusing reads on the un-prefixed sibling. The refusal direction is the safer one (false positive on the gate, not a leak), and the collision is contrived enough — _-prefixed parse_class names are reserved in practice for Parse's own system classes — that we accept the trade-off. Applications that genuinely need both can either rename one, or call agent_hidden on both explicitly.

Parameters:

  • klass (Class)

Returns:



185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'lib/parse/agent/metadata_registry.rb', line 185

def hidden_name_variants_for(klass)
  variants = []
  if klass.respond_to?(:parse_class) && klass.parse_class
    pc = klass.parse_class.to_s
    variants << pc
    variants << pc.sub(/\A_/, "") if pc.start_with?("_")
  end
  if klass.respond_to?(:name) && klass.name && !klass.name.include?("::") && !variants.include?(klass.name)
    # Skip names containing `::` -- those are Ruby constant paths
    # (e.g. `"Parse::User"`) that no LLM would write in a `$lookup`,
    # and including them only adds noise to `hidden_name_set`.
    variants << klass.name
  end
  variants
end

#join_projection_fields(class_name) ⇒ Hash?

Resolve the wire-format projection set used when this class appears as an included pointer on another class's query. Drives the auto-projection that turns keys: ["user"] + include: ["user"] into keys: "user,user.firstName,user.email,..." server-side.

Resolution order (first match wins):

  1. agent_join_fields → those entries (wire-format).
  2. agent_fields declared → agent_fields - agent_large_fields.
  3. Only agent_large_fields declared → all field_map properties minus the large set.
  4. None of the above → nil (no auto-projection; caller gets the full included record exactly as Parse Server returns it).

The returned array always includes ALWAYS_KEEP_FIELDS (objectId / createdAt / updatedAt). Internal Parse Server columns (_hashed_password, _session_token, _rperm, etc.) are filtered at the end as a defense-in-depth pass, identical to #field_allowlist, so an accidental property :pw, field: :_hashed_password cannot leak through the join surface.

Parameters:

  • class_name (String)

    the joined Parse class name

Returns:

  • (Hash, nil)

    Array, dropped: Array, source: Symbol or nil. project is the positive wire-format field list. dropped is the wire names this projection actively omits (used to populate the truncated_include_fields envelope). source is one of :join_fields, :allowlist_minus_large, :field_map_minus_large for diagnostics / testing.



429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
# File 'lib/parse/agent/metadata_registry.rb', line 429

def join_projection_fields(class_name)
  klass = find_model_class(class_name)
  return nil unless klass
  fmap = klass.respond_to?(:field_map) ? klass.field_map : {}
  to_wire = ->(sym) {
    mapped = fmap[sym.to_sym]
    mapped ? mapped.to_s : sym.to_s.columnize
  }
  large_wire = if klass.respond_to?(:agent_large_field_list)
      klass.agent_large_field_list.map(&to_wire)
    else
      []
    end

  join_list = klass.respond_to?(:agent_join_field_list) ? klass.agent_join_field_list : []
  if join_list.any?
    project = join_list.map(&to_wire)
    source = :join_fields
    # dropped: large fields that are NOT in the join projection.
    # The caller asked us to project to a narrow set; report large
    # fields they didn't include so they can re-ask explicitly.
    dropped = large_wire - project
    return finalize_join_projection(project, dropped, source)
  end

  allow_list = klass.respond_to?(:agent_field_allowlist) ? klass.agent_field_allowlist : []
  if allow_list.any?
    allow_wire = allow_list.map(&to_wire)
    project = allow_wire - large_wire
    # If everything in the allowlist is also large, fall through
    # rather than projecting to an empty set (would surface a useless
    # `{}` user object).
    unless project.empty?
      dropped = large_wire & allow_wire
      return finalize_join_projection(project, dropped, :allowlist_minus_large)
    end
  end

  if large_wire.any?
    # Strip mode: no positive allowlist, but we know which fields are
    # heavy. Project to (declared properties - large fields). Limited
    # to fields the Ruby model knows about — server-side columns not
    # declared as `property` won't come back, but that's an honest
    # trade-off (we can only project what we can name).
    known_wire = fmap.values.map(&:to_s)
    project = known_wire - large_wire
    return nil if project.empty?
    dropped = large_wire & known_wire
    return finalize_join_projection(project, dropped, :field_map_minus_large)
  end

  nil
end

#property_descriptions(class_name) ⇒ Hash<Symbol, String>

Get property descriptions for a Parse class if registered.

Parameters:

  • class_name (String)

    the Parse class name

Returns:



518
519
520
521
522
# File 'lib/parse/agent/metadata_registry.rb', line 518

def property_descriptions(class_name)
  klass = find_model_class(class_name)
  return {} unless klass&.respond_to?(:property_descriptions)
  klass.property_descriptions || {}
end

#register_hidden_class(klass, except: nil) ⇒ Object

Register a class as hidden from agent tools (opt-in PII denial).

Parameters:

  • klass (Class)

    the model class

  • except (Symbol, nil) (defaults to: nil)

    when :master_key, the class is still reachable by master-key agents but refused for session-bound agents. When nil (default), the class is hidden from every agent regardless of auth context. Re-calling register_hidden_class with a different except: value updates the scope (last-write-wins) — this is what lets an application re-mark Parse::Session with the relaxed scope after the parse-stack default marked it with the strict one.



73
74
75
76
77
78
79
80
81
82
# File 'lib/parse/agent/metadata_registry.rb', line 73

def register_hidden_class(klass, except: nil)
  @hidden_mutex.synchronize do
    @hidden_classes << klass unless @hidden_classes.include?(klass)
    if except.nil?
      @hidden_exceptions.delete(klass)
    else
      @hidden_exceptions[klass] = except
    end
  end
end

#register_searchable(class_name, field:, filter_fields: []) ⇒ Object

Register a class as searchable via the semantic_search tool.

Parameters:

  • class_name (String)

    the Parse class name

  • field (Symbol)

    the :vector property to search

  • filter_fields (Array<Symbol>) (defaults to: [])

    fields the agent may filter on



602
603
604
605
606
607
608
609
# File 'lib/parse/agent/metadata_registry.rb', line 602

def register_searchable(class_name, field:, filter_fields: [])
  @searchable_mutex.synchronize do
    @searchable_classes[class_name.to_s] = {
      field: field.to_sym,
      filter_fields: Array(filter_fields).map(&:to_sym),
    }
  end
end

#register_tenant_scope(class_name, field, from:) ⇒ Object

Register a tenant scope rule for a class.

Parameters:

  • class_name (String)

    the Parse class name

  • field (Symbol)

    the field to scope on

  • from (Proc)

    callable receiving agent, returning the scope value



577
578
579
580
581
# File 'lib/parse/agent/metadata_registry.rb', line 577

def register_tenant_scope(class_name, field, from:)
  @tenant_scope_mutex.synchronize do
    @tenant_scope_rules[class_name.to_s] = { field: field.to_sym, from: from }
  end
end

#register_tenant_scope_bypass(class_name, bypass_proc) ⇒ Object

Register a bypass proc for a class's tenant scope.

Parameters:

  • class_name (String)

    the Parse class name

  • bypass_proc (Proc)

    callable receiving agent, returning truthy to bypass



587
588
589
590
591
# File 'lib/parse/agent/metadata_registry.rb', line 587

def register_tenant_scope_bypass(class_name, bypass_proc)
  @tenant_scope_bypass_mutex.synchronize do
    @tenant_scope_bypasses[class_name.to_s] = bypass_proc
  end
end

#register_visible_class(klass) ⇒ Object

Register a class as visible to agents.

Parameters:

  • klass (Class)

    the model class



58
59
60
61
62
# File 'lib/parse/agent/metadata_registry.rb', line 58

def register_visible_class(klass)
  @visible_mutex.synchronize do
    @visible_classes << klass unless @visible_classes.include?(klass)
  end
end

#resolve_searchable!(class_name) ⇒ Class

Resolve a class name to its model class for semantic_search, enforcing the three opt-in / safety gates. Called at dispatch time (all classes loaded), which is why the tenant-scope cross- check is order-independent.

Parameters:

Returns:

  • (Class)

    the resolved Parse::Object subclass.

Raises:



647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
# File 'lib/parse/agent/metadata_registry.rb', line 647

def resolve_searchable!(class_name)
  name = class_name.to_s
  rule = searchable_rule(name)
  if rule.nil?
    raise Parse::Agent::ValidationError,
          "Class '#{name}' is not registered for semantic search. " \
          "Declare `agent_searchable field: :<vector_field>` on the model."
  end
  if hidden?(name)
    raise Parse::Agent::AccessDenied.new(
      name, "Class '#{name}' is not accessible to this agent.",
      kind: :hidden_class,
    )
  end
  if any_tenant_scope? && tenant_scope_rule(name).nil?
    raise Parse::Agent::MissingTenantScope,
          "Class '#{name}' is searchable but declares no agent_tenant_scope " \
          "while other classes do. Refusing to expose an un-scoped searchable " \
          "surface in a tenant-aware deployment; add agent_tenant_scope to '#{name}'."
  end
  klass = find_model_class(name)
  unless klass.is_a?(Class) && klass.respond_to?(:find_similar)
    raise Parse::Agent::ValidationError,
          "Class '#{name}' is registered searchable but no Parse::Object model " \
          "with a :vector property could be resolved."
  end
  klass
end

#resolve_tenant_scope(class_name, agent) ⇒ Hash?

Resolve the effective tenant scope for a class and agent.

Returns nil when:

  • No agent_tenant_scope is declared for this class (back-compat pass-through).
  • The bypass condition is satisfied (admin agents, etc.).

Returns { field: Symbol, value: Object } when a scope should be enforced.

Raises Parse::Agent::AccessDenied when:

  • A scope rule is declared and the bypass is not satisfied, but the agent's scope value (from: proc) returns nil — meaning the agent has no tenant binding and must not touch this class.

Parameters:

  • class_name (String)

    the Parse class name

  • agent (Parse::Agent)

    the agent instance

Returns:

  • (Hash, nil)

    { field: Symbol, value: Object } or nil

Raises:



718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
# File 'lib/parse/agent/metadata_registry.rb', line 718

def resolve_tenant_scope(class_name, agent)
  rule = tenant_scope_rule(class_name)
  unless rule
    # Lint: in a tenant-aware deployment, an agent-visible class with no
    # agent_tenant_scope is the silent cross-tenant case (resolve_searchable!
    # raises for the search path, but the general query path passes through
    # for back-compat). Warn once per class so it isn't discovered only by
    # leaked rows; do not raise — a genuinely global class is legitimate.
    #
    # Gated to classes EXPLICITLY opted into the agent surface (via
    # `agent_fields` → visible, or `agent_searchable`). resolve_tenant_scope
    # runs for every class a tool touches, so without this gate the warning
    # also fires for _User / _Role / _Session and incidental tables — noise
    # that trains operators to ignore the signal.
    if any_tenant_scope? && agent_visible_for_lint?(class_name)
      warn_unscoped_agent_class!(class_name)
    end
    return nil
  end

  return nil if tenant_scope_bypassed?(class_name, agent)

  value = rule[:from].call(agent)
  if value.nil?
    raise Parse::Agent::AccessDenied.new(
      class_name,
      "Agent has no tenant binding for class '#{class_name}' which requires tenant scoping",
    )
  end

  { field: rule[:field], value: value }
end

#searchable_field(class_name) ⇒ Symbol?

Returns the searchable vector field, or nil.

Parameters:

Returns:

  • (Symbol, nil)

    the searchable vector field, or nil.



619
620
621
# File 'lib/parse/agent/metadata_registry.rb', line 619

def searchable_field(class_name)
  searchable_rule(class_name)&.fetch(:field, nil)
end

#searchable_filter_fields(class_name) ⇒ Array<Symbol>

Returns the declared filter-field allowlist.

Parameters:

Returns:

  • (Array<Symbol>)

    the declared filter-field allowlist.



625
626
627
# File 'lib/parse/agent/metadata_registry.rb', line 625

def searchable_filter_fields(class_name)
  searchable_rule(class_name)&.fetch(:filter_fields, []) || []
end

#searchable_rule(class_name) ⇒ Hash?

Returns { field:, filter_fields: } or nil if not opted in.

Parameters:

Returns:

  • (Hash, nil)

    { field:, filter_fields: } or nil if not opted in.



613
614
615
# File 'lib/parse/agent/metadata_registry.rb', line 613

def searchable_rule(class_name)
  @searchable_mutex.synchronize { @searchable_classes[class_name.to_s] }
end

#tenant_scope_bypassed?(class_name, agent) ⇒ Boolean

Check whether the given agent should bypass the tenant scope for a class. Returns false when no bypass is registered or when the bypass proc raises.

Parameters:

  • class_name (String)

    the Parse class name

  • agent (Parse::Agent)

    the agent instance

Returns:

  • (Boolean)


690
691
692
693
694
695
696
697
698
699
# File 'lib/parse/agent/metadata_registry.rb', line 690

def tenant_scope_bypassed?(class_name, agent)
  bypass = @tenant_scope_bypass_mutex.synchronize { @tenant_scope_bypasses[class_name.to_s] }
  return false unless bypass
  begin
    !!bypass.call(agent)
  rescue StandardError
    # A bypass proc that raises is treated as not-bypassed (fail closed).
    false
  end
end

#tenant_scope_rule(class_name) ⇒ Hash?

Return the tenant scope rule for a class name, or nil if none declared.

Parameters:

  • class_name (String)

    the Parse class name

Returns:

  • (Hash, nil)

    { field: Symbol, from: Proc } or nil



680
681
682
# File 'lib/parse/agent/metadata_registry.rb', line 680

def tenant_scope_rule(class_name)
  @tenant_scope_mutex.synchronize { @tenant_scope_rules[class_name.to_s] }
end

#unregister_hidden_class(klass) ⇒ Object

Reverse a prior register_hidden_class call. Used by agent_unhidden to re-expose a class that was marked hidden by an upstream declaration (typically a parse-stack built-in like Parse::Product or a base class in an application's own model hierarchy). Removing the class from the registry is what actually allows query_class / aggregate / schema enumeration etc. to address it again — the per-class @agent_hidden ivar alone is not consulted by the tool surface.

Parameters:

  • klass (Class)

    the model class



92
93
94
95
96
97
# File 'lib/parse/agent/metadata_registry.rb', line 92

def unregister_hidden_class(klass)
  @hidden_mutex.synchronize do
    @hidden_classes.delete(klass)
    @hidden_exceptions.delete(klass)
  end
end

#visible_class_namesArray<String>

Get visible class names (Parse class names).

Returns:



218
219
220
221
222
# File 'lib/parse/agent/metadata_registry.rb', line 218

def visible_class_names
  visible_classes.map do |klass|
    klass.respond_to?(:parse_class) ? klass.parse_class : klass.name
  end
end

#visible_classesArray<Class>

Get all registered visible classes.

Returns:



212
213
214
# File 'lib/parse/agent/metadata_registry.rb', line 212

def visible_classes
  @visible_mutex.synchronize { @visible_classes.dup }
end