Where your data goes

Back to Vantis Steering

Almost none of it leaves.

The timesheets, rate cards, quotations, estimates, source code and all four databases are read by plain code on the machine doing the reading. Only prose that needs reading comprehension is sent, and only the part that does.

10

passages per search

about 900 bytes each — the only document text sent while answering. Separately, all 3,854 passages were embedded once when the index was built.

1

resource reached

your own Azure AI Foundry, in Sweden — the chat model and the embeddings are both deployments inside it. No third party is in the path.

0

databases sent

the four systems of record and vst_derived are read by plain code, locally, and go nowhere.

One requirement, every hop.

A real run against CR-K2-0101 — “the steering system shall deliver a peak rack force of at least 8000 N” — in the order it happened, with what actually moved at each step. Tool and argument names are the ones in the source; the timings are from the recorded run.

7 turns means 7 requests to the model — countable, and checkable against the cost line the desk prints under every answer. 6 tool calls, 77 seconds end to end, 0 schema retries.

It ends in a refusal, and that is the specimen worth showing: the pricing tool found no comparable work and declined to invent a number. Twenty-three of the twenty-four requirements in this bid end the same way.

1Before any model — a number is looked up, on your side

Nothing has left your network. A requirement number is looked up on your own server, and an unauthenticated request never gets this far.

POST /api/assess
{ "ref": "CR-K2-0101" }          <- a number, not a document

0 model requests. 0 bytes leave. `authorize()` refuses an unauthenticated request before any database is opened, and `fetchRequirement` reads one row from vst_alm locally.

  1. 1

    The bid engineer picks a requirementbrowser

    A requirement number leaves the browser. Nothing else — no document, no timesheet, no customer data.

    POST /api/assess
    { "ref": "CR-K2-0101", "loop": "sdk" }

    A reference, not a question. The requirement text is not sent from the browser — it is read server-side from the system of record, so what gets assessed is what the customer actually wrote rather than what someone pasted.

  2. 2

    The guard runs firstyour network

    Nothing moves. An unauthenticated request is refused here, before any database is opened. Network isolation is a control still to arrange.

    authorize({ configuredKey, presentedKey, isDev })

    Fail-closed. No key configured means refuse, not allow. Nothing is parsed and nothing is connected to until this passes.

  3. 3

    The requirement is read — the one system-of-record touchyour network

    One row is read from your requirements database, on your own machine. It goes no further than this server.

    fetchRequirement('CR-K2-0101')
      -> new Client({ connectionString: urlFor('vst_alm') })

    The only read of a system of record on the whole request path; every other query in this walkthrough goes to the derived database. It returns the in-force revision only, because "in force" is a claim and `effective_to is null` is what backs it.

2The requirement’s own words go out — 98 bytes

The requirement’s own words leave, to one host: your Azure AI Foundry resource in Sweden. No documents, no database rows, no timesheets.

"The steering system shall deliver a peak rack force of at
 least 8000 N at 20 °C and 13.5 V supply."          98 bytes

1 model request. The requirement text plus two tool definitions — names, descriptions and argument shapes. No document has been read yet, so none can be sent.

  1. 4

    The requirement and the tool definitions go outcrosses to the model

    The requirement’s own words leave your network, to one host: your Azure AI Foundry resource in Sweden. No documents and no database rows are attached.

    messages: [ system prompt, the requirement as written ]
    tools:    [ search_documents, find_comparable_work ]
    store:    false

    The prompt is an ordered procedure; the tools are names, descriptions and argument shapes. One requirement — 8000 N of rack force, ASIL B, verification by test — and no programme documents, no past jobs, no rates. None of that has been read yet, so none of it can be sent.

  2. 5

    It asks what the programme saysfrom the model

    Nothing leaves. This is the model naming a tool; the request is served on your own machine.

    search_documents({
      question: "rack force capacity 8000 N verification",
      doc_type: null,
      programme: "PRG-KST-K2"
    })

    `programme` is the argument that matters. The corpus states the same requirement, in the same words, for sixty-seven different cars — without it the search answers for all of them at once and the answer is confidently about the wrong vehicle.

3Five searches — 40 passages out, 1,069 files stay

Up to 10 passages leave per search, about 900 bytes each. This is where the one personal field travels: each closure report names its author.

requirements/PRG-KST-K2/CRS-KST-K2-001_RevB.md:41
  "… at least 8000 N at the rack …"
  Prepared by: I. Arnaud      <- 12 people across 220 reports

5 model requests. Passages returned: 10, 0, 10, 10, 10 — in 1487, 934, 2092, 1389 and 1523 ms, every search against your own Postgres. The second found nothing and that was returned as nothing.

  1. 6

    Hybrid search over your Postgresyour network

    Nothing leaves. Your index is read locally; the 1,069 files stay where they are.

    vst_derived.document_chunks
    
      1.  10 passages from 4 files    1487ms
      2.   0 passages from 0 files     934ms
      3.  10 passages from 5 files    2092ms
      4.  10 passages from 4 files    1389ms
      5.  10 passages from 4 files    1523ms

    Meaning and keyword together, fused by RRF, over vectors in your own database. Note the second call: zero results, returned as zero. An empty answer is a fact about the corpus and the model is allowed to have it — a search that quietly widened until it found something would be inventing evidence.

  2. 7

    Only the matched passages crosscrosses to the model

    Up to 10 passages leave, to the same one host — about 900 bytes each. The rest of the corpus is not sent, not summarised and not named.

    role: "tool", name: "search_documents"
    content: [ 10 passages — file, line, and the text ]

    Each passage carries the file it came from and the line it starts on. The other thousand-odd files in the corpus are not sent, not summarised, and not named.

  3. 8

    It reads two revisions against each otherfrom the model

    Nothing leaves. This is the model comparing passages it has already been given.

    CRS-KST-K2-001_RevB.md  ·  CRS-KST-K2-001_RevA.md
    system-requirements-PRG-KST-K2.md

    Searches 4 and 5 pull Rev A and Rev B of the same specification. That is the model checking whether the wording changed — which is where the conflict in the final answer comes from, rather than from a rule somebody wrote down in advance.

4Five labels out, a refusal back

Five short labels leave. No hours, no rates, no customer names. The provider is told to retain nothing — though Azure keeps prompts up to 30 days for abuse monitoring, which is still to be turned off.

change_class: "validation_only"   asil: "B"
element_kind: "mechanical"        tooling_required: false
safety_case_impact: false

1 model request. `find_comparable_work` matched 0 of 203 jobs with attributable hours and returned no price, in 2455 ms. The floor is 3 — below it there is no median worth quoting.

  1. 9

    It classifies the work and asks for comparable jobsfrom the model

    Five short labels leave — the kind of change, the part, the safety level. No hours, no rates, no customer names.

    find_comparable_work({
      change_class:        "validation_only",
      element_kind:        "mechanical",
      asil:                "B",
      safety_case_impact:  false,
      tooling_required:    false
    })

    The model does not estimate. It describes the work in the five terms the history is indexed by, and the tool decides whether there is enough of it to answer.

  2. 10

    Four derived tables, and a countyour network

    Nothing leaves. Your timesheets and rate cards are read locally and never sent; only a count comes back.

    vst_derived  derived_effort · effort_split_lines
                 rate_card_lines · extracted_facts
    
    0 of 203 jobs with attributable hours match    2455ms

    The systems of record are not opened here. The work of reading them happened offline in `derived:grade`, which is what makes a per-request answer take two seconds instead of twenty.

  3. 11

    The refusal crosses — with its arithmeticcrosses to the model

    Counts leave — how many past jobs matched — and no money and no hours. The provider is told to retain nothing, though prompts are kept up to 30 days for abuse monitoring, which is an open item.

    "Only 0 past job(s) match … out of 203 with attributable
     hours. Fewer than 3 is not enough for a median to mean
     anything, so there is no price.
    
     What each part of the filter costs you —
       safety_case_impact: 146 on its own, 0 without it
       asil:                43 on its own, 0 without it
       change_class:        19 on its own, 0 without it
       element_kind:        17 on its own, 0 without it
    
     Those are COUNTS, not prices."

    The tool will not take a median of two jobs, and it says why in terms the reader can check. It also refuses to hand back a cheaper number by dropping a filter — a median over a wider set is a different number about different work.

5The answer, checked against the files and filed

Nothing further leaves. The answer is checked, then written to your own database on a connection that cannot reach a system of record.

"finding": "change_needed",  "cost": { "eur": null }
insert into assess_history …        <- your database

0 further model requests. 6 citations were checked against the files they name — 3 exact, 5 corrected, 0 unresolved — then the answer was validated and written to your own database.

  1. 12

    The assessment comes back as structured datafrom the model

    The answer comes back. Nothing further leaves after this point.

    {
      "requirement_ref": "CR-K2-0101",
      "finding": "change_needed",
      "citations": [ 6 — file, line, quote ],
      "conflicts": [{ "about": "measurement point for the
          required 8000 N (at the rack vs at the motor)" }],
      "unverified_claims": [ "No supplier test report …" ],
      "cost": { "comparable_jobs": 0, "eur": null,
                "refused_because": "…" },
      "decisions_for_human": [ … ]
    }

    Note what the schema has no field for: there is no price unless the tool produced one, and `refused_because` is where the absence has to be explained. It is strict, so a number cannot be smuggled in as an extra key.

  2. 13

    Every quoted line is checked against the fileyour network

    Nothing leaves. Files are opened on your own machine to correct the line numbers.

    resolve-citations.ts  ->  CORPUS_DIR
    
      3 exact  ·  5 corrected  ·  0 unresolved

    The only filesystem read in a request, and it runs after the model has finished. A retrieved passage knows the line it STARTS on; the sentence quoted from it may be eleven lines further down. Five of eight line numbers on this run were wrong and were repaired by finding the quote. A value the model cannot know is a value it will approximate.

  3. 14

    Validated, then recordedyour network

    Nothing leaves. The answer is written to your own database, on a connection that cannot reach a system of record.

    shape -> coherence -> insert into assess_history
      connection: derivedUrl(), never urlFor()

    JSON Schema checks the shape; a second layer checks sense — an unresolved conflict with nobody named to decide it is rejected, because that is the system quietly picking a side. The write resolves a different connection entirely, so it cannot reach a system of record even by mistake.

  4. 15

    The assessment appearsbrowser

    Nothing leaves. The reader sees the result; one personal field travelled in it — the author’s name on each report.

    event: answer

    Tool steps stream as they happen, because a seventy-seven-second walk with nothing on screen looks broken. The assessment itself is sent once, whole, after it has passed validation.

Before any of that: building the index.

One pass over the corpus, once, so that a question can be answered from passages instead of from whole files. It is the only time the full corpus text is sent anywhere — and it goes to the same Foundry resource in your own tenant that the model is deployed in.

1Once, before any question is asked

Every file in the corpus is read on your own machine and cut into passages. Nothing has left yet.

1,069 files  ->  3,854 passages
average 137 tokens each, longest 1,520 characters

0 model requests. Closure reports, requirements and the EPS source, split so a clause or a function keeps its context.

  1. 1

    The corpus is read and splityour network

    Nothing leaves. Files are read from disk and cut into passages, locally.

    docs/steering/corpus/  ->  3,854 passages

    Prose is split by heading and code by function, so a passage is a unit somebody could actually cite rather than a fixed number of characters.

2Each passage is embedded — this is the part that is sent

The text of all 3,854 passages goes to your own Azure AI Foundry resource, once. The passage text is sent; a list of numbers comes back.

text-embedding-3-small   1536 numbers per passage
3,854 passages, one time, EMBEDDINGS=hosted (the default)

This is the only time the full corpus text crosses, and it is not during a question. Set EMBEDDINGS=local to do it on your own machine instead, at 384 dimensions.

  1. 2

    Passage text out, vectors backcrosses to the model

    All 3,854 passages leave — to the same resource the chat model lives in, in your tenant, in Sweden. No third party.

    POST /embeddings
    { "model": "text-embedding-3-small",
      "input": [ "… at least 8000 N at the rack …", … ] }

    The same Foundry resource as every other call on this page. It is a deployment in your own subscription rather than a shared public endpoint, which is why this is a statement about where your data sits rather than a promise from a supplier.

3Stored as an index you own

Nothing leaves. The vectors land in your own Postgres, beside the text they came from.

vst_derived.document_chunks   3,854 rows

0 model requests. Rebuildable from the files at any time, which is what makes the embedding step repeatable rather than a one-way door.

  1. 3

    Vectors land in your databaseyour network

    Nothing leaves. The index is yours and can be rebuilt or deleted at will.

    insert into document_chunks (text, embedding, …)

    Deleting the index does not lose anything: the corpus on disk is the source, and `pnpm steering:index` rebuilds it.

Why so little of it has to.

The reading is three separate jobs, split for cost and for quality. That the data-protection answer falls out of it is the useful accident.

Stays on the machine

The whole parse pipeline — 9,931 timesheet lines, 1,933 document fields, 168 rates, 293 quoted lines, 298 estimates. Plain code, no model, four seconds, no network call. That is the overwhelming majority of the volume and there is nothing in it to review.

Leaves, one document at a time

220 closure reports, about 900 bytes each, sent singly with one system prompt and no other context. Only prose that needs reading comprehension goes, and only the part that does.

So “nothing may leave our tenant” is not a blocker here. The parse pipeline is unaffected either way, and the extraction points at a model deployment inside your own subscription — one environment variable. Nothing in the design assumes a public API, which is why it is built on Foundry with Entra rather than on a key and a URL.

How the facts got out of the files.

A file can be read. It cannot be counted, filtered or compared — you cannot ask 9,931 timesheet rows “how many hours went into gearbox changes” by reading them, and nobody has time to try. So the job is to get the facts into a database without inventing anything that was not there. That second half is the whole difficulty.

  1. 12,978 rows in vst_derived, every one carrying its file and line number
  2. 2,827 passages, each keeping the heading trail it sat under
  3. 1,320 answers, and not one of them quoting a sentence that was not in the file

They are not three steps of one process — they are three different problems that happen to live in the same folder. The order is the point: parsing is free and exact, so it goes first and becomes the harness everything riskier is checked against.

1

Parsing

Spreadsheets exported to text. Code reads them — no model, four seconds, free to run again. Most of the work, and the part least able to go quietly wrong.

A timesheet

before

pmo/timesheets/2019-Q4.csv

# Vantis Steering Systems — timesheet export
# period: 2019 Q4   exported: 2019-12-05
# charge code format changed in 2022 and again in 2024.
# an empty 'approved' column means the line was never
# signed off, not that it was rejected.
week_ending,employee,charge_code,hours,activity,approved
2019-10-01,P. Strand,1158,15.0,hardware,Y
2019-10-15,"Lindqvist, Maja",1158,7.0,hardware,

Four lines of comment before the header, a name with a comma inside it, and a last column that is empty rather than “N”.

after · what is stored, one row per line

week ending
2019-10-01
who
P. Strandexactly as written — not tidied
charge code
1158
hours
15.0
activity
hardware
signed off
yes
read from
2019-Q4.csv line 7

A rate card

before

pmo/rate-cards/2019-CN.csv

# Vantis Steering Systems — approved engineering rates
# year: 2019   region: CN
# approved by: E. Palmer (finance)   date: 2018-11-04
# rates are fully loaded and include overhead.
discipline,rate_eur_per_hour
systems,59.52
hardware,57.80

The two facts that decide whether a rate can be used at all — which year, which region — are not in the table. They are in a comment above it, and in the filename.

after · what is stored

year
2019from the comment AND the filename — both are read and must agree
region
CNsame
discipline
systems
rate
59.52 EUR/h
read from
2019-CN.csv line 6
how
A reader written by hand, not an off-the-shelf one — these files have comments above the header, names with commas in them, and three charge-code formats.
what we never do
Tidy. Two spellings of a name stay two spellings; deciding they are one person is your call, not a file reader’s.
why you can trust it
A failure is an error, never a silent gap. Every row carries its file and line, and the totals are checked against your own records.
2

Indexing

Specifications, assessments, review notes — prose, not tables. You cannot query a paragraph, so each document is cut into passages small enough to find and specific enough to be worth finding. No model reads them, and the index is built once.

A quotation

before

pmo/quotes/QUO-0004.md

## Post-award assumptions

Rates held to 2026-07-18. Tooling excluded unless
listed. Any change to the rack-force target after
award is re-quoted.

A heading, then a paragraph that only means anything underneath it. Cut on length instead of structure and “re-quoted” arrives with nothing saying what.

after · what is stored, one row per passage

passage
Quotation QUO-0004 > Post-award assumptionsthe heading trail is kept with the text
found by
meaning and exact wordsa part number has to match literally
read from
QUO-0004.md, with its position
how
Cut on headings, never on a fixed length. A fixed window splits a sentence in half and separates a number from the heading that explains it.
what each piece keeps
Its heading trail, so a fragment is still citable — and a table is never split from its header row.
how a question finds it
By meaning and by exact words at once. A part number has to match literally.

702

documents

2,827

passages

under a minute

to build the whole index

pennies

one-off

3

Extraction

a model reads

Written reports, not spreadsheets. The columns you need to compare two jobs are in nobody's document — so something has to read English and decide. The only part that can be quietly wrong, which is why everything below exists to make it cheap to disbelieve.

Everything that follows is one real file: pmo/closure-reports/EFF-2021-0443.md

1–3

What we ask for

The contract with the model. Each line is here so that the next part is possible at all.

4

The check

One plain search of the file. No second model, no answer key, nothing to buy.

5–7

What the check cannot see

A real sentence can still be the wrong answer. This is the part that caught one.

What it produced

before

pmo/closure-reports/EFF-2021-0443.md

A change to the existing mechanical design. The work was on the
gearbox. The existing safety case remained valid and was not
reopened. Tooling was modified and re-qualified. 1 interface(s)
were affected.

Five facts, written as English, in nobody’s column.

after · what is stored, one row per answer

kind of change
modify hardwareline 14
part touched
gearboxline 14
safety case reopened
noline 17
tooling needed
yesline 18
interfaces affected
1line 19
safety level
does not sayand that is recorded, not guessed
how
One request per document. It must give back the value and the sentence it read it from — it cannot answer without showing where.
then we check it
That sentence is searched for in the file. Not there, and the answer is thrown away. Inventing a value is easy; inventing a value and a sentence that exists word-for-word is not.
why you can trust it
Two checks, neither needing a right answer to compare with: every stored fact is re-verified against its own file, and a question that gets answered on documents which never mention it is being guessed at.

And then there is a table.

Every step above ends in rows, in one database of ours — vst_derived, separate from the four Vantis runs and the only one the product reads. 19,141 rows so far, and each one carries the file and the line it was read from. Open any of them to see a real row, column by column.

1

Parsing

14,774 rows

Read straight out of the spreadsheets by code. Most of the rows on the page, and the part that cannot be quietly wrong.

2

Indexing

2,827 rows

The prose, cut on its headings into passages that can be found by meaning as well as by exact words.

3

Extraction

1,320 rows

The answers a model read out of the closure reports, each with the sentence it came from — and the two that were thrown away for quoting a sentence that was not in the file.

All three, joined on the document they came from

One row per closure report: what kind of change, what part, what safety level, how many interfaces, and the hours it took. This is the table a price is actually queried from — and the reason the reading was worth doing at all.

Every claim, and how it is known.

The question a security review actually asks is not whether something is true — it is how you know. We captured the request and asserted on it and the vendor’s documentation says so are different answers, so every row says which one it is. The two commands take a second and make no network call; run them in front of somebody if they push.

  • Only 220 documents are sent, one per request, with nothing else attached

    pnpm steering:derived-compliance-check

    verified here

    The check captures the real outgoing HTTP body — 5,664 bytes, one system prompt, one document. Not read from the source that builds the request, read from the bytes that leave.

  • Exactly one host is contacted, and it is our resource

    pnpm steering:derived-compliance-check

    verified here

    The same capture, read for its URL: one <resource>.services.ai.azure.com host, inside our own tenant.

  • The provider is told to retain nothing

    pnpm steering:derived-compliance-check

    verified here

    store: false, asserted on the outgoing bytes rather than on the line of code that sets it. Left unset the provider default is to retain, so this is the difference between a policy and a fact.

  • No conversation is held on their servers

    pnpm steering:derived-compliance-check

    verified here

    No previous_response_id, no conversation field. Each request carries its own context and nothing persists between them.

  • Authentication is a short-lived Entra token, not a static key

    pnpm steering:derived-compliance-check

    verified here

    Authorization: Bearer, no api-key header, nothing beginning sk-. There is no long-lived model credential in this repository to leak.

  • The resource is in the EU

    az cognitiveservices account list -o table

    verified here

    Read from Azure rather than from a diagram: swedencentral, kind AIServices. This is the one claim here you can check without our code at all.

  • Nothing in the ingest can open your databases

    pnpm steering:derived-boundary-check

    verified here

    A build-time guard on the symbol names. When extraction leaves a gap, the cheapest fix available to anyone is one line that reads the right answer out of vst_alm instead — nothing would fail, the grade would go up, and the number would mean nothing. So it is made impossible rather than agreed to.

  • One personal-data field crosses: the author’s name on a closure report

    verified here

    Every one of the 220 reports carries a `Prepared by:` line — 12 distinct people across the set. Nothing else about a person does: no email addresses, no employee ids, and no per-person hours, because effort is aggregated by discipline before it is written down. Counted over the corpus, not estimated: `grep -c "Prepared by" docs/steering/corpus/pmo/closure-reports/*.md`. If your review needs that field gone, it is a redaction at extraction time rather than a change to anything downstream.

  • The model is a deployment in your own resource, not a shared service

    pnpm steering:derived-compliance-check

    verified here

    The capture reads one <resource>.services.ai.azure.com host inside your own tenant, and the deployment name comes from your own configuration — `FOUNDRY_OPENAI_ENDPOINT` and `FOUNDRY_CHAT_DEPLOYMENT`. There is no shared public endpoint in this path and no third-party inference provider. That is the honest answer to "does it train on our data": the arrangement is a dedicated deployment in your subscription, in the region you chose.

  • Prompts are NOT retained for abuse monitoring

    must be arranged

    Not true by default. Azure OpenAI keeps prompts and completions for up to 30 days for abuse monitoring, readable by authorised Microsoft reviewers on a flagged case, and store: false does not turn it off — different mechanism. Modified Abuse Monitoring has not been applied for. On closure reports that name a car maker’s unannounced programmes, that application belongs before the first real run.

  • The endpoint is not reachable from the internet

    must be arranged

    Public network access is enabled. An Entra token is required so it is not open, but network isolation is a separate control a regulated customer usually wants: Private Endpoint, public access off. A configuration change rather than a code change, and not made here.

What is kept, and by whom.

You keep everything derived

vst_derived is yours, in your subscription, and every row in it carries the file and line it was read from. Nothing derived is held anywhere else.

The provider keeps one thing we have not turned off

Abuse-monitoring retention, up to 30 days. It is a separate mechanism from store: false and it is on by default. Named here rather than in a footnote, because it is the first thing a reviewer of this corpus will ask about.

This page describes the pipeline as it stands. Chunking and embedding are not built yet, and when they are there will be a second thing that leaves — the passages themselves. It will be added here, with the same column saying how it is known. Meridian Pharma’s equivalent is here.