Add first working implementation version - #3
Merged
Conversation
The conformance suite now needs a second configuration value from .env (CEL_EXPR_DIR, the reference-checkout directory), and testdb's private loader could not serve it. Extracting the reader keeps a single definition of "process environment first, then .env" instead of two parsers that could drift on quoting or precedence.
initdb runs /docker-entrypoint-initdb.d alphabetically, and the evaluator will arrive as numbered scripts (020_values, 030_parse, ...) that must run after the schema exists. Renaming install.sql to 000_install.sql makes alphabetical order the install order, so compose and a bare psql loop agree without an orchestrating script.
The plan was bare float8::text, measured as agreeing with cel-go on five probes. The committed fuzz comparison falsified that on its first run (74/4096 random doubles): Go's shortest %g switches to scientific notation at decimal exponent >= 6 while Postgres stays plain up to e+14, and on values whose shorter form lands exactly on a round-trip tie Go accepts the short form while Postgres's Ryu prints one extra digit. cel-go's string(double) is fmt %g (common/types/double.go:141, v0.32.0), so parity requires re-rendering: cel._double_text applies the exponent rule and shortens digits while the result still casts back to the same double. The fuzz test now holds this function to %g continuously in CI; a mismatch reopens the question with a concrete value in hand.
TestSimple walks every cel-spec simple-test file as file/section/case subtests, so the single-file and single-case selectors work from this first runner commit. Each case runs the parse/check/eval stages separately against the database and fails naming the stage that broke -- today every non-skipped case fails with "cel.parse is not installed", which is the point: the corpus is measurable before the evaluator exists, and an infrastructure problem can never masquerade as a conformance result. What is not attempted is printed, never implied: six files skip with reasons, and 258 descriptor-dependent cases inside included files are named in a generated list (internal/cmd/skipgen scans the corpus for the proto2/proto3 test-message names; a test regenerates and diffs it so the list cannot drift from the checkout). Expected values convert through internal/codec into the tagged-jsonb value shape, with the comparison rules the corpus implies: map entries order-agnostic, NaN equal to NaN, timestamps by instant, eval_error by existence only, and exact int64/uint64 round-trips via json.Number. Each file runs under the env union its features require -- deliberately stricter than cel-go's own harness, which enables every extension globally -- and oracle.Options builds the same composition on the reference side so a disputed case is refereed under identical environments. macros2 was measured to need exactly two_var_comprehensions. CI pins the cel-spec checkout by commit for the same reason cel-go is pinned in go.mod: the corpus defines what the conformance number means, so it moves only deliberately.
The conformance section justified per-file envs by saying cel-go's conformance_test.go enables extensions selectively. Measured false: it builds one environment with every extension enabled globally. The per-file design stays -- it is the stricter setting and the property the registry exists to prove -- but its justification should not cite a precedent that does not exist, and the oracle side is now noted as configured per-file to match. Also updates the install-script name for the ordered sql/ layout.
PostgreSQL text and jsonb strings categorically reject U+0000, so the 20 parse/string_literals cases whose expected values contain a NUL code point cannot pass on this substrate, ever. The skip generator now detects them by walking expected values and bindings for NUL runes and names them with their own reason, keeping the limitation printed rather than discovered. Raw-string cases like r'\000' hold backslash text, not NUL, and still run; bytes with NUL are unaffected (base64 payloads).
A hand-written lexer and precedence-climbing parser in PL/pgSQL, the
approach cel-go itself validates by carrying an equivalent Pratt
parser. Errors travel as OUT parameters end to end -- a
BEGIN/EXCEPTION block's subtransaction would break the PARALLEL SAFE
label inside a parallel worker -- and parse failures come back as an
{"errors": [...]} envelope, never an exception.
Macro expansion is a registry lookup from the first commit (day-one
invariant 5): the parser resolves (function, arity, receiver) against
cel.macro rows visible to the env and EXECUTEs the expander, and the
standard six macros register through that same path, expanding to
cel-go's exact comprehension shapes with accumulator @Result.
The normalizations that carry semantic weight are all in: the minus
sign folds into numeric literals so -9223372036854775808 parses
exactly, even !/- chains collapse, && and || chains rebalance into a
balanced tree, and float literals mirror Go's ParseFloat at the edges
(overflow is a parse error, values at or below 2^-1075 round to
signed zero -- where Postgres's own cast would raise instead).
Tested by diffing tree shapes against cel-go v0.32.0 on a curated
expression list covering every node kind, plus a rejection list of
expressions cel-go refuses. Corpus-wide, zero non-skipped cases are
rejected at the parse stage. Nine comparison cases carrying raw NUL
bytes inside their expression text join the NUL skip category: they
cannot be sent to Postgres as a text parameter at all.
The tree walk carries errors and unknowns as tagged values end to end: commutative absorption for && and || (false && error is false in either order), lazy ternary branches, LHS-error-first equality, first-error-then-merged-unknowns strictness, and the comprehension fold whose loop only a genuine bool false terminates -- the rule that lets exists recover from early errors. None of it is bolt-on; that is why it lands with the first overload rather than after. Dispatch is table-driven from the first call: candidates come from cel.overload rows visible in the env union, matched against runtime kinds in declaration order, and invoked through EXECUTE with the uniform impl(args jsonb[]) signature. The absorbing ids -- logical and/or, conditional, not_strictly_false, equals, plus the index qualifiers -- are rows with NULL impls that the core recognizes by id after finding them through the same table. Indexing sits with them because cel-go treats it as attribute machinery, which is what admits losslessly-coercible double list indices at runtime; plain signature matching could not without also corrupting arithmetic selection. Name resolution is scope-major, measured against cel-go: an iteration variable shadows an outer binding of a longer dotted name, and a container qualifies candidates longest-first. cel.eval gains an options parameter carrying the container, which unchecked evaluation needs and checked ASTs will not. Double arithmetic routes finite operands through exact numeric computation because Postgres float8 raises where IEEE saturates; re-entry to float8 uses the true rounding boundaries (2^1024 - 2^970 and 2^-1075). Postgres also considers NaN equal to NaN, so the NaN checks are direct comparisons rather than x <> x. Every disable_check case in the corpus now passes; all remaining TestSimple failures are the not-yet-written check stage.
The checker ports cel-go's parameter-unification algorithm (checker.go/types.go at the pinned v0.32.0): overloads resolve in declaration order with result-type widening, type parameters unify with an occurs check and most-general rebinding, and every call gets its overload ids bound into the AST so eval dispatches on ids, never on runtime types. Idents and selects that name declarations are rewritten to qualified idents, matching how cel-go feeds its interpreter. Stdlib part two adds the conversion functions, string tests and matches(). Conversion bounds follow cel-go's overflow.go, which excludes the double representations of the int64 boundaries themselves; string parsing follows Go strconv. Two Postgres exactness traps surfaced while greening fp_math and conversions, both measured in the workspace log: the float8::numeric cast yields the shortest decimal text rather than the exact binary value, so cel._f2n rebuilds the exact numeric from mantissa and exponent; and numeric ^ truncates negative integer powers to 16 significant digits, so 2^-k is built as 5^k * 1e-k from exact parts. Name shadowing adopts cel-go's disambiguation protocol: when a comprehension variable shadows a global that wins resolution, the checker keeps a leading dot on the rewritten ident and eval resolves dotted names against the input activation only, skipping comprehension frames. Of the thirteen milestone files eleven are fully green; the 33 remaining cases in comparisons and conversions all need the well-known types (wrapper idents, timestamp(), duration()) and move to the next phase's target.
The status paragraph still said no parser or checker existed, and the architecture block predated the options parameter. cel.check and cel.eval gained an optional options jsonb (container, extra decls) because unchecked evaluation resolves names at runtime and the conformance corpus's disable_check container cases cannot pass without it; the plain forms stay as wrappers, so this widens the API rather than changing it.
Timestamps and durations, the nine wrapper types, Struct, Value,
ListValue, Any and NullValue, all registered through the same four
tables as the standard library. Timestamp values carry {seconds,
nanos, offset} explicitly because Postgres timestamptz is
microsecond-precision and CEL is nanosecond; calendar getters run on
a wall-clock timestamp derived from the seconds part, with IANA
names resolved by Postgres's own tzdata, so nanos never enter a
timestamptz.
Semantics follow cel-go v0.32.0 (strict RFC 3339 acceptance, the
year 0001-9999 seconds range, Go ParseDuration for duration
strings, FormatFloat 'f' rendering for string(duration)), except
duration.getMilliseconds, where the corpus and cel-java agree on
the sub-second component against cel-go's total; the workspace log
records that adjudication.
Enum constants (NullValue.NULL_VALUE) ride the type registry as an
enum map on the row's kind, resolved to int-typed idents by the
checker and by a shared type-or-enum lookup in eval, mirroring
cel-go's Provider.FindIdent.
A third numeric exactness trap surfaced here: numeric division
selects a result scale that can drop fractional digits on 20-digit
nanosecond totals, so flooring a quotient misplaced the overflow
boundary by a nanosecond. Timestamp normalization now uses exact
integer div() with a manual floor correction.
With this, all fifteen core conformance files pass; the remaining
red files are extension libraries and type deduction, owed to later
phases.
The v1 scope excluded Int32Value and family on the assumption they need a descriptor pool. Implementing the well-known types showed they do not: wrappers are JSON-shaped and construct through registered type rows like Struct or Value, and the comparisons and dynamic conformance files exercise them directly. The owner approved the change when the evaluator plan was drawn up (workspace decision 3); it lands with sql/070_wkt.sql, which implements them.
Three pieces finish the core milestone. The two-variable comprehension macros (all/exists/existsOne/transformList over index-and-value or key-and-value pairs) register through the macro table and reuse the one-variable fold shapes where cel-go's do; the map transforms fold through cel.@mapInsert, whose insert-on-existing -key error comes with it. The evaluator's comprehension loop already carried the second variable, so the whole of macros2 went green on registration alone. Function declarations in a conformance case's type_env become caller-scoped overloads: the runner passes them through check options and the checker consults them after the registry rows, which is what the type_deduction functions and type_parameters sections exercise. The optionals extension lands as its declaration surface (of/ofNonZeroValue/none/value/hasValue and the optional_type row); the syntax sugar and remaining functions belong to the extension phase. One checker behaviour was adjudicated corpus-first against both reference implementations: joining null with a nullable type keeps the nullable type. cel-go answers null and skips those corpus cases in its own build as known-wrong; cel-java's checker is the same algorithm. The corpus describes the intended fix, so it wins; the workspace log records the reasoning. Unknown propagation, which the corpus cannot cover (its unknowns file is an empty stub), gets its own suite: 24 cases diffed against cel-go partial evaluation, agreeing on absorption in the logic operators (including unknown beating error), conditionals, strict propagation, containers and comprehension folds. Extension scripts live at the top of sql/ with a 1xx prefix rather than the planned sql/ext/ subdirectory: initdb executes only the mounted directory's top level, so a subdirectory would silently not install.
The seven extension libraries land as registry rows plus PL/pgSQL
impls under their own env names, which is the point of the design:
none of them touches the evaluator core, and none is visible in the
standard env. strings carries the string.format mini-language, whose
fixed and scientific clauses round half-even over the exact decimal
expansion of the double the way Go's correctly-rounded formatter
does. math runs its bit operations in numeric two's complement
because bigint shifts take the count mod 64 and uint64 does not fit
bigint. optionals completes with the syntax operators; the checker
types _?._ by field-selection logic and unwraps optional operands,
and the evaluator adopts cel-go's if-present qualification (a
missing key under an optional is none, not an error) and splices
[?x] / {?k: v} literal elements. network validates with Go netip's
strictness before handing classification and containment to
Postgres inet machinery.
Two behaviours were adjudicated corpus-first where cel-go could not
referee: out-of-range indexOf/lastIndexOf offsets error (cel-java
agrees), and hex-form IPv4-mapped IPv6 parses and unmaps while the
dotted form is rejected (cel-go rejects both but does not run
network_ext in its own conformance). or/orValue are strict rather
than short-circuiting; every corpus case passes strict.
With this, every in-scope conformance file passes on a fresh
install: 1841 case passes, 288 case skips from the 287 named skip
entries plus 6 named file skips, zero failures.
The architecture promises a single evaluate(source, activation, env) for the application role that gets EXECUTE on nothing else. It composes the three stages and folds parse or check rejections into a CEL error value, so callers handle exactly one result type; anyone needing the distinct stages or caller-managed memoization uses parse/check/eval directly.
The status paragraph still described the well-known types and every extension library as unwritten. All of it now exists and the in-scope conformance corpus passes on a fresh install, so the paragraph describes the shipped pipeline and where the skip list lives instead of a plan.
The corpus commit was written down only in the CI workflow. The conformance report about to be added prints that commit as provenance, and a number is not reproducible if the corpus it was measured on is recorded in a place nothing checks: a workflow edited to a different ref and a report claiming the old one would each be internally consistent and jointly wrong. Pin now lives beside the corpus loader, the workflow is checked against it, and the local checkout is checked too -- a developer whose cel-spec has wandered learns it from a named failure rather than from a report that disagrees with everyone else's. HeadSHA is deliberately soft: a checkout unpacked from an archive has no commit to report, which is a working configuration rather than a broken one.
The suite was the only thing that could run a corpus case: runCase took a *testing.T and reported through it. The conformance report being added next needs the same execution, and a generator that ran the corpus its own way would eventually claim a pass the suite fails -- two runners disagreeing is worse than no report. RunCase now returns a CaseResult and the subtest is a switch over it, so there is exactly one place a case is parsed, checked, evaluated and compared. No behaviour changes: the comparisons are the same, moved from t.Fatalf to errors carrying the same text.
The conformance claim existed only as terminal output: the suite printed its skips and its pass count and then they were gone. That is unciteable -- a reader cannot date it, reproduce it, or see what was left out -- and it is the failure mode the skip list was made visible to avoid, one level up. The report is generated, never written, and covers both sides. cel-go now runs the same cases through the same comparator, so "where do the two differ" is measured per case rather than remembered: eleven cases today, each named with the expression and both verdicts. Those were adjudicated during development and only survived in a scratch register; now they are in the tree. Provenance is corpus commit, cel-go pin and PostgreSQL major version, and deliberately carries no date -- those three decide the numbers, and a timestamp would only make the staleness test go red once a day. That test compares the whole file, so a report that stops describing this tree fails the build. The JSON sidecar carries the same data for anything that wants to consume rather than read it.
The number was defensible and the reasoning behind it was not written anywhere a reader could reach: which cases we deliberately answer differently from cel-go, and on what evidence, existed only in a scratch register. A conformance figure whose exceptions are undocumented asks to be taken on trust, which is the opposite of what measuring it was for. This is written for someone deciding whether to put their policy expressions through it, so it leads with the claim and its pins and spends most of its length on the boundaries: what protobuf support costs and why it is refused, the one permanent substrate limit (NUL in strings), why the corpus outranks cel-go where they disagree, and each divergence with the evidence that settled it -- including the one case where no implementation matches the corpus and we followed it anyway. The three divergences with no corpus case are called out separately. A difference nothing tests is the kind that surprises someone later.
It still opened with "Status: scaffolding -- there is no parser, checker or evaluator yet", which stopped being true several milestones ago. That is the first thing a reader sees, and a project that misdescribes itself in its first paragraph earns doubt about everything after it. The scope section is brought in line too: the well-known types moved into scope once it was clear they need no descriptor pool, the extension libraries are implemented rather than planned, and both conformance documents are linked from where a reader would look for the claim. The quick-start example now runs an actual expression instead of selecting a version string, and shows the staged form as well -- cel.evaluate checks with no declarations, so an expression with a free variable fails there, which is worth learning from the README rather than from a puzzling error. Volatility labels are stated as the catalog reports them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Share the .env reader across test packages
The conformance suite now needs a second configuration value from
.env (CEL_EXPR_DIR, the reference-checkout directory), and testdb's
private loader could not serve it. Extracting the reader keeps a
single definition of "process environment first, then .env" instead
of two parsers that could drift on quoting or precedence.
Order sql/ scripts by numeric prefix
initdb runs /docker-entrypoint-initdb.d alphabetically, and the
evaluator will arrive as numbered scripts (020_values, 030_parse, ...)
that must run after the schema exists. Renaming install.sql to
000_install.sql makes alphabetical order the install order, so compose
and a bare psql loop agree without an orchestrating script.
Match string(double) formatting to Go's %g
The plan was bare float8::text, measured as agreeing with cel-go on
five probes. The committed fuzz comparison falsified that on its first
run (74/4096 random doubles): Go's shortest %g switches to scientific
notation at decimal exponent >= 6 while Postgres stays plain up to
e+14, and on values whose shorter form lands exactly on a round-trip
tie Go accepts the short form while Postgres's Ryu prints one extra
digit. cel-go's string(double) is fmt %g (common/types/double.go:141,
v0.32.0), so parity requires re-rendering: cel._double_text applies
the exponent rule and shortens digits while the result still casts
back to the same double.
The fuzz test now holds this function to %g continuously in CI; a
mismatch reopens the question with a concrete value in hand.
Add the conformance corpus runner
TestSimple walks every cel-spec simple-test file as
file/section/case subtests, so the single-file and single-case
selectors work from this first runner commit. Each case runs the
parse/check/eval stages separately against the database and fails
naming the stage that broke -- today every non-skipped case fails
with "cel.parse is not installed", which is the point: the corpus
is measurable before the evaluator exists, and an infrastructure
problem can never masquerade as a conformance result.
What is not attempted is printed, never implied: six files skip with
reasons, and 258 descriptor-dependent cases inside included files are
named in a generated list (internal/cmd/skipgen scans the corpus for
the proto2/proto3 test-message names; a test regenerates and diffs it
so the list cannot drift from the checkout).
Expected values convert through internal/codec into the tagged-jsonb
value shape, with the comparison rules the corpus implies: map
entries order-agnostic, NaN equal to NaN, timestamps by instant,
eval_error by existence only, and exact int64/uint64 round-trips via
json.Number.
Each file runs under the env union its features require --
deliberately stricter than cel-go's own harness, which enables every
extension globally -- and oracle.Options builds the same composition
on the reference side so a disputed case is refereed under identical
environments. macros2 was measured to need exactly
two_var_comprehensions.
CI pins the cel-spec checkout by commit for the same reason cel-go is
pinned in go.mod: the corpus defines what the conformance number
means, so it moves only deliberately.
Correct the cel-go harness claim in CLAUDE.md
The conformance section justified per-file envs by saying cel-go's
conformance_test.go enables extensions selectively. Measured false:
it builds one environment with every extension enabled globally. The
per-file design stays -- it is the stricter setting and the property
the registry exists to prove -- but its justification should not cite
a precedent that does not exist, and the oracle side is now noted as
configured per-file to match. Also updates the install-script name
for the ordered sql/ layout.
Skip corpus cases whose strings contain NUL
PostgreSQL text and jsonb strings categorically reject U+0000, so the
20 parse/string_literals cases whose expected values contain a NUL
code point cannot pass on this substrate, ever. The skip generator
now detects them by walking expected values and bindings for NUL
runes and names them with their own reason, keeping the limitation
printed rather than discovered. Raw-string cases like r'\000' hold
backslash text, not NUL, and still run; bytes with NUL are unaffected
(base64 payloads).
Add the CEL parser and macro engine
A hand-written lexer and precedence-climbing parser in PL/pgSQL, the
approach cel-go itself validates by carrying an equivalent Pratt
parser. Errors travel as OUT parameters end to end -- a
BEGIN/EXCEPTION block's subtransaction would break the PARALLEL SAFE
label inside a parallel worker -- and parse failures come back as an
{"errors": [...]} envelope, never an exception.
Macro expansion is a registry lookup from the first commit (day-one
invariant 5): the parser resolves (function, arity, receiver) against
cel.macro rows visible to the env and EXECUTEs the expander, and the
standard six macros register through that same path, expanding to
cel-go's exact comprehension shapes with accumulator @Result.
The normalizations that carry semantic weight are all in: the minus
sign folds into numeric literals so -9223372036854775808 parses
exactly, even !/- chains collapse, && and || chains rebalance into a
balanced tree, and float literals mirror Go's ParseFloat at the edges
(overflow is a parse error, values at or below 2^-1075 round to
signed zero -- where Postgres's own cast would raise instead).
Tested by diffing tree shapes against cel-go v0.32.0 on a curated
expression list covering every node kind, plus a rejection list of
expressions cel-go refuses. Corpus-wide, zero non-skipped cases are
rejected at the parse stage. Nine comparison cases carrying raw NUL
bytes inside their expression text join the NUL skip category: they
cannot be sent to Postgres as a text parameter at all.
Add the evaluator core and stdlib part one
The tree walk carries errors and unknowns as tagged values end to
end: commutative absorption for && and || (false && error is false
in either order), lazy ternary branches, LHS-error-first equality,
first-error-then-merged-unknowns strictness, and the comprehension
fold whose loop only a genuine bool false terminates -- the rule
that lets exists recover from early errors. None of it is bolt-on;
that is why it lands with the first overload rather than after.
Dispatch is table-driven from the first call: candidates come from
cel.overload rows visible in the env union, matched against runtime
kinds in declaration order, and invoked through EXECUTE with the
uniform impl(args jsonb[]) signature. The absorbing ids -- logical
and/or, conditional, not_strictly_false, equals, plus the index
qualifiers -- are rows with NULL impls that the core recognizes by
id after finding them through the same table. Indexing sits with
them because cel-go treats it as attribute machinery, which is what
admits losslessly-coercible double list indices at runtime; plain
signature matching could not without also corrupting arithmetic
selection.
Name resolution is scope-major, measured against cel-go: an
iteration variable shadows an outer binding of a longer dotted name,
and a container qualifies candidates longest-first. cel.eval gains
an options parameter carrying the container, which unchecked
evaluation needs and checked ASTs will not.
Double arithmetic routes finite operands through exact numeric
computation because Postgres float8 raises where IEEE saturates;
re-entry to float8 uses the true rounding boundaries (2^1024 - 2^970
and 2^-1075). Postgres also considers NaN equal to NaN, so the NaN
checks are direct comparisons rather than x <> x.
Every disable_check case in the corpus now passes; all remaining
TestSimple failures are the not-yet-written check stage.
Add the type checker and stdlib part two
The checker ports cel-go's parameter-unification algorithm
(checker.go/types.go at the pinned v0.32.0): overloads resolve in
declaration order with result-type widening, type parameters unify
with an occurs check and most-general rebinding, and every call gets
its overload ids bound into the AST so eval dispatches on ids, never
on runtime types. Idents and selects that name declarations are
rewritten to qualified idents, matching how cel-go feeds its
interpreter.
Stdlib part two adds the conversion functions, string tests and
matches(). Conversion bounds follow cel-go's overflow.go, which
excludes the double representations of the int64 boundaries
themselves; string parsing follows Go strconv.
Two Postgres exactness traps surfaced while greening fp_math and
conversions, both measured in the workspace log: the float8::numeric
cast yields the shortest decimal text rather than the exact binary
value, so cel._f2n rebuilds the exact numeric from mantissa and
exponent; and numeric ^ truncates negative integer powers to 16
significant digits, so 2^-k is built as 5^k * 1e-k from exact parts.
Name shadowing adopts cel-go's disambiguation protocol: when a
comprehension variable shadows a global that wins resolution, the
checker keeps a leading dot on the rewritten ident and eval resolves
dotted names against the input activation only, skipping
comprehension frames.
Of the thirteen milestone files eleven are fully green; the 33
remaining cases in comparisons and conversions all need the
well-known types (wrapper idents, timestamp(), duration()) and move
to the next phase's target.
Bring CLAUDE.md up to date with the built pipeline
The status paragraph still said no parser or checker existed, and
the architecture block predated the options parameter. cel.check and
cel.eval gained an optional options jsonb (container, extra decls)
because unchecked evaluation resolves names at runtime and the
conformance corpus's disable_check container cases cannot pass
without it; the plain forms stay as wrappers, so this widens the API
rather than changing it.
Add the well-known types
Timestamps and durations, the nine wrapper types, Struct, Value,
ListValue, Any and NullValue, all registered through the same four
tables as the standard library. Timestamp values carry {seconds,
nanos, offset} explicitly because Postgres timestamptz is
microsecond-precision and CEL is nanosecond; calendar getters run on
a wall-clock timestamp derived from the seconds part, with IANA
names resolved by Postgres's own tzdata, so nanos never enter a
timestamptz.
Semantics follow cel-go v0.32.0 (strict RFC 3339 acceptance, the
year 0001-9999 seconds range, Go ParseDuration for duration
strings, FormatFloat 'f' rendering for string(duration)), except
duration.getMilliseconds, where the corpus and cel-java agree on
the sub-second component against cel-go's total; the workspace log
records that adjudication.
Enum constants (NullValue.NULL_VALUE) ride the type registry as an
enum map on the row's kind, resolved to int-typed idents by the
checker and by a shared type-or-enum lookup in eval, mirroring
cel-go's Provider.FindIdent.
A third numeric exactness trap surfaced here: numeric division
selects a result scale that can drop fractional digits on 20-digit
nanosecond totals, so flooring a quotient misplaced the overflow
boundary by a nanosecond. Timestamp normalization now uses exact
integer div() with a manual floor correction.
With this, all fifteen core conformance files pass; the remaining
red files are extension libraries and type deduction, owed to later
phases.
Move the wrapper types into scope
The v1 scope excluded Int32Value and family on the assumption they
need a descriptor pool. Implementing the well-known types showed
they do not: wrappers are JSON-shaped and construct through
registered type rows like Struct or Value, and the comparisons and
dynamic conformance files exercise them directly. The owner approved
the change when the evaluator plan was drawn up (workspace decision
3); it lands with sql/070_wkt.sql, which implements them.
Add two-var comprehensions and close out the core
Three pieces finish the core milestone. The two-variable
comprehension macros (all/exists/existsOne/transformList over
index-and-value or key-and-value pairs) register through the macro
table and reuse the one-variable fold shapes where cel-go's do; the
map transforms fold through cel.@mapInsert, whose insert-on-existing
-key error comes with it. The evaluator's comprehension loop already
carried the second variable, so the whole of macros2 went green on
registration alone.
Function declarations in a conformance case's type_env become
caller-scoped overloads: the runner passes them through check
options and the checker consults them after the registry rows, which
is what the type_deduction functions and type_parameters sections
exercise. The optionals extension lands as its declaration surface
(of/ofNonZeroValue/none/value/hasValue and the optional_type row);
the syntax sugar and remaining functions belong to the extension
phase.
One checker behaviour was adjudicated corpus-first against both
reference implementations: joining null with a nullable type keeps
the nullable type. cel-go answers null and skips those corpus cases
in its own build as known-wrong; cel-java's checker is the same
algorithm. The corpus describes the intended fix, so it wins; the
workspace log records the reasoning.
Unknown propagation, which the corpus cannot cover (its unknowns
file is an empty stub), gets its own suite: 24 cases diffed against
cel-go partial evaluation, agreeing on absorption in the logic
operators (including unknown beating error), conditionals, strict
propagation, containers and comprehension folds.
Extension scripts live at the top of sql/ with a 1xx prefix rather
than the planned sql/ext/ subdirectory: initdb executes only the
mounted directory's top level, so a subdirectory would silently not
install.
Add the extension libraries; corpus goes green
The seven extension libraries land as registry rows plus PL/pgSQL
impls under their own env names, which is the point of the design:
none of them touches the evaluator core, and none is visible in the
standard env. strings carries the string.format mini-language, whose
fixed and scientific clauses round half-even over the exact decimal
expansion of the double the way Go's correctly-rounded formatter
does. math runs its bit operations in numeric two's complement
because bigint shifts take the count mod 64 and uint64 does not fit
bigint. optionals completes with the syntax operators; the checker
types ?. by field-selection logic and unwraps optional operands,
and the evaluator adopts cel-go's if-present qualification (a
missing key under an optional is none, not an error) and splices
[?x] / {?k: v} literal elements. network validates with Go netip's
strictness before handing classification and containment to
Postgres inet machinery.
Two behaviours were adjudicated corpus-first where cel-go could not
referee: out-of-range indexOf/lastIndexOf offsets error (cel-java
agrees), and hex-form IPv4-mapped IPv6 parses and unmaps while the
dotted form is rejected (cel-go rejects both but does not run
network_ext in its own conformance). or/orValue are strict rather
than short-circuiting; every corpus case passes strict.
With this, every in-scope conformance file passes on a fresh
install: 1841 case passes, 288 case skips from the 287 named skip
entries plus 6 named file skips, zero failures.
Add the cel.evaluate one-shot entry point
The architecture promises a single evaluate(source, activation, env)
for the application role that gets EXECUTE on nothing else. It
composes the three stages and folds parse or check rejections into a
CEL error value, so callers handle exactly one result type; anyone
needing the distinct stages or caller-managed memoization uses
parse/check/eval directly.
Record the completed evaluator in CLAUDE.md
The status paragraph still described the well-known types and every
extension library as unwritten. All of it now exists and the
in-scope conformance corpus passes on a fresh install, so the
paragraph describes the shipped pipeline and where the skip list
lives instead of a plan.
Give the cel-spec corpus pin a single home
The corpus commit was written down only in the CI workflow. The
conformance report about to be added prints that commit as
provenance, and a number is not reproducible if the corpus it was
measured on is recorded in a place nothing checks: a workflow edited
to a different ref and a report claiming the old one would each be
internally consistent and jointly wrong.
Pin now lives beside the corpus loader, the workflow is checked
against it, and the local checkout is checked too -- a developer
whose cel-spec has wandered learns it from a named failure rather
than from a report that disagrees with everyone else's.
HeadSHA is deliberately soft: a checkout unpacked from an archive
has no commit to report, which is a working configuration rather
than a broken one.
Lift the conformance case runner out of the test
The suite was the only thing that could run a corpus case: runCase
took a *testing.T and reported through it. The conformance report
being added next needs the same execution, and a generator that ran
the corpus its own way would eventually claim a pass the suite
fails -- two runners disagreeing is worse than no report.
RunCase now returns a CaseResult and the subtest is a switch over
it, so there is exactly one place a case is parsed, checked,
evaluated and compared. No behaviour changes: the comparisons are
the same, moved from t.Fatalf to errors carrying the same text.
Generate a conformance report from an actual run
The conformance claim existed only as terminal output: the suite
printed its skips and its pass count and then they were gone. That
is unciteable -- a reader cannot date it, reproduce it, or see what
was left out -- and it is the failure mode the skip list was made
visible to avoid, one level up.
The report is generated, never written, and covers both sides.
cel-go now runs the same cases through the same comparator, so
"where do the two differ" is measured per case rather than
remembered: eleven cases today, each named with the expression and
both verdicts. Those were adjudicated during development and only
survived in a scratch register; now they are in the tree.
Provenance is corpus commit, cel-go pin and PostgreSQL major
version, and deliberately carries no date -- those three decide the
numbers, and a timestamp would only make the staleness test go red
once a day. That test compares the whole file, so a report that
stops describing this tree fails the build.
The JSON sidecar carries the same data for anything that wants to
consume rather than read it.
Write down what the conformance claim excludes
The number was defensible and the reasoning behind it was not
written anywhere a reader could reach: which cases we deliberately
answer differently from cel-go, and on what evidence, existed only
in a scratch register. A conformance figure whose exceptions are
undocumented asks to be taken on trust, which is the opposite of
what measuring it was for.
This is written for someone deciding whether to put their policy
expressions through it, so it leads with the claim and its pins and
spends most of its length on the boundaries: what protobuf support
costs and why it is refused, the one permanent substrate limit (NUL
in strings), why the corpus outranks cel-go where they disagree, and
each divergence with the evidence that settled it -- including the
one case where no implementation matches the corpus and we followed
it anyway.
The three divergences with no corpus case are called out
separately. A difference nothing tests is the kind that surprises
someone later.
Correct the README's status and scope
It still opened with "Status: scaffolding -- there is no parser,
checker or evaluator yet", which stopped being true several
milestones ago. That is the first thing a reader sees, and a project
that misdescribes itself in its first paragraph earns doubt about
everything after it.
The scope section is brought in line too: the well-known types moved
into scope once it was clear they need no descriptor pool, the
extension libraries are implemented rather than planned, and both
conformance documents are linked from where a reader would look for
the claim.
The quick-start example now runs an actual expression instead of
selecting a version string, and shows the staged form as well --
cel.evaluate checks with no declarations, so an expression with a
free variable fails there, which is worth learning from the README
rather than from a puzzling error. Volatility labels are stated as
the catalog reports them.