Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ futures = "0.3"
getrandom = "0.3"
graphql-composition = "=0.12.2"
graphql-orm = { path = "crates/graphql-orm", version = "0.30.0", default-features = false }
graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.3" }
graphql-orm-ai-tool-profiles = { path = "crates/graphql-orm-ai-tool-profiles", version = "0.10.4" }
graphql-orm-backup = { path = "crates/graphql-orm-backup", version = "0.7.2", default-features = false }
graphql-orm-operation-catalog = { path = "crates/graphql-orm-operation-catalog", version = "0.4.0" }
graphql-orm-router-protocol = { path = "crates/graphql-orm-router-protocol", version = "0.2.1" }
Expand Down
18 changes: 17 additions & 1 deletion crates/graphql-orm-ai-tool-profiles/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,29 @@ title: "graphql-orm-ai-tool-profiles changelog"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-09-01
last_reviewed: 2026-09-02
review_by: 2027-02-11
supersedes: []
---

# Changelog

## [0.10.4] - 2026-09-02

### Fixed

- Capability discovery now ranks public resolver descriptions and root
operation identity ahead of incidental nested-field vocabulary, splits
PascalCase public names into searchable terms, and uses explicit mechanical
result shape only to break semantic ties.

### Security

- A weak shape match can no longer displace a resolver-authored semantic match.
Shape metadata alone still cannot admit an otherwise unrelated capability,
and exact namespace, kind, entity/operation, authority and execution checks
remain unchanged.

## [0.10.3] - 2026-09-01

### Added
Expand Down
2 changes: 1 addition & 1 deletion crates/graphql-orm-ai-tool-profiles/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "graphql-orm-ai-tool-profiles"
version = "0.10.3"
version = "0.10.4"
edition = "2024"
authors = ["Toby Martin <toby@dastari.net>"]
description = "Backend-neutral GraphQL AI tool profile compiler and manifest contracts"
Expand Down
11 changes: 10 additions & 1 deletion crates/graphql-orm-ai-tool-profiles/MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,22 @@ title: "graphql-orm-ai-tool-profiles migration guide"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-09-01
last_reviewed: 2026-09-02
review_by: 2027-02-11
supersedes: []
---

# Migration Guide

## 0.10.3 to 0.10.4: resolver-first semantic discovery ranking

Adopt `graphql-orm-ai-tool-profiles` 0.10.4 from one reviewed full monorepo
revision. No capability-index wire shape or execution contract changes.
Discovery now tokenizes PascalCase public names, ranks public resolver/root
semantics ahead of nested field vocabulary, and uses explicit result shape only
as a semantic tie-breaker. Hosts should retain exact namespace, kind and
entity/operation filters whenever they know those canonical values.

## 0.10.2 to 0.10.3: proof-bearing pre-dispatch provider failure

Adopt `graphql-orm-ai-tool-profiles` 0.10.3 with `graphql-orm-ai` 0.95.12 from
Expand Down
19 changes: 11 additions & 8 deletions crates/graphql-orm-ai-tool-profiles/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "graphql-orm-ai-tool-profiles"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-09-01
last_reviewed: 2026-09-02
review_by: 2027-02-11
supersedes: []
---
Expand All @@ -24,7 +24,7 @@ are separate runtime decisions and must remain default-deny.

```toml
[dependencies]
graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.10.3" }
graphql-orm-ai-tool-profiles = { git = "https://github.com/Dastari/graphql-orm.git", rev = "<reviewed-full-40-character-commit-sha>", version = "0.10.4" }
serde_json = "1"
```

Expand Down Expand Up @@ -134,12 +134,15 @@ semantic summaries and exact fingerprints. It intentionally contains no JSON
Schema, GraphQL document/SDL, database name, resolver URL, policy expression,
credential, authority or secret/hidden field.

`AiCapabilityIndex::search` provides bounded deterministic discovery with
exact namespace/kind/entity filters and stable ID tie-breaking. Explicit list,
details, search, keyset, or aggregate intent ranks the matching compiler-owned
operation shape first; public entity, execution-target, and namespace relevance
rank next. Every candidate still requires positive lexical relevance, and
non-matching shapes remain eligible. Search
`AiCapabilityIndex::search` provides bounded deterministic semantic discovery
with exact namespace/kind/entity filters and stable ID tie-breaking. Public
resolver descriptions, operation/name/entity identity, execution target and
namespace rank ahead of nested field or relationship vocabulary. Explicit
mechanical result-shape intent is only a tie-breaker among otherwise relevant
candidates, so a weak list-shaped match cannot displace a resolver whose
description names the requested business result. PascalCase operation and
entity names contribute their component terms. Every candidate still requires
positive semantic relevance, and non-matching shapes remain eligible. Search
returns exact candidate/index/schema/semantic/target-policy fingerprints but
grants no authority. Each entry also carries conservative compiler-owned root
and total result-record bounds for later planning. The runtime package owns
Expand Down
201 changes: 175 additions & 26 deletions crates/graphql-orm-ai-tool-profiles/src/capability_index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -832,11 +832,15 @@ fn entry_matches_query(entry: &AiCapabilityIndexEntry, query: &AiCapabilitySearc

#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
struct SearchRank {
root_semantics: u64,
nested_semantics: u64,
shape: u8,
entity: u16,
target: u16,
namespace: u16,
lexical: u64,
}

impl SearchRank {
fn has_semantic_match(self) -> bool {
self.root_semantics > 0 || self.nested_semantics > 0
}
}

fn rank_entries<'a>(
Expand All @@ -851,7 +855,7 @@ fn rank_entries<'a>(
.filter(|(_, entry)| entry_matches_query(entry, query))
.filter_map(|(target_id, entry)| {
let rank = search_rank(target_id, entry, &terms, shape_intent);
(rank.lexical > 0).then_some((rank, entry))
rank.has_semantic_match().then_some((rank, entry))
})
.collect::<Vec<_>>();
ranked.sort_by(|left, right| {
Expand Down Expand Up @@ -1356,7 +1360,8 @@ fn search_rank(
terms: &BTreeSet<String>,
shape_intent: Option<AiCapabilityOperationShape>,
) -> SearchRank {
let mut score = 0_u64;
let mut root_semantics = 0_u64;
let mut nested_semantics = 0_u64;
let id = search_terms(entry.id.as_str());
let name = search_terms(&entry.name);
let operation = search_terms(&entry.operation_name);
Expand All @@ -1366,6 +1371,7 @@ fn search_rank(
.map(search_terms)
.unwrap_or_default();
let target = search_terms(target_id.as_str());
let namespace = search_terms(&entry.namespace);
let description = search_terms(&entry.description);
let relationships = entry
.relationships
Expand All @@ -1383,24 +1389,23 @@ fn search_rank(
.flat_map(|field| search_terms(&format!("{} {}", field.name, field.description)))
.collect::<BTreeSet<_>>();
for term in terms {
if search_shape_term(term) && entry.operation_shape != AiCapabilityOperationShape::Custom {
if search_shape_term(term) {
continue;
}
score += u64::from(id.contains(term)) * 10;
score += u64::from(name.contains(term)) * 9;
score += u64::from(operation.contains(term)) * 8;
score += u64::from(entity.contains(term)) * 8;
score += u64::from(relationships.contains(term)) * 7;
score += u64::from(fields.contains(term)) * 4;
score += u64::from(description.contains(term)) * 3;
root_semantics += u64::from(id.contains(term)) * 12;
root_semantics += u64::from(name.contains(term)) * 11;
root_semantics += u64::from(operation.contains(term)) * 10;
root_semantics += u64::from(entity.contains(term)) * 9;
root_semantics += u64::from(target.contains(term)) * 8;
root_semantics += u64::from(namespace.contains(term)) * 8;
root_semantics += u64::from(description.contains(term)) * 7;
nested_semantics += u64::from(relationships.contains(term)) * 3;
nested_semantics += u64::from(fields.contains(term)) * 2;
}
SearchRank {
root_semantics,
nested_semantics,
shape: u8::from(shape_intent == Some(entry.operation_shape)),
entity: u16::try_from(terms.intersection(&entity).count()).unwrap_or(u16::MAX),
target: u16::try_from(terms.intersection(&target).count()).unwrap_or(u16::MAX),
namespace: u16::try_from(terms.intersection(&search_terms(&entry.namespace)).count())
.unwrap_or(u16::MAX),
lexical: score,
}
}

Expand Down Expand Up @@ -1447,13 +1452,43 @@ fn search_terms(value: &str) -> BTreeSet<String> {
}

fn search_tokens(value: &str) -> Vec<String> {
value
.split(|character: char| !character.is_ascii_alphanumeric())
.filter_map(|term| {
let term = semantic_key(term);
(!term.is_empty()).then_some(term)
})
.collect()
let bytes = value.as_bytes();
let mut tokens = Vec::new();
let mut token = String::new();
for (index, byte) in bytes.iter().copied().enumerate() {
if !byte.is_ascii_alphanumeric() {
push_search_token(&mut tokens, &mut token);
continue;
}
let previous = index
.checked_sub(1)
.and_then(|previous| bytes.get(previous));
let next = bytes.get(index + 1);
let camel_boundary = !token.is_empty()
&& byte.is_ascii_uppercase()
&& previous.is_some_and(|previous| {
previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase() && next.is_some_and(u8::is_ascii_lowercase))
});
if camel_boundary {
push_search_token(&mut tokens, &mut token);
}
token.push(char::from(byte));
}
push_search_token(&mut tokens, &mut token);
tokens
}

fn push_search_token(tokens: &mut Vec<String>, token: &mut String) {
if token.is_empty() {
return;
}
let semantic = semantic_key(token);
token.clear();
if !semantic.is_empty() {
tokens.push(semantic);
}
}

fn semantic_key(value: &str) -> String {
Expand Down Expand Up @@ -1683,6 +1718,120 @@ mod tests {
);
}

#[test]
fn resolver_description_semantics_outrank_incidental_nested_list_matches() {
let semantic = semantic_catalogue();
let target_id = GraphqlExecutionTargetId::parse("communications-service").expect("target");
let mut mailbox = static_entry(
descriptor(
"communications.query.activity",
"Returns messages from the monitored support mailbox for operator triage.",
0,
),
&target_id,
"schema-v1",
&semantic.fingerprint,
"policy-v1",
)
.expect("mailbox entry");
mailbox.kind = AiCapabilityKind::GeneratedQuery;
mailbox.name = "CurrentActivity".to_owned();
mailbox.operation_name = "CurrentActivity".to_owned();
mailbox.operation_shape = AiCapabilityOperationShape::Custom;
mailbox.fingerprint = entry_fingerprint(&mailbox);

let mut records = static_entry(
descriptor("records.query.customer_cards", "List customer records.", 0),
&target_id,
"schema-v1",
&semantic.fingerprint,
"policy-v1",
)
.expect("record entry");
records.kind = AiCapabilityKind::GeneratedQuery;
records.name = "List CustomerRecord".to_owned();
records.entity_name = Some("CustomerRecord".to_owned());
records.operation_name = "CustomerRecords".to_owned();
records.operation_shape = AiCapabilityOperationShape::List;
records.relationships = vec![AiCapabilityRelationshipSummary {
name: "Assignments".to_owned(),
description: "Recent support activity associated with the customer.".to_owned(),
target_entity: "Assignment".to_owned(),
to_many: true,
arguments: BTreeSet::new(),
}];
records.fingerprint = entry_fingerprint(&records);

let entries =
BTreeMap::from([(mailbox.id.clone(), mailbox), (records.id.clone(), records)]);
let index = AiCapabilityIndex {
version: AI_CAPABILITY_INDEX_VERSION,
target_id,
schema_fingerprint: "schema-v1".to_owned(),
semantic_catalogue_fingerprint: semantic.fingerprint,
target_policy_fingerprint: "policy-v1".to_owned(),
entries,
fingerprint: "a".repeat(64),
limits: AiCapabilityIndexLimits::default(),
};
let result = index
.search(&AiCapabilitySearchQuery {
text: "list recent messages from the support mailbox".to_owned(),
namespace: None,
kind: Some(AiCapabilityKind::GeneratedQuery),
entity_or_operation: None,
maximum_results: 1,
})
.expect("resolver-hint search");
assert_eq!(result.candidates.len(), 1);
assert_eq!(
result.candidates[0].id.as_str(),
"communications.query.activity"
);
}

#[test]
fn camel_case_operation_names_supply_searchable_semantic_terms() {
let semantic = semantic_catalogue();
let target_id = GraphqlExecutionTargetId::parse("application").expect("target");
let mut entry = static_entry(
descriptor("application.query.activity", "Returns bounded records.", 0),
&target_id,
"schema-v1",
&semantic.fingerprint,
"policy-v1",
)
.expect("entry");
entry.kind = AiCapabilityKind::GeneratedQuery;
entry.name = "MonitoredMailboxMessages".to_owned();
entry.operation_name = "MonitoredMailboxMessages".to_owned();
entry.fingerprint = entry_fingerprint(&entry);
let index = AiCapabilityIndex {
version: AI_CAPABILITY_INDEX_VERSION,
target_id,
schema_fingerprint: "schema-v1".to_owned(),
semantic_catalogue_fingerprint: semantic.fingerprint,
target_policy_fingerprint: "policy-v1".to_owned(),
entries: BTreeMap::from([(entry.id.clone(), entry)]),
fingerprint: "b".repeat(64),
limits: AiCapabilityIndexLimits::default(),
};
let result = index
.search(&AiCapabilitySearchQuery {
text: "mailbox messages".to_owned(),
namespace: None,
kind: Some(AiCapabilityKind::GeneratedQuery),
entity_or_operation: None,
maximum_results: 1,
})
.expect("camel-case search");
assert_eq!(result.candidates.len(), 1);
assert_eq!(
result.candidates[0].operation_name,
"MonitoredMailboxMessages"
);
}

#[test]
fn ordinary_request_vocabulary_does_not_infer_operation_shape() {
for text in [
Expand Down
4 changes: 2 additions & 2 deletions crates/graphql-orm-ai/docs/implementation-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ title: "Implementation Status"
kind: reference
status: active
owner: graphql-orm-ai-maintainers
last_reviewed: 2026-09-01
last_reviewed: 2026-09-02
review_by: 2027-02-01
supersedes: []
---
Expand All @@ -12,7 +12,7 @@ supersedes: []

`graphql-orm-ai` is at crate version `0.95.14` with AI schema module
`0.64.0`. It uses workspace `graphql-orm` `0.30.0`, backend-neutral
`graphql-orm-ai-tool-profiles` `0.10.3`, and external `agql-auth`
`graphql-orm-ai-tool-profiles` `0.10.4`, and external `agql-auth`
`0.19.0` at `1d2e9fe2e1576105212a7b340a11abf8cad0382d`.

Completed stateless local-provider turns can carry a proof-bearing contained
Expand Down
Loading