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.
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
-
#accessible?(class_name) ⇒ Boolean
Check whether a class name is accessible to agent tools.
-
#agent_methods(class_name, agent_permission: :readonly) ⇒ Hash<Symbol, Hash>
Get agent methods for a Parse class filtered by permission.
-
#allow_collscan?(class_name) ⇒ Boolean
Check whether COLLSCANs are explicitly permitted for the given class.
-
#any_tenant_scope? ⇒ Boolean
True if any class declares agent_tenant_scope.
-
#canonical_filter(class_name) ⇒ Hash?
Look up the canonical "valid state" filter declared via
agent_canonical_filteron the model class. -
#class_description(class_name) ⇒ String?
Get the class description for a Parse class if registered.
-
#enriched_schema(class_name, server_schema, agent_permission: :readonly, edges: nil) ⇒ Hash
Enrich a server schema with local model metadata.
-
#enriched_schemas(server_schemas, agent_permission: :readonly) ⇒ Array<Hash>
Enrich multiple schemas at once.
-
#field_allowlist(class_name) ⇒ Array<String>?
Resolve the agent_fields allowlist for a Parse class name.
-
#filter_visible_schemas(schemas) ⇒ Array<Hash>
Filter schemas to only include visible classes.
- #finalize_join_projection(project, dropped, source) ⇒ Object private
-
#has_metadata?(class_name) ⇒ Boolean
Check if a model class has agent metadata.
-
#has_visible_classes? ⇒ Boolean
Check if any classes are registered as visible.
-
#hidden?(class_name) ⇒ Boolean
Check whether a class name is denied to agent tools.
-
#hidden_class_names ⇒ Array<String>
Class names (Parse class names) that are hidden from every agent tool.
-
#hidden_exception_for(class_name) ⇒ Symbol?
Look up the per-class hidden-exception scope (
:master_keyor nil) for a Parse class name. -
#hidden_name_set ⇒ Array<String>
All hidden-class name variants a caller might submit.
-
#hidden_name_variants_for(klass) ⇒ Array<String>
Compute the set of names a caller might use to reference
klass. -
#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.
-
#property_descriptions(class_name) ⇒ Hash<Symbol, String>
Get property descriptions for a Parse class if registered.
-
#register_hidden_class(klass, except: nil) ⇒ Object
Register a class as hidden from agent tools (opt-in PII denial).
-
#register_searchable(class_name, field:, filter_fields: []) ⇒ Object
Register a class as searchable via the
semantic_searchtool. -
#register_tenant_scope(class_name, field, from:) ⇒ Object
Register a tenant scope rule for a class.
-
#register_tenant_scope_bypass(class_name, bypass_proc) ⇒ Object
Register a bypass proc for a class's tenant scope.
-
#register_visible_class(klass) ⇒ Object
Register a class as visible to agents.
-
#resolve_searchable!(class_name) ⇒ Class
Resolve a class name to its model class for
semantic_search, enforcing the three opt-in / safety gates. -
#resolve_tenant_scope(class_name, agent) ⇒ Hash?
Resolve the effective tenant scope for a class and agent.
-
#searchable_field(class_name) ⇒ Symbol?
The searchable vector field, or nil.
-
#searchable_filter_fields(class_name) ⇒ Array<Symbol>
The declared filter-field allowlist.
-
#searchable_rule(class_name) ⇒ Hash?
{ field:, filter_fields: } or nil if not opted in.
-
#tenant_scope_bypassed?(class_name, agent) ⇒ Boolean
Check whether the given agent should bypass the tenant scope for a class.
-
#tenant_scope_rule(class_name) ⇒ Hash?
Return the tenant scope rule for a class name, or nil if none declared.
-
#unregister_hidden_class(klass) ⇒ Object
Reverse a prior
register_hidden_classcall. -
#visible_class_names ⇒ Array<String>
Get visible class names (Parse class names).
-
#visible_classes ⇒ Array<Class>
Get all registered visible classes.
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.
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.
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() 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).
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.
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.
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.
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.
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. 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() 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).
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: , 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").
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.
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.
539 540 541 542 |
# File 'lib/parse/agent/metadata_registry.rb', line 539 def (class_name) klass = find_model_class(class_name) klass&.respond_to?(:has_agent_metadata?) && klass. end |
#has_visible_classes? ⇒ Boolean
Check if any classes are registered as visible.
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".
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_names ⇒ Array<String>
Class names (Parse class names) that are hidden from every agent tool.
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.
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_set ⇒ Array<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.
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_classstripped 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.
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):
agent_join_fields→ those entries (wire-format).agent_fieldsdeclared →agent_fields - agent_large_fields.- Only
agent_large_fieldsdeclared → allfield_mapproperties minus the large set. - 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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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_names ⇒ Array<String>
Get visible class names (Parse class names).
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_classes ⇒ Array<Class>
Get all registered visible classes.
212 213 214 |
# File 'lib/parse/agent/metadata_registry.rb', line 212 def visible_classes @visible_mutex.synchronize { @visible_classes.dup } end |