Skip to content

Latest commit

 

History

195 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

edtf

ci OpenSSF Scorecard SLSA build SLSA source SLSA dependency OpenSSF Best Practices OpenSSF Baseline

coverage

crates.io docs.rs npm ghcr edtf fair-software

A complete, spec-exact Rust implementation of EDTF — the Extended Date/Time Format, standardized as the profile in ISO 8601-2:2019 Annex A — covering conformance levels 0, 1 and 2 in full.

One zero-dependency core, compiled into every layer that needs it, so a date that is valid in your application is valid in your database — always:

Crate What it is
edtf-core The implementation. #![no_std], zero runtime dependencies. Parsing, validation, level classification, calendar bounds, three-valued temporal relations, value enumeration, canonical formatting, positioned errors. Optional serde feature.
edtf-normalize Deterministic prose-date → EDTF normalizer at the human input boundary: "1980s"198X, "circa 1920"1920~, "около 1920 г."1920~. Honest ambiguity ("12/04/1985" returns both readings, never a guess), every output valid canonical EDTF by construction. English and Russian pattern tables; no_std + alloc, no third-party dependencies (only edtf-core).
edtf-calendars Proleptic Julian (Old Style) → Gregorian conversion at the ingest boundary: day precision converts exactly, year/month precision returns honest earliest/latest spans. #![no_std], no third-party dependencies (only edtf-core).
edtf-wasm WebAssembly bindings for JavaScript: isValid, level, canonical, parse (JSON summary), relation, and normalize (prose → EDTF via edtf-normalize).
edtf-postgres Postgres extension (via pgrx, Postgres 14–18): edtf_valid(), edtf_level(), edtf_canonical(), edtf_min(), edtf_max(), edtf_relation() as SQL functions.
edtf-cli The edtf command-line tool: validate / canonical / level / info over arguments or stdin, plus relation (three-valued comparison of two expressions) and from-julian (Old Style → Gregorian EDTF). Installable anywhere via cargo install edtf-cli (or pin it with mise: "cargo:edtf-cli").

Contributions are welcome. The contributing guide, code of conduct, governance and security policy are served org-wide from monumental-archive/.github rather than duplicated here. Repo-specific reading: the roadmap and the security assurance case.

Why this exists

This replaces an EDTF layer that ran edtf.js inside Postgres under plv8. It worked, but plv8 compiles V8 from source — a database build took 40 minutes — and the standing cost was a JIT-ing JavaScript runtime, with its own heap, GC and threads, resident in every Postgres backend.

That constraint is what the design answers. A Postgres backend is fork()ed per connection, unwinds errors through siglongjmp, and allocates from transaction-scoped memory contexts; nothing carrying its own runtime belongs inside one. So edtf-core is #![no_std] with zero runtime dependencies — which is also precisely what lets the same code become a small WebAssembly bundle and a static CLI binary. One implementation, three deployment shapes, no divergence to reconcile. Installing the extension is now a tarball download and CREATE EXTENSION.

The old engine's behaviour was not discarded, but it is an oracle rather than an authority: tests/fixtures/legacy pins its verdicts as a cross-check, and where they disagree with the Annex A reading in docs/spec-notes.md, the spec wins and the divergence gets a documented note.

What "complete" means here

  • Level 0: calendar dates, reduced precision, date-times with UTC/shift, date intervals.
  • Level 1: letter-prefixed (Y…) and negative years, seasons 21–24, whole-expression ?~% qualification, the level 1 unspecified-digit shapes (201X, 2004-XX, 1985-04-XX, …), open (..) and unknown (empty) interval ends, qualified interval endpoints.
  • Level 2: exponential years, significant digits (S), sub-year codes 25–41, sets ({…}/[…]) with .. ranges, group and individual qualification, unspecified digits anywhere, ..-bounded interval endpoints.
  • Real calendar validation: 1985-02-30 is rejected; 2000-02-29 is accepted and 1900-02-29 is not; 1985-02-3X is rejected because no February day starts with 3; X900-02-29 is rejected because no year ending in 900 is a leap year.
  • Bounds: every expression maps to earliest/latest calendar days (156X → 1560-01-01…1569-12-31), the primitive for range queries.
  • Relations: three-valued comparison under uncertainty — is 1985~ before 199X? Definitely; is 198X before 1985? Possibly. Six coarsened Allen relations, each impossible / possible / definite, never over-asserting (semantics: D23 in the spec notes).
  • Value enumeration: values() lazily yields the concrete calendar values an expression denotes — {1667,1668,1670..1672} expands exactly as ISO 8601-2 §6.4 does, 156X yields its ten years, 1985-0X-31 its five valid months, XXXX-XX-XX streams ~3.65M days without allocating. Intervals and ..-open set elements are honestly Unenumerable (semantics: D24–D29 in the spec notes).
  • Canonical formatting: Display renders the spec-preferred form (ISO 8601-2 §8.2.4) — ?2004-?06-?11 normalizes to 2004-06-11?.
  • Strict profile boundaries: everything Annex A excludes is rejected — basic format (19850412), week/ordinal dates, durations, and the entire explicit-form designator system are not EDTF and do not parse.

Every grammar production and every judgment call is documented with ISO section citations in docs/spec-notes.md, including 29 resolved decisions (D1–D29) and one identified erratum in the ISO text itself (Annex A.6.3 Example 2, which contradicts its own normative clause).

Usage

use edtf_core::{Edtf, Bound};

assert!(edtf_core::is_valid("2004-06~-11"));      // level 2, June ~2004, day 11
assert!(!edtf_core::is_valid("1985-02-30"));      // no such day

let d = Edtf::parse("156X-12-25").unwrap();
assert_eq!(d.level(), 2);
assert!(d.has_unspecified());
let b = d.bounds();                                // 1560-12-25 ..= 1569-12-25

use edtf_core::Relation;
let a = Edtf::parse("1985~").unwrap();
assert_eq!(a.relation(&Edtf::parse("199X").unwrap()).definite(),
           Some(Relation::Before));                // ~ never moves bounds

SQL, after CREATE EXTENSION edtf_postgres:

SELECT edtf_valid('1985-24');                        -- true (winter 1985)
SELECT edtf_min('156X'), edtf_max('156X');           -- 1560-01-01, 1569-12-31
SELECT edtf_relation('1985~', '199X');               -- {definitely_before}
-- e.g. as a consistency rule: born must not be after died
-- CHECK (NOT ('definitely_after' = ANY(edtf_relation(born, died))))
SELECT daterange(edtf_min(production_date),
                 edtf_max(production_date), '[]') @> DATE '1965-06-15'
FROM artworks;

Installing the Postgres extension

Prebuilt, attested tarballs are attached to each release, so installing needs no Rust toolchain and no cargo-pgrx. Verify before extracting — the archive unpacks into / as root, so checking the signature afterwards is a postmortem, not verification.

From v1.3.0 on, releases are unified (one v* tag for the repository) and are signed by the organisation's signer, not by this repository:

VERSION=1.3.0
PG=18                 # 14, 15, 16, 17 or 18
ARCH=amd64            # amd64 or arm64 — dpkg's spelling, not uname's
FILE="edtf_postgres-${VERSION}-pg${PG}-linux-${ARCH}.tar.gz"

gh release download "v${VERSION}" --repo monumental-archive/edtf \
  --pattern "${FILE}" --pattern SHA256SUMS

# <signer-commit> is looked up fresh: it is the sign.yml@<sha> `uses:` pin
# in the tree that produced this release — the tree's one statement of
# which signer it trusts.
gh attestation verify "${FILE}" --owner monumental-archive \
  --signer-workflow monumental-archive/signer/.github/workflows/sign.yml \
  --signer-digest <signer-commit> \
  --source-ref "refs/tags/v${VERSION}" \
  --deny-self-hosted-runners

sha256sum --check --ignore-missing SHA256SUMS

sudo tar -xzf "${FILE}" -C /

Every release also carries a verification verdict (an artifact VSA) in the attestation store, signed under a second, separate identity. The full consumer recipe — verdicts, SBOMs and VEX included — is the organisation's runbook.

Releases up to and including edtf-*-v1.2.3 (2026-08-08) predate the transfer into monumental-archive, which happened on 2026-08-21. They were per-crate, and they were signed by this repository's own publish.yml under its former owner, so they verify with the older recipe and not the one above:

gh attestation verify "${FILE}" --repo monumental-archive/edtf \
  --source-ref "refs/tags/edtf-postgres-v${VERSION}" \
  --signer-workflow CarlAllenn/edtf/.github/workflows/publish.yml

That identity is recorded here rather than removed: those artifacts are immutable and were genuinely signed that way, and a consumer holding one needs to be able to check it. Nothing new is signed under it.

Then, as any user with CREATE on the database — the extension is marked trusted, so superuser is not required:

CREATE EXTENSION edtf_postgres;

From v1.2.0 the shipped library is stripped; each cell also publishes an edtf_postgres-dbgsym-… tarball carrying the full debug info. It is only needed for debugging a crash: extract it into / and gdb finds the symbols via the library's .gnu_debuglink under /usr/lib/debug. From the same release each crate's CycloneDX SBOM is attested against the tarball bytes — gh attestation verify as above proves both provenance and the dependency list.

Upgrading a previously installed copy: extract the new tarball over the old one, then ALTER EXTENSION edtf_postgres UPDATE;. Extracting without running the update leaves the new library registered under the old version. If you are running pg_upgrade, install the extension into the new cluster before upgrading.

Support matrix

Postgres amd64 arm64 glibc floor
14, 15, 16, 17, 18 2.36

Built inside postgres:<major>-bookworm against that image's pgdg pg_config, and installed-and-exercised on both bookworm and trixie before release. The tested set and the shipped set are the same set.

glibc 2.36 is a floor, not a target: Debian 12, Debian 13 and Ubuntu 24.04 are all fine, Ubuntu 22.04 (2.35) is not. musl/Alpine is not covered — a different libc entirely. The tarball assumes the Debian pgdg layout (/usr/lib/postgresql/<major>/lib, /usr/share/postgresql/<major>); a Postgres built from source under a different prefix needs the files placed by hand from pg_config --pkglibdir and --sharedir.

A major is dropped from the matrix as it reaches end of life; Postgres 14 reaches EOL in November 2026.

JavaScript, via the wasm package:

import { isValid, parse, normalize } from "edtf-wasm";
isValid("2004-06~-11");            // true
JSON.parse(parse("1985-04-12/.."));// { kind: "interval", earliest: "1985-04-12", latest: "infinity", … }
JSON.parse(normalize("circa 1920"));// { kind: "normalized", edtf: "1920~", … }

Development

Toolchain and every linter are pinned via mise with a checksum lockfile; git hooks (lefthook) and CI run the same gauntlet:

mise install     # pinned Rust + all linters
mise run ci      # the org gate: every linter, then the test suite
mise run wasm    # build the WebAssembly artifact
mise run pg:test # Postgres extension tests (needs `cargo pgrx init` once)

The test suite includes a 63-case conformance corpus derived from every Annex A example plus adversarial cases, every example from the Library of Congress EDTF specification page as an interop cross-check (tests/fixtures/loc), a second interop corpus harvested from the edtf.js and python-edtf test suites (tests/fixtures/interop — shared verdicts enforced, implementation extensions pinned as must-rejects, every divergence a documented decision), ~200 spec-derived assertions, bounds verification, a canonical-form round-trip property over all fixtures, model-side property tests (proptest generates random valid values as structured data and checks round-trip identity, level stability and bounds ordering), and a deterministic fuzz harness (hundreds of thousands of hostile inputs per run; the parser must never panic, and everything it accepts must round-trip). On top of that, coverage-guided fuzzing (fuzz/, cargo-fuzz) runs nightly in CI against the same never-panic and round-trip properties. Parse errors carry the byte offset of the problem: 1985-02-30invalid EDTF at offset 8: day is out of range for the month.

Performance

Criterion benchmarks live in crates/edtf-core/benches/core.rs (mise run bench). Representative numbers, Apple M1 Pro, rustc 1.97.1, --release:

Input parse canonicalize bounds
1985-04-12 187 ns 283 ns 190 ns
1985-04-12T23:20:30+04:30 150 ns 458 ns 209 ns
2004-06~ 106 ns 203 ns 139 ns
?2004-06-~11 192 ns 295 ns 199 ns
156X-12-25 185 ns 284 ns 173 ns
Y-17E7S3 74 ns 178 ns 15 ns
{..1983-12-31,1984-10-10..1984-11-01,1984-11-05..} 1.25 µs 1.20 µs 795 ns
1985-02-30 (rejected) 189 ns

License

Licensed under either of Apache License 2.0 or MIT license at your option (SPDX: MIT OR Apache-2.0).

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.

About

Spec-exact EDTF (ISO 8601-2) in Rust, levels 0–2 — no_std core, prose-date normaliser (EN/RU), Julian conversion, CLI, WebAssembly, and Postgres extension.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages