Contents
- Merge pg_mentat into mentat — one repo, two storage backends
- Part 1 — Every difference, and what to do about it
- 1.1 Security: mentat_eval gives any database user the server’s filesystem
- 1.2 :rules vs :with — the two parsers disagree on what a query means
- 1.3 :in binding forms — pg_mentat has them, mentat throws them away
- 1.4 History queries: the 5th pattern place ?added
- 1.5 Reverse pull :person/_friends — both have it, one in the parser, one outside
- 1.6 Plain keywords as pattern values — pg_mentat accepts [?e :status :active]
- 1.7 Limit::None vs Limit::Unlimited
- 1.8 Built-in transaction functions :db.fn/cas, :db/retractEntity
- 1.9 enum-set 0.0.8 vs enumset 1.1
- 1.10 Modern-Rust idioms (the rest of the drift)
- 1.11 The EDN escape fix — the same fix, committed twice
- 1.12 Copyright headers
- 1.13 Dependency declarations in the shared crates
- 1.14 Toolchain and flake
- 1.15 Workspace lints
- 1.16 Release profile
- 1.17 CI
- 1.18 Dead weight in mentat
- 1.19 Two mino scripting layers, 998 and 732 lines, one design
- 1.20 Two temporal models
- 1.21 Functional gaps (not drift — features only one side has)
- 1.22 mentatd could serve either backend — later
- 1.23 Git history and branches
- 1.24 Deep nesting crashes the server — in the default build (found 2026-09-24)
- 1.25 mino-rs moves into this repo
- Part 2 — mino-rs refresh from upstream mino
- Part 3 — Target layout
- Part 4 — Tasks
- [test] fn deep_value_is_an_error_not_a_crash() {
- [test] fn deep_query_is_an_error() {
- [test] fn deep_tx_is_an_error() { assert!(edn::parse::entities(&nested(100_000)).is_err()); }
- [test] fn limit_is_exact() {
- [test] fn brackets_in_strings_and_comments_dont_count() {
- [test] fn mixed_delimiters() {
- [test]
- [test]
- [test]
- [test]
- [test]
- [test] fn in_bindings() {
- [test] fn five_place_pattern() {
- [test] fn rules_three_spellings() {
- [test] fn with_vars_still_with_vars() {
- [test] fn plain_keyword_value() {
- [test] fn reverse_pull() {
- [test] fn cas_is_a_known_tx_fn() {
-
- Task 3: Adopt workspace lints, release profile, pinned toolchain (both repos)
- Task 4: Import pg_mentat’s history into the mentat repo
- Task 5: Restructure into crates/ and one workspace
- Task 6: One flake, one CI, delete dead weight
- Task 7: mentat-script — one scripting layer, two backends
- Task 8: Refresh crates/mino from upstream mino 9c65bb50
- Task 9: mino.store backends over the real engines (optional, after 8)
- Task 10: Replace unimplemented!() in the SQLite crates; lints on
- Task 11: :db.fn/cas and :db/retractEntity in the SQLite transactor
- Task 12: History patterns, historical q, and :in collection bindings on SQLite
- Task 13: Documentation of the combined project
- Task 14: Release 1.7.0 and retire the pg_mentat repo
-
- Part 5 — Self-review
Merge pg_mentat into mentat — one repo, two storage backends
For agentic workers: REQUIRED SUB-SKILL: use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (
- [ ]) syntax.
Goal: One git repository (codeberg.org/gregburd/mentat) that builds either
(a) the mentat crate, an embedded Datomic-like store on SQLite, or (b) the
pg_mentat PostgreSQL extension on pgrx, plus mentatd. One copy of the shared
front-end (edn, core-traits, core), one copy of the mino scripting glue,
one toolchain, one lockfile, one CI.
Architecture: The two projects already share their front-end and differ only
in their storage/query backend. Reconcile the three shared crates first (they
drifted in both directions), then bring pg_mentat’s history into the mentat repo
with git subtree-style path rewriting so its 1,258 commits survive, then point
both backends at the single front-end. mino-rs moves into the repo too, as
crates/mino (§ 1.24): upstream mino is archived, so this project maintains the
interpreter from here on.
Tech stack: Rust 1.98 (stable, pinned), edition 2021, pgrx 0.17 (pg13–18), rusqlite 0.40 (bundled SQLite), mino-rs, nix flake devShells.
Spec: this document. Evidence for every difference below was taken from the
two working trees on 2026-09-24: ~/ws/mentat @ a48aec6d (branch pg /
mino-scripting) and ~/ws/pg_mentat @ 6188a12 (branch main), plus
~/src/mino @ 9c65bb50 and ~/src/mino-rs @ v0.1.0.
Global constraints
- Version: 1.6.1 everywhere (highest across both repos; pg_mentat and mentatd are at 1.6.1, mentat at 0.14.0). The first release of the merged repo is 1.7.0.
- Toolchain floor
rust-version = "1.88"(pg_mentat’s); pinned toolchain 1.98. - License: Apache-2.0 (both repos already are).
crates/minokeeps mino’s MIT license file (MIT-licensed code may sit in an Apache-2.0 repo; itsCargo.tomlsayslicense = "MIT"). - No local-filesystem references anywhere in the repo: no
path =that leaves the repo, no/home/...or~/src/...in anyCargo.toml,.cargo/config.toml, script, or test. Workspace-internalpath =deps are fine. CI checks this (Task 6). - Upstream mino is archived. We find and fix its bugs ourselves;
~/src/minois a reference only, never a build or test input. - The default
cargo buildat the repo root builds the SQLite side only. It must never requirepg_config, libclang, or a PostgreSQL install. - Nothing is lost: every feature present in either copy of a shared crate survives.
- pg_mentat’s full git history is preserved (no squash import).
- Work on a branch
merge/pg-mentat;master/mainare touched only at release. - Every task ends green on:
cargo test --workspace(SQLite side) andcargo pgrx test pg16(extension), unless the task says otherwise.
Part 1 — Every difference, and what to do about it
Ranked by consequence. Each item: what differs, evidence, decision, why.
1.1 Security: mentat_eval gives any database user the server’s filesystem
What. pg_mentat 1.6.0 added #[pg_extern] fn mentat_eval(script) behind the
script feature. It builds mino_rs::Interpreter::new(), which unconditionally
installs slurp, spit, mkdir-p, rm-rf, file-exists? (mino-rs
src/store.rs:398-402) plus the store’s WAL/snapshot file prims. The extension
issues no REVOKE, so PostgreSQL’s default applies: EXECUTE is granted to
PUBLIC. Any role that can connect can run
SELECT mentat_eval('(slurp "/etc/passwd")') or (rm-rf "/var/lib/postgresql")
with the postgres OS user’s privileges. There is also no step or heap limit, so
(loop [] (recur)) pins a backend forever.
Exposure today. script is off by default (default = ["pg16"]) and none of
the flake, Dockerfile, or release workflow enable it, so shipped artifacts are
safe. Anyone who builds with --features script is not.
Decision (settled 2026-09-24: mentat_eval stays open to every role). No
REVOKE. That makes the sandbox the entire defense, so it must hold against a
hostile caller, not just a careless one. Three distinct ways a script can hurt the
server, each needing its own guard:
| Attack | Guard |
|---|---|
Host access: slurp, spit, rm-rf, mkdir-p, file-exists?, the store’s file-path open |
Capability gating. Interpreter::sandboxed() installs the language, regex, bignum, atoms and the in-memory store only. Every prim that touches the host is absent (unbound), not merely refused. |
CPU: (loop [] (recur)) |
Step limit (upstream MINO_OPT_LIMIT_STEPS): counter in eval, throws :eval/limit. Plus a hook every 4096 steps that calls pgrx::check_for_interrupts!() so statement_timeout and pg_cancel_backend work. |
Memory in one step: (range 100000000000), (apply str (repeat 1e9 "x")), (vec (range …)) — a single prim call that allocates unboundedly, invisible to a step counter |
Allocation budget (upstream MINO_OPT_LIMIT_HEAP): bulk-producing prims (range, repeat, str, into, vec, concat, mapv, apply, string builders) charge the budget per element and throw :eval/limit when exceeded. Charging per element also advances the step counter, so these prims can’t dodge the step limit either. |
Stack: non-tail recursion (defn f [n] (inc (f (dec n)))) with n = 10⁶ |
Depth limit. Verified: today this aborts the process with fatal runtime error: stack overflow (SIGABRT). In a Postgres backend that crashes the server into recovery for every connected client. A counter on Interp for eval/apply nesting throws :eval/limit past max_depth; in pg_mentat the hook also calls pg_sys::stack_is_too_deep() (exposed by pgrx-pg-sys 0.17) as a second line. |
Limits come from GUCs, all PGC_SUSET (a superuser or ALTER SYSTEM sets them;
ordinary roles cannot raise them for their own session):
| GUC | Default |
|---|---|
mentat.script_max_steps |
10,000,000 |
mentat.script_max_heap_bytes |
64 MB |
mentat.script_max_depth |
2,000 |
statement_timeout still applies on top, via the interrupt hook.
What an open mentat_eval means for data access: a script runs as the calling
role through SPI, so it can read and write exactly the pg_mentat stores that role
can already reach with mentat_query/mentat_transact. No privilege is gained.
Do not make mentat_eval SECURITY DEFINER — that would turn it into a
privilege escalation.
Interpreter::new() keeps full host access for the mino CLI and tests. Both
scripting layers (mentat script.rs, pg_mentat script.rs) use sandboxed().
Why first. Today’s code is the version the decision makes public. It is the only item that is an active hazard rather than a maintenance cost.
1.2 :rules vs :with — the two parsers disagree on what a query means
What. mentat parses rule definitions under :rules
(edn/src/lib.rs:601). pg_mentat parses them under :with, in an alternative
listed before :with ?var… (pg_mentat/edn/src/lib.rs:592-593), and its tests
use :with [[(adult ?p) …]] (or_rule_tests.rs:94).
Why it matters. In Datomic, :with names variables that keep duplicate
tuples in aggregates; rules arrive through :in %. pg_mentat’s parser tries the
rules alternative first and falls back, so :with ?x still works, but the same
query keyword means two different things depending on its argument shape, and
neither project matches Datomic for rules.
Decision. Accept all three spellings in the merged grammar, with Datomic’s as
canonical:
- :in $ % + a rules input — Datomic’s form. Add it (pg_mentat’s query layer
already has input-binding plumbing for %-style inputs via in_bindings).
- :rules [...] — keep (mentat’s, explicit and unambiguous).
- :with [[(rule …) …]] — keep as a deprecated alias, recognized only when
the argument is a vector of rule clauses (the parser already distinguishes this
by shape). Emit a deprecation warning from pg_mentat’s query entry point; remove
in 2.0.
Keep the :with ?var… meaning unchanged. Update pg_mentat’s tests to use :rules
and add tests for all three spellings.
1.3 :in binding forms — pg_mentat has them, mentat throws them away
What. pg_mentat’s grammar parses :in as binding forms
(:in binding()+ → InBindings, pg lib.rs:587), supporting ?x, [?x ...],
[?a ?b], [[?a ?b]]. mentat’s grammar still parses :in variable()+ → InVars
(lib.rs:596); the InBindings variant and binding() rule exist in mentat’s
edn but are dead code (the ponytail: note at query.rs:1014). pg_mentat’s
query engine consumes in_bindings (functions/query.rs:891,1190); mentat’s
algebrizer does not.
Decision. Take pg_mentat’s grammar, extended to accept source vars: :in parses as
(src_var | binding)+ — $/$name go to the existing (previously always
empty) ParsedQuery.in_sources, everything else to InBindings (found during
Task 2: neither grammar accepted $ in :in), and
ParsedQuery::in_vars stays derived from the scalar bindings (pg_mentat already
does this, query.rs:1104-1118), so mentat’s algebrizer keeps working unchanged
for scalar inputs. Then teach mentat’s algebrizer BindColl/BindTuple/BindRel
inputs as a follow-up task (§ Task 12): until then it must reject non-scalar
bindings with a clear error rather than silently dropping them. Also make sure
variable() accepts $ (source vars) inside :in — check against the existing
src_var() rule.
1.4 History queries: the 5th pattern place ?added
What. pg_mentat’s Pattern has a fifth place, added, so
[?e ?a ?v ?tx ?added] works in :where; Pattern::new takes six arguments
(pg query.rs:795-855, grammar lib.rs:422-447). mentat’s Pattern has four
places. mentat supports ?added only through (tx-data $ ?tx) binding
(query-algebrizer/src/clauses/tx_log_api.rs:435). mentat’s algebrizer builds
Pattern { … } literals in 33 places across 5 files, which will all fail to
compile once the field exists.
Decision. Adopt the 5-place Pattern in the shared edn. It is Datomic’s
history-db shape and pg_mentat’s history queries need it.
- Add the field; keep Pattern::simple for 3-place construction.
- Add a Pattern::with_added(...) builder and make the 33 literal sites in
mentat’s algebrizer use ..Pattern::default_places() or pass
PatternNonValuePlace::Placeholder. Mechanical.
- mentat’s algebrizer: a pattern with a non-placeholder added place is rejected
with AlgebrizerError::UnsupportedHistoryPattern (SQLite’s datoms table has
no retractions; history lives in transactions). A follow-up (§ Task 12) routes
such patterns to the transactions table, which does have added.
- The collect_mentioned_variables change in pg query.rs:1313 comes along.
1.5 Reverse pull :person/_friends — both have it, one in the parser, one outside
What. mentat added reverse pull to the shared parser: a
raw_backward_namespaced_keyword pull alternative, a reverse: bool field on
NamedPullAttribute, and Display support (mentat lib.rs:371-382,
query.rs:467-521), consumed by query-pull/src/lib.rs:138-311. pg_mentat
implements reverse pull entirely inside its own pull-pattern parser
(functions/pull.rs:265-300, its own PullAttrSpec { reverse, forward_ident })
and never touches edn’s pull types.
Decision. Keep mentat’s edn version (the field is additive; nothing in
pg_mentat constructs NamedPullAttribute). pg_mentat’s own parser stays for now —
it supports pull options the edn grammar doesn’t (:default, :limit, map specs,
recursion depth). Record as later debt: move pg_mentat’s richer pull grammar into
edn so both backends parse pull the same way.
1.6 Plain keywords as pattern values — pg_mentat accepts [?e :status :active]
What. In a pattern’s value place, mentat accepts only namespaced keywords
(if x.is_namespaced(), mentat query.rs:381) and returns None for plain ones
(// … yet.). pg_mentat accepts any keyword (pg query.rs:378). Datomic accepts
both.
Decision. Take pg_mentat’s behavior. Check mentat’s algebrizer first: it
resolves PatternValuePlace::IdentOrKeyword against the schema
(clauses/pattern.rs:524-538) and falls back to a keyword constant when the ident
is unknown — a plain keyword takes the fallback path, so this should just work.
Add algebrizer tests with a plain-keyword value to prove it before merging.
1.7 Limit::None vs Limit::Unlimited
What. Same variant, two names. mentat: Limit::None (used in 10 places in
query-algebrizer, query-projector, query-sql). pg_mentat: Limit::Unlimited
(functions/query.rs:5101), which also matches its Offset::Unlimited.
Decision. Limit::Unlimited — it matches Offset::Unlimited, and None
reads as Option::None in a match. Rename mentat’s 10 uses.
1.8 Built-in transaction functions :db.fn/cas, :db/retractEntity
What. pg_mentat’s edn defines BuiltinTxFn { Cas, RetractEntity }
(pg entities.rs:227-247) and its transactor recognizes :db.fn/cas,
:db/cas, :db.fn/retractEntity, :db/retractEntity
(functions/transact.rs:452-457). mentat has neither — no grep hit anywhere in
db/, transaction/, or edn/.
Decision. Keep the BuiltinTxFn enum in the shared edn (additive, zero
cost). Port both functions to mentat’s SQLite transactor as their own task
(§ Task 11): retractEntity expands to retractions of every datom with that e
(plus component entities, as Datomic does); cas reads the current value inside
the write transaction and fails with a typed error on mismatch. These are among
the most-used Datomic transaction functions; mentat should have them.
1.9 enum-set 0.0.8 vs enumset 1.1
What. mentat’s core-traits and core use the long-dead enum-set crate,
which requires a hand-written unsafe fn from_u32 using mem::transmute
(mentat core-traits/lib.rs:305-313). pg_mentat switched to enumset 1.1:
#[derive(EnumSetType)] on ValueType, by-value set ops, a test for the
extension trait (pg value_type_set.rs). enumset’s derive supplies
Clone, Copy, PartialEq, Eq, which is why pg dropped those from the derive list.
Decision. Take pg_mentat’s. It deletes an unsafe block and a 2016-era
dependency. Nothing outside core-traits/core names enum_set
(grep: only those three files). ValueTypeSet::iter() changes return type
(enumset::EnumSetIter); check mentat’s algebrizer for callers that name the old
type — callers that only iterate are unaffected.
1.10 Modern-Rust idioms (the rest of the drift)
What. Behavior-identical rewrites present only in pg_mentat’s copies:
f64::NAN for std::f64::NAN; is_none_or for map_or(true, …);
is_multiple_of for % == 0; Self:: in From impls; #[expect(dead_code)]
for #[allow(dead_code)]; inline format args; runnable doctests replacing
ignored ones in types.rs (using Value::from_symbol/from_keyword, which both
copies have); a #[expect(clippy::expect_used, reason = …)] in
microsecond_precision.
Decision. Take all of pg_mentat’s. They need Rust ≥ 1.82/1.87 (is_none_or,
is_multiple_of), which the 1.88 floor covers, and the doctest change turns two
dead examples into tests.
One exception. In core/src/lib.rs:335, pg_mentat wrote
edn::parse::value(&expected_output) where mentat has
edn::parse::value(expected_output) — pg’s is a needless borrow that clippy flags
and that pg’s workspace lints merely allow. Keep mentat’s line.
1.11 The EDN escape fix — the same fix, committed twice
What. fix(edn): unescape \n/\t/\r, and escape text when printing exists as
mentat a48aec6d and pg_mentat 6188a12. The trees match; the commits don’t.
Decision. Nothing to reconcile in code. During the history import (§ Task 4) the two commits both land; the second becomes a no-op. Keep both for provenance.
(Correction to my earlier review: I said pg_mentat lacked this fix. It doesn’t.)
1.12 Copyright headers
What. mentat’s files say Copyright 2016-2018 Mozilla; pg_mentat’s say
Copyright 2016 Mozilla. The original upstream header was Copyright 2016
Mozilla; mentat’s range was introduced in its own 8dbb2c2f WIP commit.
Decision. Copyright 2016-2018 Mozilla — Mozilla’s work on these files ran
through 2018, and a range is the accurate statement. Contributors after that are
credited in authors and git history, not in the header. Mechanical sweep.
1.13 Dependency declarations in the shared crates
What. mentat declares tilde requirements (~0.4, ~5.0, ~1.25); pg_mentat
declares exact-ish minimums (0.4, 5.1.0, 1.21.0). Real resolution drift is
small: chrono 0.4.45/0.4.44, uuid 1.25.0/1.23.1, ordered-float 5.0.0/5.3.0,
itertools 0.15 vs 0.14 (edn uses only diff_with and Itertools, both stable
across those). pg_mentat’s core also depends on thiserror 2.0; mentat’s
error crates already use thiserror ~2.0.
Decision. Move every shared third-party dependency to
[workspace.dependencies] in the root manifest, one version each, and have
crates say chrono.workspace = true. Pick the higher of each pair
(itertools 0.15, ordered-float 5.3, uuid 1.25, chrono 0.4.45, peg 0.8.6,
pretty 0.12.5, indexmap 2.14, bytes 1.12, serde 1.0.229). One Cargo.lock,
committed (pg_mentat commits its lock; mentat gitignores it — an extension
and a server binary want a committed lock, and cargo’s current guidance says
commit it for libraries too).
1.14 Toolchain and flake
What. mentat: rust-toolchain.toml says stable, flake uses
rust-bin.stable.latest (→ 1.98 today, floats). pg_mentat: no
rust-toolchain.toml, flake pins rust-bin.stable."1.90.0", Cargo says
rust-version = "1.88", and its flake carries the real pgrx machinery: LLVM/clang
for bindgen, per-PG-version packages, a sandbox-safe cargo pgrx package that
never calls cargo pgrx init.
Decision. Pin 1.98 in rust-toolchain.toml (a floating stable makes the
nix build and the rustup build disagree the day a release lands), keep
rust-version = "1.88" as the MSRV. Take pg_mentat’s flake as the base — it is
the harder one to get right — and add a lean devShells.sqlite without
clang/postgres next to a devShells.pg (default). Package outputs:
packages.mentat-cli, packages.pg_mentat-pg{13..18}, packages.mentatd.
1.15 Workspace lints
What. pg_mentat has a [workspace.lints.clippy] table (deny todo,
unimplemented, dbg_macro, print_stdout, print_stderr, exit,
mem_forget, await_holding_lock, …). mentat has none, and contains 15
todo!/unimplemented!/dbg! sites (e.g. query-algebrizer/src/clauses/resolve.rs:194,210-212,
db/src/cache.rs:1926). tools/cli prints to stdout by design.
Decision. Adopt pg_mentat’s lint table at the workspace level. Crates opt in
with [lints] workspace = true: the shared front-end and the pg side on day one;
mentat’s SQLite crates after Task 10 replaces their unimplemented!()s with typed
errors (each of those is a reachable panic on a query shape the parser accepts).
tools/cli opts in with print_stdout = "allow".
1.16 Release profile
What. mentat: opt-level = 3, lto = true, debug = false. pg_mentat:
opt-level = 3, lto = "fat", codegen-units = 1.
Decision. pg_mentat’s (lto = true is "fat" already; codegen-units = 1
is the real difference and matters for an extension’s per-query hot path).
Add [profile.release-dev] inheriting release with lto = "thin",
codegen-units = 16 for fast local optimized builds.
1.17 CI
What. mentat: eight GitHub workflows from the Mozilla era (Travis,
Taskcluster, cross-compile, grcov, msrv…) — mostly dead. pg_mentat: a Forgejo
ci.yml (Codeberg runs Forgejo Actions), a GitHub ci.yml, installcheck across
PG versions, docker, nix, release, cargo-deny via deny.toml, and a
.gitlab-ci.yml.
Decision (settled 2026-09-24). Codeberg is the only place anyone pushes;
Codeberg push-mirrors to GitHub (pg_mentat’s mirror already does — its main
tips match on both sides). So two CI systems run on every push, each for a
different job:
- Forgejo Actions on Codeberg — the gate. One
.forgejo/workflows/ci.yml:fmt,clippy,deny,test-sqlite,test-pg (13-18),test-mino,test-ffi,nix-build. - GitHub Actions on the mirror — publishing. It runs whatever is in
.github/workflows/of the mirrored tree, so that directory is the merged repo’s release pipeline. Keep, with paths updated forcrates/pg/pg_mentat:release.yml(on tag:cargo pgrx packageper PG version → GitHub Release assets),docs.yml(mdBook → GitHub Pages),installcheck.yml,docker-test.yml,nix-test.yml. Drop pg_mentat’s GitHubci.ymlandaudit.yml— Forgejo’s CI is the gate; duplicating it on GitHub only produces a second, occasionally-disagreeing red X. - Delete mentat’s eight Mozilla-era GitHub workflows (
clippy_check,clippy-ng,cross_compile,grcov(wants aCOVERALLS_TOKENnobody has),msrv,nightly_lints,quickstart,audit),dependabot.yml(GitHub-side PRs against a mirror can’t be merged there),FUNDING.yml,.travis.yml,.taskcluster.yml, and.gitlab-ci.yml(no GitLab mirror). - The
docs.ymlPages site will now come from the mentat mirror, so its URL changes fromgburd.github.io/pg_mentattogburd.github.io/mentat. The mentat GitHub repo still has Mozilla’s oldgh-pagesbranch;docs.ymlreplaces it. - Verify before Task 14 that
codeberg.org/gregburd/mentathas a push mirror togithub.com/gburd/mentat. It isn’t mirroring today: GitHubmasterise55376e9, Codebergmasteris201ec39d(Codeberg is ahead), andmino-scripting/v0.14.0never reached GitHub.teacan’t read mirror settings with the current token (needs repo admin scope), so check in the Codeberg web UI: Settings → Repository → Mirror settings → Push mirrors. Enable Sync when commits are pushed.
1.18 Dead weight in mentat
What. sdks/android, sdks/swift (last touched 2018), ffi/ (their C ABI),
automation/, build/, fixtures/, NOTES, .ignore, _/ (a scratch
flake.nix, mov.edn, shell.nix), .vscode/, docs/ Jekyll site.
Decision. Delete sdks/, automation/, _/, .vscode/, NOTES, .ignore
(and the CI files listed in § 1.17). Keep ffi/ — a C ABI over the embedded
store is exactly what non-Rust hosts need; build it in CI so it stops rotting.
Keep fixtures/ and build/ if the test suite reads them (check first; delete if
not). Replace the Jekyll site with pg_mentat’s mdBook (docs/book.toml) and fold
mentat’s about.md/tutorial.md into it.
1.19 Two mino scripting layers, 998 and 732 lines, one design
What. mentat src/script.rs (998 lines) and pg_mentat
functions/script.rs (732 lines) implement the same mentat.store/* surface
(same 14 prims). They share nine function names but drifted in bodies:
db_value, inst_value, uuid_value differ. mentat converts
Binding/TypedValue → mino; pg_mentat converts JSON → mino (its engine returns
JSON). The storage calls differ by backend, as they must.
Decision. Extract a mentat-script crate holding everything
backend-independent: the db-value map shape, handle/eid/keyword argument parsing,
tx_report_value, the inst/uuid/keyword value builders, and the prim registration
driven by a trait:
pub trait ScriptBackend {
fn basis_tx(&self) -> Result<i64, String>;
fn transact(&mut self, edn: &str) -> Result<TxReport, String>;
fn with(&mut self, db: &DbRef, edn: &str) -> Result<TxReport, String>;
fn q(&self, db: &DbRef, query_edn: &str) -> Result<Value, String>;
fn pull(&self, db: &DbRef, eids: &[i64], pattern_edn: &str) -> Result<Value, String>;
fn datoms(&self, db: &DbRef) -> Result<Vec<(i64, String, Value)>, String>;
// entity / read / entities default-implemented on top of q + pull
}
mentat implements it over Store; pg_mentat over its engine functions. Each
side keeps only its conversion code (Binding→Value, JSON→Value). One test suite
(mentat-script/tests/) runs the Datomic-model tests against both backends — the
same 11 behaviors, proven twice. This is where the two layers' behavior was
already drifting (the three differing helper bodies), so it pays for itself fast.
1.20 Two temporal models
What. pg_mentat supports as-of/since for full Datalog q (its query
engine accepts {"asOf": T} / {"since": T} inputs; functions/time_travel.rs).
mentat supports them only for datoms/entity/read by replaying the
transactions table, and makes q on a historical db an error.
Decision. Not a reconciliation — a capability gap. It stays until someone
teaches mentat’s algebrizer to target transactions with a tx bound (same work as
routing ?added patterns, § 1.4, so do them together in Task 12). The shared
mentat-script test suite marks the historical-q test #[cfg(backend = "pg")]
until then, so the gap is visible in one place.
1.21 Functional gaps (not drift — features only one side has)
From pg_mentat’s own port inventory (docs/pg_mentat-port-inventory.md, already
in mentat’s repo) plus this review:
| Feature | mentat | pg_mentat | Plan |
|---|---|---|---|
:db.fn/cas, retractEntity |
no | yes | port (Task 11) |
?added / history in :where |
via tx-data only |
yes | port (Task 12) |
Historical q (as-of/since) |
no | yes | port (Task 12) |
:in coll/tuple/rel bindings |
no | yes | port (Task 12) |
| Reactive subscriptions (LISTEN/NOTIFY) | no | yes | PG-only, stays |
| BM25 full-text, pgvector, pg_trgm, PostGIS, rum | no | yes | PG-only, stays |
| Materialized/virtual views, multi-store, excision | partial | yes | later, per inventory |
mentatd HTTP/WebSocket server |
no | yes | stays PG-only; see 1.22 |
Embedded C ABI (ffi) |
yes | no | keep, SQLite-only |
1.22 mentatd could serve either backend — later
mentatd depends on edn and talks to PostgreSQL through tokio-postgres; it
does not link the extension. It could front the SQLite store too. Out of scope for
the merge; it moves under crates/pg/mentatd and keeps working as-is.
1.23 Git history and branches
What. Both repos descend from Mozilla’s f7621712 (2016-07-05); mentat has
1,056 commits since, pg_mentat 1,258. mentat’s origin/pg still holds
d7e2b554 mid-s/failure/thiserror/g, which our tip already supersedes (no
failure crate remains; both error files it touches use thiserror).
origin/improv-base holds 1af15e15, a second copy of the escape fix.
pg_mentat has seven stale feature branches from April/May 2026.
Decision.
- Import pg_mentat’s history under crates/pg/ with git filter-repo
--to-subdirectory-filter on a clone, then merge with
--allow-unrelated-histories (they are related, but the rewritten paths make
git treat them as unrelated; that’s expected). Shared crates from the pg side
land in crates/pg/_import/{edn,core,core-traits} and are deleted in the same
merge after their changes are applied to the canonical copies (Task 2 does the
application first, so the delete loses nothing).
- Tags: pg_mentat’s v1.2.1..v1.6.1 come along (they point into its history).
mentat’s own v0.* tags stay. No collisions (checked).
- Close origin/pg (d7e2b554): superseded. Close origin/improv-base:
duplicate of a48aec6d. Don’t import pg_mentat’s April branches; they stay
in the read-only pg_mentat repo.
- Default branch stays master (settled 2026-09-24). pg_mentat’s main
history arrives under crates/pg/ via the import merge; no main branch is
created in the merged repo.
- When 1.7.0 ships you mark both pg_mentat repos (Codeberg and its GitHub mirror)
read-only with a message pointing at mentat (settled 2026-09-24). Task 14
prepares the text and a final pointer commit; the settings change is yours.
Hygiene note from this review: computing a merge-base with
git fetch ../pg_mentat HEAD:refs/tmp/x imports that repo’s tags. I did that once
and cleaned up the 13 leaked tags; use git fetch --no-tags in every task below.
1.24 Deep nesting crashes the server — in the default build (found 2026-09-24)
What. Reproduced on a throwaway PG16 cluster with the installed pg_mentat
1.6.1, default build, no script feature: an unprivileged role runs
mentat_query with a query containing a vector nested 3,000 deep. The backend
dies with SIGSEGV; the postmaster terminates every server process and runs crash
recovery. Depth 2,000 returns a parse error. mentat_transact crashes the same
way. The shared edn crate’s peg grammar recurses once per nesting level
(vector() = "[" value()* "]"); pg_mentat’s MAX_EDN_NESTING = 100 check
(types/edn.rs:15) inspects the value after edn::parse::value returns, and
mentat_query/mentat_transact never call it.
mino-rs has the same class of bug on paths an eval-depth limit doesn’t cover
(release build, 8 MB stack): recursive calls ~10⁶; GC marking of live nested
data ~200k (gc 0.5 traces recursively); pr-str ~50k; the reader ~50k; =
~100k; pr-str of an atom that contains itself, always.
Can this be fixed by making the recursion a tail call? Tested, no (rustc 1.95, 1 MB stack, depth 10⁶):
| Shape | opt-level 0 | opt-level 3 |
|---|---|---|
Accumulator f(n-1, acc+1) — a true tail call |
overflows | survives |
Parser [ value* ] — child pushed into parent Vec after the call |
overflows | overflows |
1 + f(n-1) |
overflows | survives, but only because LLVM rewrote it into a loop (zero self-calls left in the asm) |
Same, with the +1 chosen by runtime data, as an interpreter does |
overflows | overflows |
Rust never guarantees tail calls: they happen at -O, not in debug builds,
and not across Result/? or Drop. Nightly become (explicit_tail_calls)
guarantees one, but only when the call is the return value. That is never true
here: a parser needs the child to finish the parent, an evaluator needs
(f (dec n)) to compute (inc …), a printer writes the closing ] after the
children. Recursion whose result is used can’t be a tail call in any language;
C compilers would face the same limit.
The fix that works, verified in the same harness: an explicit worklist on the
heap (a Vec of partially-built containers) with a depth cap. A 10⁶-deep input
parses on a 1 MB debug stack; a cap of 1,000 returns an error cleanly. The
experiment also found a second trap: the compiler-generated Drop of a 10⁶-deep
tree overflows too. Every recursive owned type needs an iterative Drop, or a
depth cap at construction so no such tree exists.
Decision.
- edn: fail past MAX_NESTING = 256 before the grammar runs. Measured:
~2.9 KB of stack per level unoptimized, ~470 B in release, so 256 fits a 1 MB
stack in a debug build; the deepest query/tx in either test suite nests 5.
In peg that means a depth counter threaded through the recursive rules via a
parser argument, or a linear bracket prescan before peg runs; the prescan is
simpler and also bounds the Drop problem because no deep value is ever built.
Strings, chars and comments must be skipped by the prescan.
- mino-rs: reader, printer, =, hash, compare get depth caps; the printer
gets cycle detection (atoms/vars); GC tracing becomes a worklist (vendor the
gc crate into the repo and change its mark, or replace it — decided in
Task 1b after reading its trace code); recursive Value drops become iterative.
- Regression tests at every SQL entry point that parses text: mentat_query,
mentat_transact, mentat_pull, mentat_pull_many, the edn type’s input
function, mentat_eval.
1.25 mino-rs moves into this repo
Decision (2026-09-24). mino-rs becomes crates/mino in the mentat repo, with
its full history (27 commits, git filter-repo --to-subdirectory-filter). The
codeberg.org/gregburd/mino-rs repo gets a pointer README and goes read-only
with the pg_mentat repos.
What changes:
- The crate name stays mino-rs (library name mino_rs), so no use line
changes. Version continues from the workspace (1.6.1, then 1.7.0); its
separate 0.x series ends at v0.1.0.
- mentat’s path = "../../src/mino-rs" becomes a workspace path; pg_mentat’s
git = ".../mino-rs", tag = "v0.1.0" goes away when pg_mentat moves in (Task 4).
Until then pg_mentat’s 1.6.x fixes can’t use a workspace path, so its security
releases pin the in-repo crate by git: git = "https://codeberg.org/gregburd/mentat",
tag = "…" (a remote, not a local path).
- The conformance corpus becomes part of the repo:
crates/mino/tests/corpus/*.clj, the 12 files the harness gates on (208 KB,
MIT), frozen at mino ead6e160. MINO_SRC and .cargo/config.toml’s
/home/gburd/src/mino go away; tests use env!("CARGO_MANIFEST_DIR").
Refreshing to upstream’s final 9c65bb50 (Task 8) replaces those files in one
commit, so the diff shows what changed.
- We own the language now. Bugs found in this port are fixed here and recorded
in crates/mino/CHANGELOG.md; ~/src/mino is a reference, never an input.
Part 2 — mino-rs refresh from upstream mino
mino-rs v0.1.0 ports mino ead6e160. Upstream 9c65bb50 is 489 commits
newer (233 fixes, 54 features) and, as of 2026-09-15, archived: its README
now says “Discontinued — archived LLM experiment… Do not use it in production.”
What this means. mino-rs becomes the maintained implementation of this
language. Pull upstream’s last good work once, then own the code; don’t plan
around future upstream fixes. Keep ~/src/mino as the reference oracle binary
(rebuilt at 9c65bb50).
What upstream fixed or added that mino-rs lacks (verified against the rebuilt binary and a probe of mino-rs v0.1.0):
| Upstream (9c65bb50) | mino-rs v0.1.0 | Take it |
|---|---|---|
#uuid "…" reads to a real UUID value, type = :uuid, prints #uuid "…", round-trips via read-string |
unbound symbol: parse-uuid |
yes — unblocks the inst/uuid limitation in both scripting layers |
#inst "…" prints as #inst "…" and round-trips |
prints a calendar map | yes |
read-string |
missing | yes |
Classed catch (catch ArithmeticException e …), keyword catch (catch :eval/type e …), :default (ADR 32, 37) |
syntax error / unbound e |
yes — upstream’s corpus relies on it |
Regex lookahead (?=…)/(?!…) |
rejected (MCT001) | yes — fancy-regex supports it natively; just stop rejecting |
re-seq with no match → () |
nil |
yes |
(keyword 42) → nil |
throws | yes |
clojure.string/join stringifies non-string separators |
throws | yes |
1.5M bigdec literal |
read error | yes — bigdec deferred earlier, now in corpus |
delay has its own type |
tagged map | yes |
mino.store backend seam (ADR 35): durability behind a 5-op {:kind :initial :wal-entries :commit :checkpoint :close} map; :memory/:file built in; third-party backends by keyword |
hard-wired file WAL in src/store.rs |
yes — it is the natural home for a SQLite- or Postgres-backed mino.store, and ADR 42 says exactly this |
Store fixes: entity-specs preserved across compact/merge; centralized db-value constructor; bundled-lib errors carry :mino/kind |
older store.clj | yes (comes with store.clj refresh) |
Reader: reject ::/:::; merge chained ^meta on symbols |
older reader | yes |
compare orders vectors by count first; abs -0.0 keeps sign |
already match | nothing |
52 new test files, +490 lines in store_test.clj, +209 in regex_test.clj, new store_backend_test.clj, reader_features_test.clj |
— | yes — the corpus is the oracle |
JIT, bytecode VM, TLS/HTTP/WebSocket/UDP/tar, signals, terminal, core.logic/core.match/spec bundles |
out of scope | no (same YAGNI line as the original port) |
Refresh procedure. The resources (core.clj, mino/store.clj,
clojure/{string,instant}.clj) are loaded verbatim, so copy the new versions,
run the corpus, and implement whatever prims the new .clj calls that the port
lacks. Known new core.clj dependencies: delay*, lazy-keep, lazy-map-indexed,
lazy-remove, __transduce-fuse, find-keyword, rerun-seq, mino-version.
lazy-* can be eager (same stance as the port’s existing map/filter);
__transduce-fuse can be the identity (it’s an optimization hook). Resource sizes
move: core.clj 4141→4244 lines, store.clj 1899→2144, instant.clj 267→315.
Release as part of the workspace (no separate mino-rs version; § 1.25).
Part 3 — Target layout
mentat/ (codeberg.org/gregburd/mentat)
├── Cargo.toml workspace; [workspace.package] version = "1.6.1"
│ default-members = the SQLite side only
├── Cargo.lock committed
├── rust-toolchain.toml channel = "1.98"
├── flake.nix devShells.{pg,sqlite}; packages.{mentat-cli,pg_mentat-pgNN,mentatd}
├── deny.toml from pg_mentat
├── .forgejo/workflows/ci.yml
├── crates/
│ ├── edn/ shared (reconciled)
│ ├── core-traits/ shared (reconciled)
│ ├── core/ shared (reconciled)
│ ├── script/ NEW: mentat-script (backend-independent mino glue)
│ ├── mino/ the mino-rs interpreter (moved in, § 1.25) + tests/corpus/
│ ├── sqlite/
│ │ ├── mentat/ the `mentat` crate (today's root src/ + tests/)
│ │ ├── db/ db-traits/ sql/ sql-traits/ transaction/ public-traits/
│ │ ├── query-algebrizer{,-traits}/ query-projector{,-traits}/
│ │ ├── query-pull{,-traits}/ query-sql/
│ │ ├── ffi/
│ │ └── cli/ today's tools/cli
│ └── pg/
│ ├── pg_mentat/ the pgrx extension (+ pg_mentat.control, sql/)
│ └── mentatd/
├── docs/ mdBook (from pg_mentat) + merged mentat docs
├── benchmarks/ docker/ scripts/ from pg_mentat
├── CHANGELOG.md pg_mentat's, continued; mentat history summarized
├── META.json Trunk.toml pgxn / trunk metadata (paths updated)
└── README.md one README, two build recipes
Why default-members: pgrx crates need pg_config at build time. With
default-members listing only the SQLite crates and the shared front-end, a plain
cargo build / cargo test works on any machine; the extension builds with
cargo pgrx … from crates/pg/pg_mentat, and cargo build -p mentatd works
without PostgreSQL headers (it only needs tokio-postgres).
Crate names don’t change (mentat, pg_mentat, mentatd, edn, mentat_core,
core_traits, …), so downstream use paths and the extension’s .so/control
names stay identical.
Part 4 — Tasks
Order: crash fix → bring mino in and harden it → open mentat_eval →
reconcile shared crates in place (both repos, so each stays green) → import
history → restructure → shared script crate → mino refresh → feature ports →
release. Tasks 1a–3 happen in the existing repos, which proves the reconciled
front-end against both backends before anything moves.
Task 1a: EDN nesting cap — the server crash (§ 1.24)
Repos: mentat (edn/), then the identical change in pg_mentat (edn/).
Files: edn/src/lib.rs (public parse entry points), new edn/src/depth.rs
(prescan), edn/tests/nesting.rs; pg_mentat pg_mentat/src/nesting_tests.rs.
Interfaces produced: pub const MAX_NESTING: usize = 256; (measured; see § 1.24)
pub fn check_nesting(input: &str, max: usize) -> Result<(), ParseError> —
linear, O(1) stack, skips string/char literals and ; comments. Every public
parse:: entry point (value, parse_query, entities, and any other pub rule
reachable from outside the crate) is wrapped so it runs check_nesting first.
The error names the depth and the limit.
- [ ] Step 1: failing tests (
edn/tests/nesting.rs, own process so a pre-fix overflow only kills this file): ```rust fn nested(n: usize) -> String { format!(“{}{}”, “[”.repeat(n), “]”.repeat(n)) }[test] fn deep_value_is_an_error_not_a_crash() {
let e = edn::parse::value(&nested(100_000)).unwrap_err(); assert!(e.to_string().contains(“nesting”), “{e}”); }[test] fn deep_query_is_an_error() {
let q = format!(“[:find ?e :where [?e :a/b {}]]”, nested(100_000)); assert!(edn::parse::parse_query(&q).is_err()); }[test] fn deep_tx_is_an_error() { assert!(edn::parse::entities(&nested(100_000)).is_err()); }
[test] fn limit_is_exact() {
assert!(edn::parse::value(&nested(edn::MAX_NESTING)).is_ok()); assert!(edn::parse::value(&nested(edn::MAX_NESTING + 1)).is_err()); }[test] fn brackets_in_strings_and_comments_dont_count() {
let s = format!(“[\”{}\“ ; {}\n \[ ]”, “[”.repeat(5000), “[”.repeat(5000)); assert!(edn::parse::value(&s).is_ok()); }[test] fn mixed_delimiters() {
let s = format!(“{}{}”, “[({#{”.repeat(300), “}})]”.repeat(300)); // 1200 deep assert!(edn::parse::value(&s).is_err()); }
`` Run each test binary on a 1 MB stack as well (RUST_MIN_STACK=1048576) — the cap must hold on small stacks, since PostgreSQL backends often run withmax_stack_depth` = 2 MB. - [x] Step 2: implement
depth.rsand wrap the entry points. (Done: 1,000 did not fit a 1 MB debug stack — ~350 levels do — so the limit is 256.) - [ ] Step 3: all existing
ednand workspace tests pass. Commit in mentat:fix(edn): cap nesting depth before parsing (stack overflow on deep input). - [ ] Step 4: pg_mentat. Same
ednchange (copy the files). Add#[pg_test]s, run as a role created in the test, formentat_query,mentat_transact,mentat_pull,mentat_pull_many, and theedntype’s input function, each with 100,000-deep input: each returns an ERROR and the next statement on the connection succeeds. Also maketypes/edn.rs’sMAX_EDN_NESTINGcheck run before parsing by callingcheck_nestingwith 100. Repro script for the record (throwaway cluster, not the dev one):initdbinto amktemp -d,pg_ctl -o "-p 54399 -k $D -c listen_addresses=''",CREATE EXTENSION pg_mentat; CREATE ROLE nobody LOGIN, then asnobody:SELECT mentat_query($q$[:find ?e :where [?e :a/b <3000 × [ ]>]]$q$, '{}'::jsonb). - [ ] Step 5:
cargo pgrx test pg13 … pg18green. Commit, bump to 1.6.2, CHANGELOG entry (disclosure wording per your decision), tagv1.6.2, push to Codeberg. Push mentat’s commit too.
Task 1b: Move mino-rs into the repo and harden it (§ 1.1, § 1.24, § 1.25)
Repo: mentat (branch pg, before the restructure: the crate lands at
mino/, and Task 5 moves it to crates/mino with the others).
- [ ] Step 1: import with history.
bash git clone --no-local ~/src/mino-rs /tmp/mino-import && cd /tmp/mino-import git filter-repo --to-subdirectory-filter mino --tag-rename 'v:mino-v' cd ~/ws/mentat git fetch --no-tags /tmp/mino-import main:refs/import/mino git merge --allow-unrelated-histories refs/import/mino -m "merge: import mino-rs (27 commits) as mino/" git update-ref -d refs/import/mino(--tag-renamekeepsv0.1.0from colliding with mentat’s own tags; bring it over explicitly asmino-v0.1.0if wanted.) - [ ] Step 2: vendor the corpus. Copy the 12 gated files from mino
ead6e160tomino/tests/corpus/(git -C ~/src/mino show ead6e160:tests/<f>), plusLICENSEasmino/tests/corpus/LICENSE. Changetests/conformance.rstoconcat!(env!("CARGO_MANIFEST_DIR"), "/tests/corpus/…"). Deletemino/.cargo/config.toml. Addminoto the workspace members; mentat’smino-rsdependency becomes{ path = "mino", optional = true }. Confirmgrep -rn '/home/\|\.\./\.\./' --include=*.toml .finds nothing outside intra-workspace paths. - [ ] Step 3:
cargo test -p mino-rs— all 112 unit + 12 conformance pass with no environment variables set. - [ ] Step 4: the hardening below (was “Task 1”): sandbox, step/heap/depth
limits, plus the § 1.24 paths — reader/printer/
=/hash/comparedepth caps, printer cycle detection, iterative GC tracing, iterativeDropfor deepValues. Each gets a test inmino/tests/sandbox.rs(separate process) that crashes today and returns:eval/limit(or a reader/printer error) after. - [ ] Step 5: point
codeberg.org/gregburd/mino-rs’s README atmentat/minoand push that one commit; you mark the repo read-only with the pg_mentat ones.
Task 1c: Open mentat_eval on the hardened interpreter (pg_mentat 1.6.3)
pg_mentat’s script feature switches from mino-rs git tag v0.1.0 to
git = "https://codeberg.org/gregburd/mentat" at the commit/tag from Task 1b
(cargo finds the mino-rs package inside that workspace), then the pg_mentat
steps of the hardening task below (GUCs, check hook, tests), released as 1.6.3.
Hardening details for Tasks 1b/1c (the sandbox and limits)
Repos: mentat (mino/), pg_mentat.
Files:
- Modify: mino/src/embed.rs (add Interpreter::sandboxed(), set_limits, set_check_hook)
- Modify: mino-rs/src/eval/mod.rs (split Interp::new into core + optional
host installs; step counter checked in eval)
- Modify: mino-rs/src/store.rs:390-405 (move fs prims to install_host_fs)
- Modify: pg_mentat/pg_mentat/src/functions/script.rs:79 (use sandboxed(),
limits from GUCs, interrupt + stack check)
- Modify: pg_mentat/pg_mentat/src/lib.rs (register the three GUCs in _PG_init)
- Modify: mino-rs/src/prim/collections.rs:408 (range) and the other bulk
producers listed in § 1.1 (charge the allocation budget per element)
- Modify: mentat/src/script.rs (use sandboxed())
- Test: mino-rs/tests/sandbox.rs; pg_mentat/src/script_security_tests.rs
Interfaces produced:
rust
pub struct Limits { pub steps: Option<u64>, pub heap_bytes: Option<u64>, pub depth: Option<u32> }
impl Interpreter {
pub fn sandboxed() -> Interpreter;
pub fn set_limits(&mut self, limits: Limits);
/// Runs every 4096 steps and on every depth increase past depth/2.
pub fn set_check_hook(&mut self, hook: Box<dyn FnMut() -> Result<(), Throw>>);
}
// On any limit: Throw of {:mino/kind :eval/limit, :mino/data {:limit :steps|:heap|:depth, :value N}}
// Internal: Interp::charge(&mut self, elements: u64, bytes: u64) -> Result<(), Throw>
- [ ] Step 1: failing tests in mino-rs
```rust
[test]
fn sandboxed_has_no_filesystem() { let mut it = Interpreter::sandboxed(); for f in [“slurp”, “spit”, “rm-rf”, “mkdir-p”, “file-exists?”] { let err = it.eval(&format!(“({f} \”/tmp/x\“)”)).unwrap_err(); assert!(err.contains(“unbound symbol”), “{f} reachable: {err}”); } assert_eq!(it.eval_to_string(“(+ 1 2)”).unwrap(), “3”); assert!(it.eval(“(mino.store/open)”).is_ok()); // in-memory store still there } fn limited() -> Interpreter { let mut it = Interpreter::sandboxed(); it.set_limits(Limits { steps: Some(100_000), heap_bytes: Some(8 << 20), depth: Some(500) }); it }[test]
fn step_limit_stops_infinite_loop() { let err = limited().eval(“(loop [] (recur))”).unwrap_err(); assert!(err.contains(“:eval/limit”) && err.contains(“:steps”), “{err}”); }[test]
fn heap_limit_stops_one_step_allocation() { for bomb in [“(range 100000000000)”, “(count (vec (range 100000000)))”, “(apply str (repeat 100000000 \"x\”))“] { let err = limited().eval(bomb).unwrap_err(); assert!(err.contains(”:eval/limit"), “{bomb}: {err}”); } }[test]
fn depth_limit_turns_stack_overflow_into_an_error() { // Today this SIGABRTs the whole process. It must return an error instead. let err = limited() .eval(“(defn f [n] (if (zero? n) 0 (inc (f (dec n))))) (f 1000000)”) .unwrap_err(); assert!(err.contains(“:eval/limit”) && err.contains(“:depth”), “{err}”); }[test]
fn limits_leave_ordinary_scripts_alone() { let mut it = limited(); assert_eq!(it.eval_to_string(“(reduce + (range 1000))”).unwrap(), “499500”); assert_eq!(it.eval_to_string(“(loop [i 0] (if (< i 10000) (recur (inc i)) i))”).unwrap(), “10000”); }
`` Put these inmino-rs/tests/sandbox.rs`: each integration test file is its own process, so the depth test’s pre-fix SIGABRT fails that file without taking the rest of the suite down with it. - [ ] Step 2:
cargo test -p mino-rs sandboxed step_limit→ FAIL. - [ ] Step 3: implement.
Interp::new_bare+install_language(core, regex, bignum, atoms, store prims minus file I/O, core.clj, bundled libs) +install_host_fs.new()= both;sandboxed()= language only. Storeopenwith a path must error:store/backendundersandboxed()(no:filebackend installed). Step counter: au64onInterp, incremented at the top ofeval; the check hook runs every 4096 steps. Depth: increment on entry toevalandfunc::apply, decrement on every exit path (use a guard struct withDropso?returns can’t skip it). Heap: bulk prims callit.charge(n, n * size_of::<Value>())before allocating; loops that build output charge in chunks of 1024. DefaultLimitsforsandboxed(): none — the host chooses; pg_mentat always sets all three. - [ ] Step 4: pass; full
cargo testgreen (112 unit + 12 corpus). - [ ] Step 5: pg_mentat. Register the three GUCs from § 1.1 in
_PG_init(GucRegistry::define_int_guc,GucContext::Suset).build_interpreter():rust let mut it = mino_rs::Interpreter::sandboxed(); it.set_limits(Limits { steps: Some(SCRIPT_MAX_STEPS.get() as u64), heap_bytes: Some(SCRIPT_MAX_HEAP_BYTES.get() as u64), depth: Some(SCRIPT_MAX_DEPTH.get() as u32), }); it.set_check_hook(Box::new(|| { pgrx::check_for_interrupts!(); if unsafe { pgrx::pg_sys::stack_is_too_deep() } { return Err(mino_rs::error::throw_str("mentat_eval: stack depth limit")); } Ok(()) }));NoREVOKE:mentat_evalkeeps PostgreSQL’s defaultEXECUTEforPUBLIC(decision recorded in § 1.1). NotSECURITY DEFINER. - [ ] Step 6: pg tests (
#[pg_test],--features script), run as an ordinary role created in the test (CREATE ROLE eval_user LOGIN; SET ROLE eval_user;):mentat_eval('(slurp "/etc/passwd")')→ error containingunbound symbol; same forspit,rm-rf,mkdir-p,(mentat.store/open "/tmp/x").(loop [] (recur))→:eval/limit; withstatement_timeout = '200ms'andmentat.script_max_stepsraised by a superuser to 10¹², the same script ends withcanceling statement due to statement timeout(proves the interrupt hook).(range 100000000000)→:eval/limit.(defn f [n] (if (zero? n) 0 (inc (f (dec n))))) (f 1000000)→:eval/limit, and the next statement on the same connection succeeds (proves the backend survived).SET mentat.script_max_steps = 1000000000aseval_user→permission denied to set parameter.eval_userwithoutSELECTon a store’s tables gets the same permission error frommentat_evalas frommentat_query(SPI runs as the caller).
- [ ] Step 7: mentat
script.rs→sandboxed(); its 11 tests stay green. - [ ] Step 8: commit. mentat side lands with Task 1b; pg_mentat side is
release 1.6.3 (Task 1c). CHANGELOG entry: names the exposure in 1.6.0–1.6.2
builds that enabled
--features script, states that shipped artifacts didn’t, documents the three GUCs, and saysmentat_evalis intentionally callable by every role. Push to Codeberg; pg_mentat’s push mirror carries it to GitHub (Task 14 is where pg_mentat stops receiving pushes).
Task 2: Reconcile the shared front-end (edn, core-traits, core)
Where: a branch in each repo, with byte-identical results for the three crates. Do it in mentat first, then copy the three directories into pg_mentat and fix pg_mentat’s call sites. Both must pass their full suites.
Files: edn/src/{lib,query,entities,types,*}.rs, core-traits/{lib,value_type_set,values}.rs,
core/src/*.rs, their Cargo.tomls; mentat call sites in
query-algebrizer/src/{clauses/pattern.rs,clauses/predicate.rs,validate.rs,types.rs,lib.rs},
query-projector/src/translate.rs, query-sql/src/lib.rs.
Interfaces produced (the reconciled edn):
- Pattern { source, entity, attribute, value, tx, added };
Pattern::new(src, e, a, v, tx, added) -> Option<Pattern>;
Pattern::simple(e, a, v) unchanged.
- NamedPullAttribute { attribute, alias, reverse } (mentat’s).
- Limit::{Unlimited, Fixed, Variable}; Offset::{Unlimited, Fixed, Variable}.
- QueryPart::{InBindings, Rules, WithVars, …}; :in → InBindings;
:rules and :with [[…]] → Rules; ParsedQuery.in_vars derived from scalar
bindings.
- entities::BuiltinTxFn::{Cas, RetractEntity}.
- PatternValuePlace from any keyword (namespaced or plain).
- core_traits::ValueType: EnumSetType; ValueTypeSet::iter() -> enumset::EnumSetIter<ValueType>.
- [ ] Step 1: pin current behavior with tests, before changing anything. In
mentat’s
edn/tests/(createparse_contract.rs) add parse tests for every item above using today’s spellings, so each change below shows up as a deliberate test edit: ```rust use edn::parse;[test] fn in_bindings() {
//$/$nameare sources (in_sources), not bindings:$can’t be a // Variable, and counting it as one would shift pg_mentat’s positional //inputs. Neither grammar accepted:in $ ...before this change. let q = parse::parse_query(“[:find ?x :in $ [?a …] :where [?x :foo/bar ?a]]”).unwrap(); assert_eq!((q.in_bindings.len(), q.in_sources.len()), (1, 1)); }[test] fn five_place_pattern() {
let q = parse::parse_query(“[:find ?e :where [?e ?a ?v ?tx ?added]]”).unwrap(); assert!(matches!(&q.where_clauses[0], edn::query::WhereClause::Pattern(p) if matches!(p.added, edn::query::PatternNonValuePlace::Variable(_)))); }[test] fn rules_three_spellings() {
for q in [“[:find ?p :rules [[(adult ?p) [?p :person/age ?a]]] :where (adult ?p)]”, “[:find ?p :with [[(adult ?p) [?p :person/age ?a]]] :where (adult ?p)]”] { assert_eq!(parse::parse_query(q).unwrap().rules.len(), 1, “{q}”); } }[test] fn with_vars_still_with_vars() {
let q = parse::parse_query(“[:find (count ?x) :with ?y :where [?x :a/b ?y]]”).unwrap(); assert_eq!(q.with.len(), 1); }[test] fn plain_keyword_value() {
assert!(parse::parse_query(“[:find ?e :where [?e :task/status :done]]”).is_ok()); }[test] fn reverse_pull() {
assert!(parse::parse_query(“[:find (pull ?e [:person/_friends]) :where [?e :person/name]]”).is_ok()); }[test] fn cas_is_a_known_tx_fn() {
let _ = edn::entities::BuiltinTxFn::Cas; } ```
- [ ] Step 2: run; the tests that encode pg_mentat-only features fail on
mentat (
in_bindings,five_place_pattern, the:with [[…]]rules case,plain_keyword_value,cas_is_a_known_tx_fn). That’s the gap list. - [ ] Step 3: apply, in this order, re-running the suite after each:
- core-traits/core →
enumset1.1 (pg_mentat’slib.rs,value_type_set.rs,core/src/lib.rsextern), keep mentat’sparse::value(expected_output)line. - Modern idioms from § 1.10 across the three crates.
Limit::None→Limit::Unlimited; fix 10 sites.- Plain keyword value place (pg
query.rs:378, drop the// … yet.arm). Add an algebrizer test:[:find ?e :where [?e :task/status :done]]against a schema where:task/statusis:db.type/keyword; must return the entity. BuiltinTxFnenum intoentities.rs.:in→InBindingsgrammar (pglib.rs:587) + theParsedQueryderivation. In mentat’s algebrizer, reject non-scalarin_bindingswithAlgebrizerError::UnsupportedInputBinding(String)(new variant).- Rules: keep
:rules, add pg’s:with rule_definitions()alternative before:with variable()+. Keep pg’s placement ofwhere_clauseafterrule_invocationor mentat’s before — peg rules are order-independent by name; keep mentat’s (documented) comment. - 5-place
Pattern(pgquery.rs, grammarlib.rs:419-447). Fix mentat’s 33Pattern {literals by addingadded: PatternNonValuePlace::Placeholder. Inquery-algebrizer/src/clauses/pattern.rsreject a non-placeholderaddedwithAlgebrizerError::UnsupportedHistoryPattern. - Copyright header sweep to
2016-2018. - Deps to
[workspace.dependencies]versions from § 1.13 (in mentat’s root manifest; pg_mentat gets the same table in Step 5).
- core-traits/core →
- [ ] Step 4: mentat green:
nix develop --command cargo test --workspaceand--features mino. Commit:refactor(edn,core): reconcile shared front-end with pg_mentat. - [ ] Step 5: pg_mentat. Copy the three reconciled directories over pg_mentat’s.
Fix fallout:
NamedPullAttributegainsreverse(pg never constructs it — expect zero sites),Limit::Unlimitedalready matches,Patternalready 6-arg. Add pg tests for:rulesspelling and reverse-pull parsing viaedn.cargo pgrx test pg16green (plus pg17/pg18 in CI). Commit the same message. - [ ] Step 6:
diff -r mentat/edn pg_mentat/edn(and core, core-traits) is empty exceptCargo.tomlrepository/descriptionlines. Record the resulting tree hash of each crate in the commit messages — Task 4 checks it.
Task 3: Adopt workspace lints, release profile, pinned toolchain (both repos)
Files: root Cargo.toml (both), rust-toolchain.toml (both; create in
pg_mentat), pg_mentat flake.nix:22 ("1.90.0" → "1.98.0").
- [ ] Step 1: pin
channel = "1.98"in bothrust-toolchain.toml; pg flake to 1.98; mentat flake fromstable.latesttostable."1.98.0". - [ ] Step 2: copy pg_mentat’s
[workspace.lints.clippy]into mentat’s root manifest. Add[lints] workspace = truetoedn,core,core-traitsonly. - [ ] Step 3: release profile from § 1.16 +
release-devin both. - [ ] Step 4:
cargo clippy --workspace -- -D warningson the three shared crates, both repos. Fix what fires. Commit.
Task 4: Import pg_mentat’s history into the mentat repo
Where: mentat, branch merge/pg-mentat from pg (a48aec6d + Tasks 1–3).
- [ ] Step 1: in a scratch clone of pg_mentat:
bash git clone --no-local ~/ws/pg_mentat /tmp/pgm-import && cd /tmp/pgm-import git filter-repo --path-rename edn/:crates/pg/_import/edn/ \ --path-rename core/:crates/pg/_import/core/ \ --path-rename core-traits/:crates/pg/_import/core-traits/ \ --path-rename pg_mentat/:crates/pg/pg_mentat/ \ --path-rename mentatd/:crates/pg/mentatd/ \ --path-rename Cargo.toml:crates/pg/_import/Cargo.toml \ --path-rename Cargo.lock:crates/pg/_import/Cargo.lock \ --path-rename flake.nix:crates/pg/_import/flake.nix \ --path-rename flake.lock:crates/pg/_import/flake.lock \ --path-rename README.md:crates/pg/_import/README.md \ --path-rename CHANGELOG.md:crates/pg/_import/CHANGELOG.md \ --path-rename Makefile:crates/pg/_import/Makefile \ --path-rename LICENSE:crates/pg/_import/LICENSE \ --path-rename .gitignore:crates/pg/_import/.gitignore \ --path-rename .github/:crates/pg/_import/.github/ \ --path-rename docs/:docs/pg/ \ --path-rename scripts/:scripts/pg/Everything else in pg_mentat’s root (benchmarks/,docker/,Dockerfile,deny.toml,demo.sql,META.json,Trunk.toml,.forgejo/,.gitlab-ci.yml,.envrc,ChangeLog) has no mentat counterpart and keeps its path. - [ ] Step 2: in mentat:
bash git fetch --no-tags /tmp/pgm-import main:refs/import/pg_mentat git fetch /tmp/pgm-import 'refs/tags/*:refs/tags/*' # v1.2.1..v1.6.1 git merge --allow-unrelated-histories refs/import/pg_mentat \ -m "merge: import pg_mentat history (1,258 commits) under crates/pg/" git update-ref -d refs/import/pg_mentat(Tag names don’t collide: mentat has onlyv0.*, checked 2026-09-24.) - [ ] Step 3: verify:
git log --oneline -- crates/pg/pg_mentat | wc -lis in the hundreds;git log --follow crates/pg/pg_mentat/src/functions/query.rsreaches 2025.diff -r edn crates/pg/_import/ednis empty (Task 2 made them identical) — if not, stop and reconcile before deleting. - [ ] Step 4: keep what later tasks need, then drop the rest:
bash git mv crates/pg/_import/.github/workflows crates/pg/github-workflows # Task 6 Step 3b git mv crates/pg/_import/CHANGELOG.md CHANGELOG.pg_mentat.md # Task 6 Step 5 git rm -r crates/pg/_importCommitchore: drop pg_mentat's copies of the shared crates (identical to edn/, core/, core-traits/). Nothing builds yet at this commit’scrates/pg/*— Task 5 wires it. Note that in the commit message so bisect users know to skip it.
Task 5: Restructure into crates/ and one workspace
Files: every crate directory moves; root Cargo.toml rewritten; each crate’s
path = "../x" dependencies rewritten.
- [ ] Step 1:
git mvper § Part 3:edn core core-traits→crates/; SQLite crates →crates/sqlite/; rootsrc/+tests/+build/version.rs→crates/sqlite/mentat/;tools/cli→crates/sqlite/cli;ffi→crates/sqlite/ffi. One commit, moves only (keeps--followworking). - [ ] Step 2: root manifest: ```toml [workspace] resolver = “2” members = [“crates/edn”, “crates/core-traits”, “crates/core”, “crates/mino”, “crates/script”, “crates/sqlite/”, “crates/pg/pg_mentat”, “crates/pg/mentatd”] default-members = [“crates/edn”, “crates/core-traits”, “crates/core”, “crates/mino”, “crates/script”, “crates/sqlite/”, “crates/pg/mentatd”]
[workspace.package] version = “1.6.1” edition = “2021” rust-version = “1.88” license = “Apache-2.0” repository = “https://codeberg.org/gregburd/mentat” authors = [ …union of both author lists… ]
[workspace.dependencies] # § 1.13; plus rusqlite 0.40, pgrx 0.17; mino-rs = { path = “crates/mino” }
[workspace.lints.clippy] # § 1.15
[profile.release] # § 1.16
``
Every crate:version.workspace = true,edition.workspace = true, etc.
- [ ] **Step 3:** rewrite intra-workspacepath =deps; pg_mentat'sedn
dependency keepsfeatures = [“serde_support”].
- [ ] **Step 4:** lockfile: delete both old locks,cargo generate-lockfile,
git add Cargo.lock, removeCargo.lockfrom.gitignore.
- [ ] **Step 5:**cargo test(default members) green;
cd crates/pg/pg_mentat && cargo pgrx test pg16green;
cargo build -p mentatdgreen withoutpg_config` on PATH. Commit.
Task 6: One flake, one CI, delete dead weight
Files: flake.nix (from pg_mentat’s, paths updated), .forgejo/workflows/ci.yml,
deletions per § 1.17–1.18, deny.toml.
- [ ] Step 1: flake: base on pg_mentat’s;
cargo pgrx packageruns incrates/pg/pg_mentat; adddevShells.sqlite,packages.mentat-cli,packages.mentatd.nix build .#pg_mentat-pg16 .#mentat-cli .#mentatdall build. - [ ] Step 2: delete
sdks/ automation/ _/ .vscode/ NOTES .ignore .travis.yml .taskcluster.yml .gitlab-ci.yml .github/dependabot.yml .github/FUNDING.yml .github/workflows/{audit,clippy_check,clippy-ng,cross_compile,grcov,msrv,nightly_lints,quickstart}.yml(mentat’s) and pg_mentat’s.github/workflows/{ci,audit}.yml. Checkfixtures/andbuild/for readers (grep -rn 'fixtures/' crates/); move used ones under the crate that reads them. - [ ] Step 3:
.forgejo/workflows/ci.yml(the gate) jobs:fmt,clippy -D warnings,cargo deny check,test-sqlite(cargo test+--features mino),test-pg(matrix pg13–18,cargo pgrx test),test-ffi,nix-build. - [ ] Step 3b: GitHub publishing workflows (run on the mirror). Move
release.yml,docs.yml,installcheck.yml,docker-test.yml,nix-test.ymlfromcrates/pg/github-workflows/(parked there by Task 4) to the root.github/workflows/; delete the rest of that directory. In each, replacepg_mentat/paths withcrates/pg/pg_mentat/, and makerelease.ymlalso attach amentat-cliLinux binary.release.ymltriggers ontags: ["v*"]today; the mirror will pushv0.14.0and pg_mentat’s 13 oldv1.2.1..v1.6.1tags to GitHub, and each would start a release build. Narrow it: keeptags: ["v*"]and add a first job step that exits unless the tag is ≥v1.7.0(sort -Vcomparison), or list the patternv1.[7-9].*plusv[2-9].*. Alternatively enable the mirror only after the tag import, so the old tags arrive in one push before the workflow file exists on GitHub — but the gate is simpler and survives re-syncs. - [ ] Step 4: Makefile: targets
test,test-pg,package-pg PG=16,install-pg, plus pg_mentat’supgrades/install-upgrade-scripts/smoke(paths updated) and mentat’soutdated/fix. - [ ] Step 5: docs: mdBook at
docs/(pg_mentat’sbook.toml), mentat’s Jekyll pages converted intodocs/src/embedded/. One README with two quickstarts. CHANGELOG:CHANGELOG.pg_mentat.md(parked by Task 4) becomesCHANGELOG.md, replacing mentat’s; prepend a “1.7.0 — merged repository” section and a condensed history of mentat 0.x. Commit.
Task 7: mentat-script — one scripting layer, two backends
Files:
- Create: crates/script/{Cargo.toml,src/lib.rs,src/values.rs,src/prims.rs,tests/model.rs}
- Modify: crates/sqlite/mentat/src/script.rs → implements ScriptBackend
- Modify: crates/pg/pg_mentat/src/functions/script.rs → implements ScriptBackend
Interfaces produced: pub trait ScriptBackend (§ 1.19);
pub fn install(it: &mut mino_rs::Interpreter, backend: Rc<RefCell<dyn ScriptBackend>>);
pub struct DbRef { conn: i64, basis_tx: i64, as_of: Option<i64>, since: Option<i64> };
value builders db_value, tx_report_value, inst_value, uuid_value, kw_ns, str_val.
- [ ] Step 1: move the 11 Datomic-model tests from mentat’s
tests/mino_script.rsintocrates/script/tests/model.rs, parameterized by a backend factory; run them against an in-memory fake backend first. - [ ] Step 2: implement the crate; fake backend green.
- [ ] Step 3: mentat implements
ScriptBackendoverStore; the model tests run against it (crates/sqlite/mentat/tests/script_model.rs). - [ ] Step 4: pg_mentat implements it over its engine; the model tests run as
#[pg_test]s. Historical-qtest gated to pg until Task 12. - [ ] Step 5: delete the duplicated helpers from both
script.rsfiles; each keeps only its result conversion. Commit.
Task 8: Refresh crates/mino from upstream mino 9c65bb50
Where: crates/mino in this repo. Reference (not an input): ~/src/mino/mino
built at 9c65bb50.
- [ ] Step 1: copy upstream
src/core.clj,lib/mino/store.clj,lib/clojure/{string,set,instant}.cljoverresources/, and upstream’s versions of the 12 corpus files overtests/corpus/(one commit, so the diff shows what changed upstream); addstore_backend_test.cljandreader_features_test.cljto the corpus and gate them. - [ ] Step 2: run the corpus; collect failures by category. Implement, each
with its own commit and an oracle-checked unit test:
#uuid→Value::Uuid(Gc<[u8;16]>),type→:uuid, printer#uuid "…".#inst→ an instant value that prints#inst "…"(keep the map payload thatclojure.instantexpects; add the print method upstream uses).read-stringprim over the existing reader.- Classed and keyword
catchper ADR 32/37 (the table in upstreamdocs/adr/32-classed-catch-kind-dispatch.md), first-match-wins, unknown class symbol = compile error. - Regex: stop rejecting
(?=/(?!(fancy-regex implements them); keep rejecting lookbehind only if upstream still does (checkre_compile.c). re-seqno-match →();(keyword <non-string>)→nil;joinstringifies separators; negative-decimal-scale and::reader rules.- BigDec:
Value::BigDecover a pure-Rust decimal (e.g.bigdecimal),Mliteral, printer, tower contagion as upstream’sbigdec.c. delayas its own value type.- New core.clj prims:
delay*,lazy-keep,lazy-map-indexed,lazy-remove(eager),__transduce-fuse(identity),find-keyword,rerun-seq,mino-version(returns"0.2.0"). - Store backend seam: the new store.clj routes durability through
{:kind :initial :wal-entries :commit :checkpoint :close}maps. Re-homesrc/store.rs’s WAL/snapshot code as the:filebackend’s five ops;:memoryis the in-memory one; keep the byte-for-byte file format.
- [ ] Step 3: remove the output-only inst/uuid workaround from
crates/script(values now round-trip for real) and add a test that writes an instant and a uuid throughtransactand reads them back. - [ ] Step 4: full corpus green (record pass counts per file in the commit);
bump nothing —
crates/minois versioned with the workspace.
Task 9: mino.store backends over the real engines (optional, after 8)
With the seam in place, a mino script can use mino.store on top of Mentat
storage instead of a file. Register a :mentat backend whose :commit writes the
tx-info to the host store’s log. Only do this if a user asks for mino.store
semantics over Mentat storage; mentat.store/* already covers Datomic-style use.
Mark as YAGNI in the plan tracker until then.
Task 10: Replace unimplemented!() in the SQLite crates; lints on
Files: the 15 sites (e.g. query-algebrizer/src/clauses/resolve.rs:194,210-212,
db/src/cache.rs:1926).
- [ ] Step 1: for each site write a test that reaches it through the public API (a query or transact that parses successfully) and asserts a typed error.
- [ ] Step 2: replace each with a typed error variant.
cache.rs:1926: read it first — if it’s a genuinely unreachable arm,unreachable!("reason")is fine (clippy’sunimplementedlint doesn’t cover it). - [ ] Step 3:
[lints] workspace = trueon every SQLite crate;cliallowsprint_stdout.cargo clippy --workspace -- -D warningsgreen. Commit.
Task 11: :db.fn/cas and :db/retractEntity in the SQLite transactor
Files: crates/sqlite/db/src/tx.rs (entity expansion), crates/sqlite/db/src/internal_types.rs,
crates/sqlite/db-traits/errors.rs (new CasMismatch { e, a, expected, actual }),
tests crates/sqlite/mentat/tests/tx_fns.rs.
- [x] Step 1: tests, modeled on pg_mentat’s
cas_tests.rsandcomprehensive_retract_tests.rs(port the assertions, not the SPI plumbing): cas succeeds on match; cas fails withCasMismatchand commits nothing; cas withnilold value asserts only if absent; retractEntity removes every datom with thate, removes datoms where it’s thevof a ref, and recurses into:db/isComponentrefs; both spellings (:db.fn/*and:db/*) work. (Done, minus ref-value retraction: pg does NOT retract datoms whereeis a ref value, so we match pg and leave incoming refs intact — see thetest_retract_entity_leaves_incoming_refstest.) - [x] Step 2: implement in the transactor’s entity-expansion pass using
BuiltinTxFnfromedn;casreads inside the IMMEDIATE transaction. - [x] Step 3: green; commit. (feat 4bf01aca, test 2cb415e5.)
Task 12: History patterns, historical q, and :in collection bindings on SQLite
Closes § 1.3, § 1.4, § 1.20 together, because all three are “teach the algebrizer a new table source or input shape.”
- [ ] Step 1: tests (port from pg_mentat’s
history_tests.rs,temporal_tests.rs,input_parameter_tests.rs):[?e ?a ?v ?tx ?added]over the history source;qagainst anas-of Tdb;:in $ [?x ...],:in $ [?a ?b],:in $ [[?a ?b]]. - [ ] Step 2: algebrizer: a
DatomsTable::Transactionssource that exposesadded; a pattern with a non-placeholderaddedor a query against an as-of/since db uses it with atx <= T/tx > Tconstraint. For as-of on the current-state columns, filter to the latest assertion per(e,a,v)at or before T without a later retraction (a correlatedNOT EXISTSontransactions). - [ ] Step 3: input bindings:
BindColl→IN (…)or a VALUES join,BindTuple→ scalar bindings,BindRel→ a VALUES table. RemoveUnsupportedInputBinding/UnsupportedHistoryPattern. - [ ] Step 4: remove the pg-only gate from the historical-
qmodel test incrates/script. Both backends now pass all model tests. Commit.
Task 13: Documentation of the combined project
- [ ] One README: what it is, the two targets,
cargo buildvscargo pgrx install, the scripting layer, and a security section onmentat_eval: callable by every role by design; runs sandboxed (no host access) under three superuser-set limits (steps, heap, depth) plusstatement_timeout; reaches only the stores the caller can already query; must not be madeSECURITY DEFINER. - [ ]
docs/src/architecture.md: the front-end / backend split, the crate map, which features exist on which backend (table from § 1.21, updated). - [ ] Update
/tmp/pg_mentat-mino-integration-guide.md’s successor indocs/src/scripting.md— it describes a design that now lives incrates/script.
Task 14: Release 1.7.0 and retire the pg_mentat repo
- [ ] Mirror check first (§ 1.17): confirm the Codeberg → GitHub push mirror
exists for
mentatand syncs on push. Without it, the tag in the next steps won’t trigger GitHub’srelease.ymland no extension binaries get built. - [ ] Workspace version 1.6.1 → 1.7.0; CHANGELOG entry lists: merged repository,
reconciled front-end (with the user-visible grammar changes:
:inbindings, 5-place patterns, plain keyword values,:rules/:with [[…]]/:in %), SQLitecas/retractEntity/history/as-ofq, mino moved into the repo and refreshed to upstream’s final release, the nesting-depth crash fix, and the sandboxedmentat_evalwith its three limit GUCs. - [ ] Merge
merge/pg-mentat→master(stays the default branch). Its tip201ec39dis from 2023, so fast-forward is impossible:git merge --no-ff, message pointing at this plan. - [ ] Annotated tag
v1.7.0. Pushmasterand the tag to Codeberg only:git push origin master v1.7.0. Never push to GitHub directly; the mirror carries it. - [ ] After the mirror syncs: GitHub
masterequals Codebergmaster; the GitHub Release forv1.7.0has the per-PG-version extension tarballs; the Pages site is up at the new URL. - [ ] pg_mentat’s PGXN/Trunk metadata (
META.json,Trunk.toml) point atcodeberg.org/gregburd/mentat; publish the extension from there. - [ ] Delete remote branches
pg(d7e2b554, superseded) andimprov-base(duplicate). Deletemino-scriptingafter 1.7.0 ships. - [ ] pg_mentat repo, last push: replace README with a pointer and push to its
Codeberg
main(the mirror copies it to GitHub). Suggested text:Moved. pg_mentat now lives in the
mentatrepository, which builds both the embedded SQLite store and this PostgreSQL extension: https://codeberg.org/gregburd/mentat (mirror: https://github.com/gburd/mentat). This repository is read-only. Releases after 1.6.2 are published there. Then you mark both pg_mentat repos read-only. Leave the pg_mentat mirror enabled until the pointer commit shows on GitHub, then turn the mirror off — pushing into an archived GitHub repo fails.
Part 5 — Self-review
Coverage. Every item from the diff inventory has a decision and a task: 1.1→T1b/T1c, 1.24→T1a/T1b, 1.25→T1b/T5, 1.2/1.3/1.4/1.5/1.6/1.7/1.8/1.9/1.10/1.12/1.13→T2, 1.11→T4 (no-op), 1.14/1.15/1.16→T3/T5/T6, 1.17/1.18→T6, 1.19→T7, 1.20→T12, 1.21→T11/T12, 1.22→T5 (moves, no change), 1.23→T4/T14, Part 2→T8.
Risk order. The only step that can lose work is deleting pg_mentat’s copies
of the shared crates (T4 Step 4); it’s gated on an empty diff -r against the
canonical copies, which T2 guarantees, and it parks the two things later tasks
need (.github/workflows, CHANGELOG.md) before deleting.
The one step that is hard to undo is the first push after the mirror is on: it
sends 13 pg_mentat tags and the new master to a public GitHub repo. Do it once,
at Task 14, with release.yml already gated (T6 Step 3b).
What I’d cut if time is short. T9 (already marked YAGNI), T12 (the gap is documented and test-gated), T13’s architecture page. Never cut T1.
Decisions recorded 2026-09-24.
1. Push to Codeberg only; Codeberg mirrors to GitHub. The merged repo is
mentat. You mark the pg_mentat repos read-only once 1.7.0 ships.
→ § 1.17, § 1.23, Task 6 Steps 2–3b, Task 14.
2. mentat_eval stays callable by PUBLIC, sandboxed and limited.
→ § 1.1, Task 1. Found while applying this: step limits alone don’t cover
single-call allocation or deep recursion (the latter crashes the process
today), so Task 1 now adds heap and depth limits too.
3. Default branch stays master. → § 1.23, Task 14.
One thing to check before Task 14: the mentat Codeberg → GitHub push mirror.
GitHub’s mentat master is behind Codeberg’s today, and v0.14.0 and
mino-scripting never arrived, so it’s either off or failing.