Strata — Type System, Contracts and Lineage Rules
Formal rules that the typecheck and lineage passes of the reference implementation implement.
1. Value types
T := Int64 | Float64 | Decimal(p,s,p>) | String | Bool | Date | Timestamp
| Uuid | Json | Money(currency) | Array(T) | ColumnSetMoney(C)isDecimal(38,2)refined by currency; arithmetic between twoMoney(C)must shareC, resultMoney(C). Mixed currency = compile error (echo of the dimensional-safety gap, §4.1 of research).- Nullability is a separate flag on a column:
Col = (T, nullable: bool). Unknownis the initial type of unresolved columns; it only exists mid-inference and must resolve before codegen.
Subtyping (<:):
Int64 <: Float64 <: Decimal(38,?) (via implicit but explicit-safe widening through cast() only)
Date <: Timestamp (only via cast)
Array(T) <: Array(U) iff T = U (invariant)
Money(C) <: Decimal(38,2) when C discarded explicitly (cast only)The field of narrowing (e.g. Float64 returned where Decimal was promised) is always a cast error at the boundary — narrowing never happens implicitly.
2. Operator typing rules
Let ⊕ range over + - * / %, ≶ over == != < <= > >=.
lit(n:int) : Int64, nonnull
lit(x:float) : Float64, nonnull
lit(s:str) : String, nonnull
lit(bool) : Bool, nonnull
lit(none) : Unknown, nullable
x ⊕ y (numeric) : Float64 if either is Float64|Decimal
Decimal(p,s) if both Decimal and ops are + - *
Int64 otherwise (integer ops)
nullability: nullable(y) iff nullable(x) or nullable(y)
x / y : Float64 always (no integer division)
x + y (String) : String, nullable OR (via '||' sugar)
concat(Array) : T, nullable
coalesce(x,y) : (type(x) ⊔ type(y)), nullable=false
(if any argument nonnull the result is judged nonnull; tightened by runtime pin)
upper/lower(x) : String, nullability(x)
count(...) : Int64, nonnull -- never null, empty group ⇒ 0
sum(x) : Int64|Float64|Decimal (same as x), nullable=true -- empty group ⇒ NULL
avg(x) : Float64, nullable=true
max/min(x) : type(x), nullability(x) -- of non-empty else null: marked nullable
x ≶ y : Bool, nullable = nullable(x) or nullable(y)
not/neg : preserve nullability of operand
case/if : result type = least upper bound of branch types; nullable = OR of branchesUnknown ⊕ T etc. is an error surfaced as "unresolved column".
3. Domain/semantic annotations (contract layer)
A contract field is Col = (T, {nullable, unique, primary, protected, enum:Set,String>, classification, partition_by, freshness}).
Derived rules:
primary_key ⇒ nonnull, unique.protected⇒ removing/renaming/narrowing this column in an upstream change is a breaking change for every consumer referencing it (E-family error in the producer's PR).enum {a,b,c}⇒ any expression feeding it must be proven to range over a subset (string equality against enum literals is tracked; otherwise runtime pin).unique⇒ the model must contain the column in agroup/aggregateproducing one row per value; the planner adds a uniqueness assertion.classification: str⇒ maps to a masking rule at the storage boundary; absence ofmaskwhen classification present is a lint warning (W004-style).
4. Column-level lineage
Lineage is a property of the AST/semantic graph, not a post-hoc parser.
Definitions:
- A lineage path is a tuple
(node_id, column, transform_kind), wheretransform_kind ∈ {passthrough, derived, aggregated, renamed, grouped, filtered}. - Each output column of a
modelcarriesOrigin = List[Path]. - Rules:
select { x = col }⇒Origin(x) = Origin(col)— identity,passthrough.derive { y = f(cols...) }⇒Origin(y) = ⋃ Origin(c) for c ∈ cols,derived.aggregate { s = sum(x), g = group_key }⇒Origin(s) = [Origin(x)]kindaggregated;Origin(g) = [Origin(g)]kindgrouped.join L on cond⇒ all columns from the left side keep their origin; right columns keep their origin but are markedjoined; join predicates record lineage edges for both sides (used for join-cardinality checks).filter cond⇒ does not change origins; contributes dependency edges (the model reads those columns).- Pass-through of
SELECT *is expanded against the upstream schema at compile time (never at runtime).
5. Contract conformance (three-phase enforcement)
Phase A — authoring (compile-time/lint in the editor):
- Inferred output columns must be a superset of required contract fields (missing ⇒
E010). - Type conformance: promised
Tmust equal inferredT, or inferred<:promised, or there is an explicitcast(elseE011). - Nullability: promised
nonnullrequires inferrednonnull(elseE012). primary_keypresent and non-implicit (E013if a protected column is missing).
Phase B — planning (compile-time against catalog reality):
- Every
ref/sourceresolves to a known schema in the current catalog; a column that moved (renamed/narrowed upstream since a different run) is an errorE030–E032, generated in the producer's plan via the cross-project contract. - Blast-radius: reverse lineage edge set
Down(m,c)= all models thatrefa column whose origin includesm.c.strata lineage-diff base..headyields exactlyDownnamed per column.
Phase C — runtime (write pin before materialization):
- Re-check the actual physical schema (Arrow/CSV/untrusted input) against the contract: types, nullability, enum membership, nested-collection optionality, uniqueness assertion, freshness.
- Violation ⇒ abort the apply; last-known-good stays live (blue-green contract §6 of compiler design).
6. Determinism & fingerprinting
Fingerprint(model) = H(model_source) ⊕ ⋃_upstream Fingerprint(u) ⊕ H(schema-version)(content-addressed, not time-addressed).- A model is stale if
Fingerprint(now) ≠ Fingerprint(last_apply);strata planrecomputes only stale models (topological minimal set). - Same-stale-set + same upstream layers ⇒ byte-identical artifact (given deterministic function set; non-deterministic functions such as
randare banned at the type level except behind explicitseed).
7. Grammar-context for LLM generation
For grammar-constrained decoding the valid-token set at each position is derived from a GBNF encoding of spec/grammar.md; the type checker then rejects (before codegen) programs whose output violates any contract, so an LLM cannot emit a syntactically valid program that silently breaks lineage/contracts (mirror of PyDough's "cannot express wrong join").