Powered by AppSignal & Oban Pro

Azure Service Compatibility Advisor

azure_service_compatibility.livemd

Azure Service Compatibility Advisor

Mix.install([
  # {:datalog, path: Path.join(__DIR__, "..")},
  {:datalog, github: "chgeuer/ex_datalog"},
  {:kino, "~> 0.18"}
])

Setup

πŸ“š Other Examples: Cocktail Advisor | SecPAL DSL Tutorial

Motivation: As an Azure field engineer, I constantly check whether a certain technology permutation is supported β€” β€œCan I integrate App Service into a VNET with Private Link to talk to Blob Storage in West Europe, and is it GA?” This search is labor-intensive.

Worse, when a permutation isn’t supported during a customer conversation, and then months later a product group ships a preview, the feedback loop to the original customer is pure luck β€” you have to (a) notice the announcement and (b) remember which customer needed it.

This notebook models that problem using Datalog β€” the same engine that powers the cocktail advisor. Swap cocktails for Azure architectures, ingredients for service capabilities, and you get a β€œWhat can I build?” / β€œWhat’s missing?” / β€œWho’s unblocked now?” engine.

alias Datalog.{Database, Clause, Literal, Variable, Constant, Resolution, Substitution}

The Knowledge Base

We model Azure as a set of facts and rules.

Facts are ground truths β€” things we know from docs, release notes, or announcements:

Predicate Meaning
service(S) S is an Azure service
region(R) R is an Azure region
feature(F) F is a networking/integration feature
available_in(S, R) Service S is deployed in region R
supports(S, F, Status) Service S supports feature F at maturity Status (ga/preview)
compatible(S1, S2, F) Services S1 and S2 can be connected via feature F
customer_needs(C, S1, S2, F, R) Customer C needs S1↔S2 via F in region R

Rules derive new knowledge from existing facts:

Rule Meaning
can_use(S1, S2, F, R) The full permutation works (both available, compatible, feature GA)
can_use_preview(S1, S2, F, R) Same, but allowing preview features
blocked(C, S1, S2, F, R) Customer C needs something that doesn’t work yet
unblocked(C, S1, S2, F, R) Customer C was blocked, but it works now!
# --- Variables (reused across rules) ---
s = Variable.new(:s)
s1 = Variable.new(:s1)
s2 = Variable.new(:s2)
f = Variable.new(:f)
r = Variable.new(:r)
c = Variable.new(:c)
status = Variable.new(:status)

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Azure Services                                            β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

services = ~w(app_service blob_storage sql_database cosmos_db
              key_vault container_apps aks event_hubs service_bus)

service_facts =
  for svc <- services do
    Clause.fact(Literal.from_terms(:service, [svc]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Regions                                                    β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

regions = ~w(west_europe north_europe east_us west_us southeast_asia)

region_facts =
  for reg <- regions do
    Clause.fact(Literal.from_terms(:region, [reg]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Features                                                   β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

features = ~w(private_link vnet_integration service_endpoint managed_identity)

feature_facts =
  for feat <- features do
    Clause.fact(Literal.from_terms(:feature, [feat]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Regional availability                                      β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

availability = [
  # service              regions where it's available
  {"app_service",        ~w(west_europe north_europe east_us west_us southeast_asia)},
  {"blob_storage",       ~w(west_europe north_europe east_us west_us southeast_asia)},
  {"sql_database",       ~w(west_europe north_europe east_us west_us southeast_asia)},
  {"cosmos_db",          ~w(west_europe north_europe east_us west_us)},
  {"key_vault",          ~w(west_europe north_europe east_us west_us southeast_asia)},
  {"container_apps",     ~w(west_europe east_us west_us)},
  {"aks",                ~w(west_europe north_europe east_us west_us southeast_asia)},
  {"event_hubs",         ~w(west_europe north_europe east_us west_us)},
  {"service_bus",        ~w(west_europe north_europe east_us west_us southeast_asia)},
]

availability_facts =
  for {svc, regs} <- availability, reg <- regs do
    Clause.fact(Literal.from_terms(:available_in, [svc, reg]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Feature support (service, feature, maturity)               β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

support = [
  {"app_service",    "private_link",       "ga"},
  {"app_service",    "vnet_integration",   "ga"},
  {"app_service",    "managed_identity",   "ga"},
  {"blob_storage",   "private_link",       "ga"},
  {"blob_storage",   "service_endpoint",   "ga"},
  {"blob_storage",   "managed_identity",   "ga"},
  {"sql_database",   "private_link",       "ga"},
  {"sql_database",   "service_endpoint",   "ga"},
  {"sql_database",   "managed_identity",   "ga"},
  {"cosmos_db",      "private_link",       "ga"},
  {"cosmos_db",      "service_endpoint",   "ga"},
  {"cosmos_db",      "managed_identity",   "preview"},
  {"key_vault",      "private_link",       "ga"},
  {"key_vault",      "service_endpoint",   "ga"},
  {"key_vault",      "managed_identity",   "ga"},
  {"container_apps", "vnet_integration",   "ga"},
  {"container_apps", "managed_identity",   "ga"},
  # Container Apps Private Link is only preview
  {"container_apps", "private_link",       "preview"},
  {"aks",            "private_link",       "ga"},
  {"aks",            "vnet_integration",   "ga"},
  {"aks",            "managed_identity",   "ga"},
  {"event_hubs",     "private_link",       "ga"},
  {"event_hubs",     "service_endpoint",   "ga"},
  {"event_hubs",     "managed_identity",   "ga"},
  {"service_bus",    "private_link",       "ga"},
  {"service_bus",    "service_endpoint",   "ga"},
  {"service_bus",    "managed_identity",   "ga"},
]

support_facts =
  for {svc, feat, maturity} <- support do
    Clause.fact(Literal.from_terms(:supports, [svc, feat, maturity]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Facts: Pairwise compatibility (which services can talk via what)  β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

compatibility = [
  {"app_service",    "blob_storage",   "private_link"},
  {"app_service",    "blob_storage",   "service_endpoint"},
  {"app_service",    "sql_database",   "private_link"},
  {"app_service",    "sql_database",   "service_endpoint"},
  {"app_service",    "cosmos_db",      "private_link"},
  {"app_service",    "key_vault",      "private_link"},
  {"app_service",    "event_hubs",     "private_link"},
  {"app_service",    "service_bus",    "private_link"},
  {"container_apps", "blob_storage",   "private_link"},
  {"container_apps", "sql_database",   "private_link"},
  {"container_apps", "cosmos_db",      "private_link"},
  {"container_apps", "key_vault",      "private_link"},
  {"container_apps", "event_hubs",     "private_link"},
  {"container_apps", "service_bus",    "private_link"},
  {"aks",            "blob_storage",   "private_link"},
  {"aks",            "sql_database",   "private_link"},
  {"aks",            "cosmos_db",      "private_link"},
  {"aks",            "key_vault",      "private_link"},
  {"aks",            "event_hubs",     "private_link"},
  {"aks",            "service_bus",    "private_link"},
]

compatibility_facts =
  for {from, to, feat} <- compatibility do
    Clause.fact(Literal.from_terms(:compatible, [from, to, feat]))
  end

:ok

Deriving Knowledge: Rules

This is where Datalog shines. Instead of writing imperative queries, we declare what it means for a permutation to work, and the engine figures out which ones do.

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Rule: can_use(S1, S2, F, R) β€” full GA permutation works          β”‚
# β”‚                                                                    β”‚
# β”‚ can_use(S1, S2, F, R) :-                                          β”‚
# β”‚   compatible(S1, S2, F),                                           β”‚
# β”‚   available_in(S1, R),                                             β”‚
# β”‚   available_in(S2, R),                                             β”‚
# β”‚   supports(S1, F, "ga"),                                           β”‚
# β”‚   supports(S2, F, "ga").                                           β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

can_use_rule = Clause.new(
  Literal.from_terms(:can_use, [s1, s2, f, r]),
  [
    Literal.from_terms(:compatible, [s1, s2, f]),
    Literal.from_terms(:available_in, [s1, r]),
    Literal.from_terms(:available_in, [s2, r]),
    Literal.from_terms(:supports, [s1, f, "ga"]),
    Literal.from_terms(:supports, [s2, f, "ga"]),
  ]
)

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Rule: can_use_any β€” works with any maturity (incl. preview)       β”‚
# β”‚                                                                    β”‚
# β”‚ can_use_any(S1, S2, F, R) :-                                      β”‚
# β”‚   compatible(S1, S2, F),                                           β”‚
# β”‚   available_in(S1, R),                                             β”‚
# β”‚   available_in(S2, R),                                             β”‚
# β”‚   supports(S1, F, _Status1),                                      β”‚
# β”‚   supports(S2, F, _Status2).                                      β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

can_use_any_rule = Clause.new(
  Literal.from_terms(:can_use_any, [s1, s2, f, r]),
  [
    Literal.from_terms(:compatible, [s1, s2, f]),
    Literal.from_terms(:available_in, [s1, r]),
    Literal.from_terms(:available_in, [s2, r]),
    Literal.from_terms(:supports, [s1, f, Variable.new(:_status1)]),
    Literal.from_terms(:supports, [s2, f, Variable.new(:_status2)]),
  ]
)

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Rule: preview_only β€” works, but only because of preview features  β”‚
# β”‚                                                                    β”‚
# β”‚ preview_only(S1, S2, F, R) :-                                     β”‚
# β”‚   can_use_any(S1, S2, F, R),                                      β”‚
# β”‚   not can_use(S1, S2, F, R).                                      β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

preview_only_rule = Clause.new(
  Literal.from_terms(:preview_only, [s1, s2, f, r]),
  [
    Literal.from_terms(:can_use_any, [s1, s2, f, r]),
    Literal.negate(Literal.from_terms(:can_use, [s1, s2, f, r])),
  ]
)

rules = [can_use_rule, can_use_any_rule, preview_only_rule]

:ok

Assembling the Database

all_clauses =
  service_facts ++
  region_facts ++
  feature_facts ++
  availability_facts ++
  support_facts ++
  compatibility_facts ++
  rules

db = Database.new(all_clauses)

IO.puts("Database loaded: #{Database.size(db)} clauses")
IO.puts("  Services:      #{length(services)}")
IO.puts("  Regions:        #{length(regions)}")
IO.puts("  Compatibility:  #{length(compatibility)} pairs")
IO.puts("  Rules:          #{length(rules)}")

Query 1: What GA permutations work in West Europe?

This is the bread-and-butter question: β€œWhat can I actually recommend to a customer in West Europe today?”

ctx = %Resolution.Context{database: db, max_depth: 100}

goal = Literal.from_terms(:can_use, [s1, s2, f, Constant.new("west_europe")])
results = Resolution.query_substitutions(goal, ctx)

results
|> Enum.map(fn subst ->
  {:ok, svc1} = Substitution.lookup(subst, s1)
  {:ok, svc2} = Substitution.lookup(subst, s2)
  {:ok, feat} = Substitution.lookup(subst, f)
  %{from: svc1.value, to: svc2.value, via: feat.value}
end)
|> Enum.sort_by(&amp;{&amp;1.from, &amp;1.to, &amp;1.via})
|> Kino.DataTable.new(name: "GA permutations in West Europe")

Query 2: What’s only available in preview?

These are the risky recommendations β€” works, but not production-blessed yet.

goal = Literal.from_terms(:preview_only, [s1, s2, f, r])
results = Resolution.query_substitutions(goal, ctx)

results
|> Enum.map(fn subst ->
  {:ok, svc1} = Substitution.lookup(subst, s1)
  {:ok, svc2} = Substitution.lookup(subst, s2)
  {:ok, feat} = Substitution.lookup(subst, f)
  {:ok, reg}  = Substitution.lookup(subst, r)
  %{from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Enum.sort_by(&amp;{&amp;1.from, &amp;1.to})
|> Kino.DataTable.new(name: "Preview-only permutations (not yet GA)")

Query 3: Can I use App Service + Blob Storage via Private Link in West Europe?

The specific question from the journal entry.

goal = Literal.from_terms(:can_use, [
  Constant.new("app_service"),
  Constant.new("blob_storage"),
  Constant.new("private_link"),
  Constant.new("west_europe")
])

results = Resolution.query_substitutions(goal, ctx)

case results do
  [_ | _] -> IO.puts("βœ… Yes! App Service β†’ Blob Storage via Private Link in West Europe is GA.")
  []      -> IO.puts("❌ No. This permutation is not available (or not GA) yet.")
end

The Customer Notification Scenario

This is the second (and arguably more valuable) scenario: tracking what customers need, and automatically detecting when a previously-blocked requirement becomes possible.

We model customer requirements as facts, then use rules and negation-as-failure to find who is blocked and β€” after adding new capabilities β€” who is newly unblocked.

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Customer requirements                                             β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

customer_needs = [
  # Contoso wants Container Apps β†’ Blob Storage via Private Link in West Europe
  # (this requires Container Apps Private Link to go GA β€” currently preview!)
  {"contoso",   "container_apps", "blob_storage",  "private_link", "west_europe"},

  # Fabrikam wants Container Apps β†’ SQL Database via Private Link in East US
  {"fabrikam",  "container_apps", "sql_database",  "private_link", "east_us"},

  # Northwind wants App Service β†’ Cosmos DB via Private Link in Southeast Asia
  # (Cosmos DB not available in Southeast Asia yet!)
  {"northwind", "app_service",    "cosmos_db",     "private_link", "southeast_asia"},

  # Woodgrove wants AKS β†’ Event Hubs via Private Link in West Europe (should work!)
  {"woodgrove", "aks",            "event_hubs",    "private_link", "west_europe"},
]

customer_facts =
  for {cust, svc1, svc2, feat, reg} <- customer_needs do
    Clause.fact(Literal.from_terms(:customer_needs, [cust, svc1, svc2, feat, reg]))
  end

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Rule: blocked(C, S1, S2, F, R) β€” customer needs it, can't do it  β”‚
# β”‚                                                                    β”‚
# β”‚ blocked(C, S1, S2, F, R) :-                                       β”‚
# β”‚   customer_needs(C, S1, S2, F, R),                                β”‚
# β”‚   not can_use(S1, S2, F, R).                                      β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

blocked_rule = Clause.new(
  Literal.from_terms(:blocked, [c, s1, s2, f, r]),
  [
    Literal.from_terms(:customer_needs, [c, s1, s2, f, r]),
    Literal.negate(Literal.from_terms(:can_use, [s1, s2, f, r])),
  ]
)

# β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
# β”‚ Rule: satisfied β€” customer needs it and it works at GA            β”‚
# β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

satisfied_rule = Clause.new(
  Literal.from_terms(:satisfied, [c, s1, s2, f, r]),
  [
    Literal.from_terms(:customer_needs, [c, s1, s2, f, r]),
    Literal.from_terms(:can_use, [s1, s2, f, r]),
  ]
)

db_with_customers = Database.new(
  all_clauses ++ customer_facts ++ [blocked_rule, satisfied_rule]
)

ctx2 = %Resolution.Context{database: db_with_customers, max_depth: 100}

:ok

Who’s blocked right now?

goal = Literal.from_terms(:blocked, [c, s1, s2, f, r])
blocked_results = Resolution.query_substitutions(goal, ctx2)

blocked_results
|> Enum.map(fn subst ->
  {:ok, cust} = Substitution.lookup(subst, c)
  {:ok, svc1} = Substitution.lookup(subst, s1)
  {:ok, svc2} = Substitution.lookup(subst, s2)
  {:ok, feat} = Substitution.lookup(subst, f)
  {:ok, reg}  = Substitution.lookup(subst, r)
  %{customer: cust.value, from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Kino.DataTable.new(name: "🚫 Blocked customers")

Who’s satisfied right now?

goal = Literal.from_terms(:satisfied, [c, s1, s2, f, r])
satisfied_results = Resolution.query_substitutions(goal, ctx2)

satisfied_results
|> Enum.map(fn subst ->
  {:ok, cust} = Substitution.lookup(subst, c)
  {:ok, svc1} = Substitution.lookup(subst, s1)
  {:ok, svc2} = Substitution.lookup(subst, s2)
  {:ok, feat} = Substitution.lookup(subst, f)
  {:ok, reg}  = Substitution.lookup(subst, r)
  %{customer: cust.value, from: svc1.value, to: svc2.value, via: feat.value, region: reg.value}
end)
|> Kino.DataTable.new(name: "βœ… Satisfied customers")

Simulating an Announcement: Container Apps Private Link goes GA!

Months later, the Container Apps team announces GA for Private Link. We add the new fact and re-evaluate β€” who just got unblocked?

# The announcement: Container Apps Private Link is now GA!
new_fact = Clause.fact(Literal.from_terms(:supports, ["container_apps", "private_link", "ga"]))

db_after_announcement = Database.add(db_with_customers, new_fact)
ctx3 = %Resolution.Context{database: db_after_announcement, max_depth: 100}

# Re-query blocked customers
goal = Literal.from_terms(:blocked, [c, s1, s2, f, r])
still_blocked = Resolution.query_substitutions(goal, ctx3)

still_blocked_set =
  still_blocked
  |> Enum.map(fn subst ->
    {:ok, cust} = Substitution.lookup(subst, c)
    {:ok, svc1} = Substitution.lookup(subst, s1)
    {:ok, svc2} = Substitution.lookup(subst, s2)
    {:ok, feat} = Substitution.lookup(subst, f)
    {:ok, reg}  = Substitution.lookup(subst, r)
    {cust.value, svc1.value, svc2.value, feat.value, reg.value}
  end)
  |> MapSet.new()

# Compare: who was blocked before but isn't anymore?
previously_blocked_set =
  blocked_results
  |> Enum.map(fn subst ->
    {:ok, cust} = Substitution.lookup(subst, c)
    {:ok, svc1} = Substitution.lookup(subst, s1)
    {:ok, svc2} = Substitution.lookup(subst, s2)
    {:ok, feat} = Substitution.lookup(subst, f)
    {:ok, reg}  = Substitution.lookup(subst, r)
    {cust.value, svc1.value, svc2.value, feat.value, reg.value}
  end)
  |> MapSet.new()

newly_unblocked = MapSet.difference(previously_blocked_set, still_blocked_set)

IO.puts("πŸ“’ Announcement: Container Apps Private Link is now GA!\n")

if MapSet.size(newly_unblocked) > 0 do
  IO.puts("πŸŽ‰ Newly unblocked customers β€” send them an email!\n")

  newly_unblocked
  |> Enum.each(fn {cust, svc1, svc2, feat, reg} ->
    IO.puts("  πŸ“§ #{cust}: #{svc1} β†’ #{svc2} via #{feat} in #{reg} now works!")
  end)
else
  IO.puts("No customers were unblocked by this announcement.")
end

IO.puts("\n🚫 Still blocked:")

still_blocked
|> Enum.each(fn subst ->
  {:ok, cust} = Substitution.lookup(subst, c)
  {:ok, svc1} = Substitution.lookup(subst, s1)
  {:ok, svc2} = Substitution.lookup(subst, s2)
  {:ok, feat} = Substitution.lookup(subst, f)
  {:ok, reg}  = Substitution.lookup(subst, r)
  IO.puts("  ⏳ #{cust.value}: #{svc1.value} β†’ #{svc2.value} via #{feat.value} in #{reg.value}")
end)

What’s Next?

This is a proof of concept. In a real system, you’d extend it with:

  • Temporal facts β€” supports(S, F, ga, ~D[2025-03-15]) to track when something became available
  • Source tracking β€” documented_at(S, F, "https://learn.microsoft.com/...") to link back to docs
  • Transitive connectivity β€” if A talks to B via Private Link and B talks to C via Private Link, can A reach C?
  • Constraint predicates β€” SKU requirements, throughput limits, pricing tier prerequisites
  • Feed ingestion β€” parse Azure updates RSS / change logs into new facts, auto-detect unblocked customers
  • A Phoenix LiveView UI β€” browse the knowledge graph, subscribe to customer notifications