Skip to content

Add initial working implementation - #3

Merged
lemuelroberto merged 35 commits into
emfga:mainfrom
lemuelroberto:develop
Aug 31, 2026
Merged

lemuelroberto merged 35 commits into
emfga:mainfrom
lemuelroberto:develop

Conversation

@lemuelroberto

Copy link
Copy Markdown
Member
  • Add store table and create/delete functions
    The store is the conformance-isolation unit: every corpus test runs
    in a fresh store, so the harness skeleton (next) needs create_store
    before anything else exists. Ids are uuidv7() per workspace decision
    3 -- time-ordered like upstream's ULIDs so latest-model resolution
    stays index-backed -- which is what makes PostgreSQL 18 the floor.

    delete_store is a silent no-op on a missing id, matching the
    observed oracle behaviour (v1.19.0 DELETE /stores returns 204 for a
    store that is already gone). The cascade mechanism for dependent
    rows is deliberately deferred to the phase that creates those
    tables, where the suite can see its cost.

  • Add conformance harness skeleton
    The suite exists before the engine so that nothing the engine ever
    skips can be silent: every deliberate skip goes through a register
    the run prints, and the skip list for the imported upstream runners
    is harvested from go test -json output rather than written by
    hand. From this first commit the corpus loads from the pinned
    openfga module's embedded assets (never copied files), single-file
    and single-case selectors work, and an inventory test locks the
    loader to the corpus totals at the pin (161 tests, 188 stages,
    1227 assertions) so a decode regression turns red instead of
    shrinking the suite.

    The error scheme (docs/ERRORS.md) is frozen now because everything
    later raises through it: one SQLSTATE in the implementation-defined
    class YF per upstream API error code, derived mechanically, with a
    single class-wide catch that can never swallow genuine bugs the way
    WHEN OTHERS would.

    Corpus string ids map into the engine's uuid-only id domain through
    deterministic uuidv5 (property-tested for bijectivity); ids
    upstream's own grammar refuses pass through unmapped so both sides
    refuse the original.

    go.work is committed deliberately: vendor/ holds the pinned
    cel4postgres SQL bundle, and in workspace mode the go command stops
    treating that directory as Go vendoring; without it every go
    command fails with "inconsistent vendoring".

    Ref: https://github.com/openfga/openfga/blob/v1.19.0/tests/check/check.go#L188-L196

  • Extend CI with Go steps and reinstall proof
    The Go steps arrive with the harness they test: gofmt and vet keep
    the tree honest, and the suite runs through -json into confreport
    and skipgen so every CI log carries the generated conformance
    report and the run-derived skip list -- the no-silent-scope-
    reduction mechanism working in public. confreport exits non-zero
    on any failed test or failed-to-build package, so nothing is lost
    to the pipe.

    The idempotent-reinstall step re-runs the by-hand install loop
    against the already-installed database because that loop is the
    documented upgrade path; proving it on every run keeps a
    non-idempotent script from ever landing.

  • Add phase-1 oracle probes for check semantics
    The plan forbids encoding a measured behaviour before a container
    run closes its measurement entry, so the probes come before the
    resolver they specify. Each test names the measurements.md entry
    it closes and logs OBSERVED lines that are the record.

    The load-bearing finding: cycle handling at v1.19.0 depends on the
    resolver shape. A self-recursive relation resolves a tuple cycle
    to a plain false with no cycle flag, so base-true but-not that
    cycle answers true; a cycle spanning two relations keeps the flag
    and the same exclusion answers false. The engine therefore needs
    both mechanisms, kept distinct. Also measured: the depth boundary
    (first 2002 at 26 userset links; computed chains free), the full
    error-swallowing matrix, check validation order (proto shape, then
    request key user-first, then contextual tuples as 2027), and that
    bad contextual tuples are 2027 while bad request keys are 2000.

    The language module lands here pinned to the exact pseudo-version
    openfga v1.19.0 builds against, because probes write models as
    DSL.

    Ref: https://github.com/openfga/openfga/blob/v1.19.0/internal/graph/check.go#L320-L372

  • Add model storage and the phase-1a check resolver
    Model writes store the verbatim upstream JSON and derive the
    normalized rows the resolver reads, including the reachability
    closure that makes the PathExists prune a single indexed lookup at
    resolve time. Set operators contribute their children's edges to
    the closure — an over-approximation that can only prune less,
    never wrongly refuse.

    The resolver implements this/computed_userset/union with the
    semantics the measurement session pinned down (measurements.md
    M01/M04/M05/M06/M10): depth charged only on dispatch with the 26th
    dispatch raising too-complex, deferred operand-error re-raise
    through a class-wide SQLSTATE catch (never WHEN OTHERS), the
    two-valued cycle outcome threaded through every operator, and the
    measured request validation order with bad contextual tuples
    refused as invalid_tuple. Intersection, difference and TTU raise a
    non-class internal error until phase 1b so an unimplemented
    operator can never be swallowed as an operand error.

    The tuple write path is the single mutation function decision 8
    requires, with write-grade validation shared verbatim by the
    contextual-tuple gate; deletes are validated syntactically only so
    rows stranded by a model change stay deletable.

  • Replay the check corpus differentially (phase 1a)
    The sqlclient completes its first three real methods, owning the
    three adapter translations: proto to the engine's snake_case jsonb
    request shapes, engine SQLSTATEs back to the gRPC codes the corpus
    asserts, and uuid-mapping of corpus string ids. Proto-shape
    validation reuses the api module's generated validators so shape
    errors surface exactly as the oracle's interceptor surfaces them —
    with store and model ids exempted from the ULID pattern, which is
    the id-domain divergence extended to its logical end.

    The replayer mirrors upstream's runner contract: each case twice
    (normal and ctxTuples), fresh store per test on both engines,
    seeded shuffles with the seed printed on every run, 40-tuple write
    chunks, and every assertion checked against the corpus expectation
    on both engines at once — corpus conformance and differential
    agreement in one assert.

    The four model-change cases forced the read path to grow
    upstream's FilterInvalidTuples rule: stored tuples invalid under
    the request's model are invisible, not errors; contextual tuples
    skip the filter because the request gate already validated them
    against that very model.

    All 1a-eligible check cases pass (314 subtests); TTU, intersection
    and exclusion models wait behind the printed phase-1b gate.

  • Complete the resolver operators (phase 1b)
    Intersection, exclusion and TTU land with the semantics the
    measurement session fixed: intersection walks operands in model
    order and the first false-or-cycled operand decides and carries
    its flag; exclusion refuses on a true-or-cycled subtrahend and
    re-raises a deferred operand error only when the other side did
    not produce the swallowing answer; a TTU parent whose type does
    not define the computed relation is skipped, the one asymmetric
    undefined-relation case.

    The cycle guard grows the shape distinction M04 measured: a cycle
    that stays inside one self-recursive relation resolves to plain
    false (upstream's recursive fast path deduplicates without a
    flag, so base-true but-not that cycle answers true), while a
    cycle crossing relations keeps the flag and the same exclusion
    refuses. The visited array's segment since the first occurrence
    of the re-entered key decides which case applies.

    With the 1b gate flipped, every non-condition check case in both
    corpus files passes differentially in both variants — 1247
    subtests, conditions remaining the only classifier skip.

  • Add batch_check and real store deletion
    batch_check follows the measured v1.19.0 contract (measurements.md
    M34): more than 50 items and duplicate correlation ids refuse the
    whole request; an item's own validation failure is captured under
    its correlation id without failing the batch. Because one SQL
    statement is one snapshot, batch answers are mutually consistent —
    stronger than upstream's concurrent goroutines, and worth stating
    as a product property. Identical items resolve once.

    delete_store now removes dependent rows by explicit deletes rather
    than FK cascades, keeping the tuple insert path free of FK-check
    overhead; the suite's t.Cleanup made the previous orphan
    accumulation visible.

  • Promote the id-domain decision into CLAUDE.md
    Decisions 1-3 and 7 of the v1-design workspace, settled with the
    owner during design: native uuid ids in canonical spelling only,
    the nil uuid reserved as the wildcard sentinel, uuidv7 store and
    model ids, and the PostgreSQL 18 floor that uuidv7() implies. The
    schema that encodes all of this landed in phase 1, so the
    constitution catches up in the promotion commit the plan
    schedules.

  • State the PG18 floor and uuid id domain in README
    The two facts a consumer must know before installing: the version
    floor that native uuidv7() implies, and the deliberate refusing-
    direction id-domain divergence from upstream OpenFGA, with what it
    buys — native uuid storage, indexes, and joins against the
    application's own tables.

  • Pin the id-domain divergences from both sides
    The four day-one divergences the uuid id domain causes are now
    tests, not just policy: each requires the oracle to accept what
    the engine refuses, so a gap upstream closes — or an engine
    regression to accepting — turns the suite red and demands
    docs/CONFORMANCE.md change in the same commit. That file starts
    here too, stating what conformant currently claims (the check
    corpus differentially green), what v1 excludes, and the direction
    vocabulary every future pin uses.

  • Wire the imported runners and land phase-2 groundwork
    The sqlclient now satisfies upstream's tests.ClientInterface by
    compile-time assertion: the streamed list-objects shim adapts the
    unary call into the client-stream shape with errors surfacing on
    first Recv, exactly as the real server delivers them. The runner
    invocation itself is gated behind the conditions phase with a
    printed skip, because execution surfaced a sequencing fact the
    plan missed: the matrix's one shared model declares a CEL
    condition and most stages write conditioned tuples, so the
    imported runners cannot go green before conditions land
    (workspace ISSUES.md 4).

    The measurement side of phase 2 is done and closes the weight
    question: the depth boundary is uniform across resolver shapes at
    the pin — userset chains, TTU chains and self-recursive shapes
    all raise 2002 at the 26th level, with engine parity asserted
    differentially — so no weight machinery is needed for check.
    Benchmarks put a direct check at 0.22ms and the depth-exceeded
    error path at 26ms, settling the errors-as-values question in
    favour of keeping the deferred re-raise.

    The ancient google.golang.org/genproto pseudo-version dragged in
    by grpc-middleware collides with the split-out googleapis modules;
    a replace to the modern module (which no longer contains the
    split directories) resolves the ambiguity.

  • Implement list_objects by reverse expansion (phase 3)
    An iterative BFS over userset nodes, seeded from the subject (plus
    the typed-wildcard node for plain subjects), walking direct,
    computed and TTU edges from the normalized model with a global
    seen-set. Termination is dedup alone: the phase-3 measurement
    session showed reverse expansion at the pin never charges
    tuple-hop depth — a 100-link chain lists all 101 objects while a
    forward check on the same chain refuses as too complex — so the
    plan's per-hop depth charge was measured wrong and is not encoded.
    The upstream-internal inconsistency is mirrored, not repaired.

    Candidates whose path crossed a relation containing intersection
    or difference carry a sticky taint and are confirmed by the
    forward resolver with a fresh budget, which validates the entire
    path and makes optimistic expansion through those operators sound.
    Validation follows the measured order — contextual tuples as
    invalid_tuple, then type_not_found and relation_not_found (the
    list_objects-specific 2021/2022 codes), then the subject.

    Every non-condition corpus case passes differentially in both
    variants (481 assertion subtests), each returned object confirmed
    by a check, and both engines cap at exactly 1000 results.

  • Implement conditions through cel4postgres (phase 4)
    The OpenFGA CEL dialect registers through cel4postgres's registry
    tables exactly as its own extensions do — the ipaddress opaque
    type reusing its netip-strict parser, in_cidr over inet — under an
    'openfga' env composing 'standard'. Typed parameters convert per
    upstream's grammar; conditions parse at model write into a cached
    AST, with type errors deferred to evaluation because the checker
    has no declared-variable support (a documented accepting-at-write
    divergence that never affects an authorization answer).

    Evaluation semantics are the measured ones (M07): the tuple's
    condition context wins over the request context per key; a
    referenced-but-missing parameter refuses BEFORE evaluation — the
    corpus proved upstream does not let || short-circuit around a
    missing operand, so the evaluator pre-scans idents in the AST;
    exact-key reads let a contextual duplicate replace the stored row
    while iterator reads concatenate; condition errors are held per
    read and dropped when a sibling row grants; list_objects collects
    per-candidate errors and raises only while results stay under the
    cap. A corpus failure exposed that condition-layer contextual-
    tuple invalidity maps to invalid_tuple on Check but
    validation_error on ListObjects (measured, M38), so the tuple
    validator carries two error codes.

    With the phase-4 gate lifted the entire YAML corpus passes for
    check and list_objects — abac included — and upstream's imported
    runners (~3000 generated assertions) run green through the SQL
    adapter in a dedicated CI step, list_users sub-asserts skipped by
    flag until phase 5.

    Ref: https://github.com/openfga/openfga/blob/v1.19.0/tests/check/check.go#L1050

  • Add list_users with measured set algebra
    list_users is forward expansion from object#relation, but its
    semantics only fell out of container measurement (M17/M18/M23/
    M24): userset filters are reflexive at every expansion node, not
    just the target; the wildcard algebra needs upstream's
    NoRelationship double negation, so sets carry exceptions —
    (plus, wild, minus) — letting user:* survive concrete
    subtraction without resurrecting excluded users through an
    intersection sibling, and making (* but not (* but not {c}))
    collapse to {c}; depth refuses one tuple-hop earlier than
    check; and condition errors are immediate on relevant paths
    while paths that cannot reach the filter type are pruned before
    their tuples are touched (fga._lu_reachable over the stored
    reachability closure), which the imported matrix pins via
    assertion_list_users_no_path_to_employee.

    ListUsersRequest carries contextual_tuples as a flat TupleKey
    array, unlike the other APIs' wrapped shape; the adapter and
    engine both encode that.

    With the API in place the imported upstream runners no longer
    need their list_users -skip: the dedicated CI step folds back
    into the main test invocation, and tests/listusers runs as a
    third imported runner.

  • Add write gates, model validation and fga.read
    Phase 6 turns the write path from permissive to measured: every
    refusal in the 40-rule inventory ran against the pinned oracle
    first (measurements M16, M25, M29, M35, M39) and is asserted
    differentially, so a code is never guessed. Highlights the
    measurements forced: in-request duplicates refuse 2004 across
    both lists before any row lands; on_duplicate/on_missing
    "ignore" tolerates the identical row and the missing delete but
    aborts (bare gRPC 10, a new YFG-class mapping) when the existing
    row holds a different condition; condition contexts are scanned
    for control characters before the undeclared-parameter check;
    and upstream's field length limits live in the proto validators
    (code 3), not the server.

    Model writes now run the CONFIG-* matrix under YF156 (2056),
    raw-proto measured: reserved names, dangling references, rewrite
    cycles, operand arity for union and intersection alike, the
    three tupleset rules, and the condition key/name match.
    Whole-model entrypoint analysis stays an accepting-direction
    divergence, documented rather than half-built.

    fga.read pages by ulid keyset with the token bound to a hash of
    its filter: upstream's positional token silently misaligns when
    reused under a changed filter (measured M27), which seems worth
    refusing — pinned two-sided as PIN-READ-1, alongside PIN-CTX-1
    for the 32768-byte context boundary measured over jsonb text
    here versus proto bytes upstream.

    docs/write-causes.json bijects the 53 error construction sites
    in upstream's write-path files at the pin onto dispositions;
    TestWriteCausesInventory extracts them from the module cache on
    every run and fails on any unattributed, reworded or stale
    entry, so a pin bump cannot silently change the refusal surface.

  • Add expand and the real-world model sweep
    Expand's contract came entirely from measurement (M19/M20):
    every error is 2000 — including undefined type and relation,
    where check answers 2021/2022 — an unmet-able conditioned tuple
    still appears in the tree (no condition evaluation), and the
    TTU computed list follows tuple order while the users leaf gave
    no provable ordering, so the differential comparison is
    order-insensitive over both. With no upstream corpus, the
    differential suite is the contract.

    The 38-fixture real-world sweep (openfga sample stores plus
    tsfga's production models) immediately earned its keep by
    exposing two semantic gaps the YAML corpus never touches:

    Condition-error scoping is upstream's filtered-iterator rule
    (M40, internal/iterator/filter.go at the pin): a held condition
    error is scoped per model-graph edge and dropped when ANY row
    of that edge passes its filter, even if the passing row's
    branch resolves false — not the coarser held-until-grant rule
    the abac corpus was compatible with. Check now groups userset
    and tupleset rows per edge; list_objects prunes its BFS to the
    subgraph between the user and the target relation, since
    upstream never reads (or errors on) edges that cannot lead to
    the target.

    Deep recursive negatives near the depth boundary turned out to
    be strategy-dependent upstream (M41): the same 29-link role
    chain answers false at any depth on a minimal store and refuses
    2002 at 25 hops on the snowflake fixture's full store. The
    engine keeps the documented budget; the false-vs-2002
    disagreement class is pinned two-sided (PIN-DEPTH-1) and the
    sweep tolerates exactly that class, never true-vs-false.

  • Ship the release channels and grants template
    Both distribution channels from CLAUDE.md decision 7 now build
    and prove themselves: scripts/build-release.sh flattens the
    vendored cel4postgres bundle plus sql/ into the all-in artifact
    (one psql -f on a bare database) and an engine-only file for
    databases already running the pinned cel4postgres;
    scripts/pgtle-wrap.sh turns the same artifact into a
    pgtle.install_extension script, validated by CREATE EXTENSION on
    a pg_tle build of the pinned image. CI installs through both
    channels on every run and the manual release workflow
    (cel4postgres's shape: smoke, pgtle, publish, all triple-guarded
    to main on the canonical repo) publishes them.

    900_grants.sql implements workspace decision 8's design: two
    conventional NOLOGIN group roles behind existence checks, so the
    script is a silent no-op until the consumer creates them and
    initdb order is unaffected. Entry points run with caller rights,
    so the privilege boundary is table DML — CI proves a reader can
    check but not write. docs/INSTALL.md documents all of it, and
    the engine version moves to 0.1.0 in its one home.

    The real-world sweep also gained the last measured tolerance:
    upstream's user-first iteration surfaces condition errors from
    tuples on objects structurally outside the query, and does so
    nondeterministically (M42, market fixture) — same request, same
    store, sometimes false, sometimes 2000 — so the class cannot be
    pinned two-sided. The sweep tolerates false-vs-2xxx
    disagreements in both directions, logging each, and still fails
    any true-vs-anything disagreement.

  • Update README for the completed v1 surface
    The status section stops promising and starts describing: the
    surface is complete, verified differentially against the pinned
    oracle, and every knowing divergence is pinned and documented.
    Installation points at the release artifact first and
    docs/INSTALL.md for the pg_tle channel and the privilege
    template.

  • Fix the setup-go action pin
    The pinned SHA did not exist in actions/setup-go, so every CI
    run failed at job setup before reaching a single step. Replace it
    with the real v6.0.0 commit; the checkout and harden-runner pins
    resolve correctly.

  • Make committed docs stand without the workspace
    Code comments, docs and test names leaned on planning artifacts a
    fresh checkout does not have: gitignored measurement ids (M07,
    M40), plan section references (doc 04 §7, plan phase 6), the
    measurements.md register, and numbered decision citations
    (decision 3, CLAUDE.md decision 10) that force a reader to go
    count entries in another file. None of that carries meaning on a
    new machine.

    Make every reference self-contained: the probe tests under
    conformance/ (renamed from TestProbeM35... to behaviour-named
    tests) become the citable record of measured oracle behaviour,
    divergence classes point at docs/CONFORMANCE.md, and each
    decision citation is replaced by its substance stated inline
    (the uuid id domain, the dependency-free install, the single
    write path that keeps a changelog addable).

    Also drop machinery that only existed for the phased build-out
    and is inert now that the surface is complete: the plan-phase
    skip gate in the conformance suite, the corpus feature
    classifier that fed it, the unused unimplemented() helper in
    sqlclient, and confreport's section reading the workspace-local
    measurements file.

  • Bump setup-go to v7 for the Node 24 runtime
    The v6.0.0 pin declares runs.using node20, which GitHub-hosted
    runners have deprecated: every run logs a warning that the action
    is being forced onto Node 24. v7.0.0 declares node24 natively and
    changes nothing else relevant (an ESM migration and dependency
    bumps; the go-version-file input is unchanged). checkout v7.0.1
    and harden-runner v2.20.0 already declare node24, so this was the
    only action on the deprecated runtime.

  • Add the benchmark scenario and generator library
    The benchmark suite needs datasets that are comparable across
    machines and sizes before anything can be measured. This library
    makes every fixture and every query a pure function of (seed,
    scenario, size): three scenario models (direct grants, deep TTU
    chains, wide usersets) with pinned apportionment constants, and
    query-selection rules that keep per-query work size-invariant so
    a 100M-tuple run answers the same question as a 100k one.

    Rows stream in fga.tuple primary-key order for btree locality at
    load time; ULIDs match fga._ulid()'s wire format (asserted by a
    sort-compatibility test) but are generated monotonically so bulk
    loads append into tuple_ulid_idx. The log-spaced histogram is
    pinned at 1µs–100s / ×1.04 buckets so percentiles stay comparable
    across binary versions. No new module dependencies.

    Tests replay the models over the generated tuples with a tiny
    in-memory resolver, so the selection rules themselves are
    verified (every hit resolves, every miss does not), not just row
    counts.

  • Add the fixture loader and the bench compose stack
    Fixtures COPY straight into fga.tuple, pre-sorted in PK order
    with client-side monotonic ULIDs — a deliberate bypass of the
    100-op write gate for setup only; fga.write throughput stays its
    own benchmark. A manifest in a tooling-owned fga_bench schema
    makes loads idempotent against a persistent database, and the
    engine is reinstalled unconditionally first so a persistent
    volume can never measure a stale schema. At 10M+ the two
    secondary indexes are dropped before COPY and recreated by
    re-running sql/050_tuple.sql, keeping that file the single DDL
    source.

    compose.bench.yaml is self-contained rather than an override:
    compose merging appends sequence keys, so an override could
    never remove the base tmpfs mount — and a benchmark database
    silently living in RAM would invalidate every 10M+ number. The
    named volume avoids the alpine uid-70 bind-mount trap, the
    oracle is dropped (benchmarks never call it), and cluster_name
    identifies the stack so results can record their storage class.

    The integration test proves fixtures end-to-end: generated hits
    must be allowed and generated misses denied by fga.check itself.

  • Add the bench binary with check measurement
    The runner measures closed-loop warm-cache service times on a
    single connection: a seq-scan priming pass per fixture, a
    duration-based warmup on the same loop as the measured phase
    (ops recorded, so warmup work is visible), then timed ops
    extended until the -min-ops sample floor is met. Results land as
    one JSON file per scenario x size, carrying the environment
    block (engine and PG versions, tuned pg_settings plus every
    non-default row, CPU, governor, storage class, git state, dead-
    tuple counts) that benchreport will later diff before comparing
    runs.

    The CLI resolves its database like the conformance suite does,
    reinstalls the engine unconditionally before measuring, and
    validates scenario, size and feature names against the library's
    single source so a drifted workflow input fails loudly. Only
    check is wired so far; the remaining features slot into the same
    case dispatcher.

  • Wire the remaining features into the bench runner
    batch_check sends 50-check batches (the verified upstream cap)
    mixing the three check streams, so one sample is one snapshot-
    consistent batch. The deep read page reuses a token the engine
    itself minted — only the keyset position is replaced with the
    store's 90th-percentile ulid — because the token binds a filter
    hash whose construction belongs to the engine, not the harness;
    the filter is pinned to the stream's first query since the case
    measures keyset positioning, not filter variety.

    The mutating pair runs last: write does 100-op write+delete
    pairs against a scratch store so the dataset is unchanged across
    iterations, and model writes accumulate immutable rows in their
    own scratch store, recreated outside any timed window. The
    runner test executes every case with tiny budgets so a broken
    request shape fails in seconds, not mid-campaign, and asserts no
    scratch store survives.

  • Add benchreport, the markdown result renderer
    Renders bench result JSON as one markdown section per run: an
    env header that states what the numbers are (closed-loop
    warm-cache service times) and where they came from, then a table
    of ops/s and percentiles. With -baseline it adds delta columns —
    informational only, per decision 8: the exit code is non-zero
    solely for malformed input or a baseline that measured a
    different fixture, since that comparison would be a lie.
    Environment differences (storage, governor, any captured
    pg_setting, a reused load) warn in the output and still exit 0;
    git fields never warn because differing commits are the point.
    Percentiles from fewer than 100 samples carry a ~ marker.

    Output goes to stdout so CI appends it to $GITHUB_STEP_SUMMARY
    by redirect, the same composition confreport uses. Golden-file
    tests pin the format.

  • Document the benchmark methodology
    The numbers only mean something if their framing travels with
    them: closed-loop warm-cache service times on one connection are
    not production tail latency, a uniform query stream is cold-
    heavy at scale on purpose, and CI figures from shared tmpfs
    runners spot order-of-magnitude movement rather than small
    regressions. This page states all of that once, alongside the
    reproduction commands, the quiet-machine checklist (recorded,
    never enforced), the fixture-loading disclosures (the fga_bench
    manifest schema, the COPY bypass, the 10M+ index drop), and the
    baseline policy for CI deltas.

  • Let a baseline bundle one result per scenario
    A bench invocation writes one result file per scenario, so a
    committed baseline for a size is naturally three results, not
    one. benchreport now accepts a JSON array in the baseline file
    and matches each candidate by scenario, refusing when the entry
    is missing or measured a different fixture — the same
    comparability rule as before, per scenario.

  • Add the benchmark CI workflow
    Separate from ci.yml so benchmark noise never gates a merge.
    Pull requests run 100k with short per-case budgets (~9 minutes
    computed against the 15-minute timeout: 39 cases at roughly 8s
    each plus stack setup); workflow_dispatch runs a chosen size at
    the full budgets with a longer timeout. The report lands in the
    job summary and the raw JSON uploads as a 30-day artifact.

    The dispatch size choices duplicate internal/bench/sizes.go
    because GitHub cannot generate input options; the comment names
    the source of truth and the binary validates its input, so drift
    fails loudly. Deltas appear only once a curated baseline is
    committed under docs/benchmarks/ — until then summaries show
    absolute numbers, per the recorded baseline policy. If PR runs
    prove too slow on shared runners, the named fallback is demoting
    the PR trigger to dispatch-only.

  • Keep the engine reinstall out of the bench tests
    Running go test ./... executes the bench and conformance
    packages in parallel against the one compose database, and the
    reinstall's internal-helper DROPs race in-flight conformance
    queries: measured as "cache lookup failed for function"
    (SQLSTATE XX000) in the imported check runners. The reinstall
    belongs to the bench CLI, which owns its database exclusively;
    the suite database is shared and already installed by compose.

  • Size the PR bench budget from a measured run
    The planned ~9-minute PR budget assumed ~8s per case; a full
    100k run at the PR knobs took 24m25s because -min-ops 100
    dominates the slow cases. write:churn measures ~3.4s per
    100+100-op pair in every scenario (~340s per scenario for that
    case alone) and fanout's list_users:many ~1.4s per op. PR runs
    now pass -min-ops 25 — slow-case percentiles get the report's ~
    annotation, an accepted trade for a ~12-minute run — under a
    20-minute timeout. The dispatch timeout rises to 90 minutes:
    full budgets plus the same sample floor put a 1m run near an
    hour.

  • Number the first release 0.0.1
    The tree carried 0.1.0 from v1 delivery, but no release has ever
    been published, and the owner chose 0.0.1 for the first one --
    matching cel4postgres, whose first release used the same number.
    Changed only in the version's single home; everything else reads
    it from here.

  • Wait for the final server in the smoke loops
    The readiness loops probed psql/pg_isready through docker exec,
    which connects over the unix socket -- and initdb's temporary
    bootstrap server listens there while the init scripts are still
    running. Each script in the bundle commits its own transaction,
    so fga.version() answers right after 010 commits, mid-install,
    and the grants step then ran against a half-installed schema
    (CI run 33441073819: fga.list_users missing). Only the final
    server listens on TCP, so probing 127.0.0.1 proves initdb is
    done.

  • Estimate one row for the tuple string parsers
    Both parsers always return exactly one row, but as set-returning
    functions they carried the SRF default estimate of 1000, so the
    delete loop's parse cross join was priced at a million rows.
    That cost crosses jit_above_cost on stock settings, and because
    plpgsql plan caching does not cache JIT output, PostgreSQL
    LLVM-compiled that trivial statement on every delete: ~34ms per
    tuple, ~260x slower than the same loop with jit off. Every
    write:churn benchmark number published so far measured LLVM
    compile time, not the write path.

    ROWS 1 corrects the estimate wherever the parsers appear and
    removes the JIT trigger without depending on server settings.
    Measured on the suite database: 100 deletes through fga.write
    drop from ~3400ms to ~10ms, in line with the jit-off control.

The store is the conformance-isolation unit: every corpus test runs
in a fresh store, so the harness skeleton (next) needs create_store
before anything else exists. Ids are uuidv7() per workspace decision
3 -- time-ordered like upstream's ULIDs so latest-model resolution
stays index-backed -- which is what makes PostgreSQL 18 the floor.

delete_store is a silent no-op on a missing id, matching the
observed oracle behaviour (v1.19.0 DELETE /stores returns 204 for a
store that is already gone). The cascade mechanism for dependent
rows is deliberately deferred to the phase that creates those
tables, where the suite can see its cost.
The suite exists before the engine so that nothing the engine ever
skips can be silent: every deliberate skip goes through a register
the run prints, and the skip list for the imported upstream runners
is harvested from `go test -json` output rather than written by
hand. From this first commit the corpus loads from the pinned
openfga module's embedded assets (never copied files), single-file
and single-case selectors work, and an inventory test locks the
loader to the corpus totals at the pin (161 tests, 188 stages,
1227 assertions) so a decode regression turns red instead of
shrinking the suite.

The error scheme (docs/ERRORS.md) is frozen now because everything
later raises through it: one SQLSTATE in the implementation-defined
class YF per upstream API error code, derived mechanically, with a
single class-wide catch that can never swallow genuine bugs the way
WHEN OTHERS would.

Corpus string ids map into the engine's uuid-only id domain through
deterministic uuidv5 (property-tested for bijectivity); ids
upstream's own grammar refuses pass through unmapped so both sides
refuse the original.

go.work is committed deliberately: vendor/ holds the pinned
cel4postgres SQL bundle, and in workspace mode the go command stops
treating that directory as Go vendoring; without it every go
command fails with "inconsistent vendoring".

Ref: https://github.com/openfga/openfga/blob/v1.19.0/tests/check/check.go#L188-L196
The Go steps arrive with the harness they test: gofmt and vet keep
the tree honest, and the suite runs through -json into confreport
and skipgen so every CI log carries the generated conformance
report and the run-derived skip list -- the no-silent-scope-
reduction mechanism working in public. confreport exits non-zero
on any failed test or failed-to-build package, so nothing is lost
to the pipe.

The idempotent-reinstall step re-runs the by-hand install loop
against the already-installed database because that loop is the
documented upgrade path; proving it on every run keeps a
non-idempotent script from ever landing.
The plan forbids encoding a measured behaviour before a container
run closes its measurement entry, so the probes come before the
resolver they specify. Each test names the measurements.md entry
it closes and logs OBSERVED lines that are the record.

The load-bearing finding: cycle handling at v1.19.0 depends on the
resolver shape. A self-recursive relation resolves a tuple cycle
to a plain false with no cycle flag, so base-true but-not that
cycle answers true; a cycle spanning two relations keeps the flag
and the same exclusion answers false. The engine therefore needs
both mechanisms, kept distinct. Also measured: the depth boundary
(first 2002 at 26 userset links; computed chains free), the full
error-swallowing matrix, check validation order (proto shape, then
request key user-first, then contextual tuples as 2027), and that
bad contextual tuples are 2027 while bad request keys are 2000.

The language module lands here pinned to the exact pseudo-version
openfga v1.19.0 builds against, because probes write models as
DSL.

Ref: https://github.com/openfga/openfga/blob/v1.19.0/internal/graph/check.go#L320-L372
Model writes store the verbatim upstream JSON and derive the
normalized rows the resolver reads, including the reachability
closure that makes the PathExists prune a single indexed lookup at
resolve time. Set operators contribute their children's edges to
the closure — an over-approximation that can only prune less,
never wrongly refuse.

The resolver implements this/computed_userset/union with the
semantics the measurement session pinned down (measurements.md
M01/M04/M05/M06/M10): depth charged only on dispatch with the 26th
dispatch raising too-complex, deferred operand-error re-raise
through a class-wide SQLSTATE catch (never WHEN OTHERS), the
two-valued cycle outcome threaded through every operator, and the
measured request validation order with bad contextual tuples
refused as invalid_tuple. Intersection, difference and TTU raise a
non-class internal error until phase 1b so an unimplemented
operator can never be swallowed as an operand error.

The tuple write path is the single mutation function decision 8
requires, with write-grade validation shared verbatim by the
contextual-tuple gate; deletes are validated syntactically only so
rows stranded by a model change stay deletable.
The sqlclient completes its first three real methods, owning the
three adapter translations: proto to the engine's snake_case jsonb
request shapes, engine SQLSTATEs back to the gRPC codes the corpus
asserts, and uuid-mapping of corpus string ids. Proto-shape
validation reuses the api module's generated validators so shape
errors surface exactly as the oracle's interceptor surfaces them —
with store and model ids exempted from the ULID pattern, which is
the id-domain divergence extended to its logical end.

The replayer mirrors upstream's runner contract: each case twice
(normal and ctxTuples), fresh store per test on both engines,
seeded shuffles with the seed printed on every run, 40-tuple write
chunks, and every assertion checked against the corpus expectation
on both engines at once — corpus conformance and differential
agreement in one assert.

The four model-change cases forced the read path to grow
upstream's FilterInvalidTuples rule: stored tuples invalid under
the request's model are invisible, not errors; contextual tuples
skip the filter because the request gate already validated them
against that very model.

All 1a-eligible check cases pass (314 subtests); TTU, intersection
and exclusion models wait behind the printed phase-1b gate.
Intersection, exclusion and TTU land with the semantics the
measurement session fixed: intersection walks operands in model
order and the first false-or-cycled operand decides and carries
its flag; exclusion refuses on a true-or-cycled subtrahend and
re-raises a deferred operand error only when the other side did
not produce the swallowing answer; a TTU parent whose type does
not define the computed relation is skipped, the one asymmetric
undefined-relation case.

The cycle guard grows the shape distinction M04 measured: a cycle
that stays inside one self-recursive relation resolves to plain
false (upstream's recursive fast path deduplicates without a
flag, so base-true but-not that cycle answers true), while a
cycle crossing relations keeps the flag and the same exclusion
refuses. The visited array's segment since the first occurrence
of the re-entered key decides which case applies.

With the 1b gate flipped, every non-condition check case in both
corpus files passes differentially in both variants — 1247
subtests, conditions remaining the only classifier skip.
batch_check follows the measured v1.19.0 contract (measurements.md
M34): more than 50 items and duplicate correlation ids refuse the
whole request; an item's own validation failure is captured under
its correlation id without failing the batch. Because one SQL
statement is one snapshot, batch answers are mutually consistent —
stronger than upstream's concurrent goroutines, and worth stating
as a product property. Identical items resolve once.

delete_store now removes dependent rows by explicit deletes rather
than FK cascades, keeping the tuple insert path free of FK-check
overhead; the suite's t.Cleanup made the previous orphan
accumulation visible.
Decisions 1-3 and 7 of the v1-design workspace, settled with the
owner during design: native uuid ids in canonical spelling only,
the nil uuid reserved as the wildcard sentinel, uuidv7 store and
model ids, and the PostgreSQL 18 floor that uuidv7() implies. The
schema that encodes all of this landed in phase 1, so the
constitution catches up in the promotion commit the plan
schedules.
The two facts a consumer must know before installing: the version
floor that native uuidv7() implies, and the deliberate refusing-
direction id-domain divergence from upstream OpenFGA, with what it
buys — native uuid storage, indexes, and joins against the
application's own tables.
The four day-one divergences the uuid id domain causes are now
tests, not just policy: each requires the oracle to accept what
the engine refuses, so a gap upstream closes — or an engine
regression to accepting — turns the suite red and demands
docs/CONFORMANCE.md change in the same commit. That file starts
here too, stating what conformant currently claims (the check
corpus differentially green), what v1 excludes, and the direction
vocabulary every future pin uses.
The sqlclient now satisfies upstream's tests.ClientInterface by
compile-time assertion: the streamed list-objects shim adapts the
unary call into the client-stream shape with errors surfacing on
first Recv, exactly as the real server delivers them. The runner
invocation itself is gated behind the conditions phase with a
printed skip, because execution surfaced a sequencing fact the
plan missed: the matrix's one shared model declares a CEL
condition and most stages write conditioned tuples, so the
imported runners cannot go green before conditions land
(workspace ISSUES.md 4).

The measurement side of phase 2 is done and closes the weight
question: the depth boundary is uniform across resolver shapes at
the pin — userset chains, TTU chains and self-recursive shapes
all raise 2002 at the 26th level, with engine parity asserted
differentially — so no weight machinery is needed for check.
Benchmarks put a direct check at 0.22ms and the depth-exceeded
error path at 26ms, settling the errors-as-values question in
favour of keeping the deferred re-raise.

The ancient google.golang.org/genproto pseudo-version dragged in
by grpc-middleware collides with the split-out googleapis modules;
a replace to the modern module (which no longer contains the
split directories) resolves the ambiguity.
An iterative BFS over userset nodes, seeded from the subject (plus
the typed-wildcard node for plain subjects), walking direct,
computed and TTU edges from the normalized model with a global
seen-set. Termination is dedup alone: the phase-3 measurement
session showed reverse expansion at the pin never charges
tuple-hop depth — a 100-link chain lists all 101 objects while a
forward check on the same chain refuses as too complex — so the
plan's per-hop depth charge was measured wrong and is not encoded.
The upstream-internal inconsistency is mirrored, not repaired.

Candidates whose path crossed a relation containing intersection
or difference carry a sticky taint and are confirmed by the
forward resolver with a fresh budget, which validates the entire
path and makes optimistic expansion through those operators sound.
Validation follows the measured order — contextual tuples as
invalid_tuple, then type_not_found and relation_not_found (the
list_objects-specific 2021/2022 codes), then the subject.

Every non-condition corpus case passes differentially in both
variants (481 assertion subtests), each returned object confirmed
by a check, and both engines cap at exactly 1000 results.
The OpenFGA CEL dialect registers through cel4postgres's registry
tables exactly as its own extensions do — the ipaddress opaque
type reusing its netip-strict parser, in_cidr over inet — under an
'openfga' env composing 'standard'. Typed parameters convert per
upstream's grammar; conditions parse at model write into a cached
AST, with type errors deferred to evaluation because the checker
has no declared-variable support (a documented accepting-at-write
divergence that never affects an authorization answer).

Evaluation semantics are the measured ones (M07): the tuple's
condition context wins over the request context per key; a
referenced-but-missing parameter refuses BEFORE evaluation — the
corpus proved upstream does not let || short-circuit around a
missing operand, so the evaluator pre-scans idents in the AST;
exact-key reads let a contextual duplicate replace the stored row
while iterator reads concatenate; condition errors are held per
read and dropped when a sibling row grants; list_objects collects
per-candidate errors and raises only while results stay under the
cap. A corpus failure exposed that condition-layer contextual-
tuple invalidity maps to invalid_tuple on Check but
validation_error on ListObjects (measured, M38), so the tuple
validator carries two error codes.

With the phase-4 gate lifted the entire YAML corpus passes for
check and list_objects — abac included — and upstream's imported
runners (~3000 generated assertions) run green through the SQL
adapter in a dedicated CI step, list_users sub-asserts skipped by
flag until phase 5.

Ref: https://github.com/openfga/openfga/blob/v1.19.0/tests/check/check.go#L1050
list_users is forward expansion from object#relation, but its
semantics only fell out of container measurement (M17/M18/M23/
M24): userset filters are reflexive at every expansion node, not
just the target; the wildcard algebra needs upstream's
NoRelationship double negation, so sets carry exceptions —
(plus, wild, minus) — letting user:* survive concrete
subtraction without resurrecting excluded users through an
intersection sibling, and making (* but not (* but not {c}))
collapse to {c}; depth refuses one tuple-hop earlier than
check; and condition errors are immediate on relevant paths
while paths that cannot reach the filter type are pruned before
their tuples are touched (fga._lu_reachable over the stored
reachability closure), which the imported matrix pins via
assertion_list_users_no_path_to_employee.

ListUsersRequest carries contextual_tuples as a flat TupleKey
array, unlike the other APIs' wrapped shape; the adapter and
engine both encode that.

With the API in place the imported upstream runners no longer
need their list_users -skip: the dedicated CI step folds back
into the main test invocation, and tests/listusers runs as a
third imported runner.
Phase 6 turns the write path from permissive to measured: every
refusal in the 40-rule inventory ran against the pinned oracle
first (measurements M16, M25, M29, M35, M39) and is asserted
differentially, so a code is never guessed. Highlights the
measurements forced: in-request duplicates refuse 2004 across
both lists before any row lands; on_duplicate/on_missing
"ignore" tolerates the identical row and the missing delete but
aborts (bare gRPC 10, a new YFG-class mapping) when the existing
row holds a different condition; condition contexts are scanned
for control characters before the undeclared-parameter check;
and upstream's field length limits live in the proto validators
(code 3), not the server.

Model writes now run the CONFIG-* matrix under YF156 (2056),
raw-proto measured: reserved names, dangling references, rewrite
cycles, operand arity for union and intersection alike, the
three tupleset rules, and the condition key/name match.
Whole-model entrypoint analysis stays an accepting-direction
divergence, documented rather than half-built.

fga.read pages by ulid keyset with the token bound to a hash of
its filter: upstream's positional token silently misaligns when
reused under a changed filter (measured M27), which seems worth
refusing — pinned two-sided as PIN-READ-1, alongside PIN-CTX-1
for the 32768-byte context boundary measured over jsonb text
here versus proto bytes upstream.

docs/write-causes.json bijects the 53 error construction sites
in upstream's write-path files at the pin onto dispositions;
TestWriteCausesInventory extracts them from the module cache on
every run and fails on any unattributed, reworded or stale
entry, so a pin bump cannot silently change the refusal surface.
Expand's contract came entirely from measurement (M19/M20):
every error is 2000 — including undefined type and relation,
where check answers 2021/2022 — an unmet-able conditioned tuple
still appears in the tree (no condition evaluation), and the
TTU computed list follows tuple order while the users leaf gave
no provable ordering, so the differential comparison is
order-insensitive over both. With no upstream corpus, the
differential suite is the contract.

The 38-fixture real-world sweep (openfga sample stores plus
tsfga's production models) immediately earned its keep by
exposing two semantic gaps the YAML corpus never touches:

Condition-error scoping is upstream's filtered-iterator rule
(M40, internal/iterator/filter.go at the pin): a held condition
error is scoped per model-graph edge and dropped when ANY row
of that edge passes its filter, even if the passing row's
branch resolves false — not the coarser held-until-grant rule
the abac corpus was compatible with. Check now groups userset
and tupleset rows per edge; list_objects prunes its BFS to the
subgraph between the user and the target relation, since
upstream never reads (or errors on) edges that cannot lead to
the target.

Deep recursive negatives near the depth boundary turned out to
be strategy-dependent upstream (M41): the same 29-link role
chain answers false at any depth on a minimal store and refuses
2002 at 25 hops on the snowflake fixture's full store. The
engine keeps the documented budget; the false-vs-2002
disagreement class is pinned two-sided (PIN-DEPTH-1) and the
sweep tolerates exactly that class, never true-vs-false.
Both distribution channels from CLAUDE.md decision 7 now build
and prove themselves: scripts/build-release.sh flattens the
vendored cel4postgres bundle plus sql/ into the all-in artifact
(one psql -f on a bare database) and an engine-only file for
databases already running the pinned cel4postgres;
scripts/pgtle-wrap.sh turns the same artifact into a
pgtle.install_extension script, validated by CREATE EXTENSION on
a pg_tle build of the pinned image. CI installs through both
channels on every run and the manual release workflow
(cel4postgres's shape: smoke, pgtle, publish, all triple-guarded
to main on the canonical repo) publishes them.

900_grants.sql implements workspace decision 8's design: two
conventional NOLOGIN group roles behind existence checks, so the
script is a silent no-op until the consumer creates them and
initdb order is unaffected. Entry points run with caller rights,
so the privilege boundary is table DML — CI proves a reader can
check but not write. docs/INSTALL.md documents all of it, and
the engine version moves to 0.1.0 in its one home.

The real-world sweep also gained the last measured tolerance:
upstream's user-first iteration surfaces condition errors from
tuples on objects structurally outside the query, and does so
nondeterministically (M42, market fixture) — same request, same
store, sometimes false, sometimes 2000 — so the class cannot be
pinned two-sided. The sweep tolerates false-vs-2xxx
disagreements in both directions, logging each, and still fails
any true-vs-anything disagreement.
The status section stops promising and starts describing: the
surface is complete, verified differentially against the pinned
oracle, and every knowing divergence is pinned and documented.
Installation points at the release artifact first and
docs/INSTALL.md for the pg_tle channel and the privilege
template.
The pinned SHA did not exist in actions/setup-go, so every CI
run failed at job setup before reaching a single step. Replace it
with the real v6.0.0 commit; the checkout and harden-runner pins
resolve correctly.
Code comments, docs and test names leaned on planning artifacts a
fresh checkout does not have: gitignored measurement ids (M07,
M40), plan section references (doc 04 §7, plan phase 6), the
measurements.md register, and numbered decision citations
(decision 3, CLAUDE.md decision 10) that force a reader to go
count entries in another file. None of that carries meaning on a
new machine.

Make every reference self-contained: the probe tests under
conformance/ (renamed from TestProbeM35... to behaviour-named
tests) become the citable record of measured oracle behaviour,
divergence classes point at docs/CONFORMANCE.md, and each
decision citation is replaced by its substance stated inline
(the uuid id domain, the dependency-free install, the single
write path that keeps a changelog addable).

Also drop machinery that only existed for the phased build-out
and is inert now that the surface is complete: the plan-phase
skip gate in the conformance suite, the corpus feature
classifier that fed it, the unused unimplemented() helper in
sqlclient, and confreport's section reading the workspace-local
measurements file.
The v6.0.0 pin declares runs.using node20, which GitHub-hosted
runners have deprecated: every run logs a warning that the action
is being forced onto Node 24. v7.0.0 declares node24 natively and
changes nothing else relevant (an ESM migration and dependency
bumps; the go-version-file input is unchanged). checkout v7.0.1
and harden-runner v2.20.0 already declare node24, so this was the
only action on the deprecated runtime.
The benchmark suite needs datasets that are comparable across
machines and sizes before anything can be measured. This library
makes every fixture and every query a pure function of (seed,
scenario, size): three scenario models (direct grants, deep TTU
chains, wide usersets) with pinned apportionment constants, and
query-selection rules that keep per-query work size-invariant so
a 100M-tuple run answers the same question as a 100k one.

Rows stream in fga.tuple primary-key order for btree locality at
load time; ULIDs match fga._ulid()'s wire format (asserted by a
sort-compatibility test) but are generated monotonically so bulk
loads append into tuple_ulid_idx. The log-spaced histogram is
pinned at 1µs–100s / ×1.04 buckets so percentiles stay comparable
across binary versions. No new module dependencies.

Tests replay the models over the generated tuples with a tiny
in-memory resolver, so the selection rules themselves are
verified (every hit resolves, every miss does not), not just row
counts.
Fixtures COPY straight into fga.tuple, pre-sorted in PK order
with client-side monotonic ULIDs — a deliberate bypass of the
100-op write gate for setup only; fga.write throughput stays its
own benchmark. A manifest in a tooling-owned fga_bench schema
makes loads idempotent against a persistent database, and the
engine is reinstalled unconditionally first so a persistent
volume can never measure a stale schema. At 10M+ the two
secondary indexes are dropped before COPY and recreated by
re-running sql/050_tuple.sql, keeping that file the single DDL
source.

compose.bench.yaml is self-contained rather than an override:
compose merging appends sequence keys, so an override could
never remove the base tmpfs mount — and a benchmark database
silently living in RAM would invalidate every 10M+ number. The
named volume avoids the alpine uid-70 bind-mount trap, the
oracle is dropped (benchmarks never call it), and cluster_name
identifies the stack so results can record their storage class.

The integration test proves fixtures end-to-end: generated hits
must be allowed and generated misses denied by fga.check itself.
The runner measures closed-loop warm-cache service times on a
single connection: a seq-scan priming pass per fixture, a
duration-based warmup on the same loop as the measured phase
(ops recorded, so warmup work is visible), then timed ops
extended until the -min-ops sample floor is met. Results land as
one JSON file per scenario x size, carrying the environment
block (engine and PG versions, tuned pg_settings plus every
non-default row, CPU, governor, storage class, git state, dead-
tuple counts) that benchreport will later diff before comparing
runs.

The CLI resolves its database like the conformance suite does,
reinstalls the engine unconditionally before measuring, and
validates scenario, size and feature names against the library's
single source so a drifted workflow input fails loudly. Only
check is wired so far; the remaining features slot into the same
case dispatcher.
batch_check sends 50-check batches (the verified upstream cap)
mixing the three check streams, so one sample is one snapshot-
consistent batch. The deep read page reuses a token the engine
itself minted — only the keyset position is replaced with the
store's 90th-percentile ulid — because the token binds a filter
hash whose construction belongs to the engine, not the harness;
the filter is pinned to the stream's first query since the case
measures keyset positioning, not filter variety.

The mutating pair runs last: write does 100-op write+delete
pairs against a scratch store so the dataset is unchanged across
iterations, and model writes accumulate immutable rows in their
own scratch store, recreated outside any timed window. The
runner test executes every case with tiny budgets so a broken
request shape fails in seconds, not mid-campaign, and asserts no
scratch store survives.
Renders bench result JSON as one markdown section per run: an
env header that states what the numbers are (closed-loop
warm-cache service times) and where they came from, then a table
of ops/s and percentiles. With -baseline it adds delta columns —
informational only, per decision 8: the exit code is non-zero
solely for malformed input or a baseline that measured a
different fixture, since that comparison would be a lie.
Environment differences (storage, governor, any captured
pg_setting, a reused load) warn in the output and still exit 0;
git fields never warn because differing commits are the point.
Percentiles from fewer than 100 samples carry a ~ marker.

Output goes to stdout so CI appends it to $GITHUB_STEP_SUMMARY
by redirect, the same composition confreport uses. Golden-file
tests pin the format.
The numbers only mean something if their framing travels with
them: closed-loop warm-cache service times on one connection are
not production tail latency, a uniform query stream is cold-
heavy at scale on purpose, and CI figures from shared tmpfs
runners spot order-of-magnitude movement rather than small
regressions. This page states all of that once, alongside the
reproduction commands, the quiet-machine checklist (recorded,
never enforced), the fixture-loading disclosures (the fga_bench
manifest schema, the COPY bypass, the 10M+ index drop), and the
baseline policy for CI deltas.
A bench invocation writes one result file per scenario, so a
committed baseline for a size is naturally three results, not
one. benchreport now accepts a JSON array in the baseline file
and matches each candidate by scenario, refusing when the entry
is missing or measured a different fixture — the same
comparability rule as before, per scenario.
Separate from ci.yml so benchmark noise never gates a merge.
Pull requests run 100k with short per-case budgets (~9 minutes
computed against the 15-minute timeout: 39 cases at roughly 8s
each plus stack setup); workflow_dispatch runs a chosen size at
the full budgets with a longer timeout. The report lands in the
job summary and the raw JSON uploads as a 30-day artifact.

The dispatch size choices duplicate internal/bench/sizes.go
because GitHub cannot generate input options; the comment names
the source of truth and the binary validates its input, so drift
fails loudly. Deltas appear only once a curated baseline is
committed under docs/benchmarks/ — until then summaries show
absolute numbers, per the recorded baseline policy. If PR runs
prove too slow on shared runners, the named fallback is demoting
the PR trigger to dispatch-only.
Running go test ./... executes the bench and conformance
packages in parallel against the one compose database, and the
reinstall's internal-helper DROPs race in-flight conformance
queries: measured as "cache lookup failed for function"
(SQLSTATE XX000) in the imported check runners. The reinstall
belongs to the bench CLI, which owns its database exclusively;
the suite database is shared and already installed by compose.
The planned ~9-minute PR budget assumed ~8s per case; a full
100k run at the PR knobs took 24m25s because -min-ops 100
dominates the slow cases. write:churn measures ~3.4s per
100+100-op pair in every scenario (~340s per scenario for that
case alone) and fanout's list_users:many ~1.4s per op. PR runs
now pass -min-ops 25 — slow-case percentiles get the report's ~
annotation, an accepted trade for a ~12-minute run — under a
20-minute timeout. The dispatch timeout rises to 90 minutes:
full budgets plus the same sample floor put a 1m run near an
hour.
The tree carried 0.1.0 from v1 delivery, but no release has ever
been published, and the owner chose 0.0.1 for the first one --
matching cel4postgres, whose first release used the same number.
Changed only in the version's single home; everything else reads
it from here.
The readiness loops probed psql/pg_isready through docker exec,
which connects over the unix socket -- and initdb's temporary
bootstrap server listens there while the init scripts are still
running. Each script in the bundle commits its own transaction,
so fga.version() answers right after 010 commits, mid-install,
and the grants step then ran against a half-installed schema
(CI run 33441073819: fga.list_users missing). Only the final
server listens on TCP, so probing 127.0.0.1 proves initdb is
done.
Both parsers always return exactly one row, but as set-returning
functions they carried the SRF default estimate of 1000, so the
delete loop's parse cross join was priced at a million rows.
That cost crosses jit_above_cost on stock settings, and because
plpgsql plan caching does not cache JIT output, PostgreSQL
LLVM-compiled that trivial statement on every delete: ~34ms per
tuple, ~260x slower than the same loop with jit off. Every
write:churn benchmark number published so far measured LLVM
compile time, not the write path.

ROWS 1 corrects the estimate wherever the parsers appear and
removes the JIT trigger without depending on server settings.
Measured on the suite database: 100 deletes through fga.write
drop from ~3400ms to ~10ms, in line with the jit-off control.
@lemuelroberto lemuelroberto changed the title develop Add initial working implementation Aug 31, 2026
@lemuelroberto
lemuelroberto merged commit aafbd83 into emfga:main Aug 31, 2026
3 checks passed
@lemuelroberto
lemuelroberto deleted the develop branch October 19, 2026 23:24
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