Skip to content

Add first working implementation version - #3

Merged
lemuelroberto merged 21 commits into
emfga:mainfrom
lemuelroberto:develop
Aug 30, 2026
Merged

Add first working implementation version#3
lemuelroberto merged 21 commits into
emfga:mainfrom
lemuelroberto:develop

Conversation

@lemuelroberto

Copy link
Copy Markdown
Member
  • 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.

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.
@lemuelroberto lemuelroberto changed the title develop Add first working implementation version Aug 30, 2026
@lemuelroberto
lemuelroberto merged commit da783d7 into emfga:main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant