TECHNICAL

The Data Access Service, Part 3: Enterprise Data Governance & Multi-Source Intelligence

February 22, 2026 9 min read Part 3 of 3

FireFoundry Team

Engineering

Parts 1 and 2 of this series covered data access fundamentals and advanced business modeling. But enterprise deployments need more than access and intelligence -- they need governance. Who can see what data? How do agents handle multi-step analyses? How does the system resolve ambiguous business terms like "Nike" or "MSFT"? And what happens when the answer requires combining data from multiple databases? Part 3 covers the capabilities that make DAS enterprise-ready: row-level security, scratch pad working memory, named entity recognition, and cross-database federation.

Code examples are simplified for clarity. See the DAS documentation for complete API reference.

Row-Level Security: Agents See Only What They Should

When an AI agent queries your orders table, should it see all orders or only the ones belonging to its assigned customers? Row-level security (RLS) in DAS answers this question through security predicates -- AST expressions attached to stored view definitions that are transparently injected as WHERE clauses at query time.

How It Works

Three Variable Resolution Strategies

// A view definition with a security predicate.
// When user:alice queries this view, DAS automatically adds:
//   WHERE customer_id = 42
// (resolved via lookup: alice@example.com -> customer_id 42)

// The agent's code doesn't change — security is transparent:
const result = await dasClient.query('firekicks', {
  sql: 'SELECT * FROM my_orders WHERE order_date > $1',
  params: ['2025-01-01'],
});

// Alice sees only her orders. Bob sees only his.
// The agent code is identical for both — DAS handles the filtering.

Security predicates compose with everything else: views, business rules, and the caller's own WHERE clauses all stack. A sales manager might see orders for their entire team, while a sales rep sees only their own -- same view, same agent code, different identity resolution.

// What the database actually executes for user:alice:
// SELECT * FROM orders
//   WHERE customer_id = 42              -- security predicate (injected)
//     AND order_status != 'cancelled'   -- business rule (hard_enforced)
//     AND order_date > '2025-01-01'     -- caller's filter

Scratch Pad: Working Memory for Data Analysis

Real analysis is rarely a single query. An agent answering "Which of our top 100 customers have decreased their order frequency this quarter?" needs to:

  1. Find the top 100 customers by lifetime value
  2. Calculate each customer's order frequency this quarter
  3. Compare against their historical average

Steps 2 and 3 depend on step 1's results. The scratch pad is DAS's answer to this problem: per-identity SQLite databases that store intermediate results for use in subsequent queries.

How It Works

import { DataAccessClient } from '@firefoundry/data-access-client';

const dasClient = new DataAccessClient({
  serviceUrl: process.env.FF_DATA_SERVICE_URL,
});

// Step 1: Find top customers, save to scratch pad
const topCustomers = await dasClient.queryAST('firekicks', {
  select: {
    columns: [
      { expr: { column: { column: 'customer_id' } } },
      { expr: { column: { column: 'first_name' } } },
      { expr: { column: { column: 'lifetime_value' } } },
    ],
    from: { table: { table: 'customers' } },
    orderBy: [{ expr: { column: { column: 'lifetime_value' } }, dir: 'SORT_DESC' }],
    limit: 100,
  },
  saveAs: 'top_customers', // Saved to scratch:user:alice
});

// Step 2: Query the scratch pad results alongside live data
const result = await dasClient.queryAST('firekicks', {
  select: {
    columns: [
      { expr: { column: { table: 'tc', column: 'customer_id' } } },
      { expr: { column: { table: 'tc', column: 'first_name' } } },
      { expr: { function: { name: 'count', args: [{ star: {} }] } }, alias: 'order_count' },
    ],
    from: { table: { table: 'orders', alias: 'o' } },
    joins: [{
      type: 'JOIN_INNER',
      table: { table: 'top_customers', alias: 'tc', schema: 'scratch' },
      on: {
        binary: {
          op: 'BINARY_OP_EQ',
          left: { column: { table: 'o', column: 'customer_id' } },
          right: { column: { table: 'tc', column: 'customer_id' } },
        },
      },
    }],
    groupBy: [
      { expr: { column: { table: 'tc', column: 'customer_id' } } },
      { expr: { column: { table: 'tc', column: 'first_name' } } },
    ],
  },
});

Scratch pad limits keep things bounded: maximum 1,000 rows per staged result, 10MB total staged data, 10 staged queries per request. This is working memory, not a data warehouse.

Named Entity Recognition: Resolving Business Terms to Data Values

When a user says "Show me Nike orders," the agent needs to map "Nike" to the actual database value -- which might be "NIKE, INC." in the vendor table. Named Entity Recognition (NER) in DAS handles this through value stores with fuzzy matching.

Value stores are indexed, searchable tables of canonical values pulled from your source databases. When the agent needs to resolve a term, DAS searches across six matching strategies, each with a different weight:

Results are returned as ranked candidates with scores, so the agent can take the top match or present options to the user.

const resolution = await dasClient.resolveValues({
  domain: 'product',
  queries: [
    { term: 'Nike', entityTypes: ['Vendor'] },
    { term: 'NB', entityTypes: ['Vendor'] },
  ],
  maxCandidates: 5,
  minScore: 0.3,
});

// resolution.results[0] — "Nike":
//   candidates: [
//     { value: "NIKE, INC.", score: 0.95, strategy: "prefix" },
//     { value: "NIKE SUBSIDIARIES", score: 0.82, strategy: "prefix" },
//   ]

// resolution.results[1] — "NB":
//   candidates: [
//     { value: "New Balance", score: 0.88, strategy: "initials" },
//   ]

Personalized Scopes

Personalized scopes add a learning layer. NER resolves values in priority order:

  1. User scope -- terms this specific user has confirmed before
  2. Team scope -- terms confirmed by the user's team
  3. System scope -- automatically promoted after 3+ distinct users confirm the same mapping
  4. Primary scope -- raw source data values

The learning loop is simple: when the agent resolves a term and the user confirms the match, confirm-match records the association. After enough users confirm the same mapping, it becomes system knowledge. Over time, the system gets better at resolving your organization's vocabulary -- without any manual configuration.

// After the user confirms "MSFT" means Microsoft Corp:
await dasClient.confirmMatch({
  term: 'MSFT',
  valueRowId: 456,
  storeName: 'vendors',
});

// Next time anyone resolves "MSFT", the confirmed match
// is ranked higher. After 3+ users confirm the same mapping,
// it auto-promotes to system scope — visible to everyone.

Cross-Database Federation: Combining Data Across Backends

The question that every multi-database organization eventually asks: "Can I join data from PostgreSQL and MySQL in a single query?" With DAS staged queries, the answer is yes.

Staged queries let you define a set of sub-queries, each targeting a different database connection. DAS:

  1. Builds a dependency graph between the queries
  2. Groups independent queries into execution tiers (parallel execution)
  3. Executes each tier, collecting results
  4. Injects results from earlier tiers as VALUES CTEs into later queries
  5. Handles dialect-specific serialization to ensure correct syntax for each backend
// Example: Join customer data from PostgreSQL warehouse
// with CRM activity logs from MySQL

const result = await dasClient.queryAST('analytics', {
  stagedQueries: [
    {
      alias: 'warehouse_customers',
      connection: 'pg-warehouse',  // PostgreSQL
      query: {
        columns: [
          { expr: { column: { column: 'customer_id' } } },
          { expr: { column: { column: 'name' } } },
          { expr: { column: { column: 'segment' } } },
          { expr: { column: { column: 'lifetime_value' } } },
        ],
        from: { table: { table: 'customers' } },
        where: {
          binary: {
            op: 'BINARY_OP_GT',
            left: { column: { column: 'lifetime_value' } },
            right: { literal: { numberValue: 10000 } },
          },
        },
      },
    },
    {
      alias: 'crm_activity',
      connection: 'mysql-crm',  // MySQL
      query: {
        columns: [
          { expr: { column: { column: 'customer_id' } } },
          { expr: { column: { column: 'last_contact_date' } } },
          { expr: { column: { column: 'contact_type' } } },
        ],
        from: { table: { table: 'activity_log' } },
        where: {
          binary: {
            op: 'BINARY_OP_GT',
            left: { column: { column: 'last_contact_date' } },
            right: { literal: { stringValue: '2025-01-01' } },
          },
        },
      },
    },
  ],
  // Main query joins the staged results
  select: {
    columns: [
      { expr: { column: { table: 'wc', column: 'name' } } },
      { expr: { column: { table: 'wc', column: 'segment' } } },
      { expr: { column: { table: 'ca', column: 'last_contact_date' } } },
      { expr: { column: { table: 'ca', column: 'contact_type' } } },
    ],
    from: { table: { table: 'warehouse_customers', alias: 'wc' } },
    joins: [{
      type: 'JOIN_LEFT',
      table: { table: 'crm_activity', alias: 'ca' },
      on: {
        binary: {
          op: 'BINARY_OP_EQ',
          left: { column: { table: 'wc', column: 'customer_id' } },
          right: { column: { table: 'ca', column: 'customer_id' } },
        },
      },
    }],
    orderBy: [{ expr: { column: { table: 'ca', column: 'last_contact_date' } }, dir: 'SORT_DESC' }],
  },
});

Both staged queries are in tier 0 (independent), so they execute in parallel. The main query runs after both complete, with their results injected as CTEs. The agent gets a single unified result set -- no manual ETL, no temporary tables in the warehouse, no cross-database driver juggling.

The response includes stagedStats with execution details for each sub-query:

{
  "stagedStats": {
    "stagedQueryCount": 2,
    "totalStagedRows": 245,
    "details": [
      {
        "alias": "warehouse_customers",
        "connection": "pg-warehouse",
        "tier": 0,
        "rowCount": 150,
        "durationMs": 32
      },
      {
        "alias": "crm_activity",
        "connection": "mysql-crm",
        "tier": 0,
        "rowCount": 95,
        "durationMs": 28
      }
    ]
  }
}

The Complete Picture

Across three articles, we have covered the full Data Access Service architecture. Here is how the pieces fit together:

DAS is not a database proxy. It is a data intelligence platform that makes enterprise data AI-ready -- governed, understood, and accessible across every backend your organization uses.

The Data Access Service Series

3 Enterprise Data Governance & Multi-Source Intelligence (You are here)

Getting Started

The Data Access Service is available to all FireFoundry beta partners. To start using it:

For a hands-on walkthrough, the Query Explainer tutorial builds a complete agent that uses DAS to explain SQL query performance step by step. The AI-Powered Data Science tutorial shows how to combine DAS with a code sandbox to let agents run data analysis pipelines against live databases.

Ready to connect your data? If you are already in the beta, configure a connection in the console and start querying today. If you are not yet in the beta, request access and we will get you set up.

FireFoundry Team

Engineering

The FireFoundry team builds enterprise infrastructure for AI agents. We are engineers, product thinkers, and operators who have spent years building and scaling production AI systems. Our mission is to close the gap between AI prototypes and production-grade software.

Related Posts

Request Beta Access

FireFoundry is now in private beta. Join the teams already building production AI agents on the Agent-as-a-Service platform.