Safety and privacy
Personal data sent to a model
By default, your app may send a model no personal data at all. Each
kind it sends, such as health records or names, must be listed on the
connection, and the compiler follows every value to check. A field's
kind, its category, comes from its personal: mark
(Marking personal fields)
or from a standard type that is always personal data, such as an
NhsNumber.
| Rule | What it means |
|---|---|
| Every category listed | Each PersonalData category that reaches a prompt must be in the connection's personal_data: { allowed: }; one that isn't is semantic.ai_personal_data_not_allowed. A sensitive category is flagged in the approval report |
| Followed to its fields | The compiler follows each placeholder back to the fields it was read or built from, so a name copied into a Text keeps its category |
| A conversation is health | ChatMessage is health wherever it is (AI types), so a chat sends health and must list it |
| Per-field opt-out | to_models: false on a field keeps it from every prompt, whatever its category allows (semantic.ai_field_refused) |
| What decided it | A value an if, a match or a list.filter on a marked field decided carries that field; only the records a loop goes through are left out |
| Kept when stored | A field that stores a marked field's value has its marks (semantic.ai_field_unmarked) |
| An answer carries its prompt | A model's answer, and each field read off it, carries everything its prompt was built from, checked like those fields when stored or sent again |
| Reported | The personal-data report lists each prompt, the fields and categories it sends and the connection |
| Erasure | A provider can't erase one person's prompts, so the ai connection needs an ErasureNotPossible acceptance stating the provider's retention, as any connection that receives personal data and can't erase it does (Erasure at providers) |
The full rules are under Following personal data.
A clinic whose handover summaries send each patient's notes to a model
lists health. The NHS number never leaves, whatever is listed, because
its field is marked to_models: false:
application { connections: { local_model: { driver: Driver.openai_compatible url: config.model_url } } ai: { connection: local_model models: { default: config.drafting_model } limits: { per_day: { tokens: 2000000 } } personal_data: { allowed: [PersonalData.health] } }}
use clinic/types { ClinicalNote }/// A patient of the clinic.entity Patient { id: Uuid patient_nhs_number: NhsNumber { personal: PersonalData.government_id to_models: false } notes: ClinicalNote { personal: PersonalData.health }}
use clinic/entities { Patient, PatientNotFound }use clinic/types { HandoverSummary }use clinic/failures { SummaryUnavailable }use clinic/prompts { SummariseNotes }/// Summarises a patient's notes for the clinician taking over.action hand_over( patient_id: Patient.id) fails PatientNotFound, SummaryUnavailable -> HandoverSummary { var patient: Patient = attempt Patient.get(patient_id) return attempt ai.generate( SummariseNotes { notes: patient.notes }, as: HandoverSummary ) { refused: SummaryUnavailable malformed: SummaryUnavailable timed_out: SummaryUnavailable over_budget: SummaryUnavailable too_long: SummaryUnavailable }}
- What reaches a model is each field of a
Promptan action or function builds, whichever ofai.generate,ai.streamorprompts.renderit is given to. It is also the textai.embedandai.embed_allsend, since an embedding model is sent the text too. - Each is followed back through variables, assignments, your app's
functions and actions, standard operations (text built from it,
text.join,list.map), lists, maps and objects. A query'sOutputis followed to the fields it was read from, and an event to what everyemitof it put in it. A parameter that reaches a model is checked where it is passed. Where the compiler can't follow a value to a record, a marked field read from it counts as that field. - A category comes from a field's
personal:, or from a standard type that is personal data whatever field holds it (Standard library). AnNhsNumberand aChatMessagearehealth, aPaymentCardNumberand anIbanarefinancial, and aUkNationalInsuranceNumberisgovernment_id. A placeholder's own type counts too, so a prompt with aList<ChatMessage>sendshealth. - What decided a value is sent with it. A placeholder carries a
marked field when it was made under an
ifor amatchon that field, or from what alist.filtertesting it kept, or after areturnorrejectsuch a condition may have taken. This is the same rule policy uses (Field restrictions). Somatch patient.diagnosis { Diagnosis.hiv -> "hiv" ... }sends the diagnosis. Only what a loop goes through is left out: the records and rows of afor's list, and oflist.map's andlist.count's, carry nothing into what is made from them but what decided which ones they are. Otherwise a loop over records would send each one's every field. - A field keeps the marks of what it stores. A create, an update or a
transition may store a marked field's value, or a value one decided,
only in a field with the same marks: each category, from
personal:or the field's type, andto_models: false. Otherwise it issemantic.ai_field_unmarked, since whatever reads that field later, a prompt among them, would send it unchecked. A value from a parameter is checked where it is passed. One from an event is checked where a subscriber passes it, against what everyemitput there and the event's own marks. - A model's answer carries what its prompt was built from.
ai.generate's answer andai.stream's text, and each field read off them, hold everything the prompt's placeholders were built from, since the model may put any of it anywhere in what it writes. So the.summaryof a patient's notes ishealthwhen it is stored (semantic.ai_field_unmarked) or sent to a model again, and policy holds it to who may read the notes. An embedding already carries the text it was made from. - Errors name the field and its category, never a value.
Jadpo doesn't guess what to redact. It doesn't scan free text for
names or numbers to blank out, because detectors miss things and give
false assurance. What reaches a model is what the prompt type's fields
hold. So you control it with the type (send a pick of only the fields
needed), the category list and to_models: false. Jadpo doesn't swap a
person's details for stand-ins and restore them in the answer.
Prompts and answers in logs
- Never by default. In production a call's trace span
(Telemetry) holds the prompt's
name and digest, the model, the attempts, the
TokenUsage, the estimated cost, the duration and the outcome. It never holds the rendered prompt or the answer. - In development, in full.
jadpo callsshows them with every other provider call (Fakes and the development call log). They are kept with the last 500 timelines and never committed. - Keeping them in production is a choice per prompt:
keep_content: { prompts: [answer_question] store: main keep: 30d }onai:. They are stored encrypted ashealthdata: every read recorded, exported and erased with the person the call was made for, and deleted atkeep:.
Prompt injection
Text from users can contain instructions, and no wording or format stops a model from sometimes following them. So Jadpo makes following them harmless, in four ways:
- Instructions and data are kept apart. The instructions are fixed text in a reviewed file. Free text is only ever sent inside a tagged data block under the request, never pasted into the instructions.
- The answer is data, not action. It is a typed value, checked like a request body, and your code decides what happens next. A model can call nothing: tool calling is left to packages.
- The answer proves nothing. Policy never grants access because of it, an answer can't be a reference to a record, and its text is escaped wherever it is shown.
- The budget caps the damage of an injected "write ten thousand words".
Budgets
Every app that asks a model sets a daily limit, so a loop or an injected prompt can't spend without end. You can add limits per call, per request and per caller:
| Limit | Written | Counted | Over it |
|---|---|---|---|
| Per call | max_output: on the call (default 1,000) |
Sent as the provider's maximum output tokens; the input checked against the model's context before sending | over_budget (cut off) or too_long |
| Per request | limits: { per_request: } in tokens |
Every ai call in one route request, subscriber delivery or schedule run, attempts included |
over_budget before the call that would pass it |
| Per day | limits: { per_day: { tokens: cost: } } |
The app's calls since midnight UTC; cost estimated from the package's prices | over_budget |
| Per caller per day | limits: { per_caller_per_day: } in tokens |
As per day, for each signed-in principal (or address) | over_budget |
- Checked before sending. A call is refused when the remaining
budget is below the most it could use: the input estimate plus
max_output:, timesattempts:. Each repair is counted with the conversation it resends, and each send again after a provider's error is taken before it goes ("Budgets count every send" in the design notes). After the call, it is charged its realTokenUsage, every send included. - Counts live in the main store, in a runtime table updated with a
one-step counter
(Counters and other one-step updates).
So every instance shares them and a count is never lost. A store that
can't be reached refuses the call
unavailablerather than spend without counting. - Nothing is unlimited.
per_dayis required onai:(semantic.ai_budget_missing). jadpo ai usageshows the day's and month's tokens and estimated cost by prompt and model, from the counts in the main store.
The details are under How budgets are counted.
Not built yet: the personal-data report's entry for prompts, the
ErasureNotPossible acceptance for the ai connection, and
keep_content:. What is built lists each.
Markdown for agents: /docs/safety-and-privacy.md.Built from Jadpo main at a707efb.