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 document0 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
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
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
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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 2455msThe 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.
- 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 database0 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.
- 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.
- 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.
- 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.
- 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
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.
- 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.
- 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.
- 12,978 rows in vst_derived, every one carrying its file and line number
- 2,827 passages, each keeping the heading trail it sat under
- 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.
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
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
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.
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
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
Extraction
a model readsWritten 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
What we ask for
The contract with the model. Each line is here so that the next part is possible at all.
The check
One plain search of the file. No second model, no answer key, nothing to buy.
What the check cannot see
A real sentence can still be the wrong answer. This is the part that caught one.
What it produced
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.
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.
Indexing
2,827 rows
The prose, cut on its headings into passages that can be found by meaning as well as by exact words.
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-checkverified 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-checkverified 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-checkverified 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-checkverified 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-checkverified 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 tableverified 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-checkverified 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-checkverified 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.