Skip to content

Concepts

The words on the tool surface, and what each one commits to. The design is settled and the code is not, so this page is vocabulary rather than an API you can call. Architecture says how the pieces fit.

Two kinds of claim on this page, marked at each one

Enforced today means a type, a lint, a gate or a test holds the property in the code that is here now, and the mechanism is named beside the claim. Design target means it is written down and not built: no port, no adapter, no test, and nothing that would notice if the property were false.

The identity claims are the ones to read carefully, and they are no longer all design targets. A deployment can now establish who is asking, from a signature, and no question can execute without a credential a broker minted for the source it reads. What is still absent is a data system that evaluates the asking subject: no adapter in this build can carry a per-subject credential, so every question reads as one identity - and a deployment behind only the bearer token has no per-caller identity at all, because that token authenticates the deployment. So the perimeter is real, the subject is known where leg 1 is configured, and per-caller ACCESS is not here. No sentence should be read as a control you can rely on unless it says enforced today.

A question, and what it is made of

A question names a metric, some dimensions, a grain and a bounded time range. That is the whole vocabulary. There is no field for SQL, a table, a filter expression or a list of row ids, so a question outside this shape is unrepresentable rather than refused.

Metric. A named measure somebody certified: revenue, active subscribers, churn. The name comes from a semantic layer outside this repository, and it arrives with what the metric means.

Dimension. An attribute a metric declares it can be broken down by: region, product, tariff. A dimension the metric does not declare is not a narrower question, it is a name that does not resolve, and it comes back as a refusal naming the argument that failed. Dimension values are arguments checked against an allowlist in the pinned bundle, never text pasted into a query.

Grain. The time resolution the answer is aggregated to, and one the metric supports. Daily revenue and monthly revenue are the same metric at two grains, not two metrics.

Time range. Bounded, always, and bounded in two senses. It must have both ends - an unbounded range is a table scan with a plausible name - and it must be short enough to be worth answering, because two real dates ten thousand years apart are a full scan that parses cleanly.

Enforced today. Query carries no field for SQL, a table, a predicate or a row id, so an uncertified question is unrepresentable rather than refused - and deny_unknown_fields makes a question carrying sql: an error naming the field rather than one silently dropped. TimeRange has no unbounded form, so an unbounded range fails to deserialize at all. Both are asserted by tests in sutura-catalog-local, which has a real format parser to provoke them with. The span cap is separate and sits one layer in, at resolution, where the range is demonstrably a caller's rather than an author's - the table under What you cannot ask for says what it does and does not bound.

Telling an agent all of this is a separate job from enforcing it. An agent that has not been told what this surface is will look for a field to put SQL in, and then read a refusal as an outage and retry. The types stop the damage and cannot stop the loop, so the vocabulary above is also rendered as a system prompt from the pinned bundle and the exposed operations - The agent prompt is what it says, what it deliberately leaves out, and what an operator can layer on top.

A certified definition

A definition is what a metric means: the measure it computes, the model it reads, the grains it supports, the dimensions it may be broken down by, and the filters that are part of its meaning.

Enforced today. That meaning is a closed vocabulary rather than an expression language, so there is no field a catalogue could write an arbitrary expression into. Measure is an enum of two shapes over a Term enum of two terms, RequiredFilter an enum of four operators, and deny_unknown_fields applies at every depth. sutura generates the whole statement from that, in sutura-semantic, so neither caller-authored nor catalogue-authored SQL is on the path at all.

Partly built, and named so it cannot be quiet. A metric may instead carry authored_sql: - a SQL expression a catalogue author wrote, for what the closed vocabulary cannot say: a window function, a percentile, an expression over two columns. It is a sibling of measure: rather than a field on it, exactly one of the two may be present, and a fragment is parsed at load, checked against a list of refused constructs, checked against the model's declared columns and rendered for every dialect before anything serves. A caller still has no field for SQL, and the agent prompt still never sees any. A named escape hatch for authored SQL is the record. The types and the compile exist; no catalogue document can write the key yet.

Design target, not built. A definition may instead arrive as statement text authored upstream, spliced into a generated wrapper byte for byte, because re-emitting it would substitute our reading for the author's and the number would change quietly. Nothing implements that splice: Metric has no statement field today. What exists today records the gap.

An anchor is a known result for a metric. Enforced today: sutura_app::verify_and_validate re-executes every metric that declares one, and returns the bundle as Validated only if every anchor it declares was checked and matched. A bundle whose anchors were never checked cannot reach the query path, because that function is the only thing anywhere that produces a Validated, and it takes the data system as an argument - so the type cannot be obtained without one having been asked. A compile_fail doctest holds it, because a review found the earlier version forgeable.

The pinned snapshot

Definitions do not arrive live. They arrive as a pinned snapshot: a bundle of definitions with a version and a digest over the text. Two consequences follow:

  • A catalogue edit cannot change what a question means between two invocations. It changes the digest, and the digest travels with the answer.
  • The catalogue cannot see who is asking. The load path takes no request context, so it cannot return one definition to one caller and a different one to another.

Enforced today. SemanticCatalog::load takes no argument at all, so there is no request context to pass it and none to leave out; dimension validation reads the pinned bundle rather than a live view. The digest is taken over the canonical form of the parsed definitions, so reformatting a document does not move it and changing what a metric means does - asserted by a golden either way.

A result carries the version and digest of the definitions that produced it, so a number traces back to the text that defined it. That is what makes "certified" checkable rather than asserted.

Refusal

A refusal is an answer, not an error. It is a variant of the result type, with a reason, so a caller cannot mistake it for a transport hiccup and retry until something works.

Reasons are typed rather than prose, because the variant is the contract and the message is not. A dimension that does not resolve, a value outside the allowlist, a plan that would need two data systems, a data system this process did not open: each is its own RefusalReason variant.

Enforced today. A refusal is ToolOutcome::Refusal { reason }, a variant of the result rather than an Err, and the golden suite provokes every variant a question can reach. A rejected value is never echoed back either: DimensionValueNotAllowed names the dimension and stops there, so caller text cannot be reflected into a log, a UI or an agent's context.

Built, and the limit is the deployment's rather than ours. Every outcome - a refusal as much as an answer - is written to an AuditSink before it is returned, and the record carries the principal chain. sutura-runtime ships the structured writer a deployment that attaches nothing else gets. Two things that are not the same as attribution: sutura retains nothing, so what a record is worth is what the deployment's sink is worth; and the subject in that chain is only as strong as what established it - a deployment behind the shared bearer token alone records the deployment, because that is who asked as far as anything can tell. A deployment that declares security.inbound records the caller, from a signature.

Still a design target. A refusal recorded against a subject whose access decided the answer. The record can now say who asked and which identity each leg ran under; it cannot say the two were the same, because no adapter in this build can carry a per-subject credential.

Principal, subject, and running as the caller

The principal is who is asking. When an agent asks on somebody's behalf there is a chain of them, and the subject is the identity the data system must see: the person, not the service.

Half built, and the halves are worth telling apart. The target is that every query runs as the subject: a credential is minted per request, and a leg that cannot run as the subject is refused rather than falling back to the service's own identity - because that fallback turns "you may not see these rows" into "here are the rows".

Built: answering takes a request context and a credential broker, the broker mints once for every source the plan reads, and the execution port takes what it produced with no signature that omits it. A subject with no credential at a source is refused as credential_unavailable, and each adapter refuses credential material it has nowhere to put rather than quietly ignoring it. So the fallback is not forbidden by a rule - it is absent from every signature.

Not built: an adapter that can carry a per-subject credential. Both in this build declare that they have nowhere for one to arrive, and the broker that ships mints from configuration and performs no token exchange. So what a credential says today is the deployment's own identity for this source, acknowledged by an operator - which is honest, and is not impersonation.

Enforced today, and narrower than it sounds. Nothing in the query path can choose an identity. SemanticCatalog::load takes no request context, so a catalogue cannot return one definition to one caller and another to the next; a plan resolves to exactly one named data system; and Secret implements no PartialEq and has a hand-written Debug, so credential material cannot be compared or printed by accident. In single-player that is the whole of it: the data is a file, a file has no login, and there is only ever one subject. It is a true statement about a laptop and not about a warehouse.

There is no downgrade path because there is no second identity to downgrade to - not because something forbids one. RefusalReason::SourceUnavailable does exist, but sutura-app raises it when a plan names a data system this process did not open, which is a source-name mismatch and not an identity failure. Nothing here would catch a future adapter that fell back to a service account.

sutura holds no copy of who may see what. Grants, row-level policies and masking live in the data system, administered and audited by the people who own the data. A second copy here could disagree with the original, with no way to tell which one is right.

Catalogue and data system

Two ports, deliberately separate.

A catalogue supplies definitions: metrics, dimensions, the glossary, lineage. A directory of documents in git and a metadata catalogue with an HTTP API are two adapters behind one trait. The first exists; the second is a design target.

A data system executes. ClickHouse and Postgres are the near-term targets. The port is named Warehouse, which says nothing about what sits behind it. Two adapters exist and they are different kinds of thing: sutura-exec-datafusion is the engine - it reads the CSV and Parquet files itself, executes the plan over Arrow and generates no SQL, and it is what the shipped binary links; sutura-exec-duckdb is a data source - it renders the plan into DuckDB SQL and pushes the statement down, and it is a development dependency, there to prove the rendered SQL runs somewhere. Postgres and ClickHouse are rendered for and parse-checked without either being connected to.

A plan resolves to one data system per leg. Spanning two is not a bigger version of the same problem: it is a second identity to satisfy, and a plan whose legs cannot all run as one subject is refused rather than run partly as somebody else.

Answered today, for two sources. The plan stage collects the source of the metric's own model and of every model reached through a join into a set. Three or more sources refuse as PlanSpansTooManySources; exactly two are split into a fact leg and a lookup leg, each executed against its own data system, and joined and re-aggregated above them. The count is computed from the plan rather than asserted about it afterwards, and a golden builds a two-source catalogue to provoke the split.

Two sources are not two identities, and that is the limit to read this with. Every adapter a released binary links declares it has nowhere for a subject to arrive, so both legs of a two-source answer run under the one identity the process itself has, and the answer records that same shared identity twice rather than two different ones. This is single-player federation: it is one asker's question reaching two data systems, not two data systems each applying that asker's own grants. Nothing here tests that two subjects get different rows, and nothing can until a source can execute as the asker.

And a combined answer may not be made of two different kinds of identity. Where one leg would run under a source's acknowledged shared identity and the other as the asking subject, the question is refused as LegsDecideIdentityDifferently - before a credential is minted and before either leg runs - because a total made of rows one identity was permitted to see plus rows another identity was permitted to see is a number neither of them is entitled to, and it would carry a certified metric name with valid provenance attached. Labelling it instead is not the alternative it sounds like: an answer's execution record and its rows arrive in one body on both transports, so a caller who is told would already have the rows. What is not decidable is whether two legs under the same shared posture are the same identity - nothing here names which shared identity a source is read as - so that case is answered. On any published build the refusal fires for nothing, because every linked adapter is shared-service-user; it exists so a deployment that later holds both cannot combine them.

A deployment still holds one KIND of data system. Two files sources are a two-source deployment; a files source beside a warehouse is refused at startup, naming both entries. And a build whose only linked adapter cannot run a leg - which is any build without the in-process engine - refuses a two-source question as FederationNotExecutable rather than answering half of it.

Provenance, and why results are meant to be Arrow

Rows, column descriptions and glossary text are authored by somebody else, and any of it can contain something shaped like an instruction. A delimiter cannot separate instruction from data, because the content can contain the delimiter.

Design target, not built. Results leave as Arrow with provenance in the schema metadata: a typed field a caller reads deliberately, never a string concatenated into the channel that carries instructions, with both wire envelopes sharing one encoder so neither can grow a text-blob shortcut on its own. None of that is here. There is no Arrow IPC or Flight envelope, no encoder, and no transport to carry one. The engine does execute over Arrow inside the process, which is a different claim: nothing leaves as Arrow.

Enforced today. The Warehouse port returns a RowSet - typed columns and typed Value cells with an enforced row width, not a text blob - and an answer carries Provenance beside it as its own field rather than as text mixed into the rows. The definition version and digest travel there, so a result is still not separable from what defined it. The envelope is a row type rather than an Arrow schema, and RowSet is honest about being one.

What you cannot ask for

Absences by design. The last column says what holds each one, because an absence written down and an absence enforced are not the same thing:

Not available Why What holds it
SQL, a table name, a filter expression, row ids The tool surface has no field for any of them. An uncertified question is unrepresentable, not merely refused Enforced. Query declares no such field, and deny_unknown_fields makes an attempt an error naming it
A cached result Under row-level security a query-keyed cache is a cross-user leak. There is no cache to key Enforced by absence. No mechanism can prove one: adding any cache of rows is an architecture decision, keyed on subject first or not at all
An edit to a definition Editing one forks the definition from the number it certifies. Definitions are authored upstream Enforced. Nothing on the query path writes to the catalogue, and the bundle is hashed, so an edit moves the digest that travels with the answer
A query as the service identity A leg that cannot run as the subject should be refused rather than downgraded Enforced, and narrower than it reads. Warehouse::execute takes a credential a broker minted for that source and has no signature that omits one, so there is no fallback to downgrade THROUGH; a subject with no credential is refused as credential_unavailable, and an adapter handed material it cannot use returns an error rather than answering. What is NOT enforced is the sentence people hear in it: no adapter in this build can carry a per-subject credential, so a leg still runs as this process - by declaration and with the answer recording that it did
An unbounded time range A range has to be bounded to resolve at all Enforced. TimeRange has no unbounded form, so an absent bound fails to deserialize and the refusal is unprovokable
A range too long to be worth answering A bounded range still permits a full scan: two real dates can be ten thousand years apart Enforced, on the caller's path only. Resolution refuses a span over ten years as TimeRangeTooLong, carrying two derived integers and nothing of the caller's text. It is checked there rather than in the type because the same type carries a metric's anchor range, which an author writes and no caller can reach. What it does not bound: the work inside a permitted span, or a caller asking three permitted questions in a row - a per-caller budget needs a clock and a subject, which is the same absent port as the identity row above
A definitional filter removed or renamed A metric's required filters are part of what it means Enforced. They are compiled into every plan for the metric and marked as definitional; a caller has no field that could name, select or remove one

Status

A governed single-player semantic compiler and executor over local files. That is what is here: sutura compile renders the statement for a question and sutura query answers it, over a catalogue of documents in git and the CSV or Parquet files in a directory you name, with every certified number re-executed before the bundle may be served.

Everything marked design target above is unbuilt. The identity path is no longer one of them and is not finished either: there IS a request context, a credential broker port with a static-credential implementor, an audit sink and an MCP surface, and a deployment that declares security.inbound verifies a caller's own token. What there is NOT is an adapter that can carry a per-subject credential, so per-caller ACCESS is still absent - and a deployment behind only the bearer token has no per-caller identity at all, because that token authenticates the deployment. No Arrow envelope. What exists today is the inventory, and AGENTS.md in the repository lists each invariant beside the type, lint or gate that holds it - including the rows that say outright that nothing holds them yet.

The mechanisms came first on purpose: every claim on this page is meant to be held up by a type, a lint, a hook or a gate, and those are cheaper to build before there is code to retrofit them onto.