Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Argon RFDs

Design decision records for the Argon language, runtime, and toolchain.

An RFD records why a choice was made. It is not the specification. The Lean 4 mechanization at spec/lean/ is canonical for the substrate; the reference manual (Part I of this book) describes the surface in prose. RFDs explain how those landed where they did.

Format

  • Filename: NNNN-kebab-case-name.md. Four-digit, no reuse. Numbers are not recycled even when an allocation is abandoned, so the sequence may carry gaps: 0012 was never authored — the number was skipped during a period of concurrent branch allocation (the same churn that renumbered the refinement RFD 0016 → 0017 once numeric-tower took 0016 on main) and the slot was left empty rather than reused.
  • Structure: Question · Context · Decision · Rationale · Alternatives · Consequences · Open questions.
  • States:
    • discussion — open; not committed.
    • committed — decided and binding. Implementation may or may not have landed; the decision is fixed.
    • accepted — partially implemented — decided and binding, with implementation landed in part; the State: line annotates what has shipped and what remains.
    • superseded — replaced by a later RFD. Cite the successor.

Authoring an RFD

RFDs live at spec/rfd/ in the repository. The published book reflects whatever is on main. To open a new RFD, allocate the next number, write the file, and submit it via PR — discussion happens on the PR or in linked threads, not by gating merge on consensus.

The format mirrors Oxide Computer Company’s RFD process (Yegge / Dijkstra-via-Bryan-Cantrill lineage) but lighter: no separate authoring tool, no review-state tracker, no required pre-commit lint. A repository PR is the discussion vehicle; the RFD’s State: field carries the decision status.

Index

#TitleState
0001ID architecturediscussion
0002#[comptime] attributediscussion
0003Reasoner backend dispatchdiscussion
0004pub fact declarationsdiscussion
0005Relation subsumptioncommitted
0006Field mutability via mutdiscussion
0007Missing-value semantics under OWAdiscussion
0008Standpoint-sheaf equivalence proof roadmapcommitted (Path A landed)
0009std::mlt library scopediscussion
0010Negative facts / strong negationdiscussion
0011Aggregate semantics under OWAdiscussion
0013Toolchain distributionaccepted — partially implemented
0014Runtime serving surfacediscussion
0015mutate body surface: EdgeQL-shaped, set-semanticcommitted
0016Numeric tower: exact by defaultcommitted
0017Refinement classification: where (primitive) vs iff (defined)committed
0018Production reasoner: the incremental DBSP engineaccepted — partially implemented
0019Mutation write-path correctness: construction, identity, read-your-writes, exact valuesaccepted — partially implemented
0020The runtime data engine: a composable query + reasoning pipelineaccepted — partially implemented
0021The reasoner execution engine (as built): joins, optimizer, factorization, BYODS, incrementality disciplinecommitted
0022Package-path addressing (pkg, not crate) and the build evaluability gatecommitted
0023Reflective TypeRef: type-as-value in the meta-calculus (RP-003 GAP-1)committed
0024Allen interval algebra as a library (std::allen), not substrate operatorscommitted
0025check discharge: vocabulary-staged compile-time and runtime constraint checkingaccepted
0026Trait rule members: clause-union dispatch, conformance, and the implements intrinsicaccepted — implementation planned
0027The meta-property plane: axis bindings, catalog tiers, value-position resolution, and substrate-neutral modifiersaccepted — implementation planned
0028Defeasibility redesign: honest heads, the defeat-directive plane, and strategy as a compilation schemeaccepted — implementation planned
0029Derived values and aggregate terms: body-level binding, aggregate sources, roundingcommitted
0030Package dependencies ([dependencies], path deps v1)committed
0031The relation-constraint plane + meta-property completioncommitted
0032oxup manages editor-extension installationcommitted
0033The ad-hoc query and mutation surfaceaccepted — implemented
0034Source text encoding and the Unicode lexical policy (UAX #31 identifiers, NFC, module reachability)committed
0035The composable operator-tree execution pipeline: shared LogicalPlan lowering, tree optimizer, physical mapper, generalized executor, table operatorsdiscussion
0036Heterogeneous and specialized data stores: foreign federation, external-valued attributes, persistence swap; the connector SPI, placement, world-assumption tieringdiscussion
0037The macro atom: a phase-separated, hygienic, declarative-first expander over surface syntaxdiscussion
0038The prelude, ambient scope, and symbol-precise stdlib loadingdiscussion
0039Composable mutations: nested invocation and derived readsaccepted — implemented
0040The procedural macro layer: a total, structurally-recursive meta-language over reflected syntaxdiscussion
0042The re-checkable emission boundary: a self-validating .oxbin and sound direct artifact emissiondiscussion
0043Theory packages and the neutrality boundary: where ontologies and higher-order theories livediscussion
0044Package registry, workspaces, and distribution: a static content-addressed registry over the CDNdiscussion
0045The world-assumption write-side: refuse-on-K3-not and the #[world] per-concept opt-indiscussion
0046Derivation serving surfaces: query, delta, explain, trace (kill the per-rule derive loop)discussion
0047The temporal value library (TC39-Temporal-modeled std) and the value/ontology boundarydiscussion
0048The test atom: in-language unit tests, and why a test is substrateaccepted — partially implemented
0049Error-tolerant diagnostics: recovery, source-faithful expansion, What/Where/Why/Fixdiscussion
0050Documentation architecture: three books, correctness by construction, and a verified authoring pipelinediscussion
0051oxfmt: a canonical, idempotent source formatter over the shared CSTdiscussion
0052Deployment topologies, the connection abstraction, and host-language parity: embedded vs. standalone, Rust/TS parity, access-vs-durabilitydiscussion

RFD 0001 — ID architecture

  • State: discussion
  • Opened: 2026-05-27
  • Decides: identifier types used across oxc-protocol, oxc-oxbin, oxc-instantiate, oxc-runtime, oxc-storage-mem, oxc-storage-pg, oxc-reasoning.

Question

Argon currently identifies every declared symbol, every event, every partition key, and every runtime tuple element with a 16-byte uuid::Uuid. Across ~286 call sites and ~21 distinct identifier roles, this is one type doing many jobs. The reasoner — which IS the data system — sees UUIDs in every Z-set tuple and every storage index entry. Is uuid::Uuid the right identifier type for Argon, and if not, what is?

Context

How identifiers flow through Argon

A modeler writes Argon source. The compiler lowers it to a sequence of typed axiom events stored in a .oxbin artifact. The runtime loads the artifact into a Module, opens a Store against a StorageBackend, executes mutations that emit more events, and answers queries by reasoning over the resulting fact set.

Identifiers appear at every stage:

StageIdentifier shapes
Sourcequalified_path: String (“demo::Person”)
ASTUUIDs minted from Uuid::new_v5(WORKSPACE_NS, facet ++ qualified_path)
Wire (.oxbin)UUIDs in 70+ body fields and 7 mandatory event-header fields
Storage indexesUUIDs in BTreeMap keys
Reasoner Z-setsUUIDs encoded as Value::Id(Uuid) in tuple bytes
Hot pathUUIDs everywhere; 16 bytes per identifier, 17 bytes per identifier with CBOR framing

Why the choice is load-bearing

In a system whose hot path involves billions of tuple comparisons in Z-set joins, the identifier IS the data structure. Concrete costs at scale:

  • AxiomEvent header: 128 bytes of identifiers per event (7 mandatory UUIDs + 1 SHA-256). At a billion events: ~104 GB of just IDs in the header.
  • Storage indexes: every (LiveKey, HistKey) entry carries two UUIDs (tenant + fork) plus a kind tag. ~40 bytes per index entry.
  • Z-set tuples: every Value::Id is 17 CBOR-encoded bytes. A 3-arg relation tuple is ~55 bytes for IDs alone.

Survey: what comparable systems do

We studied three prior art systems carefully (Kora, Nous, the generic UUIDv8 graph-DB proposal). Each made specific design choices that don’t map cleanly to Argon:

  • Kora (/Users/ivanleon/Code/wt/eidos/main/): Iri(NonZeroU32) backed by a process-wide LazyLock<ThreadedRodeo> interner. Per-engine ConceptIndex with TOP=0, BOTTOM=1 sentinels — owl:Thing / owl:Nothing baked in. Global static state. Tightly coupled to OWL semantics.
  • Nous (/Users/ivanleon/Code/wt/orca-mvp/main/crates/nous/): define_id! macro generating newtyped u64 ids derived from FNV-1a 48-bit hashes of qualified IRIs. Sparse → dense IdBridge rebuilt per schema; DERIVED_ID_BIT = 1<<63 overload on IndividualId. 48-bit hash collides at ~2^24 entries.
  • Generic graph-DB recommendation (UUIDv8 outer + 64-bit InternalId inner, with HLC timestamp inside the UUIDv8): the HLC timestamp inside the wire ID breaks byte-deterministic builds, which is a non-negotiable Argon property.

None of these are right for Argon directly. The substrate-neutrality requirement (no OWL Thing/Nothing), the per-build determinism requirement (no HLC inside wire IDs), and the multi-axis partition model (tenant × fork × standpoint × module — none of which are subordinate to the others) all push toward a custom design.

Decision

Argon defines ten identifier types, each tuned to its identity-source and role. No uuid::Uuid anywhere. The uuid crate is dropped from the workspace.

The types

TypeBytesIdentity sourceScopeRole
Iri(Arc<str>)Modeler-authoredI/O boundaryQualified path: "demo::Person". Surface contract; never in hot paths.
NameRef(NonZeroU32)4Symbol-table positionPer-workspaceWire-format identifier for every declared symbol (concept, relation, module, standpoint, metatype, metarel, metaxis, trait, impl, struct, enum, rule, query, mutation, compute, sink, macro, test).
TenantId(NonZeroU32)4Provisioning tablePer-deploymentTenant partition key.
ForkId(NonZeroU32)4Fork tablePer-tenantFork partition key (per-tenant scope).
IndividualId(NonZeroU64)8System-allocated counterPer-(tenant, fork)Dynamic individual identity. Replaces caller-provided UUIDs; external identifiers are data (a hasExternalId property), not identity.
EventId(NonZeroU64)8Build-deterministic counter (compile-time); HLC-derived Snowflake (runtime)Per-(tenant, fork)Per-event identity. Layout: `[tx_seconds: 32
AxiomKey([u8; 16])16BLAKE3-128 of canonical bodyPer-(tenant, fork)Logical proposition identity. Same proposition asserted twice has the same AxiomKey.
InternalId(NonZeroU64)8Built at Module::load; thrown away at unloadPer-Module-loadRuntime-only hot-path id. Layout: `[kind: 8
ContentId([u8; 32])32BLAKE3-256 of bodyContent-derivedCryptographic content hash. Replaces SHA-256.
CompositionSignature([u8; 32])32BLAKE3-256 of composition inputContent-derivedWorkspace composition signature. Replaces SHA-256.

Amendment (2026-06-11, issue #270 / PR #285). The NonZeroU32 → INT4 mapping above carries an invariant the original RFD left implicit in the Postgres encoder. Recorded here so call-site comments can cite it: a NameRef’s Postgres wire mapping is INT4 with the high bit reserved — the valid band is [1, 2^31-1]. This is enforced by the sqlx::Encode impl (oxc-protocol/src/ids.rs), which signed-converts through i32::try_from and refuses any value with the high bit set rather than letting it wrap negative on the wire. The 0 slot stays the NonZero* niche sentinel; the top bit is held in reserve. Two consequences follow. First, any hash-derived NameRef (the property_id_for_field / reflective_sort_name_ref / individual_id_from_name stand-ins, which fold a BLAKE3 prefix into an id pending the symbol-table lift) must mask into the band — & 0x7FFF_FFFF, zero-folded to 1 — or a coin-flip of field names would set the high bit and abort the mutation (PR #285). Second, folding a hash into 31 bits is not injective; PR #285 adds the load-time collision gate (OE0231) so two distinct Type::field pairs that alias one id refuse loudly instead of silently sharing a storage column. The hash stand-ins are a bridge: the sequential interning table that derives NameRefs from canonical symbol-table position (Phase 3 below) is the production follow-up tracked at #270, and it retires both the mask and the gate.

Two ways identity is derived

The 10 types split cleanly on identity-source:

Content-addressed (deterministic from source content; same source → same byte):

  • NameRef — derived from symbol-table position, which is derived from canonical-sorted qualified paths.
  • AxiomKey — BLAKE3-128 of canonical body bytes.
  • ContentId — BLAKE3-256 of body bytes.
  • CompositionSignature — BLAKE3-256 of composition input.

Allocation-addressed (system-allocated counters, deterministic per-build):

  • TenantId, ForkId — provisioning tables (counters under operator control).
  • EventId — per-event counter (deterministic in build mode; Snowflake in runtime).
  • IndividualId — per-mutation counter (deterministic within a build pass).

Iri is a surface artifact; InternalId is a runtime-only artifact. Neither participates in wire identity.

BLAKE3 unification

AxiomKey (128 bits) and ContentId (256 bits) come from a single BLAKE3-256 invocation of the canonical body bytes. AxiomKey is the first 16 bytes; ContentId is the full 32 bytes. One hash invocation per event.

BLAKE3 replaces SHA-256 throughout because: ~3× faster, parallel-friendly, same cryptographic strength, smaller code size. The replacement is a one-time wire-format change tied to this RFD.

Per-lattice sentinels (engine-local, never on wire)

Within a single Module load, the reasoner builds InternalId-space per lattice (per metatype’s subsumption lattice, per standpoint lattice, per refinement lattice). Within each lattice, the reasoner reserves:

  • The lattice’s (universal) at the lowest available InternalId for that lattice’s kind+partition.
  • The lattice’s (inconsistent) at the next one.

These are engine-local artifacts, recomputed at every Module::load, never written to the wire. The lattice’s actual top and bottom are declared concepts (e.g., the universal in a metatype’s subsumption lattice is a concept declared in stdlib like std::mlt::Class); the engine’s reservation is purely an evaluation-time optimization for short-circuit operations.

This differs from Kora/Nous, which reserve TOP=0 / BOTTOM=1 globally across all concept IDs — that’s an OWL-ism (a single owl:Thing for the whole ontology). Argon doesn’t have a single universal; each lattice has its own bounds, and they’re declared entities, not primitive IDs.

Type-distinct newtypes via the define_id! macro

Each ID type is a newtype with its own Display, Ord, Hash, Serialize, Deserialize, big-endian wire encoding, and NonZero* niche for Option<_>. Generated via a single define_id! macro (inspired by Nous’s pattern). Crossing roles requires explicit conversion — no accidental TenantIdForkId confusion at the type level.

Rationale

Why NameRef instead of UUIDv5(path)

The .oxbin format already mandates a symbol-table section (§D.5) with HDT-PFC-compressed canonical-sorted qualified paths. We’ve been routing around it by minting UUIDv5(path) instead of using the symbol-table position directly.

UUIDv5(path) gives:

  • 16 bytes per reference
  • Determinism (same path → same UUID)
  • Cryptographic collision resistance

NameRef = symbol-table position gives:

  • 4 bytes per reference
  • Determinism (canonical sort order)
  • Zero collision risk (by construction — positions are unique)
  • Native: the dictionary the format already requires

The savings are 12 bytes per declarative reference. Across hundreds of bodies in a real workspace with hundreds of references each, this is substantial. And the property — same source → same NameRef — is preserved.

Why content-addressed AxiomKey at 128 bits, not 64

A 64-bit AxiomKey saves 8 bytes per event header — ~5% of the header. The cost: birthday-collision probability ~37% at 2^32 entries; necessitates collision-handling machinery (either deterministic re-salting or content_id fallback verification on every lookup).

128 bits is birthday-safe past 2^64 entries (effectively unbounded). Collision handling unnecessary. BLAKE3-128 is fast (free, given we compute BLAKE3-256 for ContentId anyway). The 8 bytes saved on the event header aren’t where the storage wins live — those are in declarative _id fields (16 → 4 = 12 bytes saved each, multiplied across every body) and Z-set tuples (17 → 9 bytes per ID, multiplied across millions of tuples).

The architectural rule: don’t compromise the wire format for bytes that aren’t on the hot path.

Why IndividualId is system-allocated, not caller-provided

Pattern: every database treats internal entity identity as surrogate, and external (caller-provided) identity as data. Postgres uses BIGSERIAL PRIMARY KEY + external_id TEXT UNIQUE. Datomic uses partition-encoded entids + :db/ident for natural keys.

Argon mutations like register(external_id: Text) should:

  1. Allocate a fresh IndividualId(NonZeroU64) internally.
  2. Emit iof_assertion(IndividualId, Person).
  3. Emit hasExternalId(IndividualId, external_id) for the caller’s identifier.

The caller can query “find the Person where hasExternalId = ‘user_12345’” later. External identity is a property; internal identity is a surrogate. This is the pattern that scales and stays clean — and it lets IndividualId be 8 bytes (system-allocated) instead of 16 bytes (caller-provided UUID).

Why InternalId is runtime-only

InternalId layout [kind: 8 | partition: 16 | sequence: 40] is optimized for:

  • Cache-friendly Vec<u64>-indexed bitmaps (per-kind, per-partition).
  • Fibonacci-hashed U32-keyed sets in hot loops.
  • Zero-cost type discrimination via the kind byte.

But this layout is engine-policy, not modeler-visible. We reserve the right to renumber on compaction, change partition functions, etc. Making InternalId part of the wire format would couple wire to engine — wrong direction. It stays runtime-only; Module::load builds a NameRef ↔ InternalId dictionary; the reasoner operates entirely in InternalId space.

Why no UUIDs

Five reasons:

  1. We control allocation. UUIDs solve the “globally unique without coordination” problem. Argon’s identifiers are either declared (NameRef from canonical symbol position), system-allocated (EventId, IndividualId), or content-derived (AxiomKey, ContentId). No coordination problem exists.

  2. Type safety. All 21 identifier roles collapsing into one Uuid type is a regression. Newtypes per role give compile-time discrimination.

  3. Storage efficiency. Replacing UUIDs with the right-sized type per role saves 60-80% of identifier bytes across the system.

  4. Wire format determinism. UUIDv4 is random; UUIDv7 has wall-clock time; UUIDv5 is one hash family. Custom types let us pick the determinism story per role (content-hash for AxiomKey, sort-position for NameRef, deterministic-counter for EventId in build mode).

  5. No external compatibility need. Argon doesn’t need to interoperate with systems-that-mint-UUIDs at the identifier level. Federation happens at the Iri level (qualified paths), not at the binary-ID level.

Alternatives considered

Alt 1: Keep UUIDs everywhere

The status quo. Universal, well-understood. Costs: 145 bytes of identifiers per event header; 17-byte Z-set tuple elements; ~286 call sites with one type doing many jobs; full 16-byte width for partition keys with low-cardinality.

Rejected. The hot-path costs and lack of type discrimination outweigh the familiarity benefit.

Alt 2: UUIDv8 outer + 64-bit InternalId inner (the generic doc recommendation)

A two-tier system with UUIDv8 (RFC 9562, custom layout) as the durable external identifier and a packed 64-bit InternalId for hot paths.

Rejected. UUIDv8 in any recommended form embeds a timestamp (HLC), which breaks Argon’s byte-deterministic-build invariant. And the “external identifier” tier isn’t needed for Argon today — we don’t federate at the binary-ID level.

Alt 3: Snowflake-style 64-bit time-ordered IDs throughout

A single 64-bit ID type [time: 42 | shard: 10 | seq: 12] for everything.

Rejected. Conflates allocation-addressed (events) with content-addressed (declarative symbols). Concepts shouldn’t have time in their identity; events should. Single-type-for-everything is what we’re moving away from.

Alt 4: Nous’s FNV-1a 48-bit ConceptId

Hash the qualified path with FNV-1a, truncate to 48 bits, that’s the ID.

Rejected. Collides at 2^24 entries (~16M). Nous trusted IRIs not to collide; Argon shouldn’t. And it conflates declarative identity (paths) with run-of-the-mill IDs.

Alt 5: Kora’s per-engine ConceptIndex with HashMap<Iri, u32>

Each engine maintains its own Iri → u32 index, rebuilt per session.

Rejected as primary scheme (kept as inspiration for InternalId at Module::load time). Per-engine indexes don’t address the wire-format problem; they’re an in-memory representation.

Consequences

Wire format changes (major)

  • Bump oxbin_format_version major (Phase 2 of rollout — see below).
  • All _id: uuid::Uuid fields in oxc-protocol::storage::*Body types become typed: NameRef, EventId, AxiomKey, etc. per their role.
  • AxiomEvent header shrinks from 145 bytes of identifiers to 81 bytes (44% reduction).
  • .oxbin files produced under the old format are not readable under the new format. We have no externally-deployed .oxbin files; this is acceptable.

Code changes

  • uuid crate removed from workspace dependencies.
  • blake3 crate added (replaces sha2).
  • New oxc-ids crate (or oxc-protocol::ids module) carries the 10 types + define_id! macro.
  • oxc-instantiate::identity becomes the Iri ↔ NameRef ↔ symbol-table builder.
  • oxc-runtime::Module::load builds the NameRef ↔ InternalId dictionary.
  • oxc-reasoning::compile::Value becomes Value::Internal(InternalId) plus inline variants.
  • oxc-storage-mem indexes re-keyed on (TenantId, ForkId, kind) instead of (Uuid, Uuid, &'static str).

Performance

Expected wins:

  • Reasoner memory: ~30-50% reduction in Z-set tuple key storage (17 → 9 bytes per ID).
  • Event headers: 44% smaller (145 → 81 bytes).
  • Storage indexes: ~50% smaller (UUID → u32/u64 partition keys).
  • Hash operations: BLAKE3 ~3× faster than SHA-256.

Costs:

  • Module::load builds a dictionary (one pass over declared symbols; negligible).
  • Dropping UUID crate removes a well-tested dependency; replaced with custom types that need testing.

Determinism preserved

Every wire-format identifier is content-derived (NameRef from sort position; AxiomKey from BLAKE3 of body; ContentId from BLAKE3 of body; EventId from build-deterministic counter in build mode). Same source → byte-identical .oxbin. Property preserved.

Phased rollout

The full design can be landed in four independently-shippable phases:

Phase 1 — runtime InternalId, no wire change (~3 days). oxc-reasoning::compile::Value::Internal(InternalId) replaces Value::Id(Uuid). Module::load builds a Uuid ↔ InternalId dictionary. Wire format unchanged. Win: ~30% reduction in reasoner Z-set memory.

Phase 2 — wire format break (~1 week). Add oxc-protocol::ids with all 10 types. Replace every _id: uuid::Uuid in body types. Drop uuid crate, add blake3. Bump oxbin_format_version major. Win: 44% reduction in event header size; type-safe identifiers throughout the wire.

Phase 3 — full Iri interner + symbol-table lift (~3 days). Iri(Arc<str>) with per-workspace arena. The .oxbin symbol-table section becomes the load-bearing dictionary it was designed to be. Win: cleanup of qualified_path: String duplication.

Phase 4 — dense engine structures (~1 week). Fibonacci-hashed U32Set, KindBitmap (Vec<u64> indexed by InternalId), RoleEdges-style packed adjacency. Per-lattice sentinel discipline. Win: foundation for future reasoner backends (SLG, DBSP, Kripke) sharing dense set primitives.

Open questions

  • Cross-workspace federation: when two workspaces’ .oxbin artifacts need to interoperate, what’s the bridge? Open. Likely: an explicit GlobalRef type at the federation boundary only, derived from (workspace_uuid, NameRef) or from full Iri. Out of scope for this RFD.

  • Distributed minting of EventId: the shard: 12 field is reserved but currently always 0. A future RFD addresses the distributed-minting protocol (Stateless Snowflake from container IP, range pre-allocation, or CRDT-style — see “Stateless Snowflake” Chinthareddy 2025).

  • External identifier indexing: when IndividualId is system-allocated and the caller’s identifier is data (a hasExternalId property), querying by external identifier requires a property-indexed lookup. The storage layer’s per-relation indexes (book §20.3.1) cover this, but specific query ergonomics for “find by external id” want a small SDK helper.

  • AxiomKey for non-data axioms: mutation_decl, query_decl, compute_decl are declarative axioms (have stable NameRef-based identity). Should their AxiomKey be BLAKE3(NameRef) or a special discriminator? Likely the former for uniformity; settle when wire format is finalized.

RFD 0002 — #[comptime] attribute

  • State: discussion
  • Opened: 2026-05-27
  • Decides: surface syntax and semantics of compile-time evaluation in Argon. Affects oxc-parser (attribute recognition), oxc-instantiate (compile-time lifting), oxc-reasoning (the engine, run at build time on a subset of rules), oxc-oxbin (materialized fact storage), and diagnostic codes OE1307/OE1308.

Question

Argon’s reasoner can evaluate rules at runtime (the keystone path) or at compile time (when all inputs are statically known). The compile-time path is strictly faster: facts get materialized into the .oxbin at build time and shipped as data, eliminating runtime evaluation for that rule entirely. When and how should the modeler opt in to compile-time evaluation? What does it mean per rule mode? What’s the auto-vs-explicit story?

Context

Why compile-time evaluation matters

Argon’s central thesis is the reasoner IS the data system. The same engine that derives adult(p) at query time can derive adult(p) at build time — if its inputs (Person(p), hasAge(p, n)) are statically known. Materializing the derivation at build time gives:

  1. Runtime queries see literal facts, not derivations. Faster, simpler query path.
  2. The .oxbin ships the closed-world extent. Downstream consumers (other compilation units, runtime, federation peers) don’t re-derive.
  3. Compile-time errors for impossible derivations. A check rule that fails at build time is a build error, not a runtime exception.
  4. A natural staging surface. Modelers can mark which derivations are “decided at build” vs “decided at runtime” — explicit control over the deployment-time computational tier.

What “compile-time” means here

The reasoner has two distinct invocation points:

  • Runtime modeEngine instantiated inside oxc-runtime::Store; rules evaluate against live event state; result is queried via Store::query_derive.
  • Compile-time mode — same Engine, instantiated inside oxc-instantiate during .oxbin lowering; rules evaluate against the source’s declarative facts (iof_assertion events from insert operations in mutations declared inside the workspace); result is emitted into the .oxbin’s events section as fresh axiom events (with derivation provenance pointing to the comptime rule).

The “engine is the engine” property holds: same code, same semantics, different invocation context.

What’s a “static input”?

A predicate is statically known at build time if its extent is fully determined by:

  • Axioms declared in source code (pub kind, pub rel, pub mutate invocations baked into module initialization).
  • Outputs of other comptime-eligible rules (the comptime fixpoint propagates).

A predicate is runtime-bound if any contributing fact comes from:

  • Caller-invoked mutations (user runs register(...) against a running Store).
  • External federation peers.
  • Network/IO sources.

Today’s MVP only has declarative axioms — everything is statically known. But the distinction is structural, not implementation-momentary.

Decision

Attribute surface

#[comptime]
pub derive adult(p) :- Person(p), hasAge(p, n), n >= 18;

The #[comptime] attribute applies to a single declaration. Binary form in v1 — no arguments. Argument form is parser-reserved for v2 (see Open Questions).

Per-mode semantics

Mode#[comptime] semantics
deriveMaterialize at build time. The derived extent is computed by the engine during .oxbin lowering and emitted as fresh iof_assertion / relation_tuple axiom events (with derivation field pointing to the rule’s AxiomKey). Runtime queries against the rule’s predicate return the materialized facts directly; no runtime evaluation.
queryEmbed the query’s result as a literal value in the .oxbin. The query body is evaluated at build time; the result (Vec<Tuple>) is stored. Callers receive the literal. Re-running the query at runtime is a no-op — the answer is baked.
checkGate the build on the check passing. The check rule is evaluated at build time; if it produces any tuple satisfying its body, the build fails with the check’s diagnostic. (This is the strictest mode — comptime checks are essentially compile-time assertions.)
fnConst-fn-like. The function is evaluated at build time when called from a context where all its arguments are themselves comptime-known. Otherwise it falls back to runtime evaluation. Same semantics as Rust’s const fn.
mutateForbidden. Mutations are runtime operations by definition — they extend the event log at invocation time. #[comptime] on a mutation emits OE1308 ComptimeForbiddenOnMutate at build time.

Auto-lifting

The compiler automatically lifts any rule (regardless of #[comptime]) whose body predicates are all statically known and whose result fits within the declared comptime budget. The lift is transparent — produces the same axiom events as if the rule had been declared #[comptime].

#[comptime] is therefore a promise (“this rule MUST be evaluable at build time”), not just an enable. If the modeler declares #[comptime] on a rule whose body references a runtime-only predicate, the build fails with OE1307 ComptimeNotStatic.

This makes #[comptime] checkable: it’s a verification that a property holds, not just a performance hint.

Diagnostic codes

  • OE1307 ComptimeNotStatic — rule declared #[comptime] depends on runtime state (a mutation-bound predicate, a federation-bound predicate, or an indirect dependency through another runtime-only rule).
  • OE1308 ComptimeForbiddenOnMutate#[comptime] applied to a mutate declaration.
  • OE0227 AttributeArgsNotYetImplemented (existing) — #[comptime(...)] with any arguments emits this; arg-form is parser-reserved for v2.

Materialization output

A #[comptime] derive p(x) :- A(x), B(x) rule produces, at build time:

  1. The derivation: every tuple in p’s extent emitted as an iof_assertion or relation_tuple axiom event.
  2. The provenance: each emitted event carries derivation set to the rule’s AxiomKey (per RFD 0001) so retraction propagates correctly.
  3. A comptime_lifted: bool field in RuleDeclBody indicating “this rule’s facts are pre-materialized; runtime evaluators may skip it.” (Adds 1 byte to RuleDeclBody.)

Reasoning about freshness

If the source code changes such that a comptime-lifted rule’s inputs change, the .oxbin is rebuilt and the materialized facts are recomputed. The Salsa-style incremental compilation layer (book §18.2) treats comptime_lifted rules as memoization targets — their inputs are tracked, and only changed-input rules re-materialize.

Rationale

Why per-mode semantics, not uniform

The five rule modes (fn / derive / query / mutate / check) have genuinely different meanings, and “compile-time” means something different for each:

  • derive produces facts → comptime means baking those facts into the artifact.
  • query produces a result value → comptime means embedding that value.
  • check produces a verdict → comptime means asserting at build time.
  • fn is a function call → comptime means const-evaluation in the Rust/C++ tradition.
  • mutate mutates state → comptime makes no sense; mutations are by definition runtime.

A single uniform “evaluate at build time” semantics would erase these distinctions. The mode-aware semantics match the modeler’s mental model.

Why auto-lift + explicit attribute (not just one)

Auto-lift alone risks silent performance cliffs: the modeler doesn’t know whether a rule is being materialized or runtime-evaluated. Adding a runtime-only predicate elsewhere could silently degrade performance.

Explicit-only would force the modeler to annotate every rule, even the obvious cases.

Auto-lift plus #[comptime] as a checked promise gives both: most rules auto-lift transparently; the modeler can opt-in to “this MUST be comptime” for ones where the property matters. Same pattern as Rust’s const fn — the compiler can evaluate any expression at compile time when possible, but const fn is the promise that this function is callable in const context.

Why argument-form is reserved, not implemented in v1

Four argument variants we expect to want:

  • #[comptime(strict)] — fail build if auto-lift can’t reduce all inputs (stronger than the default OE1307 — even runtime-derivable-but-not-yet-evaluated counts as failure).
  • #[comptime(profile)] — emit a build-time report of cost/cardinality for this rule.
  • #[comptime(embed)] — for query mode: embed the result inline in source as a literal, not just in the .oxbin (useful for let X: extent = query foo inside a fn).
  • #[comptime(lazy)] — comptime-eligible but defer materialization until first runtime access (useful for very-large extents).

None of these are necessary in v1. We commit the parser-level syntax (#[comptime(...)] parses without error) but emit OE0227 on any non-empty arg list. This lets us add the arg-form in v2 without changing parser surface.

Why OE1307 distinguishes “comptime-eligible but not yet evaluated” from “comptime-impossible”

A comptime rule depending on another rule that’s itself comptime-eligible but not yet processed should propagate up the comptime fixpoint, not fail. The build’s job is to find the comptime fixpoint over the rule graph; only rules that hit a runtime-only predicate or a cycle (that can’t be broken by other comptime rules) fail with OE1307.

Why mutate is forbidden, not silently no-op

Mutations modify state. State at compile time is the static fact base; “running” a mutation against it would change what’s baked into the artifact, which violates the closed-world property (the .oxbin’s facts are what the modeler wrote, plus what derivations they declared). Allowing #[comptime] mutate ... would either:

  • Silently no-op (confusing — modeler thinks something happened).
  • Modify the build’s fact base (dangerous — implicit data mutation hidden in code).

Better to forbid loudly via OE1308.

Alternatives considered

Alt 1: No comptime at all — everything is runtime

The status quo. All rules evaluate at runtime; the .oxbin carries only declared facts plus rule definitions.

Rejected: gives up the major performance win of build-time materialization. Loses the natural “compile-time assertion” surface for check rules. Forces every cross-cutting query to pay runtime evaluation cost.

Alt 2: Implicit only — no attribute, just auto-lift

Every rule the compiler can prove statically-decidable is auto-materialized. No modeler annotation.

Rejected: silent performance cliffs as discussed. Loses the “this MUST hold” checkable property. Loses the natural place to thread future args (strict/profile/lazy).

Alt 3: Explicit only — every comptime rule must be annotated

No auto-lift; only #[comptime] rules are materialized at build.

Rejected: forces annotation noise on the obvious cases. Modelers will either over-annotate (defensive #[comptime] everywhere) or under-annotate (missing perf wins). Loses the simplicity of “the compiler does the right thing by default.”

Alt 4: #[const] instead of #[comptime]

Borrow Rust’s terminology for symmetry.

Rejected: “const” connotes value-level immutability in Rust, which is a different concept from “evaluated at build time.” Argon’s facts are already immutable once asserted; the relevant axis is when they’re asserted (build vs runtime), not whether they’re mutable. comptime is clearer.

Alt 5: Zig-style comptime (full compile-time computation)

Zig’s comptime does general compile-time evaluation including type-level computation. Argon could expose the same generality.

Rejected as the v1 design. Zig’s comptime is a much larger surface — it includes generic instantiation, type erasure decisions, and runtime/compile-time polymorphism. Argon’s #[comptime] is scoped to the substrate’s reasoning capability: which rules get materialized. Generalizing to Zig-shape comptime is interesting future work, but it’s a different feature.

Consequences

Wire format

RuleDeclBody gains a comptime_lifted: bool field. 1 byte added per rule decl. Negligible.

Build performance

Comptime-eligible rules add to build time (the engine runs at build). Trade-off: build is slower; runtime is faster + simpler. For workloads where the .oxbin is built once and queried many times, this is strictly positive. For dev-loop scenarios (rebuild frequently), Salsa-style incremental compilation memoizes most of the work.

Diagnostic surface

Two new emission sites (OE1307, OE1308 — already reserved in grammar.toml from the earlier diagnostic landing). The parser keeps the existing OE0227 for non-empty arg form.

Code volume

Modest:

  • Parser change: ~10 lines to recognize #[comptime] attribute on declarations.
  • oxc-instantiate change: build-time engine invocation for comptime-eligible rules, comptime fixpoint analysis, OE1307 emission. ~200-300 LOC.
  • oxc-oxbin change: emit derivation events from comptime rules into the events section. ~50 LOC.
  • RuleDeclBody schema: comptime_lifted field. ~5 LOC.

Determinism preserved

Comptime evaluation produces the same axiom events from the same source — the engine is deterministic, and the inputs are entirely from source. Byte-identical .oxbin guaranteed (per RFD 0001).

Open questions

  • The argument form in v2#[comptime(strict|profile|embed|lazy)]. Specific semantics deferred to a follow-up RFD when we have benchmarking to validate the tradeoffs. The parser-level syntax stays reserved.

  • Comptime budget — should there be a soft limit on how much work a comptime rule can do (e.g., “evaluate up to N facts; if exceeded, emit a warning and fall back to runtime”)? Useful for guarding against accidentally exponential comptime work. Likely yes, with a default of 10M facts and a CLI/config override.

  • Comptime + standpoint federation — when standpoint A imports from standpoint B and B has comptime-lifted rules, A sees B’s materialized facts. But if A re-evaluates a derived predicate against its own facts plus B’s, does the comptime materialization compose correctly under the FDE info-join? Likely yes (it’s just facts), but worth a separate verification when standpoint federation is mechanized in the Lean.

  • Interaction with retraction — a comptime-materialized rule’s derived facts have a derivation lineage. If a contributing axiom is retracted at runtime (via delete operation), the derived facts must also be retracted. The retraction propagation needs to traverse the derivation graph. Architectural: yes; implementation: needs the Phase-4-style dense provenance index from RFD 0001 to be efficient.

  • Comptime + temporal — for rules with explicit temporal qualifiers (at(t), during(...)), does comptime still make sense? The rule’s truth depends on t. Probably comptime should be allowed only when temporal qualifiers are themselves static (e.g., literal timestamps), and emit a diagnostic when they’re symbolic. Defer to the temporal-substrate work track.

RFD 0003 — Reasoner backend dispatch

  • State: discussion
  • Opened: 2026-05-27
  • Decides: how multiple reasoning backends (semi-naive, SLG, DBSP, SMT, Kripke, Kora-extension) compose under one Engine interface; the dispatch policy for per-stratum routing; the registration discipline; the modeler-facing diagnostic surface for unsupported-tier rules.

Question

Argon’s tier ladder admits seven distinct reasoning tiers (§10.1: structural, closure, expressive, recursive, fol, modal, metaorder). No single algorithm handles all seven — semi-naive Datalog handles stratified recursive; SLG tabling handles expressive; DBSP handles recursive under IVM; SMT handles fol; Kripke tableau handles modal; Kora-EL handles a slice of OWL profile semantics. How do these backends compose under one engine interface? What does the dispatcher do? When does the modeler see backend choices vs not?

Context

What’s already in place

Phase 1 of the reasoner is shipped (oxc-reasoning crate, ~530 LOC of evaluator + classifier + scaffolding). The keystone test passes: derive adult(p) :- Person(p), hasAge(p, n), n >= 18 derives the right facts end-to-end.

The current shape:

Engine
  ├── executors: Vec<Box<dyn TierExecutor>>   (registration-ordered)
  └── run_rules(...) — dispatches each rule to the first executor
                       whose supported_tiers() contains the rule's
                       classified tier.

TierExecutor trait
  ├── name(&self) -> &'static str
  ├── supported_tiers(&self) -> TierSet
  └── execute(stratum, input) -> ReasoningResult<RelationCatalog>

The single MVP implementation SemiNaiveExecutor advertises {Structural, Closure, Recursive}. The dispatcher is one-deep: pick the first executor that supports the tier. Per-rule tier comes from RuleDeclBody.main_tier, populated by oxc-instantiate::tier_classify::classify_body.

Where this falls short

The “first executor that supports the tier” policy works for the trivial case (one executor) but doesn’t say what happens when:

  1. Multiple executors claim the same tier (e.g., SemiNaive and DBSP both handle recursive). Which wins? Order-of-registration is the current answer; that’s an implicit policy with no surface.

  2. A rule’s body spans multiple tiers (e.g., a recursive rule that joins against an expressive qualified-cardinality predicate). Today the rule’s main_tier is the max of its atoms’ tiers — so the whole rule goes to whatever handles the max. That’s correct semantically but ignores stratification: the lower-tier sub-rule could run on the cheaper engine, results then fed into the higher-tier engine.

  3. Cross-backend data exchange — every backend produces RelationCatalog (Z-set BTreeMaps). Good for composition; the next stratum reads from the prior stratum’s output. But what if a backend uses internally-different data (DBSP’s arrangements vs SLG’s tabled tables)? The exchange happens at stratum boundaries via RelationCatalog; backend internals stay private.

  4. The modeler’s diagnostic surface — OE1305 (TierNotYetImplemented) is reserved but emission sites aren’t wired. When does the modeler see it? At build time (the rule’s tier exceeds any registered backend) or at query time (the rule’s classified tier is unsupportable)?

  5. Optional backends (Kora-extension)std::owl is a stdlib package that brings in the Kora-EL / Kora-DL backend. How does that get registered? Is it an explicit Engine::with_executor call, or implicit on import std::owl?

Decision

The dispatch model: per-stratum, registration-ordered, with explicit OE1305 emission

  1. Strata are the unit of dispatch. The physical plan (PhysicalPlan) carries a Vec<Stratum>. Each stratum has a tier (the max of its rules’ tiers). The dispatcher picks one executor per stratum and runs it.

  2. Registration order = preference order. Executors are pushed onto Engine::executors in registration order. The dispatcher picks the first executor whose supported_tiers() contains the stratum’s tier. This makes the policy explicit and modeler-controllable via build configuration.

  3. OE1305 emits at build time, not query time. When oxc-instantiate classifies a rule and the workspace’s configured backends don’t claim its tier, the build fails with OE1305. The modeler sees the error at compile time, before any runtime invocation.

  4. Backend registration is workspace configuration. Backends are declared in ox.toml (or the equivalent), not in source code. Default workspaces register SemiNaiveExecutor only. Adding SLG, SMT, etc. is an explicit opt-in via:

    [reasoning]
    executors = ["semi-naive", "slg-tabled"]
    

    Kora-extension is enabled by import std::owl in source, which adds kora-el (and kora-dl) to the executor list automatically.

  5. Cross-backend exchange is the shared RelationCatalog. All backends produce and consume RelationCatalog (Z-set BTreeMap). Internal representations (DBSP arrangements, SLG tabled solutions) are private. Composition happens at stratum boundaries: stratum N’s output catalog is stratum N+1’s input catalog.

The seven backends, mapped

BackendTiers coveredAlgorithmStatusEstimated effort
SemiNaiveExecutorstructural, closure, recursiveNaive/semi-naive bottom-up Datalog over Z-sets; stratified NAFPhase 1 shippeddone
DBSPExecutorrecursive (preferred over SemiNaive when IVM matters)True IVM via differential dataflowPost-Phase-4 of RFD 0001~1 month
SLGExecutorexpressiveTop-down tabled WFS (chalk-engine-shaped, NOT XSB port)Post-MVP~6-10 person-months
SMTExecutorfolExternal SMT solver (Z3 or cvc5) via unsafe logic { } blocks onlyPost-MVP~2 months
KripkeExecutormodalTableau over Kripke framesPost-MVP~3 months
KoraExtensionExecutorDL profiles when std::owl importedKora-EL (vendored)Post-MVP “compiler extension”~2 weeks integration
(Reserved) MetaorderExecutormetaorderBounded order-arithmetic procedure; semi-decidable beyond boundsSpeculativeunknown

Composition rules:

  • DBSP wins over SemiNaive when both are registered: register DBSP first so the dispatcher picks it. DBSP is a strictly more powerful evaluator (handles IVM, deltas, retractions natively); SemiNaive becomes a fallback for environments where DBSP can’t be deployed.
  • Kora-extension only handles its declared tier slice. It doesn’t claim recursive even though some EL rules look Datalog-shaped — Kora’s optimizations are OWL-specific and don’t generalize. The dispatcher uses Kora only for rules tagged as DL-shaped (a future RuleDeclBody field).

What the modeler sees

The modeler does not see backend names in source code. Rules are tier-classified by the compiler; backend selection is a deployment concern.

The modeler does see OE1305 when a rule’s tier exceeds the workspace’s configured backends. The diagnostic suggests which backend would handle it:

error[OE1305]: rule `complex_query` classified at tier `expressive` —
              no registered executor handles this tier
  --> demo.ar:42:1
  |
  | pub derive complex_query(p) :- ...
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: enable the `slg-tabled` executor in ox.toml:
        [reasoning]
        executors = ["semi-naive", "slg-tabled"]

The TierExecutor trait — refined

#![allow(unused)]
fn main() {
pub trait TierExecutor: Send + Sync {
    fn name(&self) -> &'static str;
    fn supported_tiers(&self) -> TierSet;

    /// Execute one stratum. Reads from `input` (current state +
    /// prior strata's outputs); returns the stratum's catalog delta.
    fn execute(
        &self,
        stratum: &Stratum,
        input: &RelationCatalog,
    ) -> ReasoningResult<RelationCatalog>;

    /// Optional: backend-specific cost estimate for a stratum.
    /// Returned values are comparable across backends (smaller = preferred).
    /// Default impl returns `f64::INFINITY` (never prefer).
    fn estimate_cost(&self, _stratum: &Stratum, _input: &RelationCatalog) -> f64 {
        f64::INFINITY
    }
}
}

The estimate_cost method is the hook for cost-based dispatch (v2). v1 uses pure registration-order dispatch; v2 may evolve to “pick the registered executor whose cost estimate is lowest.” Deferred to a follow-up RFD.

Stratum boundary policy

The optimizer is responsible for stratification: walk the rule dependency graph, partition into SCCs, topologically sort the SCCs into strata. Each stratum is single-tier (max of its rules). NAF is stratified across stratum boundaries (sound).

Inter-stratum NAF (a rule in stratum N+1 uses not p(...) where p is populated in stratum N) is the standard stratified-WFS case. Intra-stratum NAF is a cycle and either:

  • Handled by the backend if the backend supports WFS (SLG does; SemiNaive doesn’t beyond stratification).
  • Rejected with OE1309 (StratificationNafCycle) at build time.

Rationale

Why per-stratum, not per-rule

A real-world workload has many rules at different tiers. Dispatching per-rule means launching the backend’s per-call overhead for each. Per-stratum amortizes: the backend sees all its rules, can build its own internal arrangements once, run them all to fixpoint within its scope.

This matches DataFusion’s pattern (which inspired the architecture): each ExecutionPlan node runs to completion in one backend context; data flows between nodes via the shared columnar format. Argon’s analog: each stratum runs in one backend context; catalogs flow between strata via the shared Z-set format.

Why registration order, not declared priority

A priority: i32 field on TierExecutor would let backends declare their own preference order. Rejected because:

  • It distributes the dispatch logic — each backend needs to know its place in the ordering vs other backends.
  • It introduces a coordination problem when two backends claim the same priority.
  • Registration order achieves the same expressivity (push backends in the desired order) without per-backend coordination.

Workspace configuration (ox.toml’s [reasoning].executors = [...]) makes the order explicit and modeler-visible. That’s the right surface for the policy decision.

Why OE1305 at build time, not query time

The alternative is lazy: register backends; at query time, if no backend handles the tier, fail. Rejected because:

  • A modeler shouldn’t ship an .oxbin that they don’t know is unrunnable. Build-time emission catches it early.
  • The set of registered backends is a deployment property; the modeler knows it at build configuration time.
  • “Build it, ship it, then it fails when the user runs it” is a bad UX for a system whose virtue is “deterministic builds give you runtime guarantees.”

Why workspace-config, not in-source backend selection

Source code shouldn’t say “use SLG for this rule.” Two reasons:

  • The modeler should describe what they want derived, not how to derive it. That’s the entire point of declarative semantics.
  • Backend choice is deployment policy. A test workspace uses SemiNaive; a production workspace uses DBSP + SLG. Same source code, different deployment.

The exception: std::owl is a stdlib package that brings in OWL semantics. Importing it implicitly enables Kora-extension because the modeler is asking for OWL-shape reasoning. This isn’t “backend selection”; it’s “load this semantics extension.”

Why DBSP wins over SemiNaive when both registered

DBSP handles every workload SemiNaive handles, plus retractions and IVM. It’s strictly more powerful. Once DBSP is mature (Phase-4-of-RFD-0001 or later), the natural default is “use DBSP if available, fall back to SemiNaive otherwise.” Registration order encodes this: push DBSP first.

SemiNaive stays in the stack because:

  • It has no external dependencies (DBSP needs the timely dataflow runtime).
  • It’s simpler to reason about correctness in.
  • It’s the reference implementation that other backends can be tested against.

Why Kora is “extension” not “default”

Argon’s substrate is OWL-neutral. Baking OWL-specific optimizations into the default executor stack would couple Argon to OWL semantics — which RFD 0001 and the broader architecture explicitly avoid.

Kora becomes available when a modeler imports std::owl. That import is a deliberate statement: “I want OWL semantics for this part of my model.” The Kora backend then handles those rules; non-OWL rules go through the default backends.

Alternatives considered

Alt 1: Single mega-engine

One backend handles all tiers via dispatched algorithms internally. Examples in prior art: SWRL-DL engines that switch between Datalog and tableau internally.

Rejected: kills modularity. Adding a new backend (SLG, DBSP) requires modifying the mega-engine. Cross-engine testing becomes impossible.

Alt 2: Runtime backend resolution

Backends register themselves via plugin discovery at runtime; the engine probes for capability.

Rejected: introduces dynamic-loading complexity Rust doesn’t natively support and Argon doesn’t need. All known backends are statically-known at build time.

Alt 3: Backend per rule mode

derive rules go to one backend, query rules to another, etc.

Rejected: backend choice is about tier (what features the rule uses), not mode (what kind of declaration it is). A derive rule and a query rule with the same body should run on the same backend.

Alt 4: First-class cost-based dispatch from v1

estimate_cost is mandatory; dispatcher picks the lowest-cost backend per stratum.

Rejected for v1: backends don’t yet have meaningful cost models. SemiNaive’s cost estimate is fact-count; DBSP’s would involve arrangement-sharing analysis. Without a calibrated cost surface, “lowest cost” is noise. Registration-order is honest about the v1 state. Cost-based dispatch becomes v2 once enough backends ship to make calibration meaningful.

Consequences

Code structure

The Engine and TierExecutor trait already exist (oxc-reasoning/src/executor/mod.rs). This RFD codifies the existing API; minor refinements needed:

  • Add estimate_cost default method (deferred-use API).
  • Add workspace configuration support (ox.toml parsing for [reasoning]).
  • Wire OE1305 emission from oxc-instantiate::tier_classify when the configured backends don’t cover the classified tier.

Diagnostic surface

OE1305 emission gains a structured “suggest enabling X backend” hint. Requires the diagnostic system to know which backends could handle which tiers — a static table in oxc-diagnostics or oxc-reasoning::backend_registry.

Each future backend’s commit

Each new backend lands as:

  1. A new module in oxc-reasoning::executor::{name}.rs implementing TierExecutor.
  2. Registration plumbing in workspace config.
  3. Backend-specific tests showing the keystone-equivalent for its tier (e.g., SLG’s keystone is a recursive-aggregate rule like count over a stratified-NAF derived predicate).
  4. An interop test showing the backend composes cleanly with SemiNaive at stratum boundaries.

Kora vendoring decision

Per the earlier session decision: when we bring in Kora, we vendor the code into oxc-reasoning::executor::kora and own the implementation. We don’t depend on the upstream kora-* crates. This isolates Argon from Kora’s design choices and lets us evolve the embedded reasoner independently.

The vendoring is an explicit one-time copy; subsequent changes happen in Argon’s tree. If upstream Kora improves substantially, we re-vendor with a documented migration. This matches the pattern other projects use for embedded engines (e.g., rust-analyzer’s vendored libraries).

Open questions

  • Stratum boundary policy with retractions: when a fact is retracted in stratum N, the retraction must propagate to stratum N+1’s derived facts. The current RelationCatalog exchange is a snapshot, not a delta stream. For DBSP this is solved natively (it IS the delta stream); for SemiNaive we recompute. Architecturally clean; performance-cost matters for large stores. Defer to the DBSP integration RFD.

  • Workspace config schema: ox.toml’s [reasoning] section needs a concrete schema. Reserve the section now; nail down the schema when the second backend (DBSP or SLG, whichever lands first) makes it necessary.

  • Per-rule backend override: should the modeler ever be able to say “use SemiNaive for this rule even though DBSP is registered”? Possible use cases: debugging (compare backends on the same rule), workload-specific tuning. Defer; revisit when a real use case emerges.

  • Backend feature negotiation: a backend may support a tier but not a specific feature (e.g., SemiNaive supports recursive but not recursive with aggregates). How does the dispatcher know? Either (a) backends advertise feature support more granularly than tier, or (b) the classifier bumps tiers when specific features are used (e.g., aggregates always classify as expressive). The current classifier does (b); the trade-off is granularity vs simplicity. Probably keep (b) for v1.

  • Dynamic backend selection by data characteristics: e.g., “use DBSP if the input cardinality is over 10M; use SemiNaive otherwise.” Cost-based dispatch (v2) addresses this. Out of scope for v1.

  • Modal + temporal interaction: when temporal qualifiers nest inside modal operators (or vice versa), the relevant tier is modal per §10.1. Which backend handles? KripkeExecutor in the design; verify when the temporal-substrate work track lands the temporal evaluator.

RFD 0004 — pub fact declarations

  • State: discussion
  • Opened: 2026-05-28
  • Decides: surface syntax for declaring ground-truth facts (axioms) in source code; per-mode semantics relative to pub mutate; the relationship between source-declared facts and runtime-asserted facts in storage; how compile-time materialization (#[comptime] per RFD 0002) feeds on these facts.

Question

Today, ground-truth facts in an Argon system can only arrive at runtime via mutation invocation. The keystone test demonstrates this: there’s no way to write Person(alice) in source code; the only path is to call register(alice). Should Argon admit a source-level fact declaration syntax? If so, what’s the right surface, what’s the relationship to mutations and storage, and how does compile-time materialization fit?

Context

What the gap looks like today

pub kind Person; declares a concept. pub rel hasAge(p: Person, n: Nat); declares a relation. pub derive adult(p) :- Person(p), hasAge(p, n), n >= 18; declares a rule. Nothing in the current surface lets a modeler write Person(alice) or hasAge(alice, 25) as a fact. The only way to get those tuples into the store is to invoke a mutation at runtime.

This creates several problems:

  1. #[comptime] is starved. RFD 0002 specifies compile-time materialization for derive rules. But the build-time engine has no facts to materialize against — every fact requires a runtime mutation call. The #[comptime] flag stamps on the rule body but the lifter has nothing to evaluate.

  2. Test fixtures are awkward. The keystone test had to inject hasAge(p, 25) via a test-only helper because the only fact path was through mutation execution (which until this session didn’t bind integer args). Even after Value-typed mutation args landed, declarative tests want to express “given these facts” without runtime invocation gymnastics.

  3. Bootstrap data is awkward. Real ontologies have built-in facts: standard units, well-known individuals, vocabulary mappings (e.g., the OWL stdlib’s rdf:type predicate; the temporal stdlib’s Second, Minute instances). Today these would need runtime mutation calls at startup. Source-level facts let them ship as part of the .oxbin.

  4. The substrate’s “five atoms” model is incomplete without it. The five atoms (meta-calculus, construct, rule, trait, macro) describe declarations. But declarations alone don’t carry data — they describe shape. Facts ARE data; they should have a declarative surface.

What “fact” means semantically

A fact in Argon is an axiom event of kind iof_assertion, relation_tuple, meta_property, subsumption_axiom, or partition_axiom. Each is a positive ground proposition. Modelers writing source today can declare structure (concepts, relations) and behavior (mutations, derive rules) but cannot declare contents (ground propositions).

The minimal addition: surface syntax that emits these axiom events at build time. The .oxbin carries them just like mutation-emitted events. The reasoner sees them as live state. Compile-time materialization can finally run with input.

Decision

Surface syntax

pub fact Person(alice);
pub fact Person(bob);
pub fact hasAge(alice, 25);
pub fact hasAge(bob, 12);

The pub fact keyword sequence introduces a single ground proposition. Its body is exactly the same syntactic shape as a mutation’s insert operations — a predicate name followed by a parenthesized argument list. Each argument is either an identifier (interpreted as an Individual reference) or a literal (Int, Bool, Text).

Multiple facts can share a single declaration via a brace-delimited block:

pub fact {
    Person(alice);
    Person(bob);
    hasAge(alice, 25);
    hasAge(bob, 12);
}

This is purely a syntactic convenience — semantically identical to N independent pub fact declarations.

Identity allocation for fact arguments

When a fact references an identifier (alice, bob), the elaborator allocates a fresh IndividualId for it (per RFD 0001’s “individuals are system-allocated” rule). The mapping identifier → IndividualId is interned per workspace build, so:

  • The same identifier mentioned in two pub fact declarations resolves to the same IndividualId.
  • Different builds of the same source produce the same IndividualId (deterministic).
  • Different identifiers (alice vs bob) get distinct IndividualIds.

The identifier itself becomes available for runtime lookup via the workspace’s symbol table — callers can pass Value::Individual(alice_id) to mutations or queries. (Today the runtime hashes the variable name for unbound parameters; this convention extends naturally.)

Per-axiom-kind semantics

Fact shapeResulting axiom event
pub fact Concept(individual)iof_assertion { concept_id = NameRef(Concept), individual_id = IndividualId(individual) }
pub fact relation(arg1, arg2, ...)relation_tuple { relation_id = NameRef(relation), args = [encode(arg1), encode(arg2), ...] }
pub fact axis(target) = valuemeta_property { axis_id = NameRef(axis), target_id = NameRef(target), value } (sugar for the meta-property family per §13)
pub fact A <: Bsubsumption_axiom { sub_id = NameRef(A), super_id = NameRef(B) } (an alternative spelling of the <: in concept declarations)

The fourth row’s surface (pub fact A <: B) is shorthand for declaring subsumption outside a concept declaration’s <: clause — useful for late-binding or library-extension idioms. Initial implementation focuses on the first two rows; meta-property and subsumption shapes land when the broader §13 / §11 substrate matures.

Relationship to mutations

A pub fact declaration is equivalent to a mutation that runs at module load with no parameters. The compile-time-equivalent expansion:

// User writes:
pub fact Person(alice);
pub fact hasAge(alice, 25);

// Semantically equivalent to (but the compiler does NOT actually
// emit this; it emits the axiom events directly):
pub mutate __bootstrap_module() {
    insert iof(alice, Person);
    insert hasAge(alice, 25);
}
// + invocation of __bootstrap_module at module-load time

The advantages of NOT going through a synthetic mutation:

  1. Directly content-addressed. The fact’s AxiomKey is BLAKE3-128(canonical_body), deterministic per RFD 0001. Mutations get fresh EventIds at invocation; facts get content-stable identity.
  2. No runtime invocation needed. The .oxbin carries the events directly; the runtime sees them in Store::seed_from(&Module).
  3. Compile-time materialization works. RFD 0002’s #[comptime] lifter finally has inputs.

Interaction with #[comptime]

After pub fact lands, the comptime lifter has a non-trivial path:

pub fact Person(alice);
pub fact Person(bob);
pub fact hasAge(alice, 25);
pub fact hasAge(bob, 12);

#[comptime]
pub derive adult(p) :- Person(p), hasAge(p, n), n >= 18;

At build time, the lifter:

  1. Collects facts from pub fact declarations → catalog seed.
  2. Runs the comptime rule against the seed.
  3. Emits the derived tuples (adult(alice) in this case) as fresh iof_assertion / relation_tuple events with derivation provenance.
  4. The runtime sees the derived facts directly; no re-evaluation needed.

Mutability + lifecycle

pub fact declares an immutable ground fact. The fact exists from module-load onwards. Retraction must go through:

  • A mutation: pub mutate forget_alice() { delete iof(alice, Person); }.
  • A retraction event at runtime (when the storage layer supports it).

The fact’s AxiomKey is content-addressed, so even after retraction the event log records both the assertion and the retraction. Replaying the log reproduces the lineage.

Diagnostic surface

  • OE0210 FactReferencesUnknownConceptpub fact Person(alice) where Person is not a declared concept.
  • OE0211 FactArgArityMismatchpub fact hasAge(alice) for a binary relation.
  • OE0212 FactArgTypeMismatchpub fact hasAge(alice, "twenty-five") where the relation expects Nat.
  • OE0213 FactInsideFnpub fact is a module-level declaration; not admitted inside function bodies, traits, etc.

Rationale

Why a new keyword fact, not overloaded syntax

Alternatives we ruled out:

  • pub Person(alice); — overloads the visibility keyword. Reads ambiguously next to pub kind Person.
  • pub iof alice : Person; — too low-level; exposes the wire-format axiom kind to the surface.
  • #[fact] Person(alice); — attribute-driven, but attributes describe modifiers, not the kind of declaration. Confusing.

pub fact reads cleanly, parallels pub kind / pub rel, and is unambiguous about meaning.

Why allow block form

The block form (pub fact { ... }) is shorthand for repeated declarations. Real modules have lots of bootstrap facts (a stdlib of units, well-known individuals); writing 50 pub fact declarations vs. one block of 50 is just keyboard noise.

The block form’s semantic is identical to N independent declarations. No grouping invariants implied.

Why content-addressed AxiomKey, not synthetic mutation EventId

Two reasons:

  • Determinism: same source → same .oxbin bytes. The fact’s identity comes from its content, not from a mutation’s invocation order.
  • Lineage clarity: a pub fact declaration is a statement about reality, not an operation that “happens.” Treating it as an event with content-derived identity matches the modeler’s mental model.

Why immutable

pub fact describes ground truth as the modeler authored it. Allowing inline mutation (pub fact Person(alice); pub fact !Person(alice); — withdraw the prior?) would conflate declaration with operation. Cleaner: declarations are immutable; runtime mutations are the path for state change.

The .oxbin-resident facts can still be retracted by a mutation at runtime; the assertion + retraction live in the event log together.

Why not full datalog-style “extensional database” syntax

Some Datalog systems allow large facts via a separate file format (e.g., .csv of relation tuples, loaded by relation name). Argon could borrow this.

Rejected for the v1 surface: introduces a second source format with different rules. The same module would split between .ar (rules) and .csv (facts) — a coordination burden. pub fact keeps everything in one file format.

For very large fact sets (e.g., million-row knowledge bases), a tooling layer can pre-process external data into pub fact declarations. The language surface stays uniform.

Alternatives considered

Alt 1: No pub fact; require synthetic mutations

The modeler writes pub mutate bootstrap() { insert iof(alice, Person); } and a runtime layer calls it on module load.

Rejected: confuses operations (mutations) with declarations (facts). Every fact-heavy module would have boilerplate. The #[comptime] lifter would still need to recognize “this mutation is actually fact bootstrapping” — same problem at one layer of indirection.

Alt 2: data keyword (a la SQL)

pub data hasAge { alice = 25; bob = 12; }

Rejected: “data” overemphasizes table-shaped thinking. Argon facts can be heterogeneous (subsumption axioms, meta-properties); data reads like “tabular data only.”

Alt 3: Module-level expression syntax

pub Person(alice); at module scope (no leading keyword).

Rejected: parser ambiguity with function calls. pub would have to mean two different things depending on what follows; messy.

Alt 4: Inline in concept declarations

pub kind Person {
    instances: alice, bob, carol;
}

Rejected: couples instance enumeration to concept declaration. The whole point of separating concept from instance is that instances can be added without modifying the concept. Argon’s metatype model assumes this separation.

Consequences

Wire format

No new axiom kinds — pub fact emits existing iof_assertion, relation_tuple, etc. events. The wire format is unchanged.

Parser changes

New keyword FACT_KW. New FactDecl AST node parallel to ConceptDecl / RelDecl. ~50 LOC in grammar.toml + argon.ungrammar + oxc-parser/src/grammar.rs.

Elaboration changes

oxc-instantiate gains a lower_fact_decl similar to lower_concept_decl. ~100-150 LOC. The fact’s args resolve through the same SymbolTableBuilder as other declarations.

Identity allocation for fact-mentioned individuals

The SymbolTableBuilder gains an intern_individual(name) -> IndividualId method. The first mention of alice allocates a fresh IndividualId; subsequent mentions return the same one. Per-build deterministic.

Comptime lifter

Per RFD 0002, the comptime lifter materializes derive rules into fresh axiom events at build time. After pub fact lands, the lifter has facts to evaluate against. The lifter implementation can finally land.

Test ergonomics

Tests like the keystone become much cleaner:

#![allow(unused)]
fn main() {
let source = r#"
mod demo;
pub kind Person;
pub rel hasAge(p: Person, n: Nat);

pub fact Person(alice);
pub fact hasAge(alice, 25);

pub derive adult(p) :- Person(p), hasAge(p, n), n >= 18;
"#;
// ...load, query, assert. No mutation calls. No test helpers.
}

Open questions

  • How are pub fact declarations sorted in the events section? Per the canonical-event-key sort, events sort by (tenant, fork, standpoint, kind, vt_start, tx_from). Facts get tx_from = 0 (declared at the workspace’s origin time)? Or do they get a deterministic tx_from derived from source position? The latter preserves source order; defer to implementation.

  • Can pub fact reference future-declared symbols? pub fact Person(alice); followed by pub kind Person; — does this work? Symmetric to how derive can reference forward-declared concepts. Default: yes; the elaborator does a single AST walk and resolves all references. Concrete error case: pub fact UnknownConcept(alice); → OE0210.

  • pub fact inside mod blockspub mod sub { pub fact ... } admits per-submodule fact scoping. Symbol resolution follows module visibility rules (per §3.1). No special handling beyond what’s already established.

  • Bulk-load tooling — for million-row fact sets, a CLI/tooling story is needed. Out of scope for the language surface; addressable post-MVP.

  • Interaction with standpoint scoping — when standpoints are surfaced, a fact in standpoint X is only visible in X (and its supertypes per FDE info-join). Defer to the standpoint substrate work track.

  • Negative facts / classical negationpub not_fact Person(alice) to assert that alice is not a Person. Useful in OWA / classical contexts; relates to §12.2’s Truth4 bilattice. Defer; orthogonal to this RFD’s positive-only scope.

RFD 0005 — Relation subsumption

  • State: committed
  • Opened: 2026-05-28
  • Decides: surface syntax for declaring that one relation subsumes another; the elaborator checks (endpoint covariance, cardinality refinement, metarel compatibility); the substrate impact; whether Argon adopts UML’s three-mechanism (subsetting / redefinition / specialization) story or collapses it into a single mechanism.

Question

Today, <: is a substrate-level subsumption operator admitted only on concept declarations (pub kind Adult <: Person). The substrate’s SubsumptionAxiomBody is generic over sub_id/super_id and would accept relation IDs without modification, but the surface grammar (§5.3 rel-decl) has no <: clause and the elaborator has no relation-side covariance machinery. Should Argon extend <: to relations? If so, what does the surface look like, what does the elaborator check, and how does this interact with UML’s three property-specialization mechanisms?

Context

Concrete modeler demand

The driving case (Luiz Almeida, 2026-05-28 design thread): a temporal-substrate model needs a sub-relation whose endpoints narrow and whose tuples flow into the parent.

pub type TimePoint <: TimeInterval;
pub type AbsoluteTimePoint <: TimePoint;

pub type TimeScale {
    timePoints: [TimePoint] from timeScaleHasTimePoint.range,
}

pub type IndefiniteTimeScale <: TimeScale {
    timePoints: [AbsoluteTimePoint]
        from indefiniteTimeScaleHasAbsoluteTimePoint.range,
}

pub rel timeScaleHasTimePoint(domain: TimeScale, range: TimePoint) [1] [1..*];
pub rel indefiniteTimeScaleHasAbsoluteTimePoint(
    domain: IndefiniteTimeScale,
    range:  AbsoluteTimePoint,
) [1] [1..*];

The intent: every tuple of indefiniteTimeScaleHasAbsoluteTimePoint is also a tuple of timeScaleHasTimePoint. Today there’s no way to express this; the modeler has to write a derive rule by hand, losing the structural property and the elaborator’s covariance check.

Substrate readiness

oxc-protocol::storage::SubsumptionAxiomBody:

#![allow(unused)]
fn main() {
pub struct SubsumptionAxiomBody {
    pub sub_id:   uuid::Uuid,    // generic — accepts concept or relation IDs
    pub super_id: uuid::Uuid,
}
}

The reasoner’s subsumption-closure logic (oxc-reasoning) parameterizes over symbol kind by construction. The substrate is ready; the surface and elaborator aren’t.

Parser asymmetry (the load-bearing gap)

oxc-parser/src/grammar.rs::concept_or_rel_decl already calls supertype_clause on the concept branch (lines 608–609), parsing <: via the existing LT_COLON / SPECIALIZES_KW machinery. The rel branch (lines 599–605) does not. The grammar’s SupertypeClause node is shared. Extending <: to relations reuses existing parser infrastructure; it does not introduce new syntax.

Argon’s existing position on shadowing and override

Three spec rules constrain the design space:

  1. §3.4 name resolution does not walk <: chains. Resolution order is local → imports → auto-prelude → primordials. Verified in oxc-resolver/src/resolve.rs:207–255resolve_path walks module prefixes only.

  2. §5.4 impl Type namespaces members as Type::name. Relations declared inside impl Person are reachable only as Person::ParentOf, not at module level. Verified in oxc-resolver/src/symbols.rs:57Item::ImplBlock(_) => return None. Impl-block contents don’t register top-level symbols.

  3. §5.2 explicitly forbids implicit override. OE0206 InstantiationFieldShadows rejects iof-axis shadowing; OE0208 AmbiguousFieldFromMultipleParents rejects diamond ambiguity. Verbatim: “The substrate offers no automatic merge, override-by-position, or last-wins behavior — every collision is resolved explicitly.”

These three together eliminate the namespace problem UML’s {redefines} exists to solve: in Argon, Manager::subordinate and Employee::subordinate are already distinct qualified paths from the start.

UML’s three mechanisms and how they map

UML mechanismWhat it expressesArgon’s resolution
{subsets}R1’s tuples ⊆ R’s tuples; both relations live<: on rel-decl (this RFD)
{redefines}Subclass shadows parent’s same-named propertyNot a problemimpl Type namespacing yields distinct paths a priori
Implicit specializationCovariant refinement of inherited propertySubstrate-level subsumption — handled by subsumption_axiom; no surface mechanism needed

UML needs three mechanisms because UML conflates extent-level subsumption with namespace-level shadowing AND assumes properties propagate through the class hierarchy by name. Argon decouples all three concerns. One surface mechanism (<: on rel-decl) covers all three UML cases.

Decision

Surface

Extend rel-decl to admit a <: (or specializes) clause after the cardinality list:

rel-decl ::= attribute* 'pub'? <metarel-name> Ident generic-params?
              rel-param-list cardinality-list?
              supertype-clause?           // NEW
              rel-body? ';'?
supertype-clause ::= '<:' TypeExpr (',' TypeExpr)*
                  |  'specializes' TypeExpr (',' TypeExpr)*

The clause applies uniformly across every metarel-introducing keyword (rel, material, mediation, formal, …) — the production is shared with concept-decl’s supertype clause, identical to how SupertypeClause is currently defined in the ungrammar.

pub rel indefiniteTimeScaleHasAbsoluteTimePoint(
    domain: IndefiniteTimeScale,
    range:  AbsoluteTimePoint,
) [1] [1..*]
    <: timeScaleHasTimePoint;

Semantics

R1 <: R declares: every tuple of R1 is also a tuple of R. The reasoner’s subsumption-closure auto-derives

R(a₁, …, aₙ) :- R1(a₁, …, aₙ).

without an explicit derive rule. Queries against R see R1’s tuples; queries against R1 see only R1’s tuples.

Elaborator checks at the <: clause

  1. Arity equality. R1 and R must have the same parameter count. Mismatch → OE0150 RelationSubsumptionArityMismatch.

  2. Endpoint covariance. For each position i, R1.param[i].type <: R.param[i].type. Mismatch → OE0151 RelationSubsumptionEndpointVariance.

  3. Cardinality refinement. For each slot i, R1.cardinality[i] must be no looser than R.cardinality[i]. Formal rule: [c..d] refines [a..b] iff c ≥ a and (d ≤ b or b = *). Mismatch → OE0152 RelationSubsumptionCardinalityViolation.

  4. Metarel compatibility (MVP rule). meta(R1) == meta(R). Same metarel on both sides. Mismatch → OE0153 RelationSubsumptionMetarelMismatch. Cross-metarel subsumption (e.g., material <: formal if a vocabulary declares a metarel lattice) is deferred — see Open Questions.

  5. Acyclicity. The relation subsumption graph must be acyclic. R1 <: R1 (direct or transitive) → OE0154 RelationSubsumptionCycle. Enforced by the same acyclic-DAG check used for concept subsumption.

Substrate impact

Zero new axiom kinds. The subsumption_axiom event with sub_id = R1’s NameRef and super_id = R’s NameRef carries the fact. The reasoner’s existing concept-subsumption closure generalizes by parameterizing over the symbol kind — relations are looked up via the same RelationCatalog machinery already used for storage.

Reasoner impact

oxc-reasoning::SemiNaiveExecutor extends its subsumption-closure pass to relation IDs. The existing concept-closure is parameterized on a SymbolKind enum; the implementation lifts the same Floyd-Warshall-style transitive closure over the subsumption DAG. Cost: O(|relations| · |subsumption_edges|) at module load.

Field-view propagation

A field declared via field: [T] from Rel.endpoint projects from Rel. When R1 <: R, a subtype concept may declare a field projecting from R1:

pub type TimeScale {
    timePoints: [TimePoint] from timeScaleHasTimePoint.range,
}
pub type IndefiniteTimeScale <: TimeScale {
    timePoints: [AbsoluteTimePoint]
        from indefiniteTimeScaleHasAbsoluteTimePoint.range,
}

The two fields are distinct projections per-concept; they share a name but live in different qualified namespaces (TimeScale::timePoints vs IndefiniteTimeScale::timePoints). Access from a TimeScale-typed binding projects the broader field; access from an IndefiniteTimeScale-typed binding projects the narrower one. Subsumption-closure ensures the narrower field’s tuples appear in the broader field’s view via the underlying relation <:.

Rationale

Why one mechanism, not three

UML’s {subsets} + {redefines} + implicit specialization is the right modeling vocabulary for a language whose properties propagate by name through the class hierarchy. Argon does not have that propagation:

  • Name resolution doesn’t walk <:.
  • impl Type namespacing creates distinct qualified paths from the start.
  • §5.2 forbids implicit override.

Given these three, UML’s {redefines} solves a problem Argon doesn’t have. A subclass that wants to “redefine” a parent’s property declares its own relation in its own qualified namespace; the relation-level <: carries the extent containment; the modeler accesses through whichever qualified path is appropriate. No additional mechanism is required.

Why extend <: (not introduce a new operator)

The substrate’s subsumption relation is uniform across symbol kinds (concepts, relations, standpoints) — SubsumptionAxiomBody is already generic. Using <: consistently preserves that uniformity at the surface. Introducing a new operator (e.g., subsets, >:, #[subsets(...)]) would imply the substrate distinguishes mechanisms it does not in fact distinguish.

Why same-metarel for MVP

A vocabulary may eventually declare metarel-level subsumption (material <: formal); when that lands, the elaborator’s metarel-compatibility check follows the metarel lattice. For MVP, conservative same-metarel-required keeps the elaborator simple and matches the most common use cases. The conservative rule generalizes monotonically — relaxing it later does not break any existing models.

Alternatives considered

A. Three-mechanism mirror of UML

Add <: for subsetting AND a separate #[redefines(Parent::rel)] attribute (or redefines keyword) for namespace shadowing. Rejected. Argon’s name resolution + impl Type scoping eliminate the shadowing problem; a redefines mechanism would address a non-problem. Adds language surface for no semantic gain.

B. Implicit subsumption from endpoint typing

When a pub rel R1 has endpoints <: R’s endpoints, automatically infer R1 <: R. Rejected. Conflicts with Argon’s “no implicit override” policy (§5.2). Modeler intent must be explicit; covariant endpoint types alone do not signal a desire for tuple flow into the parent.

C. Derive-rule expansion

Tell modelers to write pub derive R(a, b) :- R1(a, b); by hand. Rejected. Loses the structural property (no covariance check), bloats the module with boilerplate, and gives the optimizer no opportunity to specialize storage layout for subsumption-closed relations.

D. Defer entirely to a follow-on

Don’t extend <: to relations now; revisit when more pressure builds. Rejected. The substrate already supports it. The grammar gap is forcing modelers (Luiz, OntoUML imports) to work around. The cost of landing is small (~1–2 days of focused work). Deferring accumulates technical debt and modeler confusion.

Consequences

What lands

PieceWhereApproximate size
<: clause in rel-decl prosespec/reference/src/05-constructs.md §5.3~5 line edit
SupertypeClause on RelDecl in ungrammarcompiler/crates/oxc-syntax/argon.ungrammar1 line
Parser: invoke supertype_clause in rel branchoxc-parser/src/grammar.rs~5 LoC
Elaborator covariance + cardinality + metarel + acyclicity checksoxc-instantiate~120 LoC + 5 diagnostic codes
Subsumption-closure for relationsoxc-reasoning~30 LoC — extend existing concept closure
Diagnostic codes OE0150–OE0154oxc-syntax/grammar.toml + appendix-c-diagnostic-codes.md5 entries
Lean: extend Argon.Substrate.Construct.Relation with subsumption witnessspec/lean/Argon/Substrate/Construct.lean~40 lines (lift the concept-side machinery generically)

What modelers gain

  • Direct expression of UML {subsets} and OntoUML’s relation specialization patterns.
  • Property narrowing in subtypes via paired relation+field declarations.
  • Cleaner OntoUML import path (relation subsumption is a first-class OntoUML construct).
  • Elaborator-verified endpoint covariance and cardinality refinement at declaration sites.

What modelers do NOT gain

  • A separate redefines mechanism — Argon doesn’t need one (see Decision).
  • Implicit override semantics — same name without <: remains unsupported; explicit qualification is the modeler’s tool.
  • Cross-metarel subsumption (MVP) — initially restricted to same-metarel; lifted when metarel-lattice support lands.

Compatibility

Pure addition. No existing code changes behavior; the new clause is optional and absent in all today’s source. The subsumption_axiom wire format is unchanged.

Open questions

OQ1 — Field-level same-name across <:

When B <: A, both declaring a same-named field whose projection comes from <:-related relations (Luiz’s case), §5.2 has no explicit rule. Three readings:

  • Permissive: allow whenever the underlying relations are in a subsumption relationship; the field views inherit the connection transitively.
  • Strict: flag as shadowing per §5.2’s no-implicit-override policy; require explicit Parent::field qualification.
  • Inherit the relation’s signal: treat the relation-level <: as the modeler’s explicit acknowledgment; no field-level signal required.

Recommend the third reading as the cleanest extension of §5.2 — the relation’s <: carries the intent through to the derived field views, no new field-level mechanism needed. Confirm before implementation.

OQ2 — Metarel lattice

Should metarels themselves admit <: (e.g., metarel material <: metarel formal in a vocabulary)? UFO’s relation taxonomy is layered; a vocabulary might want to express that material relations are a kind of formal relation. Deferring to a follow-on RFD; the conservative same-metarel rule in this RFD is forward-compatible.

OQ3 — N-ary relation subsumption

The covariance check (R1.param[i].type <: R.param[i].type for each i) generalizes to n-ary relations trivially. Verify the elaborator’s covariance pass handles ternary and higher relations without special-casing. Unlikely to be a problem — the per-position check is uniform — but the test suite should cover ternary cases explicitly.

OQ4 — Interaction with from Rel.endpoint field-view cardinality

When R1 <: R and a subtype declares a field f: [T] from R1.endpoint, what cardinality bound applies to f? The narrower relation’s slot bounds, or the broader’s? The narrower’s, almost certainly — the field is projecting from R1, not R — but verify the existing field-view elaboration respects this.

References

  • §3.4 (name resolution), §5.2 (concept supertype clauses, OE0206/OE0208), §5.3 (relations), §5.4 (impl Type) — spec/reference/src/
  • oxc-protocol/src/storage.rs:574–578SubsumptionAxiomBody
  • oxc-resolver/src/{resolve.rs, symbols.rs} — name-resolution implementation
  • oxc-parser/src/grammar.rs:599–672 — concept/rel decl parser and supertype_clause
  • oxc-syntax/argon.ungrammar — typed AST shapes
  • UML 2.5.1 §9.5 (Properties; subsetting and redefinition)
  • Carvalho, V.A., Almeida, J.P.A., Guizzardi, G. (2017). Multi-level ontology-based conceptual modeling. Data & Knowledge Engineering 109, 3–24 — for the OntoUML relation-specialization patterns this RFD enables on import.

RFD 0006 — Field mutability via mut

  • State: discussion
  • Opened: 2026-05-28
  • Decides: surface syntax for opting a field into post-construction updatability; the implicit default for non-annotated fields; the interaction with #[intrinsic], from-clauses, refinement, and metatype rigidity; how update-stmt becomes the only legal write path; the lowering to append-only event pairs; the elaborator checks and diagnostic codes.

Question

Argon’s spec today has no field-level mutability discipline. Every non-derived field is implicitly mutable via the update-stmt grammar (§7.5:306). This contradicts the rest of the architecture — the substrate is value-semantic with no borrows (§7:26), .oxbin is content-addressed and immutable (§19), the event log is append-only (§20.1), and the Lean State.lean + Fixpoint.lean prove information-monotonicity. Should Argon admit a field-level mutability marker (mut), what’s the default, and how does it interact with the existing modifiers?

Context

Today’s effective semantics

  • field-decl ::= attribute* Ident ':' TypeExpr ('=' expr)? ('from' relation-ref)? (§5.1:18). No mutability slot.
  • update-stmt ::= 'update' (Ident | pattern) 'set' '{' field-assign (',' …)* '}' ('where' expr)? ';' (§7.5:306). Any field may appear in field-assign.
  • Worked example (§7.5:334): update c: Company set { name = new }name is implicitly updatable.
  • #[intrinsic] is an attribute (not a keyword) declaring that a field must be set at kind level by every iof-instance (§5.1:135, §10.2:57). It governs construction-time required-ness, not post-construction mutability. A field can be both #[intrinsic] and (today) implicitly updatable.

What’s opt-out from mutability today

  • Refinement-determined classification (§7.5:357) — the substrate derives membership; explicit insert iof rejected with OE0211 IofInsertOnRefinedType.
  • Rigid metatype classification (§7.5:356) — kind / subkind / category individuals can’t be re-classified; insert iof / delete iof rejected with OE0210 IofInsertOnRigidType.
  • from-derived fields (§5.1:18) — value comes from a relation projection; not directly assignable.
  • Architectural.oxbin, Module, Engine (§19); axiom_events only grows via append (§20).

The genuine gap

There is no language for “field that is set at construction (or by #[intrinsic] binding, or from-derived) and may not be subsequently updated.” Modelers write pub kind Person { name: String, dob: Date, current_address: Text } with no way to say “name and dob are set-once; only current_address changes.” Today’s update-stmt admits writes to any of them.

The Argon vault’s open-questions doc (Efforts/On/Argon/scratch/open-questions response.md #18) names this verbatim: “Mutability annotations. No grep hit for mut/const/#[mut] on properties. Likely absent. Tied to #19 (change patterns). Genuine gap.”

mut is available

Per appendix-a:6-18, mut is not in the reserved keyword list. The mutation-related reserved words are mutate, insert, delete, update, upsert, detach, forget. Adding mut is a non-conflicting lexer change.

Decision

Surface

mut is a field-declaration modifier. Without it, fields are set at construction (or via #[intrinsic] kind-level binding, or by from-clause derivation) and immutable thereafter. With it, a field admits writes via update-stmt inside mutate bodies.

pub kind Person {
    name: String,                   // set at construction; not updatable
    dob: Date,                      // set at construction; not updatable
    #[intrinsic] ssn: Text,         // must be set at kind binding; not updatable
    mut current_address: Text,      // updatable post-construction
    mut current_employer: Company?, // updatable, optional
    #[intrinsic] mut current_role: EmploymentRole,  // both: required at construction, updatable later
}

pub mutate move(p: Person, addr: Text) {
    update p set { current_address = addr };       // OK
    // update p set { name = "Bob" };              // OE0820: name is not `mut`
}

Updated grammar

field-decl ::= attribute* 'mut'? Ident ':' TypeExpr ('=' expr)? ('from' relation-ref)?

Order: attribute* mut? Ident. Attributes precede mut; mut precedes the identifier. This places mut adjacent to the field name where its scope is most visible.

Defaults

  • A non-mut field is immutable post-construction.
  • A mut field is mutable post-construction.
  • A from-derived field is always derived (not directly assignable); mut on a from-derived field is rejected with OE0822 MutOnDerivedField.
  • A field with #[intrinsic] is required at construction; orthogonal to mut.

Lowering

Field mutations lower to append-only event pairs on the underlying axiom. A mutate body with update p set { current_address = "new" } emits:

  1. A retract event (Polarity::Retract) on the prior property_assertion row whose body is { entity_id = p, property_id = NameRef(current_address), value = "old" }. The retract event’s asserts_axiom points to the prior assert’s EventId.
  2. A new assert event (Polarity::Assert) on the new property_assertion row whose body is { entity_id = p, property_id = NameRef(current_address), value = "new" }.

Both share the same content-addressed AxiomKey for the proposition “p has current_address X” — the proposition’s identity is the property assertion’s logical content; the value is what changes. Per RFD 0001, AxiomKey is BLAKE3-128(canonical_body), so old and new have distinct AxiomKeys but the same (entity_id, property_id) pair identifies them as alternative assertions of the same property.

Elaborator checks

  • OE0820 UpdateImmutableFieldupdate e set { f = expr } where f is not mut. Caller is the update-stmt elaborator.
  • OE0821 MutOnRefinementDerivedField — reserved; not yet emitted (refinements are concept-level today, not field-level; this exists in case refinement gains field-level derivation later).
  • OE0822 MutOnDerivedFieldmut f: T from rel.range. The from-clause already determines the value; mut is contradictory.
  • OE0823 MutOnRelationTupleField — reserved for Form B/C relation tuple bodies whose intrinsic fields may want their own mutability discipline; nail down when relation-tuple updates land.

Interaction with the four orthogonal axes

The four existing constraint axes plus mut form a clean matrix:

AxisGovernsmut-relation
Metatype rigidity (kind vs role etc.)Whether x can stop being iof TIndependent. A rigid concept can have mut fields.
Refinement (concept-level where)Whether membership in T is derivedIndependent. Refinement determines classification, not field values. Fields of a refined concept follow normal mut rules.
from-clause (field-level)Whether the field’s value is a relation projectionmut is rejected on from-fields (OE0822).
#[intrinsic] (field-level attribute)Whether the field must be set at constructionIndependent. #[intrinsic] mut f: T is admitted — “must be specified at construction AND can change after.”

Same commit ships update-stmt

The update-stmt grammar (§7.5:306) is reserved but unimplemented in the parser/lowerer/runtime today. mut is a no-op without update. The implementation of this RFD ships:

  1. Parser: update statement parsing (§7.5:306 + 307).
  2. Lowerer: update lowers to a new Operation::Update core_ir variant which expands to retract+assert event pairs at runtime.
  3. Elaborator: rejects update p set { non_mut_field = ... } with OE0820.
  4. Runtime: execute_mutation’s operation interpreter handles Operation::Update.

Local let mut is OUT OF SCOPE for this RFD

The §7.5:293 'let' Ident (':' TypeExpr)? '=' expr ';' grammar has no rebind form. A let mut x = ...; x = ... local-rebind story is meaningful but deferred to a follow-up RFD when the mutate-body language grows expression-level computation needs. The current RFD only addresses field mutability on declarations.

Rationale

Why immutable by default

Three reasons:

  1. Aligns the surface with the substrate. Argon is content-addressed, append-only, value-semantic. Implicit field mutability is the one surface-level dissonance with the rest of the architecture. Making mutability opt-in restores coherence.

  2. Forces modelers to identify stable attributes. Real ontologies have a sharp distinction between identity-bearing attributes (birth date, SSN, kind classification) and contingent attributes (current address, status, balance). Today’s syntax doesn’t make this visible. mut-by-default would have been a research-friendly default but explicit-opt-in matches the Mercury / Rust / Haskell tradition: types tell you what’s mutable.

  3. Pairs with the rest of Argon’s modeling discipline. #[intrinsic] says “must be set”; from says “is derived”; <: says “is subsumed by”; refinement says “is constrained by.” Adding mut says “may change post-construction” — slots into the same pattern. No mut ⇒ no post-construction change.

Why keyword over attribute (mut vs #[mut])

Three considerations:

  • Visual prominence. Mutability affects how the field participates in mutations; it’s a first-class semantic property, not metadata. Keyword form (pub mut current_address: Text) signals this; attribute form (#[mut] current_address: Text) reads as decoration.

  • Consistency with rest of the surface. pub, from, where, :, = are all keyword-shaped in field declarations. #[intrinsic] is an attribute because it’s a kind-level constraint (governs the binding site), not a value-level property. mut is value-level. Keyword fits.

  • Frequency of use. Field declarations are common; mut will appear often. Keyword form is shorter (3 chars + space vs 8 chars + space).

Why ship update-stmt with mut

The update keyword is reserved but the parser doesn’t recognize it; the lowerer doesn’t emit Operation::Update. Without it, mut is a marker that nothing reads. Two reasons to ship together:

  • Coherent surface. Modelers learn mut and update together as a unit. Documentation lands as one chapter, not two halves separated by a release.
  • End-to-end testable. A pub_fact_keystone analog with pub mutate move(p: Person, a: Text) { update p set { current_address = a }; } followed by a query_derive proves the full path.

Why orthogonal mut × #[intrinsic]

The two govern different lifecycle points:

  • #[intrinsic] — “must be specified by every iof-instance at kind binding”
  • mut — “may be re-assigned post-construction by update-stmt”

A real ontology pattern: #[intrinsic] mut employment_role: Role — every employee has a role from day one, and the role can change as they’re promoted. Forcing them to choose between “intrinsic” and “mutable” is a false dichotomy.

Why no let mut in this RFD

The mutate-body statement language (§7.5:293-313) is currently minimal: let, match, insert/update/delete/upsert/detach/emit, for, if, expr;. Local rebinding is interesting but isolated — it doesn’t interact with the field-mutability story. Treating it separately lets the field-mutability discussion stay focused.

Alternatives considered

Alt 1: mut as documentation only (β from the design discussion)

Keep current “any field is implicitly updatable” semantics. Use mut as a documentation marker driving tooling (audit, indexes, change-notification).

Rejected: doesn’t close the gap the vault names. Adds noise without changing semantics. The whole virtue of mut is forcing modelers to identify which attributes change.

Alt 2: mut for non-field axes only (γ)

Skip field-level mutability; use mut for pub mut concept (concept-level opt-in to state change) or pub mut rel (relation-level opt-in to tuple update).

Rejected: pub mut concept is redundant with metatype-driven rigidity (role is already anti-rigid). pub mut rel is interesting (insert/delete vs update) but is a smaller question than field mutability and can be addressed via a future RFD once relation-tuple intrinsic-field semantics solidify.

Alt 3: Attribute form #[mut]

Use #[mut] instead of mut. Composes with the existing attribute machinery; parallel to #[intrinsic].

Rejected as primary surface. Considered seriously; the keyword form wins on visual prominence + consistency with pub / from (other field-decl keywords). Attribute form might be admitted as an alias if grep-friendly attribute scanning becomes valuable, but the canonical surface is the keyword.

Alt 4: Tri-state: mut / default / const

Add both mut (mutable) and const (set-at-construction-then-frozen) with default being “construction-time once, kind-level once, no other constraint.” (const is already reserved per appendix-a:8.)

Rejected: the three-state design is more expressive but adds modeler load. The current architectural decision is “default to immutable; opt-in to mutability via mut.” const stays reserved for compile-time-known-value semantics if/when that landing strip becomes necessary.

Alt 5: Per-field via update rule mode

Instead of marking the field, mark the mutate body: declare which fields the body may touch via a frame clause. Closer to TLA+ / Reiter SSA semantics.

Rejected: heavier syntax. Real-world ontology workflows have many small mutations touching one field each; per-mutate-body declaration would multiply boilerplate. Field-level marker is more direct.

Consequences

Wire format

property_assertion events already carry an EventId and the body. Adding mut doesn’t change the wire format — the runtime emits retract+assert event pairs as it would for any other mutation. The field-decl’s mut marker is part of the concept/struct/etc. declaration’s wire body and rides along.

core_ir

Operation enum (today: InsertIof, DeleteIof, InsertTuple, DeleteTuple, Forget) gains:

#![allow(unused)]
fn main() {
Operation::Update {
    entity: Term,
    assigns: Vec<FieldAssign>,
    where_clause: Option<Term>,
},
}

Plus the FieldAssign shape: { field_name: String, op: AssignOp, expr: Term } where AssignOp is Set | AddSet | SubSet (for =, +=, -=).

Parser

  • New mut keyword recognized at the lexer level (added to grammar.toml’s keyword list).
  • field-decl parser admits optional mut token between attributes and the ident.
  • update-stmt parser implements §7.5:306 including where clause.

Elaborator

  • lower_concept_decl / lower_struct_decl walk field declarations; carry mut flag through to wire body (ConceptDeclBody.fields[i].mut_flag or similar — TBD when the field body wire shape solidifies).
  • lower_mutate_body recognizes update-stmt, produces Operation::Update.
  • Elaborator checks (OE0820, OE0822) emitted during update-stmt lowering.

Runtime

execute_mutation’s operation interpreter gains a Operation::Update arm:

  1. For each FieldAssign, evaluate the expr against the current state.
  2. Look up the prior property assertion event for (entity, field_name).
  3. Emit a retract event for the prior + assert event for the new.

Diagnostics

Three new codes in grammar.toml, propagating to oxc-diagnostics::generated.rs + appendix-c-diagnostic-codes.md:

  • OE0820 UpdateImmutableFieldupdate e set { f = expr } where f lacks mut.
  • OE0821 MutOnRefinementDerivedField — reserved; not yet emitted.
  • OE0822 MutOnDerivedFieldmut f: T from rel.range is contradictory.

Spec migration

The spec’s worked examples need updating. Specifically:

  • §7.5:334 update c: Company set { name = new } — either annotate name as mut or rename the example to use a field that’s plausibly mut.
  • §21 walking example: review for any field updates and add mut annotations.

Lean

Argon/Syntax/Decl.lean (field-decl) gains a mut: Bool field. Argon/Storage/AxiomBody.lean doesn’t change (property assertions are already individual axiom events; the retract+assert lineage is already in Polarity + asserts_axiom). Argon/TypeSystem/FlowTyping.lean may need refinement of the immutability assumption to “non-mut fields stay immutable across mutation”; the proof obligation is small (per-field discipline rather than blanket immutability).

CI drift gate

field-decl’s Lean inductive carries mut: Bool; the matching Rust struct in oxc-protocol::storage::FieldDecl (or wherever fields are wire-typed) must include it. CI drift check covers this.

Open questions

  • Should mut admit += / -= only on numeric types? §7.5:307 field-assign allows =, +=, -=. For text fields current_address += "..." is ambiguous (string concatenation? error?). Lean toward: += / -= admitted only when the field’s type implements the arithmetic ops (Nat, Int, Real, Money, Duration). Resolve when the trait system covers numeric op overloading.

  • mut on collection fields: pub mut friends: List<Person> — does mut admit both update p set { friends = [...] } (replace) and insert p.friends(other) (push)? Probably both, but the semantics need pinning down when collection-field mutation lands.

  • mut on relation tuple bodies (Form B/C): a relation declared with intrinsic property body (§5.2 Form C) carries its own fields. Do those fields admit mut? Almost certainly yes, with the same defaults — but the syntactic surface (pub rel hasJob(p: Person, c: Company) [1] [1..*] { mut salary: Money }) needs validation. Reserved as OE0823.

  • Interaction with #[brave] / stable models (§7.8): mut writes happen at runtime; brave-derived facts at build time. The two don’t collide today but if a #[brave] rule references a mut field’s prior value, the rule’s stratification needs to account for the temporal axis. Defer to when brave rules see real use.

  • Update via partial assignment: update p set { current_address = ..., current_employer = ... } — atomic both-or-neither, or sequential? Today’s transactional semantics of mutate bodies suggests atomic; the elaborator emits the retract+assert event pairs in a single mutation transaction. Confirm when the runtime’s transaction semantics formalize.

  • Identity-preserving field mutation: when a mut field changes, the entity’s IndividualId stays the same (RFD 0001). The AxiomKey for the property changes (content-addressed). Confirm the MutationReceipt shape adequately captures the lineage for downstream consumers (provenance witnesses, audit logs).

  • Should pub fact (RFD 0004) be able to set a mut field at construction? pub fact Person(alice) { current_address: "..." } — yes, presumably, since this is construction-time assignment, not post-construction update. The fact-decl grammar in RFD 0004 doesn’t yet have a body form for setting fields; add when needed.

RFD 0007 — Missing-value semantics under OWA

  • State: discussion
  • Opened: 2026-05-28
  • Decides: what field: T (required), field: T? (optional), and field: Truth4Of<T> (epistemic) each mean under CWA and OWA; what happens when the value isn’t asserted; how Option<T> lifts (or doesn’t) inside rule-body comparison atoms; the diagnostic surface that forces the modeler to pick an intent rather than guess one.

Question

Argon today lets a modeler write pub kind Person { age: Nat? }. What does the ? mean? Two readings, both defensible, both produce different rule-evaluation outcomes:

  • Reference-ontology reading. Some persons genuinely have no age — the field’s absence is a positive fact about the world. None is on a par with Some(0) as a piece of information.
  • Implementation reading. Every person has an age, but the KB may not yet record it — the field’s absence is epistemic uncertainty. None means unknown, not no age.

The OWL/SHACL ecosystem keeps these apart by treating cardinality (owl:FunctionalProperty, sh:minCount) as TBox/SHACL-shape declarations and treating ABox-incompleteness as a property of the knowledge graph, not of the property. Argon, today, conflates them: T? is the only surface, the spec doesn’t say which intent it expresses, and §5.1 line 42 — “yield Option::None if the field’s declared type is T?, else error” — forces modelers to reach for T? whenever the KB might be incomplete, even when the modeler means the implementation reading. That’s a real loss of expressivity, and it forces the rule-evaluation semantics to invent an ad-hoc lift rule for Option<T> >= T that the spec never actually specifies.

This RFD picks the semantics, picks the surface, and documents the elaborator’s lifting discipline.

Context

The motivating thread (Almeida + Almeida, 2026-05-28)

Gustavo Ladeira (Sharpe) asked:

pub kind Person { age: Nat? }

pub derive Adult(p: Person) :- p: Person, p.age >= 18;
pub derive Minor(p: Person) :- p: Person, not Adult(p);

“In OWA, can a derive resolve to CAN? Does p.age >= 18 lift to Can if age isn’t present? And then is not Adult(p) for a person with no age Is(true) (so Minor fires erroneously) or Can (so Minor stays unknown)?”

João Paulo Almeida (UFES) sharpened the framing:

“There is a key issue here to flesh out. Optionality in this form (in an implementation) is usually ambiguous. If we are building a reference ontology (about the world), an optional age would mean there [are] people without age (so, this would be a bad modeling choice). But when this is about an implementation, we’d also like to know whether within the knowledge base this information (about age) is optional. In the RDFS/OWL world, this is partially addressed with age being a functional data property (thus every person has an age even though we might not know it), and then might be SHACL constraints to clarify whether the knowledge graph must have information about age.”

JPA’s distinction is the question this RFD answers.

What the substrate already mechanizes (and what it doesn’t)

Foundation/Truth4.lean + Foundation/Projection.lean mechanize the bilattice Truth4 = {Is, Not, Can, Both} and the Pietz–Rivieccio Exactly-True projection. Reasoning/Fixpoint.lean is K3-aware: rule-body conjunction (§6.10.5 truth table) and Kleene negation (¬Is = Not, ¬Not = Is, ¬Can = Can) are the operators the stratified fixpoint already uses.

§6.9 verbatim: “The substrate enforces this by lifting the derive/query evaluation into Truth4 under OWA and projecting to Boolean only at refinement membership / if / match boundaries.” §12.2 verbatim: “Boolean projections: Canfalse. Only Is(true) is designated.”

What the substrate does not specify:

  1. How field: T under OWA behaves when no value is asserted. §5.1 line 42 says “error.” But the OWL functional-property pattern — every Person has an age; the KB may not know it — requires this to lift to Can, not error.

  2. How Option<T> comp_op T evaluates in a rule-body atom. No rule. Today the elaborator type-errors, the parser accepts it, and the runtime would have to invent a coercion. The Gustavo trace above implicitly assumed None >= 18 lifts to Can; that assumption isn’t anywhere in the spec.

  3. Whether structural-optional and epistemic-optional have separate surfaces. §6.6 says T?Option<T>. That’s the structural reading. The implementation reading currently has no surface — and §5.1’s “else error” forecloses it.

Three real gaps. JPA’s question lands on all three.

OWL / SHACL precedent

The reference systems handle JPA’s distinction with separation of concerns:

  • TBox (OWL). Cardinality declarations (owl:FunctionalProperty, owl:minCardinality, owl:maxCardinality) state what is true of the world: every Person has an age. ABox-incompleteness does not contradict TBox cardinality under OWA — it means the missing fact is unknown.
  • SHACL shapes. Constraints over the knowledge graph (sh:minCount, sh:maxCount, sh:datatype) state what must hold of the KB. A SHACL violation means the KB is incomplete, not that the world is malformed.

Argon’s existing machinery already has the analogous parts:

  • TBox-cardinality analog. Field declarations on concepts. age: Nat says every Person has an age (OWL functional + min 1). age: Nat? says some Persons may genuinely lack an age.
  • SHACL-shape analog. where { … } refinement clauses (§6.3) and #[intrinsic] (§5.2). These constrain the KB, not the world.

The pieces are in place. What’s missing is the lift discipline that connects them under OWA.

Decision

Decision pending ratification (2026-06-12, PR #289). This RFD is in discussion state, and its OE1014 story (Decision table row 1 / the lift rule below) places the required-field-completeness diagnostic at field-access / evaluation time — a query reading an unasserted required field under CWA is the schema violation. PR #289 needed a completeness gate for the insert iof(x, T) classification side-door (audit ufo-mut-06), and shipping the evaluation-time emitter is a larger build (it lifts every required-field read into a CWA cardinality check). So #289 decides and implements a narrower, complementary site: an in-body-vs-staged discriminator at commit time. A mutate body that classifies x into T and writes fields of x in the same body is constructing x in-body; completeness is judged at body end (read-your-writes, RFD 0019 RC2) and OE1014 refuses atomically if a required field is left unset. A body that only classifies — no in-body field writes to x — is staged construction and is permitted to defer (this RFD’s “ABox-incompleteness is fine”); it currently produces no diagnostic. Rationale: a blanket write-time / construction-time refusal would break staged construction across mutations (the legal_norms_can_vote::register and keystone add_sat → set_cap patterns), which this RFD explicitly blesses; keying on whether the same body populates the individual is the discriminator that lets both shapes stay green. This is a fresh design decision owned by #289, not a clause this RFD already settled — the RFD specifies the opposite site (field-access). The field-access-time / evaluation-channel emitter (Decision table row 1 CWA column, the lift rule’s state lacks ... CWA → OE1014 line) remains the open half of this RFD and is unbuilt; it is tracked at #292, and this RFD still needs ratification to lock both halves.

Three surfaces, three distinct intents

SurfaceIntentConstruction timeQuery under OWA (value not asserted)Query under CWA (value not asserted)
field: TOntologically present. Every instance has this property.Required; absent → OE0207Access lifts to CanSchema violation; OE1014
field: T?Structurally optional. The property genuinely may not apply.May be omitted; defaults to NoneReturns None (a positive fact)Returns None (a positive fact)
field: Truth4Of<T>Epistemic-explicit. Modeler wants the four-valued shape exposed.May be Is(v) | Not | Can; defaults to CanReturns whatever is assertedReturns whatever is asserted

These three surfaces correspond exactly to JPA’s distinction (rows 1+3 are the implementation reading; row 2 is the reference-ontology reading), plus an escape hatch for modelers who want the bilattice shape directly. The default T row matches OWL functional-property + min-cardinality 1 under OWA; the T? row matches Rust-style structural Option.

The lift rule (the load-bearing piece)

In rule-body atom context, a field access p.field evaluates as follows:

For field: T (required, ontologically present):

state has hasField(p, v)        → Is(v) at the Truth4 level; surface value is v
state lacks hasField(p, _), OWA → Can at the Truth4 level
state lacks hasField(p, _), CWA → model-level error OE1014 (cardinality violation)

For field: T? (structurally optional):

state has hasField(p, v)         → Some(v)
state lacks hasField(p, _), any  → None  (a positive fact; no Truth4 lift)

For field: Truth4Of<T>:

state has hasField(p, v)         → Is(v)
state has not_hasField(p)        → Not        (explicit negative assertion)
state lacks both                 → Can

Surface comparison atoms use the resulting Truth4 / Option / value:

p.age >= 18          // T:    Is(true) | Not | Can per the trace above
                     // T?:   TYPE ERROR — see below
                     // T4Of: Is(true) | Not | Can — same as T, but explicit

Option<T> in comparison atoms is a type error

Option<T> comp_op T (and Option<T> comp_op Option<T>, and friends) is rejected by the elaborator: OE0612 OptionComparisonRequiresHandling with a diagnostic that suggests three explicit forms:

// 1. Pattern in the rule body — preferred when None should fail-closed
p.age is Some(a), a >= 18

// 2. Helper method on Option<T>
p.age.is_some_and(|a| a >= 18)

// 3. Match with explicit unknown handling
match p.age {
    Some(a)    => a >= 18,
    None       => false,           // pick: false / true / Truth4Of::Can
}

The first form (is Some(a)) compiles to a guarded match that fails the conjunction (binds nothing) on None — a positive fact about absence. The second is sugar for the same. The third lets the modeler explicitly say what None means in their model.

This is JPA’s point made structural: if you wrote T?, the language refuses to guess what None should mean in a comparison; you must say.

Required-field-under-OWA: the lifted access rule

§5.1 line 42 amends to:

No value present at field access — under OWA, lift to Can at the Truth4 level (the surface returns the field’s declared type via the K3 fail-closed projection: false for Bool, omitted for collections); under CWA, emit OE1014 RequiredFieldUnasserted (the schema declared this field present; the KB must record it). At construction time, the existing OE0207 IntrinsicPropertyMissing rule for #[intrinsic] fields is unchanged.

The asymmetry — construction strict, query OWA-lifted — is intentional and matches OWL: TBox cardinality requires presence, ABox completeness is a separate (SHACL) concern.

Diagnostic codes

CodeSeverityNameTrigger
OE0612ErrorOptionComparisonRequiresHandlingOption<T> comp_op T (or comp_op Option<T>) without explicit handling. Suggests is Some(a) / is_some_and / match.
OE0613ErrorTruth4ComparisonRequiresHandlingTruth4Of<T> comp_op T without is Is(a) || is Not || is Can handling. Suggests is outcome-suffix or match.
OE1014ErrorRequiredFieldUnassertedUnder CWA, a required field’s value is not derivable from any asserted axiom — the schema mandates presence; the KB violates it. Surfaces at evaluation, not at construction.
OW1015WarningOptionalFieldAmbiguousIntentpub kind X { f: T? } declared without a #[doc] comment or #[intent(structural)] / #[intent(epistemic)] attribute; the elaborator notes the ambiguity per JPA. Demoted to off by #[allow(optional_field_ambiguous_intent)].
OE1016ErrorTruth4OfOnStructTruth4Of<T> field type on a struct (no metatype classification). Restricted to ontologically-classified concepts.

Spec edits

SectionEdit
§5.1 line 42Add the OWA lift case (above).
§5.1 (new subsection §5.1.x)Document the three field-type intents with the table from “Three surfaces, three distinct intents.”
§6.6 line 40Footnote: T? is the structural-optional surface; for epistemic uncertainty, use T + OWA or Truth4Of<T>.
§6.9 (Interaction with NAF para)Cross-reference §5.1.x; clarify that the Truth4 lift applies only to required fields and Truth4Of<T> — structural-optional fields don’t lift, they return Option.
§7.3.1 (Rule-atom grammar)Add the lift discipline: when a comparison atom’s left side has type T, evaluation lifts to Truth4 per §6.9; when it has type Option<T>, the elaborator emits OE0612 unless the modeler handled None.
Appendix CAdd the five diagnostic codes above.

Lean mechanization

ModuleAddition
Argon/Substrate/Construct.leanExtend FieldDecl carrier with the three intent kinds (Required, StructurallyOptional, EpistemicExplicit).
Argon/Reasoning/State.leanLift accessField to return Truth4 × Value, branching on the field’s declared intent + the governing WA.
Argon/Reasoning/Fixpoint.leanThe existing K3 conjunction + negation already handles the Truth4 carrier; no change needed past the access function.
Argon/TypeSystem/Soundness/FieldAccess.lean (new)Theorem: under OWA, required-field access lifts to Can when the asserting axiom is absent; under CWA, the absence is contradictory (proof via Reasoning/Stratification.lean’s well-founded fixpoint).

The theorem statement is roughly:

theorem field_access_owa_lift
    {P : Program} {p : Individual} {f : RequiredField}
    (h_no_assertion : ¬ ∃ v, P.state.has (hasField p f v))
    (h_owa : P.worldAssumption f.declaringConcept = .open) :
    P.accessField p f = (Truth4.can, default)

with the CWA branch as a separate (sharper) statement: under CWA, h_no_assertion is provably false for any iof(p, concept) ∈ P.state (the schema mandates a value), so the case is unreachable and the elaborator’s OE1014 is sound.

The Gustavo trace, after this RFD lands

With Gustavo’s code as written (age: Nat?):

pub kind Person { age: Nat? }
pub derive Adult(p: Person) :- p: Person, p.age >= 18;
//                                         ^^^^^^^^^^^ OE0612

The elaborator rejects the comparison at OE0612, asking the modeler to pick. Gustavo then makes the modeling choice JPA was asking him to make — either:

// Reference-ontology reading: people with no age aren't adults; Minor fires
pub derive Adult(p: Person) :- p: Person, p.age is Some(a), a >= 18;
pub derive Minor(p: Person) :- p: Person, not Adult(p);
// carol (no age) → Adult = Not (the `is Some(a)` fails) → Minor = Is

or:

// Implementation reading: every person has an age; KB may not know it
pub kind Person { age: Nat }     // required, OWA-aware lift handles the gap
pub derive Adult(p: Person) :- p: Person, p.age >= 18;
pub derive Minor(p: Person) :- p: Person, not Adult(p);
// carol (no age asserted, OWA) → p.age lifts to Can
//   → Adult body = Is ⊓ Can = Can
//   → not Adult(carol) = ¬Can = Can
//   → Minor(carol) = Is ⊓ Can = Can
// Minor's extent at Bool projection: carol omitted. At Truth4Of<Person>: shown as Can.

JPA’s two intents are no longer conflated; the elaborator has forced the choice; the Truth4 lift handles whichever choice the modeler made; the diagnostic carries the explanation.

Rationale

Why three surfaces instead of two

A naive design would keep just T and T? and let OWA do all the work. That collapses JPA’s two intents into one surface (T?), reproducing today’s ambiguity. Truth4Of<T> adds a third surface for the specific case where the modeler wants the bilattice value exposed in storage — relatively rare, but exactly what one needs when modeling, e.g., a clinical-trial endpoint where “patient response = unknown” is a first-class data point separate from “patient response = no.”

Three surfaces is the minimum that lets every modeler intent be expressed cleanly:

  • T: TBox cardinality 1 + OWA-tolerant ABox.
  • T?: structural optionality (no TBox cardinality assertion).
  • Truth4Of<T>: explicit four-valued storage.

Why type-error on Option<T> comp_op T

JPA’s “ambiguous” is the load-bearing observation. If the elaborator silently coerces None to false (Reading C, SQL-style), the modeler never confronts the ambiguity and the model’s semantics drifts from intent. If the elaborator silently lifts None to Can (Reading A, Truth4-style), the modeler is again not consulted. The type error is the modeler-respecting move: ask the modeler what they mean.

The cost is one extra ceremony at every Option<T> comparison site (is Some(a), a >= 18 instead of a >= 18). The benefit is no silent ambiguity, no quiet semantic drift, and a diagnostic that doubles as documentation.

Why required-field-under-OWA lifts to Can (not errors)

Without this lift, the spec is internally inconsistent. §6.9 promises OWA semantics (knowledge is open; absence is uncertainty); §5.1 promises construction-time strict checks (required fields must be set). Today these collide: under OWA, an iof(carol, Person) axiom can be asserted with no hasAge tuple, but accessing carol.age errors out — which contradicts §6.9’s premise that absence is uncertainty.

The fix preserves both intents by separating their layers. Construction (the axiom event being inserted) stays strict — you can’t write Person { name: "carol" } without age because the construction is a single axiom and missing required fields are malformed at the event level. Query (subsequent field access on a possibly-incomplete KB) lifts to Can under OWA, matching OWL functional-property semantics exactly: every Person has an age (TBox), but we may not know it (ABox-incompleteness is fine under OWA).

CWA does not tolerate this: under CWA, an iof(carol, Person) with no hasAge(carol, _) is a schema violation, and OE1014 is correct.

Why OW1015 OptionalFieldAmbiguousIntent

The warning catches the JPA case at declaration time: a modeler writes f: T? without saying which intent they mean. The warning links to the table in §5.1.x and suggests either #[doc] documentation, an explicit #[intent(structural)] attribute, or migration to T + OWA. It can be silenced per-field. The warning is off by default in the prelude (std::* legitimately uses T? for plumbing types like Option<Person> returned from one { ... } queries) and on by default in user code with #[intent(...)] available as the disambiguator.

Alternatives considered

A. Implicit lift of Option<T> to Truth4 in rule bodies (Reading A)

Auto-translate None → Can, Some(v) → standard comparison verdict. Rejected. Silently picks the implementation reading; loses the reference-ontology reading entirely. Modelers who wrote T? to mean “some persons have no age” would find their rules treating those persons as unknown instead of positively-not-an-adult — a semantic regression that’s invisible at the source. Fails JPA’s framing.

B. Option<T> comp_op T returns Option<Bool> via functor lift (Reading C)

Auto-translate None comp_op v → None, Some(a) comp_op v → Some(a comp_op v). Force the modeler to write match on the result. Rejected. Type system gets noisier without solving the underlying ambiguity; modelers will reflexively write match { Some(b) => b, None => false } and the SQL-NULL silent collapse returns. The type-error path (Decision) forces the choice earlier where the modeler still remembers the intent.

C. Only two surfaces: T + OWA and T?

Drop Truth4Of<T> as a field type. Rejected. Loses the explicit-epistemic case (clinical-trial endpoints, audit fields marking “unknown” as a first-class value). The marginal cost of admitting Truth4Of<T> as a field type is low — it’s an existing stdlib type — and the expressivity is meaningful for medical / legal / scientific modeling.

D. Defer the entire question; leave §5.1’s “else error” rule as-is

Wait for more modelers to hit the wall. Rejected. Two of the language’s core consultants (Gustavo, JPA) hit it on day one of working through a temporal model. The substrate is Truth4-aware; the spec gap is at the surface; the cost of resolving is small (~5 elaborator checks + the diagnostic codes). Deferring would let modelers internalize ad-hoc workarounds and would force oxc-instantiate to ship without crisp Option-handling semantics.

E. Adopt OWL/SHACL syntax wholesale (#[functional], #[min_count], #[max_count])

Map TBox-cardinality + SHACL-shape vocabulary to attributes on field declarations. Rejected as the primary surface. Argon’s T / T? distinction is more ergonomic than OWL’s flat-property + cardinality-restriction model; adopting OWL vocabulary as the primary surface would be a Rust-aesthetic regression (per the user-memory directive defaulting to Rust/Cargo aesthetic). The OWL/SHACL pattern is reachable as a target via the field-type decision matrix above, without forcing modelers to write #[functional, min_count = 1] at every field site.

Consequences

Source-level

  • A pub kind Person { age: Nat } model becomes feasible under OWA where today the spec forces Nat?.
  • Option<T> comp_op T rule-body atoms must be rewritten with is Some(a) / is_some_and / match. The Cargo-style ecosystem migration: one-shot cargo ox fix rewrite per repo.
  • Truth4Of<T> becomes available as a field type. Modelers who want OWL-style four-valued storage have a first-class surface.

Substrate-level

  • Two new variants on FieldDecl (intent kind: required / optional / epistemic).
  • One new branch in accessField: returns (Truth4, Value) instead of Value. Hot-path impact: one extra tag byte in the tuple-encoding, negligible.
  • relation_tuple axiom kind unchanged; the lift happens at the accessField level, not in storage.

Runtime-level

  • The K3 truth tables (§6.10.5) are already mechanized and used; this RFD adds no new operators.
  • Query results returning collections now omit Can-valued field rows under K3 fail-closed projection — already the spec, but newly exercised at scale.

Diagnostic-level

  • Five new codes (OE0612, OE0613, OE1014, OW1015, OE1016).
  • One existing code (OE0207 IntrinsicPropertyMissing) keeps its current semantics; the new OE1014 covers the disjoint case of required-non-intrinsic absence under CWA.

Compatibility

T? retains its current Option<T> semantics — modelers who already use it for structural optionality see no behavior change. The new OE0612 may flag existing rule bodies; the suggested fix is purely mechanical (is Some(a), insertion). T under OWA gains new expressivity that didn’t exist before. No silent semantic changes to any existing well-typed program.

Open questions

OQ1 — Truth4Of<T> as field type interactions with mutation

A field declared f: Truth4Of<T> can be set to Is(v), Not, Can, or Both(v1, v2) via insert. What is the storage representation? Likely two relations: hasField_pos(p, f, v) and hasField_neg(p, f). Both is encoded as both relations holding simultaneously. The mapping is mechanical but worth specifying before implementation.

OQ2 — Interaction with refinement clauses

A refinement pub subkind Adult <: Person where { self.age >= 18 } over a required age: Nat field, under OWA, evaluates to Is | Not | Can per §6.9. Does the refinement-classification machinery in TypeSystem/Soundness/FlowTyping.lean handle the Can case? Spot-check needed: I expect yes (the existing OWA branch already covers it), but the new lifting rule introduces a Can source the type system didn’t previously consider.

OQ3 — Aggregate semantics over Can-valued cells (resolved — see RFD 0011)

What does sum { p.age | p: Person } evaluate to when some p.age lift to Can? Resolved by RFD 0011: monotone aggregators (sum non-negative, count, set_collect) evaluate to Truth4Of<T> with interval bounds [lower, upper] where lower = aggregate over filter-Is-true cells and upper = aggregate over filter-Is-true-or-Can cells; non-monotone aggregators (min, max, avg, string_join, percentile) propagate Can (any Can-cell in the filter set → whole result is Can). The Truth4 result is projected at the typed boundary per §12.2’s K3 fail-closed rule; the projection is observable via diagnostic OW0613 (info in query/fn, warning in derive, error in check) and an always-present aggregation-metadata envelope on query results carrying the interval bounds and Can-cell counts. The motivating Gustavo Ladeira / J.P. Almeida thread (2026-05-28 → 2026-05-29) is the worked example; the full design and rationale lives in RFD 0011.

OQ4 — Path traversal under Can-valued intermediate steps

For alice.parent.spouse.age, if alice.parent lifts to Can, does the full chain short-circuit to Can or does it propagate through the K3 conjunction of step verdicts? Existing field-path semantics in Reasoning/Rule.lean treats path steps as conjunctions; this would give natural propagation. Confirm.

OQ5 — #[intent(structural)] / #[intent(epistemic)] as the disambiguator

The proposed attribute makes the modeler’s intent explicit at declaration time. Should it be required for T? declarations in non-prelude modules? Two readings: (a) required (zero-ambiguity policy; matches Argon’s “no implicit override” philosophy from §5.2); (b) optional with OW1015 warning (gentler migration). Default to (b); revisit if modelers report confusion.

OQ6 — Migration path for existing UFO vocabulary

The UFO stdlib package declares several optional fields in its concept catalog (Person, Organization, …). Are these structural-optional or epistemic-optional? A scan of ufo/src/*.ar is needed to assign intents per-field before this RFD lands; this is a UFO-package PR, not an argon PR.

References

  • §5.1 (struct/concept field declarations, OE0207), §5.2 (concept supertype clauses), §6.3 (refinement, three-valued membership), §6.6 (T?Option<T>), §6.9 (CWA/OWA), §6.10.5 (strong-Kleene truth tables), §7.3.1 (rule-atom grammar, is unknown outcome-suffix), §12 (Truth4 + Pietz–Rivieccio projection) — spec/reference/src/
  • Foundation/Truth4.lean, Foundation/Projection.lean, Reasoning/Fixpoint.lean, Reasoning/State.lean — substrate mechanization
  • oxc-instantiate (elaborator), oxc-reasoning::compile::Value (runtime carrier) — implementation sites
  • W3C OWL 2 Web Ontology Language Direct Semantics, §2.3.3 (functional property axioms)
  • W3C SHACL §3 (Shape constraints)
  • Pietz, A. & Rivieccio, U. (2013). Nothing but the truth. Journal of Philosophical Logic — the Exactly-True semantics underlying §12.2’s K3 fail-closed projection.
  • Belnap, N. (1977). A useful four-valued logic. — the underlying bilattice.
  • Almeida, J.P.A. (UFES) and Ladeira, G. (Sharpe), Slack thread, 2026-05-28 — the motivating discussion.

RFD 0008 — Standpoint-Sheaf Equivalence Proof Roadmap

  • State: committed (Path A landed)
  • Opened: 2026-05-28
  • Path A landed: 2026-05-29 — Argon/Standpoint/AFTEquivalence.lean mechanizes the discrete T3 obstruction equivalence (aft_discharges_T3_obstruction proven).
  • Decides: the proof obligation, viable proof paths, and arc structure for elevating standpoint-sheaf equivalence from “axiomatized / open research” to mechanically proven in Lean.

Question

Standpoint logic (Gómez Álvarez & Rudolph 2021) and sheaf cohomology (Abramsky & Brandenburger 2011) independently formalize multi-perspective knowledge. The conjectured equivalence between them — “a set of standpoint axioms has a consistent global merger iff the associated sheaf has trivial H¹” — is currently axiomatized in Argon’s Lean mechanization, with AGENTS.md noting it as “open research.”

Should Argon push to prove this equivalence, and what’s the right proof path?

Context

Why this matters strategically

The competitive audit identified federation across standpoints as Argon’s most distinctive feature — no other production language has it as a first-class primitive. The current Lean mechanization proves a Finset-based version (Argon/Locality/SheafEquivalence.lean: grounded MCS equilibrium = minimal global section over module DAGs). This is operationally useful but does not connect to the broader logical-topological equivalence the literature poses.

Without the broader proof, Argon’s federation claim is “the runtime computes equivalent results to a sheaf model under our axiomatic embedding.” With the proof, the claim becomes “the runtime computes a sheaf model — and we know precisely when and how the model breaks.”

The user has designated this a worldclass objective. The vault’s “Standpoint-Sheaf Dictionary” note carries the conjectured translation table. This RFD makes the proof obligation concrete.

What’s already proven

In Argon/Locality/SheafEquivalence.lean (~180 lines, lake-green):

  • Theorem 1 (bottom-up computation → equilibrium): definitional.
  • Theorem 2 (equilibrium → global section): from local_fp inclusion.
  • Theorem 3 (equilibrium is minimal global section): from local_fp being a least fixpoint.

These cover a BeliefAssignment (function ModId → Finset Atom) with generic bridge and local_fp operators. The acyclicity of the module DAG is required.

Adjacent mechanization (Argon/Standpoint/Federation.lean) proves the FDE bilattice info-join (federate_eq_both_iff, strictFold_preserves_inK3) — the AFT-side analogue of “cross-source disagreement detection.”

What’s NOT proven

The three sub-theorems forming the conjecture:

Sub-theoremStatementStatus
T1S5 Kripke frame (W,R) over standpoint set with induces a canonical Grothendieck topology J on the standpoint category S.Unproved
T2Bridge rules form sheaf restriction maps satisfying the gluing axiom (local sections agreeing on overlaps extend uniquely to a global section).Unproved
T3Grounded equilibrium ≅ H⁰(F); irreducible disagreement ≅ H¹(F) ≠ 0 (Abramsky-Brandenburger 2011).Unproved

Decision

Pursue the proof in three escalating proof paths, each independently shippable.

Path A — AFT-only restatement (lowest risk, ~600 LOC) — LANDED 2026-05-29

The existing Foundation/Federation.lean’s federate_eq_both_iff is already the bilattice-algebra-side analogue of “H¹ ≠ 0 detects contextuality.” T3 is restated in AFT terms:

federate contribs = .both ↔ no global K3-section exists

This makes T3 a corollary of existing mechanized work. T1 and T2 are not discharged in this path — we lose the cohomological diagnostics (cocycle witnesses, spectral solver) but keep the soundness story.

Cost (actual): 1 new file Argon/Standpoint/AFTEquivalence.lean (~225 LOC including docstrings). One iteration through Lean’s cases/split_ifs tactics.

What landed:

  • SheafClassification inductive: consistentTrue / consistentFalse / undetermined / obstructed.
  • sheafClassify : List Truth4 → SheafClassification — direct semantic over federate.
  • aft_discharges_T3_obstruction (proven): sheaf-obstructed ↔ federate contribs = .both. The load-bearing theorem; the AFT-side analogue of “H¹(F) ≠ 0 detects contextuality.”
  • sheafClassify_consistentTrue_iff, sheafClassify_consistentFalse_iff, sheafClassify_undetermined_iff — three companion characterizations.
  • sheafObstructed_iff_disagreement_or_explicit_both — composes the above with federate_eq_both_iff for the diagnostic-surface-shaped form.
  • sheafClassify_singleton, sheafClassify_empty — base cases.

What this ships: a named theorem in Lean asserting “Argon federation = AFT-bilattice-based cross-source consistency.” Bridges the existing operational federation runtime to a categorical-flavored claim.

Path B — Frame-theoretic (medium, ~1200 LOC)

Replace the Grothendieck topology with the simpler structure of a complete Heyting algebra (frame) of standpoint-downsets. Mathlib has Order.Heyting.Basic and Topology.Sheaves.Sheaf over locales. Bridge rules become frame homomorphisms.

This discharges T1 routinely (frames induce topologies; standpoint-downsets form a frame canonically) and T2 (frame homomorphisms preserve gluing). T3 still requires work but on more familiar ground than full Grothendieck descent.

Estimated cost: ~1200 LOC. ~6 weeks.

What it ships: a partial proof of the equivalence — the topology induction and the bridge-rule-as-restriction-map sides, with T3 stated as a theorem schema parameterized over the localic sheaf machinery.

Net gain over Path A: the equivalence is now bidirectional (Argon → sheaf AND sheaf → Argon round-trip), not just embedding.

Path C — Full Grothendieck (highest, ~2000 LOC)

Mathlib has CategoryTheory.Sites and Grothendieck topologies. Construct the topology directly; prove T1, T2, T3 via descent.

The genuinely novel obstacle: connecting Hansen-Ghrist’s sheaf Laplacian diffusion (proved for vector-space stalks) to Argon’s lattice-valued knowledge requires either a box-embedding (Garcez-Lamb) relaxation or a discrete cohomology theorem. This is open research; no published work bridges it.

For the equivalence itself, this gap is not blocking — the equivalence can be discrete on the lattice side. But the computational payoff (spectral solver, cohomological diagnostics) depends on it.

Estimated cost: ~2000 LOC. ~12 weeks for the equivalence; the spectral bridge is a separate research effort.

What it ships: a research paper. Independent value beyond Argon’s runtime needs.

  1. Arc N+1 (this RFD): Path A. ✅ Landed 2026-05-29. Discharges the immediate Argon claim (“federation is sound under AFT info-join, which is the bilattice-algebra-side analogue of the sheaf claim”). Closes the wire-clean version of the worldclass objective.

  2. Arc N+2 (follow-on): Path B. Lifts to frame-theoretic, discharging T1 + T2 + a parameterized T3. The equivalence becomes bidirectional.

  3. Arc N+3 (research-paper track): Path C. Full Grothendieck. Separately publishable; not on the Argon roadmap critical path. Bridge to continuous diffusion is a deferred research obligation.

Paths A and B compose: Path A’s theorems are corollaries of Path B’s. Path B’s theorems are corollaries of Path C’s. Each arc strictly strengthens the previous.

Rationale

Why three paths rather than one

Path C is what the literature poses. Path A is what we can ship next week. Path B is the sweet spot. Allowing all three to live in the roadmap acknowledges that:

  • Argon’s runtime needs the operational claim (Path A) now.
  • The mathematical claim worth defending publicly (Path C) is research-scale.
  • The intermediate (Path B) delivers most of the value with mathlib4’s existing infrastructure.

Why not just ship Path C and skip the intermediates

Path C’s continuous-discrete bridge is a genuine open problem. If we commit to Path C exclusively, the roadmap is hostage to one unproven research result. Splitting into three paths means each is independently shippable; if Path C stalls on the continuous-discrete bridge, Paths A and B are unaffected.

Why the AFT-only path counts as discharging the worldclass objective

The vault’s “Standpoint-Sheaf Dictionary” note maps:

  • federate contribs = .both ↔ “irreducible disagreement”
  • K3-fragment containment ↔ “global section exists”

federate_eq_both_iff already proves this in Argon/Standpoint/Federation.lean. Restating it as the canonical sheaf-equivalence theorem under the AFT lens IS the worldclass deliverable for Argon’s runtime semantics, operationally. The Grothendieck topology proof is the categorical deliverable, with mathematical content beyond the runtime claim. Both are worth pursuing; Path A satisfies the worldclass objective for Argon-as-a-language; Path C satisfies the worldclass objective for Argon-as-a-publishable-research-contribution.

Alternatives considered

Alt 1: leave the equivalence axiomatized

Mark the three sub-theorems as axiom declarations with citations to Gómez Álvarez & Rudolph 2021 and Abramsky & Brandenburger 2011. AGENTS.md permits cited axioms. Cost: zero. Risk: the runtime claim depends on the citations being correct.

Rejected per user strategic clarification: “we should absolutely push for a proper proof and make sure that it’s world-class.”

Alt 2: ship only the AFT-only path

Land Path A; declare the categorical claim out of scope. Cost: ~600 LOC. Risk: ceiling-bound; the federation story remains “operational only,” with no path to spectral / cohomological diagnostics.

Rejected as too narrow. The user designated this worldclass; a worldclass deliverable includes the mathematical claim, not just the operational one.

Alt 3: ship Path C exclusively

Commit to the full Grothendieck construction; treat A and B as intermediate steps not worth landing independently.

Rejected: the continuous-discrete bridge is a research gap with no published solution. Risking the roadmap on it is unwise. Splitting into three paths lets each ship independently.

Consequences

Lean changes (per arc)

  • Arc N+1 (Path A): ~600 LOC. New file Argon/Standpoint/AFTEquivalence.lean. Extends Federation.lean with the canonical theorem statement.

  • Arc N+2 (Path B): ~1200 LOC. New file Argon/Standpoint/FrameEquivalence.lean. Depends on mathlib4 Order.Heyting.Basic + Topology.Sheaves.Sheaf.

  • Arc N+3 (Path C): ~2000 LOC. New file Argon/Standpoint/SheafEquivalence.lean (NOT the existing Locality/SheafEquivalence.lean, which proves the discrete Finset form). Depends on mathlib4 CategoryTheory.Sites. Spectral bridge is a separate research effort.

Reference book changes

§11 (Standpoints and federation) gains a new subsection citing this RFD’s three theorems as the soundness foundation. Currently §11.x reads as informal motivation; after Path A lands, it can cite the mechanized theorem directly.

Runtime impact

Path A: none. Federation runtime continues to use AFT info-join; the equivalence theorem is a soundness claim about it.

Path B: enables across[] queries to use frame-homomorphism preservation as a static guarantee. Diagnostic surface gains an “irreducible disagreement: cocycle on edges {…}” message under H¹ obstruction.

Path C: enables sheaf Laplacian diffusion as an alternative federation evaluator (spectral solver for acyclic module DAGs). Spec gain λ₁-spectral-gap as a federation cost metric.

Citation registry

Each path adds named theorems whose proof obligations cite specific prior work. The citation list (book §22 references) gains:

  • Gómez Álvarez, S. & Rudolph, S. (2021). Standpoint Logic: Multi-Perspective Knowledge Representation. (Path A, B, C)
  • Abramsky, S. & Brandenburger, A. (2011). The Sheaf-Theoretic Structure of Non-locality and Contextuality. (Path B, C)
  • Hansen, J. & Ghrist, R. (2020). Opinion Dynamics on Discourse Sheaves. (Path C, spectral bridge)
  • Mac Lane, S. & Moerdijk, I. (1992). Sheaves in Geometry and Logic. (Path C foundations)
  • Denecker, M., Marek, V., Truszczyński, M. (2000). Approximation Fixpoint Theory. (Path A AFT side)

Open questions

  • Continuous-discrete bridge for stalks. Hansen-Ghrist’s diffusion convergence is proved for vector-space stalks. Argon’s modules use lattice-valued knowledge. No published work bridges them. Path C’s spectral payoff depends on resolution; the equivalence itself does not. Is the spectral-bridge worth a dedicated research effort, or accept the discrete cohomology theorem?

  • Cocycle diagnostic surface. Under H¹ obstruction, what does the modeler see? “Irreducible disagreement on bridges {b1, b2, b3}” with a cycle witness? Diagnostic ergonomics design.

  • Functorial canonicity of T1. Classical topology literature has Grothendieck constructions for modal Kripke frames, but no published canonical induction for standpoint orders. Path C may require an originality contribution here.

  • MLT-style multi-level stalks. Standpoints can carry metatypes (a standpoint’s knowledge includes higher-order classifications). Whether sheafification respects MLT order arithmetic is unaddressed in the literature. Defer to MLT std library RFD.

  • Defeasible bridges. Bridge rules can themselves be defeasible (per Argon’s defeasibility substrate). Does the sheaf framework absorb Governatori-Rotolo +Δ/-Δ proof tags as graded restriction maps? Open.

RFD 0009 — std::mlt library scope

  • State: discussion
  • Opened: 2026-05-28
  • Last revised: 2026-05-29 (added #[order(N)] assertion decorator + completed decorator set per discussion with Tiago Sales; fixed @[…]#[…] sigil throughout to align with §14.2)
  • Decides: scope of the std::mlt library that provides Multi-Level Theory (Carvalho-Almeida 2018) as a parallel std::* package on Argon’s neutral substrate; surface syntax for decorators (relational AND assertion forms); the Datalog rules CL-1..CL-7 enforcing MLT well-formedness; per-rule diagnostic emission for OE1903–OE1907.

Question

Argon’s substrate is committed to MLT-as-library (RFD-relevant memory: “Higher-order theories as stdlib libraries”). The substrate provides the higher-order type primitives required (universe polymorphism via metaxis, level-indexed quantification via metatype) and now carries the five MLT primitive metarel kinds (MLTMetarelKind per the just-landed Argon/MetaCalculus/MLTKinds.lean). What’s needed is the library that operationalizes MLT — decorators, Datalog enforcement rules, diagnostic emission.

What should std::mlt v0.1 ship, and how should it be wired into the build pipeline?

Context

Why a library, not substrate

Per user strategic commitment: “MLT is a library, not substrate. The atoms of the language allow for MLT to be implemented, as we have higher-order type primitives, but MLT must be a library.” This preserves Argon’s neutrality. UFO, BFO, DOLCE, ML2 all ship as parallel std::* packages; the language doesn’t favor one foundational ontology over another. The substrate’s responsibility ends at exposing sufficient primitives.

This commitment is verified for MLT specifically by the just-landed substrate sufficiency scaffold:

  • instanceOf, specializes admitted via the inheritance lattice + IsCanNot machinery.
  • categorizes, partitions, subordinates admitted via MLTMetarelKind carriers.
  • Order arithmetic semantics live in Argon/MetaCalculus/Wellformed.lean as parametric Props over an abstract T.

What’s missing: the library that instantiates these primitives against modeler-written declarations and emits enforcement rules.

What MLT requires from a library

Per Carvalho-Almeida 2018 (Theorem-based MLT axiomatic theory) and Vault’s “MLT as a Library, Not Substrate” deep-dive:

  1. Decorators on metatype declarations that express MLT primitives at the surface. Two distinct kinds — relational (declare a cross-level relation between types) and assertion (declare a claim the compiler verifies against the derived fixpoint). Sigil is #[…] per Procedural macros; both kinds appear at declaration position.

    Relational decorators — desugar to canonical metarel-instance events; the relation participates in CL-1..CL-7 enforcement:

    • #[categorizes(T)] — the metatype’s instances are proper specializations of T; implies order(M) = order(T) + 1. (Carvalho-Almeida §3.2.)
    • #[partitions(T)]#[categorizes(T)] + members of the metatype pairwise disjoint and jointly exhaustive of T’s extent. (Carvalho-Almeida §3.3.)
    • #[subordinate_to(M)] — same-order subordination per Carvalho-Almeida §3.4. (Spelled subordinate_to per Higher-order modeling, not subordinates — the §3.4 relation reads “A is subordinate to B”.)
    • #[power_type_of(T)] — the metatype is the powertype of T per Cardelli 1988; every instance of T is also an instance of M. (Carvalho-Almeida §3.5.)

    Assertion decorators — desugar to a check rule verifying a claim against the derived fixpoint; fail-loud at runtime if the assertion conflicts with the iof chain, vacuous when the chain is incomplete:

    • #[order(N)] — modeler asserts has_order(Self, N); the elaborator emits an implicit check rule that fires OE1905 OrderInconsistency if the derived order disagrees. See § Decorator kinds — assertion vs. relational below for the full design.

    Tier decorators — module-level annotations that narrow the decidability tier:

    • #[order_bound(N)] — module declaration; caps every concept’s order at N. Lowers the module from tier:metaorder to a polynomial tier under the bound. (See Higher-order modeling, Tier ladder.)
  2. Datalog enforcement rules (kernel-native, fired automatically), per the Vault’s “Hybrid A+B” design pattern:

    • CL-1 Categorization: violation_categorization(X, T₂, T₁) :- categorizes(T₂, T₁), iof(X, T₂), ¬subclass(X, T₁). Emits OE1903.
    • CL-2 Partition disjointness: violation_partition_disjoint(Ind, T₃ₐ, T₃ᵦ) :- partitions(T₂, T₁), iof(T₃ₐ, T₂), iof(T₃ᵦ, T₂), T₃ₐ ≠ T₃ᵦ, iof(Ind, T₃ₐ), iof(Ind, T₃ᵦ). Emits OE1904.
    • CL-3 Order consistency: violation_order(X, T) :- concept_order(T, N), iof(X, T), concept_order(X, M), M ≥ N. Emits OE1905.
    • CL-4 Categorization inference: subclass(X, T₁) :- categorizes(T₂, T₁), iof(X, T₂). (Positive rule, no diagnostic.)
    • CL-5 Subordination requirement: violation_subordination(A, B) :- subordinates(A, B), (order(A) ≠ order(B) ∨ order(A) < 2). Emits OE1906.
    • CL-6 Powertype completeness: violation_powertype(T) :- power_type_of(M, T), iof(X, T), ¬iof(X, M). Emits OE1907.
    • CL-7 MLT compositional consistency (Carvalho-Almeida Theorem 5): a closure rule that propagates categorizes/partitions/subordinates interactions per the §3.4 composition table.
  3. Diagnostic codes OE1903–OE1907 wired into oxc-diagnostics and emitted from oxc-instantiate (or a follow-on pass) when CL-1..CL-7 detect violations.

  4. Library API surface modelers reach for:

    • use std::mlt::* brings the decorator set #[categorizes] / #[partitions] / #[subordinate_to] / #[power_type_of] / #[order] / #[order_bound] into scope.
    • std::mlt::well_formed!() build-time check macro that runs CL-1..CL-7 (plus the user’s #[order] assertions, if any) and fails the build on violation. Modelers serious about MLT correctness add this to their root module.
    • std::mlt::order(e: Entity) -> Option<Nat> library function — query a concept’s derived order at runtime; None when the iof chain is incomplete enough that order isn’t yet determined. (Operationally lowered to a one-shot query over the has_order/2 predicate per RP-003 §4.3.)

What std::mlt does NOT do

Per the strategic commitment, MLT-as-library means:

  • The library does NOT extend the substrate. No new atoms.
  • The library does NOT enforce MLT globally — only modules that use std::mlt opt in. Other modules see iof/specializes as usual without categorization arithmetic.
  • The library does NOT subsume UFO, BFO, DOLCE — those are parallel std::* libraries with independent enforcement.

Decision

Scope of v0.1

Three landings, each independent:

Phase 1 — Decorator parser + lowering (~1-2 weeks)

  • New oxc-parser recognition for the v0.1 MLT decorator set on type/metatype declarations:
    • Relational (lower to metarel_decl axiom events): #[categorizes(T)], #[partitions(T)], #[subordinate_to(M)], #[power_type_of(T)].
    • Assertion (lower to implicit check rules): #[order(N)].
    • Tier (module-level, narrows decidability tier): #[order_bound(N)].
  • oxc-instantiate lowers each decorator according to its kind:
    • Relational → canonical metarel_decl axiom event via Argon.MetaCalculus.MLT.declOfKind.
    • Assertion → synthesized check declaration; for #[order(N)] on concept X, the check is check OrderMatches { has_order(X, N) } keyed to OE1905 OrderInconsistency.
    • Tier → module-attribute on the elaborated program; the classifier reads it and narrows the tier.
  • Diagnostic emission for surface mis-use (decorator on a non-applicable position, bad argument shape, conflicting decorators on the same declaration). Reuses OE0708 ReservedAttributeName only when collision is genuine; the MLT-specific positional checks ride on a new fingerprint.
  • Sigil is fixed at #[…] (Procedural macros); the prior draft of this RFD used @[…] which was a typo against the rest of the spec.

Phase 2 — CL-1..CL-7 Datalog rules + OE1903–OE1907 (~3-4 weeks)

  • oxc-reasoning admits the seven CL-* rules as built-in rules (compiled at runtime, not modeler-written).
  • evaluate_to_fixpoint (or its semi-naive successor — depends on RFD 0003 backend dispatch) computes the violation predicates.
  • oxc-runtime lifts violation tuples to OE1903–OE1907 diagnostics at query-time-of-affected-rule.
  • Lean-side: extend Argon.MetaCalculus.Wellformed’s parametric predicates to a runtime-evaluable form; prove correctness against Carvalho-Almeida 2018’s axioms.

Phase 3 — std::mlt package + integration tests (~1-2 weeks)

  • Ship std::mlt as a workspace package with use std::mlt::* bringing in decorators.
  • Integration test exercising the Vault’s “biological taxonomy” canonical example (Animal → AnimalSpecies → DogBreed, order 0/1/2).
  • Documentation: short tutorial showing modeler-facing usage.

Total v0.1 effort: ~5-8 weeks across Phase 1+2+3.

Implementation pattern — “Hybrid A+B”

Per Vault’s MLT design synthesis: enforcement is two-layered.

  • A — Compile-time decorator expansion: #[categorizes(T)] on metatype M desugars to a canonical metarel categorizes(M, T) declaration that lands in the events list. Pure source transformation; no runtime cost. Assertion decorators (#[order(N)]) similarly desugar at compile time, but to a synthesized check rule rather than a metarel-instance event.

  • B — Runtime Datalog evaluation: CL-1..CL-7 rules fire continuously during query evaluation, detecting violations as they arise. Lazy by construction (a violation only matters when a query touches the affected predicate). Diagnostics emit at query time, not at build time.

This hybrid lets the build complete even if MLT violations exist (graceful degradation for in-progress modeling), while ensuring runtime queries fail loud if a violation is reachable.

Decorator kinds — assertion vs. relational

The two decorator kinds carry different semantic weight and warrant different mechanical treatment.

Relational decorators (#[categorizes], #[partitions], #[subordinate_to], #[power_type_of]) introduce a fact into the model: “this metatype is in this cross-level relation with that type.” They desugar to canonical metarel_decl events that participate in the CL-1..CL-7 enforcement closures. The diagnostic surface for these is the closure rules — a violation is detected when the modeler’s facts are mutually inconsistent under MLT’s axioms (Carvalho-Almeida §3.2–§3.5).

Assertion decorators (#[order(N)]) introduce a claim into the model: “this concept has this property.” They desugar to implicit check rules that fire at runtime against the derived fixpoint. The diagnostic surface is the check rule itself — a violation is detected when the modeler’s claim conflicts with what the iof chain actually derives.

#[order(N)] — design

use std::mlt::*;

#[order(2)]
pub type AnimalSpecies : TaxonomicRank <: Species  // claimed 2; derived order(AnimalSpecies) = 2 ✓

#[order(1)]
pub kind Dog : AnimalSpecies <: Animal             // claimed 1; derived order(Dog) = 1 ✓

let Lassie : Dog                                    // individuals don't need #[order]; order = 0 always

Semantics. On a concept declaration X with #[order(N)], the elaborator synthesizes a check rule equivalent to:

check OrderMatches { has_order(X, N) }   // mlt::E0007 in the library namespace; routed to OE1905 in v0.1

The has_order(_, _) predicate is std::mlt’s stratified-aggregate derivation over the iof DAG (RP-003 §4.3, lines 226–240):

  • has_order(x, 0) for any individual x;
  • has_order(t, n) for type t when n = 1 + max{m | iof(t’, t) ∧ has_order(t’, m)} (well-founded by iof acyclicity).

Three possible outcomes at check time:

StateWhat the check seesOutcome
Chain complete, has_order(X, N) derivableAssertion matches derived order✓ check passes
Chain complete, has_order(X, M) derivable for M ≠ NAssertion conflicts with derived orderOE1905 OrderInconsistency with message "#[order({N})] asserted on {X}, but iof chain derives order({M})"
Chain incomplete; has_order(X, _) undefined under the current stateAssertion neither confirmed nor refutedVacuous pass (three-valued: Unknown maps to OK under open-world)

The vacuous-pass case is the load-bearing one for incomplete-model checking — a modeler partway through wiring up the iof chain can assert #[order(2)] and the build won’t fail just because the chain isn’t done; it only fails when the chain explicitly contradicts the assertion. As the model grows toward completeness, more #[order] assertions become checkable, and any divergence shows up immediately.

Why this matters (ergonomics). Three concrete wins motivate the assertion decorator over inference-only:

  1. Incomplete-model verification. Modelers iterate. A partial iof chain doesn’t yet derive order, but the modeler has a belief about what the final order should be. #[order(N)] records that belief and turns it into a checkable invariant the moment the chain reaches completeness.
  2. Self-documenting models. A reader can see a concept’s intended tower position at a glance without traversing iof predecessors. This compounds in large models where the iof chain crosses module boundaries.
  3. Agent guidance. LLM-driven modeling is a primary v0 use case (per Argon’s stdlib-libraries design memo and design notes). Explicit assertions give the agent something to be checked against; pure inference gives the agent nothing to falsify. Agents reason better when their claims are observably refutable.

Why this design, not a procmacro. RP-003 GAP-3 calls for eventually re-homing all MLT decorators as pub macro declarations in std::mlt once the procmacro system is implemented (§14.2). For v0.1, parser-recognized attributes are simpler and ship sooner; the surface (#[order(N)]) is identical either way. Migration is internal.

Applicability. #[order(N)] is valid on:

  • Concept declarations (pub type, pub kind, and any user metatype-introduced declaration). Asserts the concept’s tower position.
  • Individual declarations (let X : T). Trivially asserts N == 0; mostly redundant since individuals always have order 0, but admitted for symmetry.

It is not valid on:

  • struct/enum declarations (no metatype, category error parallel to meta()).
  • Relation declarations (relations don’t carry order in MLT; the relation’s metarel does).

Mis-application emits OE1908 OrderAssertionMisplaced (a new code reserved by Phase 1).

Order ceiling at 2 for v1

Per Vault’s “Orca domain census” finding: real-world MLT patterns max out at order 2 (Animal → AnimalSpecies → DogBreed; no order-3 patterns required). v0.1 caps support at order 2:

  • concept_order predicate ranges over {0, 1, 2}.
  • OE1905 emits for order ≥ 3 declarations.
  • MLT* orderless types (universe polymorphism per Sozeau-Tabareau 2014) defer to v2.

This bound makes polynomial decidability concrete: D1Pred over a bounded type graph is polynomial; OE1905 enforcement is O(n²) in the type graph’s edge count.

Rationale

Why ship std::mlt rather than fold it into the language

Three reasons:

  1. Neutrality preservation. UFO and BFO have ontological commitments MLT doesn’t (UFO commits to a rigidity/sortality taxonomy; BFO commits to continuant/occurrent). If MLT were substrate, every Argon program would inherit MLT’s claims even when modeling a non-MLT ontology. Library-form means opt-in.

  2. Substrate parsimony. Adding MLT to the substrate would grow the five atoms to six. The substrate’s clean five-atom architecture (memory: “Argon substrate atoms fixed: five atoms; mutate not mutation; no event atom; standpoints first-class”) is load-bearing for the meta-calculus story; growing it for one foundational ontology breaks the symmetry.

  3. Future foundations. ML2, MLT*, DeepTelos all extend MLT in different directions. Library form lets each ship as a parallel package (std::ml2, std::mlt_star, std::deeptelos); substrate form would force one to be canonical.

Why decorators rather than a new keyword

Carvalho-Almeida’s notation uses categorizes as a relation name. The decorator form #[categorizes(T)] mirrors this exactly: “the metatype is categorized by T.” A keyword form (pub categorization Foo of T) would invent surface syntax that doesn’t appear in the literature.

Decorators also compose naturally with other attributes (#[categorizes(T)] #[disjoint] pub metatype M). A keyword form forces a single grammar position.

Why Datalog enforcement rather than compile-time

Several MLT constraints (CL-1, CL-3 in particular) require closing over the full extent of iof — instances asserted across the module. Compile-time evaluation can’t see runtime-asserted facts (added via mutations). Datalog evaluation fires lazily at query time, catching violations including runtime-added ones.

The downside: violations don’t surface until queried. A modeler can build a broken MLT module that passes the build. The catch is the build-time std::mlt::well_formed!() macro: it pre-runs CL-1..CL-7 against the build-time fact catalog and fails the build if violations exist. Modelers serious about MLT enforcement add this macro to their root module.

Why the OE1903–OE1907 codes are reserved but not yet emitted

The codes were reserved in grammar.toml when RFD 0006 landed (field mutability). The reservation predates this RFD because the diagnostic codes are part of the broader Argon diagnostic registry, not specific to std::mlt. Their EMIT SITES land with Phase 2; reservation is already in place.

Alternatives considered

Alt 1: ship MLT as part of substrate

Add categorizes/partitions/subordinates as substrate atoms; bake CL-1..CL-7 into the elaborator.

Rejected per user strategic commitment. See “Why ship std::mlt rather than fold it into the language” above.

Alt 2: ship std::mlt v0.1 with only the surface (decorators), defer Datalog

A “syntax-only” v0.1: parser admits decorators, but enforcement is documentation-only (“violations are the modeler’s responsibility”).

Rejected as too weak. Decorator-only would let broken MLT modules pass the build silently — the worst-of-both world (visible syntax claiming MLT compliance with no actual checking). Phase 2’s Datalog enforcement is what makes the library credible.

Alt 3: ship Phase 1 + 2, defer the std::mlt package wrapper (Phase 3)

Decorators + Datalog rules + diagnostics ship as part of the language; the std::mlt package is a thin re-export layer added later.

Rejected because it leaks MLT-specific names (categorizes, subordinates, etc.) into the language’s prelude. Keeping them library-prefixed (std::mlt::categorizes) maintains the neutrality claim.

Alt 4: full Carvalho-Almeida axiomatization (Theorems 1-8) in Lean before shipping

Mechanize every Carvalho-Almeida theorem in Argon/Locality/MLTAxioms.lean before allowing the Rust library to claim MLT compliance.

Rejected as too ambitious for v0.1. The substrate-sufficiency scaffold (MLTMetarelKind + classify_declOfKind theorem) discharges the expressibility claim; full axiomatization is a separate research effort (~24-38 person-months per Vault’s D-132 estimate). Defer to v0.2 or beyond.

Consequences

Wire format

No new axiom kinds. #[categorizes(T)] and its relational siblings desugar to canonical metarel_decl events; #[order(N)] desugars to a synthesized check declaration that emits a CheckDecl event keyed to OE1905. No new body types.

Lean changes

Phase 2 lands a Argon/Locality/MLTRules.lean (~400-600 LOC) that:

  • Defines the seven CL-* rule shapes against Argon.MetaCalculus.MLTKinds.
  • States the soundness theorem (CL-rules detect exactly the violations of Carvalho-Almeida’s axioms).
  • Proof handle: parametric predicates in Wellformed.lean already capture the violation semantics; this module instantiates them against runtime tuples.

Rust changes

Phase 1: oxc-parser decorator recognition + oxc-instantiate decorator-to-metarel lowering. ~300-500 LOC.

Phase 2: oxc-reasoning admits built-in rules; oxc-runtime lifts violation tuples to diagnostics. ~800-1200 LOC.

Phase 3: std::mlt package declaration in packages/std-mlt/ with re-exports. ~100-200 LOC.

Diagnostic surface

CodeNameSurfaceEmit sitePhase
OE1903CategorizationViolation#[categorizes(T)] violated at runtimeCL-1 closurePhase 2
OE1904PartitionDisjointnessViolation#[partitions(T)] overlapping instancesCL-2 closurePhase 2
OE1905OrderInconsistencyorder arithmetic violated; also the failure mode of #[order(N)] when assertion disagrees with derived orderCL-3 closure + synthesized check OrderMatches from Phase 1Phase 1 (assertion check) + Phase 2 (CL-3 closure)
OE1906SubordinationViolation#[subordinate_to(M)] mis-orderedCL-5 closurePhase 2
OE1907PowertypeIncomplete#[power_type_of(T)] extent gapCL-6 closurePhase 2
OE1908OrderAssertionMisplaced#[order(N)] on a struct/enum/relation declarationPhase 1 parser/elaboratorPhase 1

The #[order(N)] assertion is the first MLT diagnostic emitted from Phase 1 (the synthesized check rule fires under runtime evaluation but the check declaration itself lands in Phase 1’s parser/elaborator output). All other MLT diagnostics land with Phase 2’s CL-rule evaluator.

Per RP-003 GAP-4, the OE19xx codes are scheduled to migrate to the library namespace (mlt::E0001mlt::E0008); v0.1 retains OE19xx for continuity with the rest of the diagnostic registry and tracks the migration as an open question below.

Documentation

Reference book §13 (or §5.x — placement TBD) gains a subsection on MLT-as-library, citing Carvalho-Almeida 2018. The §17 ARS substrate description references MLT as one of the foundational ontologies the substrate admits via library composition.

Open questions

  • Phase 2 evaluation dispatch: CL-1..CL-7 are Datalog rules. They should run on the same executor as user-written derives. But CL-1..CL-7 are built-in, not modeler-written — should they be loaded as part of the runtime’s bootstrap, or shipped in a special “kernel” rule set? Affects how RFD 0003 (per-stratum backend dispatch) handles them.

  • Cross-library MLT consistency: if a module uses both std::mlt and std::ufo, do their iof/specializes semantics align? UFO inherits MLT semantics by reference (per Vault’s “UFO Design Limitations”); BFO does not. The interaction needs RFD-level treatment.

  • Order arithmetic via Lean universe inference vs explicit predicate: Vault’s Phase B1 + B2 work proposes mechanizing T2 substrate (Tarski-cumulative universes) so Lean’s elaborator infers order(T) automatically. v0.1 sticks with the explicit concept_order predicate; T2 mechanization is a separate research effort.

  • MLT orderless types*: universe-polymorphic types per Sozeau-Tabareau 2014. v0.1 caps at order 2; MLT* unlocks unbounded order. Defer to v0.2.

  • Powertype keyword deprecation: per Vault, pub powertype Name (categorizes|partitions) Target { instances } is currently hard-reserved in kind.rs:657 but marked for removal. The pattern system + decorator approach in this RFD obsoletes that surface. Removal unblocks pub metatype powertype = { ... } from user code. Coordinated removal with this RFD’s Phase 1.

  • MLT decidability under unbounded order: at order ≥ 3, decidability becomes uncertain (Carvalho-Almeida 2018 is silent past order 2). If v0.2 admits MLT*, the decidability classifier needs an MLT-specific tier or a refinement to the existing metaorder tier.

  • Backward compatibility of iof semantics: in MLT, iof(x, T) includes order arithmetic. In other foundational ontologies (UFO continuant, BFO universals), iof is order-agnostic. Module-local use std::mlt::* should NOT retroactively change iof semantics for non-std::mlt-using modules — but the runtime evaluator runs CL-1..CL-7 globally. Resolution: scope CL-1..CL-7 to predicates declared with MLT decorators (a metarel_decl.is_mlt_primitive flag added by Phase 1 decorator lowering).

  • OE19xx → mlt::E* namespace migration (RP-003 GAP-4): the diagnostic codes in this RFD use the language-level OE19xx range, inherited from when MLT was substrate-embedded. Per the MLT-as-library commitment, library-namespaced codes (mlt::E0001mlt::E0008, with the mlt:: prefix matching 09-higher-order.md:20) are the consistent endpoint. v0.1 keeps OE19xx for continuity with the existing registry; the migration is a coordinated rename across oxc-diagnostics, the Lean wellformedness module, and appendix-c-diagnostic-codes.md. Open question: do we migrate before or after Phase 2 emit sites land?

  • Assertion-decorator pattern as a reusable mechanism: #[order(N)] is the first assertion-style decorator. The same pattern is the natural shape for forthcoming ontological libraries — #[potency(N)] in std::potency (deep-instantiation level), #[stratum(N)] in std::ml2, etc. Should std::core expose a generic assertion-decorator builder (a procmacro helper) so each library doesn’t re-implement the lowering? Or do we accept boilerplate per library and re-evaluate when a third library wants this pattern? v0.1 hard-codes #[order]’s lowering; revisit after std::potency is sketched.

  • Vacuous-pass behavior under incomplete iof chains: #[order(N)] passes vacuously when has_order(X, _) is undefined at check time. This is the right default for incomplete-model development — but it means a modeler can ship a build with unverified assertions. Mitigation: the well_formed!() macro could escalate vacuous passes to warnings (OE1909 OrderAssertionUnverified) so modelers see what’s not yet covered. Decide whether to land that escalation in Phase 1 or defer.

  • #[order(N)] on individuals: let Lassie : Dog #[order(0)] is admitted but redundant (individuals always have order 0). Should the elaborator reject as a useless annotation (W0001-style warning) or silently accept? Argument for accepting: agent-generated models may always emit it for symmetry; the redundancy is harmless. Argument for warning: signals to the modeler that they may be confused about what #[order] means. Lean toward silent accept for v0.1; revisit if it becomes a source of confusion.

Discussion log

2026-05-29 — #[order(N)] added, decorator set completed

Tiago Sales asked in chat whether the syntax for declaring a type’s MLT order should be a refinement on the meta-calculus (pub type Species <: Taxon where { meta(self).order == 2 }) or an attribute (@[order(2)]). Neither matched the substrate’s actual design — meta(x) returns a Metatype value, not an arithmetic carrier, and @[…] is the wrong sigil. The substrate’s design is that order is computed, not declared: it falls out of the iof chain as a fixpoint over the well-founded has_order/2 predicate.

The follow-up question was the load-bearing one: “is implicit ordering sufficient for the modeler?” Tiago’s argument — that explicit assertions help with incomplete-model checking, documentation, and agent guidance — settled the question. #[order(N)] becomes a std::mlt assertion decorator that verifies the modeler’s belief against the derived fixpoint, fail-loud on conflict, vacuous-pass on incomplete chain.

Changes made to this RFD in the 2026-05-29 revision:

  1. Added #[order(N)] to the v0.1 decorator set as the canonical example of an assertion decorator (a new decorator kind alongside the existing relational decorators).
  2. Completed the relational decorator set — added #[power_type_of(T)] (was missing) and renamed #[subordinates(M)]#[subordinate_to(M)] (the §9 spelling, matching Carvalho-Almeida §3.4’s “A is subordinate to B” reading).
  3. Added #[order_bound(N)] as the module-level tier decorator (was referenced in §9 / RP-003 but not enumerated in this RFD).
  4. Fixed @[…]#[…] throughout. The @[…] form was a typo against Procedural macros and every example in the reference book.
  5. New diagnostic code OE1908 OrderAssertionMisplaced for #[order(N)] on inapplicable declarations (struct/enum/relation).
  6. New subsection “Decorator kinds — assertion vs. relational” with the #[order(N)] design in full, including the three-state check outcome table (pass / fail / vacuous) and the ergonomic rationale.
  7. New open questions: OE19xx → mlt::E* migration, assertion-decorator pattern as a reusable mechanism for std::potency / std::ml2, vacuous-pass behavior under incomplete chains, and the individual-redundancy question.

RFD 0010 — Negative facts / strong negation

  • State: discussion
  • Opened: 2026-05-29
  • Decides: surface syntax for asserting a fact’s negation as ground truth (distinct from absence-as-unknown under OWA, and distinct from default negation-as-failure in rule bodies); the wire-format mirror; how this composes with the standpoint federation runtime to make Truth4::Both operationally reachable.

Question

Today a pub standpoint X { pub fact Person(alice); } lets standpoint X assert alice ∈ Person as positive ground truth. The federation runtime’s query_dispatch then evaluates this row as Truth4::Is from X and Truth4::Can (implicit absence) from any other contributing standpoint. The AFT info-join Is ⊕ Can = Is, so the row surfaces tagged Is.

There is no current way for a standpoint to assert alice ∉ Person as positive ground truth. This means:

  • The bilattice value Truth4::Not (Belnap-Dunn’s F, AFT’s (F, F) pair) is never produced from a fact-derived per-source classification.
  • The bilattice value Truth4::Both (Belnap’s B, AFT’s inconsistent (T, F)) is consequently never produced by federate, because Is ⊕ Can ⊕ Can ⊕ ... = Is regardless of contributor count.
  • The federation runtime is operationally category-(b) bilattice-traced in the vault’s Bilattice-Native Query Evaluation taxonomy, not the category-(c) bilattice-native outcome the substrate’s proven theorems (federate_eq_both_iff, aft_discharges_T3_obstruction) advertise.

The decision: introduce a surface form for strong negation of facts, lower it to a new wire-format IofRefutation axiom kind, and have the per-standpoint materializer compute Truth4 values per (tuple, source) pair so that the runtime is operationally bilattice-native.

Context

What’s deferred from RFD 0004

RFD 0004 §Future Work explicitly anticipated this work:

Negative facts / classical negationpub not_fact Person(alice) to assert that alice is not a Person. Useful in OWA / classical contexts; relates to §12.2’s Truth4 bilattice. Defer; orthogonal to this RFD’s positive-only scope.

That deferral is now load-bearing: it gates the operational reachability of the bilattice’s most distinctive value.

The two negations distinction (Gelfond-Lifschitz 1991)

Gelfond and Lifschitz (1991) draw the canonical line between two negations every paraconsistent logic-programming surface must distinguish:

NegationMeaningSurfaceWire form
default / NAF (not)“not derivable in this scope” — closed-world inferencerule-body atom: not P(x)rule body, no wire trace
classical / strong (¬)“positively asserted to NOT hold” — open-world ground truthdeclaration: pub not_fact P(x);new IofRefutation axiom event

Argon already uses not in rule bodies (NAF, stratified). What it lacks is the declaration-level strong negation. The two are independent: NAF is a reasoning-time operator inferring absence from the current extent; strong negation is a build-time ground-truth assertion of refutation.

What the substrate already supplies

The Lean substrate is fully ready for this:

  • Argon.Foundation.Truth4 has the four-valued carrier with all algebraic laws.
  • Argon.Foundation.Bilattice.infoJoin is proven associative, commutative, idempotent.
  • Argon.Standpoint.Federation.federate is the AFT info-join over List Truth4.
  • federate_eq_classify characterizes federate outputs structurally.
  • federate_eq_both_iff: federate xs = .both ↔ hasBoth xs ∨ (hasIs xs ∧ hasNot xs) — the precise theorem stating when Both arises.
  • aft_discharges_T3_obstruction: sheafClassify contribs = .obstructed ↔ federate contribs = .both.

None of this is reachable by the runtime today because no source ever contributes .not. The Lean theorems are sound but the runtime cannot exhibit the case they characterize.

What the wire format already supplies

AxiomEvent.standpoint_id (set by the standpoint-block elaborator in commit 2776be9) tags each event with its asserting source. The materializer in commit 9aa8c73 (materialize_predicates_for_standpoint) filters per source. The dispatcher in the same commit feeds per-source classifications to query_derive_federated. The shape is right; only the negative half is missing.

The Polarity field is not a negation field

AxiomEvent.op: Polarity ∈ {Assert, Retract} is the time-evolution polarity (asserting vs withdrawing a previous claim under bitemporal extent). It is not the ontological polarity (P(x) vs ¬P(x)). Conflating them would break RP-004’s bitemporal contract. The right move is a new axiom kind for ontological refutation, orthogonal to op.

Decision

Surface (1) — declaration-level strong negation

pub not_fact Person(alice);
pub not_fact Adult(bob);
pub not_fact employed_by(alice, AcmeCorp);

Symmetric to pub fact P(x); syntactically. Lowers to a new wire event variant. Permitted at file level and inside pub standpoint X { ... } blocks; in the latter case the refutation is stamped with X.standpoint_id like positive facts (commit 2776be9’s mechanism applies uniformly).

Wire format (2) — new axiom kind IofRefutation

#![allow(unused)]
fn main() {
pub enum AxiomKind {
    ...,
    IofAssertion,
    IofRefutation,  // NEW
    ...
}
}

Body shape mirrors IofAssertionBody exactly (concept_id + individual_id). The semantic difference is carried in the variant tag, not in body fields. Encoding/decoding follow the same pattern as IofAssertion.

Symmetrically, a relation-tuple refutation lands as RelationTupleRefutation parallel to RelationTuple for the N-ary case. Same body shape; same variant-tag carries the polarity.

Lean drift (3)

Argon.Storage.AxiomKind gains iofRefutation and relationTupleRefutation constructors with @[language_interface] carried. The Lean side reads only the algebraic content; the bilattice/federation machinery is unchanged because it already handles Truth4.not symmetrically.

Materializer (4)

Store::materialize_predicates_for_standpoint is extended:

  • Maintain TWO per-source extents: positive: RelationCatalog and negative: RelationCatalog.
  • IofAssertion events stamp positive; IofRefutation events stamp negative.
  • Symmetric handling for RelationTuple / RelationTupleRefutation.

The output is PerSourceExtents { positive, negative } rather than a single RelationCatalog.

Dispatcher (5)

Store::query_dispatch for federated queries classifies each (relation, tuple) pair via the cross-product of per-source extents:

for each source S:
    in_pos = tuple ∈ positive[S].entry(rel)
    in_neg = tuple ∈ negative[S].entry(rel)
    truth4_S(tuple) =
        match (in_pos, in_neg) {
            (true,  false) => Is,
            (false, true ) => Not,
            (true,  true ) => Both,   // S already contradicts itself
            (false, false) => Can,    // S has no evidence
        }

The full row classification feeds query_derive_federated exactly as before; the AFT info-join over per-source Truth4 values yields the final row tag.

For derived (rule-output) relations, rule evaluation runs against the positive catalog per source as before; Truth4::Not contribution only arises when the rule’s head is one a refutation event positively names — which is unusual for rule heads but legal. Per-source Truth4::Both from a single source is the contradiction-with-self case (pub fact P(a); pub not_fact P(a); inside the same standpoint block) — useful as an internal consistency check; federation then surfaces it as Both.

Soundness

The implementation is the operational discharge of federate_eq_both_iff:

  • A federated row surfaces Truth4::Both ⟺ some source’s per-tuple Truth4 is Both, OR some source contributes Is and another contributes Not.
  • This is exactly the Lean theorem.

aft_discharges_T3_obstruction then lifts: sheafClassify of the federated row’s contribution list is obstructed ⟺ row tag is Both. The runtime now exhibits the contextuality-detection case the proven theorem characterizes.

Diagnostics

  • OE0640 NegativeFactArityMismatchpub not_fact P(x, y) where P has arity 1 (parallels positive-side checks in RFD 0004).
  • OE0641 NegativeFactUndeclaredPredicatepub not_fact P(x) where P is not a declared concept or relation.
  • Stratified-NAF / strong-negation interaction inside rule bodies is out of scope for this RFD: rule bodies admit only not (NAF). A future RFD may add ~ (strong negation) as a body atom, but that work composes cleanly only after IofRefutation exists at the wire level.

Rationale

Why this and not just NAF

NAF is reasoning-time and absent from the wire format. It already exists. The bilattice value Truth4::Not represents positive evidence of refutation — distinct from NAF’s “no positive evidence”. Without strong negation, a standpoint cannot DISTINGUISH “I don’t know whether P(alice) holds” from “I know P(alice) does NOT hold”. These are different epistemic states and the bilattice is designed to track exactly that distinction.

Why a new AxiomKind, not an extension of IofAssertion

Three reasons:

  1. Clean Lean drift. @[language_interface] checks variant alignment. Adding a body field forces every existing consumer to handle a new field; adding a sibling variant only forces consumers of the new variant to handle it. RP-004 chose the second pattern (IofAssertion, RelationTuple, PropertyAssertion are all sibling variants), maintaining symmetry.
  2. Storage efficiency. A standpoint with mostly positive facts wastes a byte per event encoding polarity: false. A separate kind allows the encoder to omit the discriminator in the common case.
  3. Diagnostic and tool surface. axiom-events queries that want all refutations for a standpoint scan by kind == IofRefutation directly. A polarity-field design forces every consumer to decode-and-test.

Why declaration-level, not rule-body

The conflation of strong negation and NAF in rule bodies is a known source of confusion in logic-programming languages (Gelfond-Lifschitz 1991 §1: “the two negations are often conflated in practice, leading to subtle bugs”). Argon’s design discipline is to surface them as distinct categories: NAF as a rule-body operator on the extent at evaluation time, strong negation as a declaration form that ships through the wire format. This RFD keeps to that discipline.

Why now, not as part of RFD 0004

RFD 0004 was scoped to positive ground-truth facts. The federation runtime didn’t exist then; the gap was theoretical. With federation operationally complete (commits 14–16 + standpoint scoping + dispatcher), the gap is now load-bearing: it gates the headline competitive claim (vault: bilattice-native runtime, category C, “production-scale systems do not”). RFD 0004’s deferral was correct then; closing it is correct now.

Why the standpoint federation is the right host for this

The bilattice’s most distinctive value (Both) is a cross-source notion: it requires two sources contributing opposite verdicts on the same proposition. Single-source Both arises only from a source contradicting itself (legal but unusual). Federation is the natural host because federation is where cross-source disagreement is computed. Implementing strong negation as a fact-declaration form makes it composable with the federation runtime already wired.

Alternatives

A1: pub fact !Person(alice);

Reuse pub fact with a ! prefix on the head atom. Closer to logic-programming notation. Rejected: harder to grep, conflates positive/negative in the same surface form, makes the elaborator’s per-form dispatch fuzzier, and reads worse in error messages.

A2: pub fact not Person(alice);

not keyword inline. Rejected: collides with the existing rule-body NAF not, the exact conflation Gelfond-Lifschitz warned against. Different surfaces for different semantics is a feature.

A3: Extend IofAssertion with a polarity: bool body field

Single AxiomKind, body carries polarity. Rejected per §Rationale above (Lean drift symmetry, storage efficiency, tool surface).

A4: Defer until rule-body strong negation is also designed

Bundle both surfaces into a single RFD. Rejected: declaration-level refutation is a complete, independently-useful feature — every modeler can write pub not_fact P(x); and benefit immediately; rule-body strong negation requires non-trivial stratification work (~ P(x) interacts with NAF and defeasible rules in subtle ways and probably needs an answer-set-style semantics). Shipping declaration-level first lets us learn from usage before designing the rule-body half.

Consequences

What modelers gain

pub standpoint internal_audit {
    pub fact employed_by(alice, AcmeCorp);
}

pub standpoint public_record {
    pub not_fact employed_by(alice, AcmeCorp);
}

pub query employment() -> employed_by
    across [internal_audit, public_record];

The query returns (alice, AcmeCorp) tagged Truth4::Both — explicit, runtime-visible, modeler-actionable evidence of cross-source disagreement. Under #[federate(strict)] this surfaces as OE1302 FederationDisagreement (other agent’s renumbering); under default paraconsistent the row surfaces with the Both tag intact.

This is the operational moat Argon claims and nobody else delivers (per the vault note).

What changes for existing code

Nothing breaks. Every existing source compiles unchanged because pub not_fact is a new declaration form parsed by a new branch; existing pub fact lowering is untouched; existing federation queries return identical results when no source uses not_fact (because no Truth4::Not enters the contribution list, so the info-join behaves identically).

What this opens up

  • Rule-body strong negation (future RFD): ~ P(x) in rule bodies, with answer-set-style or AFT-stable-pair semantics.
  • Refutation rules (future RFD): derive ~ Adult(p) :- Adolescent(p) — deriving negative extents from rules, not just facts.
  • Per-relation closure declarations (future RFD): a relation explicitly marked CWA could auto-emit IofRefutation for every tuple not asserted, making CWA reasoning composable with federation.

Open questions

  1. Wire-format symmetry with Retract. Should Retract of an IofAssertion event subsequently restate the proposition’s status as Can (no positive evidence; no negative evidence) or as Not (positively refuted)? Retract clearly leans toward Can; the semantic gap with IofRefutation is then preserved. The implementation should treat them distinctly.

  2. What happens when a single standpoint asserts both pub fact P(a); and pub not_fact P(a);? Per the dispatcher design above, the per-source Truth4 becomes Both directly; federation then surfaces Both. Should this also be a build-time error (single-source consistency check), or only a runtime tag? RFD position: lower without error; the bilattice tag IS the diagnostic. A separate #[strict_internal] per-standpoint attribute could opt into build-time rejection in a future RFD.

  3. Diagnostic emission scope. Should OE0640/OE0641 ALSO be emitted at federation-query time when sources disagree even under #[federate(strict)]? No — the strict-policy case is already covered by OE1302 FederationDisagreement (other agent’s renumbering). OE0640/OE0641 are build-time arity/declared-predicate checks; they don’t need a runtime counterpart.

References

  • RFD 0004 §Future Work — “Negative facts / classical negation”
  • RFD 0007 — Missing-value semantics (the related distinction between three field intents)
  • RFD 0008 — Standpoint-Sheaf Equivalence Proof Roadmap (Path A discharge)
  • Argon.Foundation.Truth4 — bilattice carrier with .not constructor
  • Argon.Standpoint.Federation.federate_eq_both_iff — proven theorem this RFD makes runtime-observable
  • Argon.Standpoint.AFTEquivalence.aft_discharges_T3_obstruction — sheaf-equivalence discharge
  • Gelfond, M. & Lifschitz, V. (1991). Classical Negation in Logic Programs and Disjunctive Databases. New Generation Computing.
  • Belnap, N. D. (1977). A useful four-valued logic.
  • Vault: Bilattice-Native Query Evaluation — operationality levels (a/b/c)
  • Vault: AFT-Grounded Truth Value Semantics for Argon — the AFT pair correspondence

RFD 0011 — Aggregate semantics under OWA

  • State: discussion
  • Opened: 2026-05-29
  • Decides: how aggregate atoms (sum, count, min, max, avg, set_collect, …) evaluate under OWA when filter or input cells carry Can verdicts; the per-mode default semantics (query / derive / check / fn); the diagnostic surface that makes the projection observable rather than silent; the aggregation-metadata envelope that surfaces interval bounds and excluded-cell counts at every data-system boundary; resolves RFD 0007 OQ3.

Question

Argon admits aggregate atoms in rule bodies and query bodies (§6.6, §7.3.1, §7.4): sum(i.value for i in income where i.taxable) > threshold and friends. Under OWA, the filter predicate i.taxable can evaluate to Can (per RFD 0007’s required-field-under-OWA lift). The substrate has not specified what aggregates do with Can-valued cells.

The spec at §12.2 has only one nearby sentence: “Set / list / record projections: Can-valued cells are omitted from the result set.” Applied literally to aggregates this is silent fail-closed at the filter — Can-cells get dropped, the sum is a definite number, the comparison yields a definite verdict, and the modeler never learns that the answer depended on uncertain data they did not see. This is the SQL-NULL bug at scale. For a language whose primary contract is “OWA semantics done right,” it is an unacceptable default.

But the formal-correctness response (the elaborator forces the modeler to declare an aggregate-Can handling at every site) is hostile to the 90% case: writing reports, dashboards, and analytics queries should feel like SQL, not like coursework on three-valued logic.

This RFD picks the semantics that gives Argon both: SQL-like ergonomics in the default case, OWA-sound interval semantics in the substrate, and a never-silent projection at the surface.

Context

The motivating Slack thread (Almeida + Almeida, 2026-05-28 → 2026-05-29)

Gustavo Ladeira (Sharpe) extended the discussion of RFD 0007 with a concrete query:

pub kind Income {
    taxable: Bool?,
    value:   Money,
}

pub query AllIncome() -> [Income] :- i: Income, select i

pub derive TotalTaxableIncomeExceedsThreshold(threshold: Money) :-
    sum(i.value for i in AllIncome() where i.taxable) > threshold

He asked: “does the derive resolve to Can if both lines below hold simultaneously?

sum(i.value for i in AllIncome() where i.taxable == true) <= threshold
sum(i.value for i in AllIncome() where (i.taxable == true) || (not exists i.taxable)) > threshold

He had derived the interval-bound semantics for monotone aggregates in his own message: lower bound = sum over confirmed-true filter; upper bound = sum over confirmed-true OR Can filter; verdict is Can exactly when the threshold falls in the open interval. The spec did not have a rule giving him that semantics.

João Paulo Almeida (UFES) had separately raised the structural-vs-epistemic ambiguity of Bool? (RFD 0007’s motivating point), which compounds: under structural-optional Bool?, Gustavo’s None-cells are not taxable (a positive fact of absence) and the aggregate is definite by structural exclusion; under epistemic Bool + OWA, missing taxable lifts to Can and the interval semantics is what he wants. RFD 0007 resolved the surface ambiguity. This RFD resolves what happens once the Can-cells reach an aggregator.

Substrate readiness

  • Aggregate grammar is in §6.6 / §7.3.1 (atom shape: aggregate (comp-op expr)?; expression shape: aggregate ::= ('sum'|'count'|'min'|'max'|'avg') '(' expr ('for' Ident 'in' expr ('where' expr)?)? ')').
  • Aggregate runtime is not yet implemented. oxc-reasoning/src/compile/rule.rs:78 returns RuleCompileError::AggregateNotYet. The semi-naive executor classifies aggregates at the expressive tier (§10.1) and rejects them; no execution path exists. This RFD is greenfield design, not a behavior change.
  • Lean mechanization has no Reasoning/Aggregate.lean. The Truth4 substrate (Foundation/Truth4.lean) + the K3 conjunction in Reasoning/Fixpoint.lean give the operators an aggregate semantics can build on; the aggregate-specific theorems do not yet exist.
  • Truth4 projection is documented at §12.2: K3 fail-closed projection (Pietz–Rivieccio Exactly-True) folds Can → false for Bool, omits Can-cells from set/list/record results. This RFD threads the aggregate question through that projection rule without contradicting it.

Why the obvious answers fail

Three semantics, evaluated independently:

OptionSemanticsProblem
O1 — Silent fail-closedCan-cells dropped from filter set; aggregate definiteUnsound under OWA. Reproduces SQL-NULL bug. Modeler is never told.
O2 — Blanket Can-propagationAny Can in filter set → whole aggregate is CanSound but over-conservative. Loses real conclusions (when sum-over-Is alone already exceeds threshold, verdict is still Can).
O3 — Interval boundsAggregate evaluates to lower/upper interval; comparison lifts to Truth4 by interval-vs-thresholdSound and informative for monotone aggregates; vacuous bounds for min/max/avg.

No single option covers all aggregator kinds. The decision is to combine them with type-driven projection, so the modeler picks the level of sophistication via the result type while the substrate always evaluates soundly.

Decision

Three rules, applied in order. Each rule is one click away from the next; the modeler picks the level at which they want to engage with Can.

Rule 1 — The substrate evaluates aggregates in Truth4

Under OWA, an aggregate agg(expr for x in S where φ(x)) evaluates over a Truth4-tagged set:

{ x ∈ S : φ(x) ∈ {Is, Can} }

— with each element marked by its filter verdict. The aggregator’s behavior on this tagged set depends on its monotonicity class:

Monotone aggregators (interval bounds)

For sum over non-negative values, count, set_collect:

lower = agg over { x : φ(x) is Is(true) }
upper = agg over { x : φ(x) is Is(true) or Can }

For sum over signed values, the bounds are sign-partitioned: lower = sum(Is) + sum(negative-valued Can cells); upper = sum(Is) + sum(positive-valued Can cells). This is monotone in each sign-class independently and computable in a single pass.

The aggregator returns the Truth4-lifted interval — internally a pair (lower, upper) carried as Truth4Of<T> with bounds metadata.

Non-monotone aggregators (propagating)

For min, max, avg, percentile, string_join, count distinct:

if { x : φ(x) is Can } is empty:
    result = agg over { x : φ(x) is Is(true) }    // definite verdict
else:
    result = Can                                   // any Can in filter set
                                                    // propagates to whole result

These aggregators have non-monotone bounds (min(A ∪ B) ≤ min(A) and the upper bound depends on Can-cell values that aren’t known), so interval semantics gives vacuous bounds in the worst case. Propagating is the only sound default that doesn’t over-claim; modelers who want sharper semantics narrow the filter explicitly to Is-only.

Aggregator-kind taxonomy

AggregatorClassBound rule
countMonotone[|Is|, |Is| + |Can|]
count distinctNon-monotone (membership-dependent)Propagating
sum (non-negative)Monotone[sum(Is), sum(Is ∪ Can)]
sum (signed)Monotone (per-sign)[sum(Is) + sum⁻(Can), sum(Is) + sum⁺(Can)]
set_collectMonotone[set(Is), set(Is ∪ Can)] (containment interval)
string_joinNon-monotone (order-dependent)Propagating
minNon-monotonePropagating
maxNon-monotonePropagating
avgNon-monotonePropagating
percentileNon-monotonePropagating

The classification is fixed at the aggregator-kind level, not per-call.

Rule 2 — The surface projection is type-driven

The Truth4-tagged aggregate result propagates until it hits a typed boundary (the declared return type of the enclosing query/derive/fn, the head of a derive, the predicate of an if/match, the membership-witness of a refinement clause). At the boundary, the §12.2 K3 fail-closed projection fires, governed by the boundary’s declared type:

Declared type at boundaryProjection rule
BoolIs(true) → true; Is(false) → false; Not → false; Can → false (Pietz–Rivieccio Exactly-True)
T (any concrete type)Is(v) → v; Not / Can → null-or-error per RFD 0007’s required-field rule
Option<T>Is(v) → Some(v); Not → None; Can → None (structural projection)
Truth4Of<T>identity — preserves the Truth4 verdict
Truth4Of<Bool>identity — Is(true) / Is(false) / Not / Can preserved

A modeler switches between ergonomic and sound semantics by changing one type annotation:

// Ergonomic: SQL-like; projects Can → false at Bool boundary.
pub query QuarterlyOK() -> Bool {
    sum(i.value for i in income where i.taxable) > threshold
}

// Sound: preserves three-way verdict for caller.
pub query QuarterlyOK() -> Truth4Of<Bool> {
    sum(i.value for i in income where i.taxable) > threshold
}

For derive rule heads, the same dial applies via the head type. A pub derive Foo(p: Person) is Truth4Of<Bool> rule populates a Truth4-aware IDB relation; consumers reading Foo(p) get Is | Not | Can directly.

Rule 3 — The projection is never silent

Two pieces ensure the modeler always sees the soundness picture, no matter which projection they chose.

3a. Per-projection diagnostic

Every fail-closed projection at an aggregate boundary emits a diagnostic with the interval bounds and excluded-cell count. The diagnostic level depends on the mode:

ModeLevelCodeRationale
query -> T (concrete T)InfoOW0613Ergonomic-first; modeler informed but not interrupted
query -> Truth4Of<T>(none)Already sound; nothing to flag
fn -> TInfoOW0613Same as query
derive (head over concrete T)WarningOW0613IDB pollution can propagate; warn more visibly
derive (head over Truth4Of<T>)(none)Sound; no need to flag
check (predicate body)ErrorOE0614Compliance — unsound checks defeat the purpose; modeler must explicitly opt out

The diagnostic carries:

14:5: info[OW0613]: aggregate filter projection fail-closed
  |
14 |   sum(i.value for i in income where i.taxable) > threshold
  |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  | filter `i.taxable` may evaluate to Can; verdict projects to Bool with
  | Can → false (Pietz–Rivieccio Exactly-True, §12.2). Interval bounds for
  | this materialization: [$1,234,567.00, $1,256,789.00]; 5 Can-cells.
  |
help: change return type to Truth4Of<Bool> to preserve Can verdict:
  pub query QuarterlyOK() -> Truth4Of<Bool> { … }
help: or filter explicitly to suppress this diagnostic:
  ... where i.taxable is Is(true)              // fail-closed, explicit
  ... where i.taxable is Is(true) or Can       // include unknowns
help: silence per-rule with #[allow(aggregate_fail_closed)]

The diagnostic is suppressible per-site with #[allow(aggregate_fail_closed)]; in check mode, the diagnostic is OE0614 (error) and the only opt-out is #[check(fail_closed)] on the check declaration, which makes the soundness loss explicit at the check site.

3b. Per-result metadata envelope

Every aggregate query result, regardless of projection target, carries an aggregation_metadata envelope alongside the projected value. The envelope is always present when the aggregate’s filter set could have been Can-valued; it is absent when the aggregate is provably Can-free.

JSON shape (REST / wire format):

{
    "value": true,
    "aggregation_metadata": {
        "result_type": "Bool",
        "projected_from": "Truth4Of<Bool>",
        "projected_verdict": "Is(true)",
        "interval": { "lower": 1234567.00, "upper": 1256789.00 },
        "excluded_can_cells": 5,
        "excluded_can_value_bounds": { "lower": 22222.00, "upper": 22222.00 },
        "verdict_robust": true
    }
}

verdict_robust = true iff the projected verdict would be the same across every completion of the Can-cells. When false, the modeler / auditor / dashboard sees that the answer depended on the projection — exactly the SQL-NULL bug made visible.

CLI presentation (ox query):

$ ox query QuarterlyOK
true

(aggregate: 5 of 100 income rows had Can-valued `taxable`;
            bounds [$1,234,567, $1,256,789];
            verdict robust to all completions)

ox query --explain prints the full envelope; standard text mode prints a one-line summary when excluded_can_cells > 0. The envelope is computed during the substrate’s Truth4 evaluation (the bounds are already needed for monotone aggregators), so the runtime cost is one allocation per aggregate, not an extra pass.

Per-mode default summary

ModeDefault semanticsDiagnosticOpt-outOpt-in to sound
query -> TErgonomic; fail-closed projection at boundaryOW0613 Info#[allow(aggregate_fail_closed)]Return Truth4Of<T>
query -> Truth4Of<T>Sound; preserves Can(none)(n/a)(already sound)
derive (head T)Ergonomic; fail-closed at head projectionOW0613 Warning#[allow(aggregate_fail_closed)]Head type Truth4Of<T>
derive (head Truth4Of<T>)Sound; IDB carries Can verdict(none)(n/a)(already sound)
checkSound; fires alert on any non-Is(true) verdictOE0614 Error if fail-closed without opt-out#[check(fail_closed)](already sound)
fn -> TErgonomic; fail-closedOW0613 Info#[allow(aggregate_fail_closed)]Return Truth4Of<T>

Worked example — Gustavo’s case

After this RFD lands, Gustavo’s pattern, with the epistemic-Bool surface (per RFD 0007) and the sound-three-verdict return type:

pub kind Income {
    taxable: Bool,                // required + OWA — epistemic reading
    value:   Money,
}

pub query AllIncome() -> [Income] :- i: Income, select i

pub derive TotalTaxableIncomeExceedsThreshold(threshold: Money)
        is Truth4Of<Bool> :-
    sum(i.value for i in AllIncome() where i.taxable) > threshold

The substrate’s monotone-sum evaluates to Truth4Of<Money> with bounds [lower, upper] per Rule 1. The comparison > threshold lifts pointwise:

threshold <  lower            → Is(true)    // every completion exceeds
threshold >= upper            → Is(false)   // no completion exceeds
lower <= threshold < upper    → Can         // depends on which Can-cells are taxable

The head type Truth4Of<Bool> preserves the verdict (Rule 2). Consumers read TotalTaxableIncomeExceedsThreshold(threshold) and pattern-match on the three outcomes. No diagnostic fires (Rule 3) — the modeler chose the sound path explicitly.

If Gustavo had written is Bool on the head, the verdict projects fail-closed (Can → false), the diagnostic fires at warning level (derive mode), and the materialized IDB record carries false for the Can-case threshold. The metadata envelope on the query result still shows the bounds and Can-cell count.

Rationale

Why “substrate sound, surface projected” rather than “force the modeler”

The earlier draft of this design (A1+B1 from the Slack discussion) made the elaborator hard-error at every where-clause that could be Can, requiring the modeler to declare an aggregate-Can handling explicitly. This was formally correct but practically hostile: every aggregate over OWA data in a routine query would error until the modeler annotated. Argon’s contract as a practical data systems language requires that day-to-day analytics queries — reports, dashboards, ad-hoc SQL-like exploration — feel like SQL with one knob upgraded, not like a three-valued-logic seminar.

The substrate-Truth4 + surface-projected design separates the two concerns. The substrate always evaluates with Truth4 fidelity (the OWA-soundness contract is preserved regardless of what the modeler writes). The surface projection is type-driven: the modeler picks ergonomic Bool or sound Truth4Of via the return type, with the type-system change costing one character. The “never silent” rule ensures the modeler is informed about the projection at every fail-closed site without being blocked. This is the design SQL should have had.

Why the per-mode default split

query is the analytics surface — ergonomics-first. derive populates the IDB and propagates downstream; an unsound derive contaminates many subsequent rules. check is compliance — a check that silently fails-closed and misses a violation is the worst possible failure mode for the system. The diagnostic level escalates monotonically with the cost of silent-wrong: info → warning → error.

This is symmetric with how Rust handles Result<T, E>: ignoring the error is allowed but loud (#[must_use] warning by default), and the language never forces you to handle every case at every call site. The “you must handle this” boundary is at the typed-result boundary, not at every intermediate computation.

Why the metadata envelope is always-present (not opt-in)

The bounds and Can-cell counts are computed by the substrate regardless (they’re load-bearing for the monotone interval evaluation). Surfacing them at the result boundary is near-free. Making them opt-in (?metadata=true) would mean most data-system consumers — dashboards, CSV exports, REST clients — never see them, and the SQL-NULL bug returns silently. Always-present means the auditor / debugger / pipeline-engineer always has the evidence; the cost is a few extra bytes per result.

The CLI’s one-line summary (only printed when excluded_can_cells > 0) keeps the common-case output clean while still being honest. ox query --explain shows the full envelope.

Why interval semantics, not propagation, for monotone aggregators

Propagation (any Can → whole result Can) is sound but loses real conclusions. When sum-over-Is alone already exceeds threshold, the verdict is definitively Is(true) regardless of the Can-cells; propagation would still say Can and the modeler / system / auditor would get a less informative answer than the data supports. Interval semantics extracts the maximum signal from the partial data without over-claiming.

For non-monotone aggregators, interval semantics gives vacuous bounds (e.g., min with Can-cells could be anything in the Can-cells’ value range); propagation is the right default there because the alternative is bounds that don’t constrain. The taxonomy fixes the classification per-aggregator-kind so modelers don’t have to reason about which gets which.

Alternatives considered

A. Silent fail-closed (the SQL-NULL default, what §12.2 implies today)

Can-cells dropped from filter set; aggregate produces a definite verdict; modeler never told. Rejected. Reproduces the SQL-NULL bug at the OWA scale. Argon’s substrate guarantees OWA-soundness; a surface that silently violates the guarantee defeats the language’s central pitch.

B. Hard-error at every aggregate over Can-potential filter (the original A1+B1)

Elaborator refuses to compile until the modeler annotates the aggregate with one of is Is(true) / is Can / propagating. Rejected. Hostile to practical analytics — every query against OWA-required field must annotate; the migration cost from existing codebases is enormous; the friction undermines the data-system-as-product story.

C. Always-Truth4Of return type

Aggregators always return Truth4Of<T>; modelers must pattern-match to extract a value. Rejected. Most aggregator results are immediately compared or summed downstream where the ergonomic fail-closed projection is what the modeler wants; forcing Truth4Of<T> everywhere is the same hostile-friction problem as B.

D. Per-call annotation in the aggregate body

sum(i.value for i where i.taxable mode=interval) > threshold. Rejected. Annotation is at the wrong site — the modeler’s intent is about the projection, not the aggregator. The return type is the natural place to declare it.

E. Two-pass query model (run with one semantics, switch interactively)

ox query defaults to fail-closed; modeler runs ox query --reanalyze sound if they want to see the Can-case. Rejected. Doesn’t work for batch / programmatic consumers, and the modeler doesn’t know to ask the second time unless something cues them. The always-present metadata envelope (Rule 3b) gives the same effect without the second invocation.

F. Defer entirely

Wait until the aggregate executor lands (oxc-reasoning::AggregateNotYet) and decide then. Rejected. The executor needs this decision before it can be implemented; the design space has been actively explored; settling now means the implementation doesn’t ship with an ad-hoc choice. RFD 0007 OQ3 already promised follow-on; this is it.

Consequences

What lands

PieceWhereApproximate size
Spec §7.4 — aggregate semantics under OWA sectionspec/reference/src/07-rules.md~50 lines
Spec §12.2 — clarify “set projection” rule is about result-set membership, NOT aggregate inputspec/reference/src/12-truth4.md~10 lines
Spec §6.6 — note in expression-aggregate grammarspec/reference/src/06-types.md~5 lines
Diagnostic codes OW0613, OE0614, OW0615compiler/crates/oxc-syntax/grammar.toml + Appendix C~3 entries
Aggregator-kind taxonomy tableoxc-protocol::core_ir::AggregateKind (extend with MonotonicityClass)~20 LoC
Reasoning/Aggregate.lean — interval-bound theorem for monotone aggregatorsspec/lean/Argon/Reasoning/~120 lines
Runtime aggregate executor with Truth4 evaluationoxc-reasoning/src/compile/aggregate.rs (new)~300 LoC, replacing AggregateNotYet
Metadata envelope wire formatoxc-protocol::query_result::AggregationMetadata (new)~40 LoC
CLI ox query aggregate-summary renderingoxc-driver/src/commands/query.rs~30 LoC
ox query --explain extensionoxc-driver/src/commands/query.rs~50 LoC

Substrate impact

  • One new theorem in Reasoning/Aggregate.lean: for any monotone aggregator A over a filter predicate φ, the interval [A(Is), A(Is ∪ Can)] is a sound over-approximation of every completion of the KB. Proven by induction on the K3 fixpoint already established in Reasoning/Fixpoint.lean.
  • No change to subsumption_axiom, iof_assertion, relation_tuple wire formats. The metadata envelope is computed at query time, not stored.
  • No change to the Z-set Storage model. Aggregates are evaluated over materialized relation extents, which already carry the Truth4 tags from the underlying fixpoint.

Modeler impact

  • Routine analytics queries compile unchanged; behavior is SQL-NULL fail-closed but with bounded transparency via the metadata envelope and the info-level diagnostic.
  • Soundness opt-in is a one-character type change (BoolTruth4Of<Bool>).
  • check rules get stricter default — modelers writing compliance checks must engage with Can or explicitly opt out, which is the right default for compliance.
  • Existing rules that compiled under the unimplemented-aggregate path will compile under this RFD with the same surface; behavior change vs the unimplemented baseline is the addition of the diagnostic and the metadata envelope.

Tooling impact

  • ox query shows aggregate summaries by default when Can-cells were excluded.
  • ox query --explain shows full metadata envelopes.
  • LSP can surface the diagnostic inline; debuggers can read the envelope.
  • REST adapters return the envelope alongside results; clients can ignore it or render it.

Compatibility

The aggregate executor is a new implementation; there is no prior behavior to preserve. The grammar surface is unchanged. The metadata envelope is additive; consumers that don’t read it see the same JSON shape they would have seen.

Open questions

OQ1 — Aggregator-kind monotonicity classification under user-defined aggregators

When modelers register custom aggregators via pub aggregate (a future grammar surface for plugin aggregators), how do they declare the monotonicity class? Likely via attribute (#[monotone] / #[propagating]) on the aggregator declaration. Out of scope for this RFD; document when the custom-aggregator surface lands.

OQ2 — Aggregate-of-aggregate composition

What does sum(count(j.value for j in i.children) for i in roots()) look like under partial Can data? The inner aggregator produces a Truth4Of<Nat> interval per row; the outer aggregator over a sequence of intervals needs an interval composition rule. For monotone outer aggregators over interval-valued inputs, the composition is sum-of-bounds — pointwise interval arithmetic. Specify in the Reasoning/Aggregate.lean mechanization; document examples in §7.4.

OQ3 — Interaction with temporal aggregates (§6.10.5 + §7.3.2)

DatalogMTL operators since/until combined with aggregates. Under OWA + temporal Can, the interval bounds need to factor in the temporal extent. The temporal substrate’s three-valued evaluation (§6.10.5) composes pointwise; the aggregate’s interval semantics composes monotonically; the combined semantics needs verification. Likely lands in a follow-on RFD on temporal-aggregate semantics.

OQ4 — #[brave] mode interaction

§7.3 admits #[brave] for stable-model semantics. Under brave + OWA, aggregates need to evaluate across stable models. Each stable model produces a definite verdict; the aggregate result is the join across models. Out of scope; document when the brave-semantics RFD is written.

OQ5 — Metadata envelope schema versioning

The envelope is a wire-format addition; future fields (additional bounds, multiple Can-cell categorizations) require a schema version. Adopt the oxc-protocol versioning policy from RFD 0001; envelope carries a version: 1 field.

OQ6 — Persistent aggregate caching under metadata

Salsa-cached aggregate results need to track the metadata envelope; cache invalidation when Can-cells are resolved (via subsequent insert facts) must re-fire dependent aggregates. Likely a small extension to the Salsa cache key but worth verifying with the oxc-runtime Salsa wiring.

References

  • RFD 0007 — Missing-value semantics under OWA §OQ3 — the open question this RFD resolves
  • §6.6 (aggregate expression grammar), §6.9 (CWA/OWA), §6.10.5 (Kleene truth tables), §7.3.1 (rule-atom aggregates), §7.3 line 72 (“stratified aggregates”), §7.4 (query mode), §12.2 (K3 fail-closed projection)
  • oxc-reasoning/src/compile/rule.rs:78AggregateNotYet (the implementation site)
  • Foundation/Truth4.lean, Foundation/Projection.lean, Reasoning/Fixpoint.lean — substrate mechanization this RFD builds on
  • Faber, W., Pfeifer, G., Leone, N. (2011). Semantics and complexity of recursive aggregates in answer set programming. Artificial Intelligence — the stratification rule §7 cites
  • Pietz, A. & Rivieccio, U. (2013). Nothing but the truth. Journal of Philosophical Logic — the K3 fail-closed projection
  • Belnap, N. (1977). A useful four-valued logic — the underlying bilattice
  • Almeida, J.P.A. (UFES) & Ladeira, G. (Sharpe), Slack thread, 2026-05-28 → 2026-05-29 — the motivating discussion

RFD 0013 — Toolchain distribution + oxup toolchain manager

  • State: accepted — partially implemented (Stage 0 deployed, Stage 4 merged)
  • Opened: 2026-06-01
  • Last revised: 2026-06-02
  • Decides: how Argon ships to engineer laptops (Mac arm64) + ODE sandbox AMIs (Linux arm64 + x86_64); whether to adopt a rustup-style toolchain manager; the AWS-hosted distribution architecture; the ~/.argon/ legacy-install cleanup story.
  • Status: Implemented — the v0.2 toolchain shipped; this RFD is the design record.

Question

v0.1.0 ships a single ox binary from a homemade install.sh against git clone. For Sharpe-internal v0.2, what’s the install + distribution + toolchain-management story?

Decision

Adopt the rustup-style architecture with an oxup manager binary + argv[0] dispatch, splitting the compiler into four logical tools (oxc / ox / ox-lsp / oxfmt), distributed via AWS S3 + CloudFront at argon.sharpe-dev.com (managed via Pulumi in the shared infra account 285688017134). The user-facing reference is the book Toolchain chapter; the dist mechanics live in infra/ + the release pipeline.

Sharpe-internal scope means: no LICENSE / no Marketplace publish / no public installer; private bucket via Cloudflare-proxied CloudFront; CI’s ArgonReleaseRole is narrow-scoped (S3 PutObject + CloudFront invalidate only). Okta-gated downloads deferred to v0.3 (separate RFD).

Account note: the distribution lives in AWS account 285688017134, which is the default SSO profile (~/.aws/config) — the shared infra account where the ODE/orca-mvp Pulumi stacks, the s3://sharpe-pulumi-state backend, and the *.sharpe-dev.com wildcard ACM cert already live. It is not the shared-admin profile (account 548277374575, which is empty). Maintainers authenticate with assume default / AWS_PROFILE=default.

Implementation status (2026-06-02)

  • Stage 0 deployed to account 285688017134: S3 argon-dist-sharpe + CloudFront E16VOSAVQFX5Y8 (argon.sharpe-dev.com, proxied) + ArgonReleaseRole (OIDC). Edge verified (HTTP 403 = healthy empty bucket).
  • Stage 4 merged (PR #6): release.yml publish-dist (OIDC → S3 → CloudFront) + nightly cron, wired via repo secret AWS_ROLE_ARGON_RELEASE + var ARGON_CLOUDFRONT_DIST_ID. Graduated to the §5 toolchain (Stages 1–3 having landed): builds all four binaries, assembles argon-<v>-<plat>.tar.gz via scripts/assemble-toolchain.sh, and publishes the [artifacts.*] channel-<channel>.toml via scripts/make-channel-manifest.sh — the schema oxup install consumes (round-trip tested in oxup/src/fetch.rs; the format is pinned by scripts/make-channel-manifest.sh). Fixed two latent contract bugs (bare-ox tarball; [platforms.*] vs [artifacts.*] manifest).
  • Stays in the mgmt account (285). A move to a workload account (prod/623) was investigated and rejected: the workload-account org SCP p-pif76ezm denies s3:PutBucketPolicy / PutBucketPublicAccessBlock to interactive SSO admins, so the CloudFront-OAC bucket policy can only be set by an SCP-exempt CI role. For an internal static CDN that’s not worth a full CI-driven deploy apparatus; 285 (where SSO admins can set bucket policies) is the right home for maintainer-operated tooling. Maintainer-local pulumi up runs from nix develop .#infra.
  • Adjacent: Argon-aware PR review bots (claude-review.yml + claude.yml, PR #7) landed alongside this work.
  • Remaining: Stages 1–3, 5–7 (binary split → share/std/oxupoxfmt → hosted install.sh → smoke) → tag v0.2.0.

Scope of infra/

This RFD authorizes the new top-level infra/ directory holding the Pulumi project (TypeScript + Bun, matches the ODE pattern at ontology-tooling/infra/) that creates the distribution stack:

  • argon-dist-sharpe S3 bucket (us-east-2, private, OAC-only read)
  • CloudFront distribution (PriceClass_100, TLS 1.2+, two cache policies)
  • Reuse of the existing *.sharpe-dev.com wildcard ACM cert (us-east-1)
  • Cloudflare CNAME argon.sharpe-dev.com → CloudFront (proxied / orange cloud)
  • ArgonReleaseRole IAM role with GitHub OIDC trust for sharpe-dev/argon tag pushes

Maintainers run pulumi up locally with the default profile (assume default). CI never touches the infra stack — only the ArgonReleaseRole it outputs.

Scope of oxup/

This RFD also authorizes the new top-level oxup/ directory (Stage 3) — a standalone Rust crate (its own Cargo.toml/Cargo.lock, not a member of compiler/’s workspace) so it stays cheap to build for bootstrap. It is a single binary with argv[0] dispatch:

  • Invoked as ox/oxc/ox-lsp/oxfmt (via symlinks), it resolves the active toolchain (§4) and execs the real tool from ~/.argon/toolchains/<spec>/bin/<tool>.
  • Invoked as oxup, it’s the manager CLI (init/install/update/default/list/which/uninstall/self-update).

Decomposition (decided during Stage 2/3): Stage 3 lands in two PRs.

  • 3a — network-free foundation: the crate, argv[0] dispatch, toolchain resolution (§4 precedence), the ~/.argon layout, and the local manager commands (which/list/default/uninstall). Its own CI job in check.yml.
  • 3b — the network layer: install/update/self-update + auto-fetch-on-miss from argon.sharpe-dev.com, plus the release.yml §5 tarball (bin/ + share/std + manifest.toml) the fetch consumes. 3b also drops the Stage 2 stdlib embed once the tarball ships share/std. Gated on the release pipeline producing real toolchains.

Three-platform build matrix (v0.2)

  • macos-arm64 — all Sharpe dev machines (Apple Silicon)
  • linux-aarch64 — ODE sandbox AMIs + arm Linux CI runners
  • linux-x86_64 — generic Linux + non-arm CI runners

Intel Mac (macos-x86_64) deferred; Windows deferred.

Build order (eight stages, ~8 focused days)

StageOutput
0infra/ Pulumi project + apply via assume default (account 285688017134)
1Compiler binary split (oxc/ox/ox-lsp/oxfmt bin targets in compiler/)
2Stdlib migration (include_str!share/std/)
3oxup crate (argv[0] dispatch + channel resolver + auto-fetch)
4Release pipeline (GitHub Actions → S3 → CloudFront invalidate)
5oxfmt opinionated formatter (parse → CST walk → normalized emit)
6Hosted install.sh at argon.sharpe-dev.com/install.sh
7Smoke + docs + clean-VM verification

Out of scope for this RFD

  • Okta-gated downloads (Google Workspace SSO) — v0.3 RFD
  • Package registry + lockfile — v0.3+ when external deps land
  • Apple Developer signing / notarization — internal binaries
  • Telemetry payloads — opt-out reserved in settings.toml; no payload sent

See

  • The book Toolchain chapter (spec/reference/src/toolchain.md) — the user-facing install/channel/version reference
  • /infra/README.md — operator’s guide for running pulumi up
  • /infra/index.ts — top-level Pulumi wiring
  • ontology-tooling/infra/ — the ODE pattern this design adapts

RFD 0014 — Runtime Serving Surface

State: discussion

Question

Should Argon expose a first-party serving surface for executing a loaded .oxbin against tenant/fork scoped runtime state?

Context

The §19 runtime contract defines the in-process Engine / Module / Store semantics, but Ode and Tide workflows need an executable boundary: load the workspace .oxbin, keep Store state warm, dispatch generated SDK descriptors, and expose forks, snapshots, derive output, and debug traces.

The old kernel and devbox-kernel path mixed this runtime work with live source loading, generated SDK transport concerns, tenant hardcoding, workflow state, and Tide-specific globals. New Argon has a compiled .oxbin, so the runtime serving layer can be Argon-native and narrower.

Decision

Add ox runtime serve backed by an oxc-serve crate. The command serves a versioned /v1 API around a loaded .oxbin:

  • GET /v1/health
  • GET /v1/module
  • GET /v1/schema
  • POST /v1/dispatch/query
  • POST /v1/dispatch/mutation
  • POST /v1/dispatch/compute
  • fork-scoped dispatch aliases under /v1/forks/{fork}/dispatch/*
  • GET /v1/forks, POST /v1/forks, GET /v1/forks/{fork}
  • DELETE /v1/forks/{fork}, POST /v1/forks/{fork}/promote
  • GET /v1/forks/{fork}/diff/{other}
  • POST /v1/forks/{fork}/derive
  • POST /v1/forks/{fork}/derive/trace
  • GET /v1/snapshot
  • GET /v1/derived/individuals/{id}
  • GET /v1/derived/individuals/{id}/explain
  • GET /v1/derived/facts/{fact_id}/explain

The serving layer supports mem and pg storage. Both backends preserve the append-only ABox event-log model; derived state and snapshots are projections over .oxbin plus visible events. Postgres additionally owns durable fork records, generation counters, scoped/as-of scans, and projection-cache invalidation.

The generated SDK remains dependency-free and transport-agnostic. It emits types, validators, metadata, descriptors, and wire parse/serialize helpers. It does not import or generate a runtime client.

Rationale

This keeps the core runtime responsibilities inside Argon, where the .oxbin, storage, rule evaluation, and fork semantics live. It also keeps environment concerns outside Argon:

  • tenant/principal selection belongs to the caller or proxy,
  • Tide workflow state and run journals belong to Tide,
  • Ode UI state and visualization layout belong to Ode,
  • SDK transport adapters belong to the host application.

The /v1 API is descriptor-based so generated SDKs can call it through a small host transport without coupling generated code to HTTP, Tide, fetch, or a specific deployment.

Alternatives

One alternative was a separate ontology-tooling service that wrapped Argon. That would have duplicated runtime semantics and left forks, storage, and derive behavior outside the implementation that owns them.

Another alternative was generating a full SDK runtime client. That is more ergonomic for simple workflows, but it couples generated output to transport and deployment concerns. The chosen design leaves that as a hand-written host adapter.

Consequences

ox runtime serve is a long-running process and therefore introduces async HTTP dependencies in the compiler workspace. The serving layer must keep a strict boundary: no generic entity writes, no ad-hoc raw query/mutation execution, no Tide or Ode state, no tenant hardcoding, and no SDK transport generation.

Hot reload is allowed only after compatibility checks. Additive schema changes may load; declaration removal or field/relation type changes are rejected when live ABox data exists.

unsafe_logic and forget remain capability-gated. Where the underlying language/runtime substrate is not enabled, the serving API refuses the request instead of pretending the capability exists.

Open Questions

  • Whether the full PosBool DNF provenance witness tree should be exposed by the current explain endpoints or by a later provenance-specific API.
  • Whether projection caches should become durable response caches for selected read endpoints or stay as backend-maintained invalidation state until DBSP arrangements land.
  • The final production IAM mapping for fork, forget, and future unsafe_logic execution budgets.

RFD 0015 — mutate body surface: EdgeQL-shaped, set-semantic

  • State: committed
  • Opened: 2026-06-03
  • Decides: the surface and semantics of the imperative mutate body that §7.5 promises but the v0.1 implementation never shipped (issue #15) — require guards, let bindings, insert-as-expression with named-field entity construction, update … set { = / += / -= }, insert … into …, for iteration, and return; the collection model these operate over (first-class, stored, generic) and its determinism contract (adopts RP-006 Track A / D-133); the subset that ships first to unblock the overlay vs. the full surface; and the commitment to mechanize mutation execution semantics in Lean. Rejects the legacy orca do { } / retract { } / emit { } clause structure. Relates to RFD 0001 (identity), RFD 0006 (field mutability), RFD 0011 (aggregates in guards).

Question

§7.5 documents a full imperative mutate body — preconditions, local bindings, typed-literal entity construction, collection inserts, control flow, and return. §7.5.1 admits the v0.1 implementation ships only a datalog-style subset (insert iof, relation-tuple insert, simple-target update … set, delete, forget) and that everything else “is designed but not yet implemented in the parser, elaborator, or runtime.” Two production mutations in packages/overlay (Ayush & Aryan’s materializeExpectedSatisfaction / recognizeSatisfaction) cannot be expressed without the missing forms; the parser reports OE0001 unexpected token … at module level the moment it meets let/for/return after a require block.

What is the mutate body’s surface and semantics, what does it operate over, and what ships first?

Context

The blocker. The overlay needs, per mutation: a precondition (one with an aggregate — occurrence.value == sum(r.value for r in records)), construction of fresh entities with named fields whose handles are reused downstream (let record = insert ExpectedSatisfactionRecord { … } then timeInterval: period), appending to a collection at a nested path, per-element posting under a for, and a returned result.

Why it doesn’t parse. mutation_stmt (compiler/crates/oxc-parser/src/grammar.rs) recognizes only insert/delete/forget/update; any other leading token falls through recovery, the require { } block’s } is mistaken for the body’s, and the remaining statements are re-parsed at module level.

Three layers, only one of them easy.

  1. AST — the Lean already mechanizes the whole imperative body (MutateDecl { require, body : List Stmt }; Stmt = letStmt | insertStmt | updateStmt | forLoop | ifStmt | returnStmt | exprStmt | …; InsertForm = typedLiteral | namedTyped | intoCollection | relation), all @[language_interface]-tagged, so the Rust AST is generated. The grammar exists; the parser just doesn’t build it.
  2. Execution — today a body lowers to a flat Vec<Operation> (a datalog DML batch: InsertIof/InsertTuple/Update{set}/Delete/Forget). There are no bindings, no data-flow between statements, no entity minting, no control flow, no return. Individuals arrive as parameters and get classified/updated; nothing is created. This is the real gap.
  3. Substrate (Lean)mutate is deliberately not a substrate rule (lowerRule returns none; it is a “host-runtime entry point”), and its execution semantics are unspecified.

The legacy precedent we reject. The orca-era vault design (D-064) gave mutations a five-clause COBOL-like structure: require { } / do { } / retract { } / emit { } / return. The new Argon already dropped the divisions (the Lean has a flat body : List Stmt, not separate do/retract clauses). This RFD finishes that move in the opposite stylistic direction from imperative paragraphs: SQL/EdgeQL-shaped statements, not COBOL divisions.

The settled direction (this RFD’s design discussion, 2026-06-02/03). The mutate body is imperative-looking but set-semantic, rhyming with EdgeQL: named-field insert as a value, update … set with collection ops, for as set-mapped iteration, guards as preconditions. Collections are first-class and stored (not derived-from-edges) — Set/Map/List are already declared generics in §6 — and governed by a determinism contract (below). Rust-like return types and error handling (Option/Result/?) are explicitly out of scope here.

Decision

1. The body is a sequence of statements; insert is an expression

mutate-decl   ::= attribute* 'pub'? 'mutate' Ident generic-params? '(' param-list ')'
                  ('->' TypeExpr)? mutate-body
mutate-body   ::= '{' stmt* tail-expr? '}'
stmt          ::= require-stmt | let-stmt | update-stmt | insert-stmt
                | delete-stmt | for-stmt | expr-stmt | return-stmt
require-stmt  ::= 'require' (expr | '{' expr (',' expr)* ','? '}') ';'
let-stmt      ::= 'let' Ident (':' TypeExpr)? '=' expr ';'
update-stmt   ::= 'update' expr (':' TypeExpr)? 'set' '{' field-assign (',' …)* '}' ('where' expr)? ';'
insert-stmt   ::= 'insert' insert-form ('since'|'during'|'at' expr)? ';'
insert-form   ::= TypeExpr '{' field-init (',' …)* '}'        // typed-literal: construct + return entity
                | Ident ':' TypeExpr '{' field-init (',' …)* '}'
                | Ident 'into' expr                            // collection add (sugar — see §4)
                | Path '(' arg-list ')'                        // relation tuple / iof — existing
delete-stmt   ::= 'delete' delete-form ('at' expr)? ';'
for-stmt      ::= 'for' Ident 'in' expr '{' stmt* '}'
return-stmt   ::= 'return' expr ';'
field-assign  ::= Ident ('=' | '+=' | '-=') expr               // set / collection-add / collection-remove

insert TypeExpr { … } is an expression that evaluates to the freshly-constructed entity (EdgeQL / SQL RETURNING). This is forced by the binding case (let record = insert … { … } then referencing record), and it makes entity construction compose. Constructing a fresh entity mints a fresh identity per RFD 0001.

2. Return: tail expression, or explicit return (which stays first-class)

  • A mutate with -> T must produce a T. The body’s tail expression (Rust’s rule — no trailing ;) is that value; since insert is an expression, ending the body with an insert T { … } returns the new entity with no keyword.
  • A value bound with let is returned by a bare tail (sr) or by explicit return sr;.
  • return expr; is first-class, not merely an early-exit affordance — it is what makes returning collections, tuples, and (later) Option/Result readable.
  • No -> T ⇒ the mutate returns unit; the body is pure effects.
  • “Return everything inserted” is rejected as a default (fragile under reordering, ambiguous for multiple inserts). To return several things, the tail is an explicit tuple or collection.

3. Collections are first-class, stored, and governed by determinism-by-observability

Adopts RP-006 Track A / D-133:

  • The invariant: no language-observable result (return values, serialization, the mutation event-log order) may depend on hash-derived order. This is stronger than “the implementation is internally deterministic” and is the correct knob — a randomly-seeded hash table is admissible iff order never leaks; a fixed-seed one still couples program meaning to the hasher the moment order is observed, breaking reproducibility across Argon’s in-process / Postgres / DBSP backends.
  • Order-sensitivity is a type property. Vec/List are ordered (order is the contract). Set/Map are unordered: the contract forbids observing order; where an observable sequence is unavoidable the language yields a canonical key-sorted order, never hash order.
  • Default backing: BTreeSet/BTreeMap (canonical iteration for free; no rehash spikes). A SwissTable/hashbrown Map is the opt-in fast path for lookup-heavy, order-insensitive use. Elastic/funnel hashing (#[dense]) is deferred pending a measured high-density win (RP-006 §5, benchmark harness pending).

4. insert … into … is kept as sugar

insert x into <coll-path> is retained (users coming from SQL INSERT INTO expect it) and desugars to a collection add: insert x into a.b.recordsupdate a.b set { records += x }. The canonical/primitive form is update … set { coll += / -= … }; insert into is surface sugar over it. (Reverses this RFD’s draft decision to drop it.)

5. for is set-mapped, and the event-order leak is closed

for x in coll { … } applies its body per element. To keep the mutation event-log order deterministic (replay, provenance), iteration over an unordered collection proceeds in canonical key-sorted order by default (RP-006 §A.3). When the analyzer can prove the body’s per-iteration effects are commutative (independent updates to disjoint targets — e.g. the overlay’s for r in records { update r.account … } when the r.account targets are distinct), the canonical sort may be elided. DBSP’s Z-set deltas may discharge this automatically for IVM-backed mutations (open question).

6. Atomicity

A mutate body commits as one transaction: all of its effects, or none. A failed require aborts with no events emitted. (Consistent with the append-only event-log substrate.)

7. What ships first

The immediate subset — exactly what compiles residential_lease.ar:

FormIn first push
require expr; and require { e, … };, incl. sum / count aggregate (RFD 0011)
let x = insert Type { … }; (insert-as-expression + binding + identity minting)
update <nested-path> set { f = e, coll += e }
insert x into coll.path; (sugar → +=)
for x in coll { … } (effect; canonical iteration)
tail-expression / return expr;; atomic commit; [x] literals, indexing, field paths
delete/upsert set-patterns; if/match in bodies; for as a returning comprehension✗ later
full Rust-like collection stdlib API; #[dense]/elastic backing✗ later
emit / detach delete / forget integration; bitemporal qualifiers on the new forms✗ later
Rust-like return types + error handling (Option/Result/?)✗ later

Ayush & Aryan’s mutations in the shipped form:

pub mutate materializeExpectedSatisfaction(
    pair: CorrelativePositionPair, perPeriodValue: Real,
) -> MaterializedExpectedSatisfaction {
    require perPeriodValue > 0;
    let period = insert TimeInterval {
        start: pair.propositionalContent.recurrence.startsOn,
        end:   pair.propositionalContent.recurrence.endsOn,
    };
    let record = insert ExpectedSatisfactionRecord {
        account: pair.book.expectedSatisfactionAccount, value: perPeriodValue,
        timeInterval: period, allenRelator: AllenRelationType::Before,
    };
    insert record into pair.book.expectedSatisfactionAccount.records;   // sugar → update … set { records += record }
    insert MaterializedExpectedSatisfaction {                           // tail = return value
        records: [record], expectedAccount: pair.book.expectedSatisfactionAccount, postedCount: 1,
    }
}

pub mutate recognizeSatisfaction(
    occurrence: OccurrenceEvent, records: [SatisfactionRecord],
) -> SatisfactionRecognition {
    require occurrence.value == sum(r.value for r in records);
    for r in records { insert r into r.account.records; }              // per-element, canonical-ordered
    insert SatisfactionRecognition {
        recordedValue: occurrence.value, satisfactionAccount: records[0].account,
    }
}

8. Lean is a committed deliverable, not a vague “later”

To unblock the overlay we ship the parser + elaborator + runtime for the immediate subset ahead of the mechanized semantics. But the substrate work is a firm obligation, tracked, not optional: (a) refine the Lean AST (insert as expression; +=/-= field-assign ops; for set-semantics); (b) mechanize mutation execution semantics (entity construction & identity, statement sequencing with bindings, collection ops, atomic event emission); (c) the soundness obligation flow-typing already assumes (FlowTyping.lean states, without proof, that mutate writes respect the monotone fixpoint discipline). Per AGENTS.md this is Lean-first substrate work; the only concession is that code may land first to unblock, with the Lean following.

Rationale

  • EdgeQL/set-semantic over imperative-procedural. Argon’s working substrate is already relational (relation-tuple and iof edges); a set-at-a-time, expression-valued mutation surface is continuous with it and with the language’s declarative core, where a COBOL-style statement machine would not be. Crucially, the choice keeps ~90% of the syntax §7.5 already documents while changing its meaning — far less throwaway work than a ground-up redesign, and the Lean AST is already close.
  • insert as expression is not a stylistic preference; it is forced by let x = insert … + downstream reuse. Once forced, the return design (tail expression) falls out, and “what if multiple inserts” stops being ambiguous because only the tail is returned.
  • Determinism-by-observability (RP-006 Track A) lets the language be deterministic and free to use any map backend, by legislating observability rather than implementation. B-tree default because canonical iteration — on the hot path for a KR language — is free, while a hash table pays an O(n log n) sort to honor the same contract.
  • Keep insert into because the cost is one desugaring rule and the benefit is meeting SQL-trained expectations; the canonical += keeps the core small.

Alternatives

  • Build §7.5 imperative as literally documented (document/OOP model). Rejected: it bakes “entities own arrays you imperatively append to” into the language, away from the relational/EdgeQL direction, and still requires the entire new execution model — so it is not actually cheaper.
  • Pure-graph: make collections derived from edges (no stored collections). Rejected by decision: collections are first-class and stored (Set/Map/List), and the stdlib provides them. insert into/for are legitimate operations on stored collection fields, not smells.
  • Drop insert into, canonicalize only on +=. Considered and reversed — kept as sugar for ergonomics.
  • Auto-return the last/all inserts. Rejected: fragile and ambiguous.
  • EdgeQL with … select with no return keyword. Rejected: return must stay first-class for Option/collection/tuple returns and future error handling.
  • Elastic/funnel hashing as default backing. Deferred: real result (arXiv:2501.02305) but the win is at high load factors and no hardened implementation exists; gated on a measured Argon-workload win (RP-006).

Consequences

  • Parser: mutation_stmt grows from four verbs to the full statement set; insert becomes expression-valued; require/let/for/return/tail-expression parsing; struct-literal-in-tail disambiguation (the Rust if x { S {} } wrinkle).
  • AST/elaborator: consume the (already-generated) Stmt/InsertForm nodes; thread an environment for let bindings and insert handles; lower the new forms — the flat Vec<Operation> model grows into a statement/expression evaluation with data-flow.
  • Runtime: entity construction with identity minting (RFD 0001); collection field +=/-=; for evaluation with canonical ordering; atomic multi-effect commit; tail/return value.
  • Types: the ordered/unordered collection contract (§3) governs all collection use, not just mutations; it should also be reflected in §6 and the type-system Lean. sum/count in guards interacts with RFD 0011 under OWA.
  • Stdlib: Set/Map/List implementations + a Rust-like API (parallel workstream; the first push needs only += and list literals).
  • Reference: §7.5 / §7.5.1 rewritten from “designed, not implemented” to the shipped surface + the explicit later-list.
  • Lean: the obligations in §8 are now tracked work, including closing the flow-typing assumption.
  • Research: RP-006 (collection-backend benchmark; Bayesian; topology) proceeds in parallel; none gates this.

Open questions

  1. Identity minting details for insert Type { … } — resolved by RFD 0001; confirm the runtime hook and how a minted id appears in the event log / provenance.
  2. Are relation/extent tables insert-only-then-read-mostly? (The #[dense]/elastic precondition — RP-006 Q1.)
  3. Does iteration dominate the reasoning workload? Validate with a real op-trace before finalizing the B-tree-vs-hashbrown default (RP-006 Q2).
  4. Do DBSP Z-set commutative-monoid deltas discharge the for event-order mitigation automatically for IVM-backed mutations? (RP-006 Q3.)
  5. Postgres bridge order — can canonical iteration order be pushed into SQL ORDER BY? (RP-006 Q4 — the riskiest leak surface.)
  6. if/match in bodies and for-as-returning-comprehension — surface + value semantics, deferred past the first push. Resolved by Amendment 1.
  7. Error handlingOption/Result/? and Rust-like return types; their own RFD.

Amendment 1 (2026-06-05) — control flow is expression-valued; bodies and branches are blocks (resolves OQ #6)

OQ #6 left the surface and value semantics of if/match in mutate bodies open. While that gap stood, the two representations drifted: the Lean surface AST split the construct into a value form (Expr.ifExpr / Expr.matchExpr, with bare-Expr branches) and an effect form (Stmt.ifStmt / Stmt.matchStmt, with List Stmt branches), whereas the Rust grammar modelled a single IF_EXPR / MATCH_EXPR over BLOCK_EXPR branches. This amendment settles it.

Decision. if and match are expressions, and their branches/arms are blocks. A block { s₁; … ; sₙ tail? } is itself an expression: it executes its statements for effect and evaluates to its optional trailing expression, or to unit () when there is none (Rust’s block rule, continuous with §1–§2’s tail-expression return and insert-as-expression). The mutate body is a block. There is exactly one if and one match form — a value use (let x = if c { a } else { b }) and an effect use (if c { insert X } else { insert Y }) are the same construct, distinguished only by whether the branch blocks have a non-unit tail.

Why (not the split).

  • Rust/Cargo aesthetic — Rust has one if, an expression; Argon defaults to that idiom.
  • Consistency — §1/§2 already chose expression-orientation (tail-expression return, insert as an expression). A statement-only if contradicts it.
  • Expressiveness — the split cannot express let x = if c { let y = f(); g(y) } else { h() }: a bare-Expr branch admits no statements, and a statement-if yields no value. Block branches give both. The split is a strict expressiveness loss, for no gain.
  • No redundancy — one construct, not a value/effect pair that must be kept in sync.

AST / Lean consequence. Add Expr.block (stmts : List Stmt) (tail : Option Expr). Expr.ifExpr / Expr.matchExpr keep Expr-typed branches/arm-bodies (which now admit block), so their signatures are unchanged. Remove Stmt.ifStmt and Stmt.matchStmt — an effectful if/match is Stmt.exprStmt (ifExpr …) over block branches. (for remains a unit-valued effect statement for now; promoting it to an expression is uniform but not required, since it never produces a value — tracked, not blocking.) Because a block-expression runs statements, expression- and statement-evaluation become a single mutual clique; Argon.Runtime.MutationSemantics and its theorems (runMutation_error_noop atomicity, the fresh-monotonicity family) are re-mechanized against the merged induction. The @[language_interface] surface mirror and CoreIR lowering update accordingly.

Code consequence. The parser already yields IF_EXPR / MATCH_EXPR / BLOCK_EXPR. The elaborator routes a block to its body-op sequence and an effectful if/match to control-flow IR (Operation::If / Operation::Match over the branch blocks’ operations), while a pure-value if lowers to Term::IfExpr (evaluated by resolve_term_to_value). The _ => {} catch-all in mutate_lower::lower_body_stmt, which silently dropped unrecognized statements, is replaced by a loud diagnostic. Tracked in #74.

Amendment 2 (2026-06-09) — value-position match realized by the IfExpr desugar; constant-pattern subset; OE0203

Amendment 1 prescribed an Operation::Match control-flow IR alongside Operation::If. The implementation that landed realizes the value half of match differently — and the prescription is amended to match it.

Decision. A value-position match over constant patterns desugars at elaboration to the existing right-nested Term::IfExpr chainmatch s { P1 => v1, P2 => v2, _ => v3 }if s == P1 { v1 } else if s == P2 { v2 } else { v3 } — with no new IR (no protocol/drift change). For constant patterns the two semantics are identical: ordered first-match, the final arm’s value as the else-branch. The executable pattern subset is payloadless enum constant paths, Int/String/Bool/Date literals, or-patterns of those (consecutive conditions sharing a body), and the wildcard _; the richer §14 forms (binders, payload/record patterns, guards, type tests, is-outcomes) are refused loudly (OE1319) until they land. Exhaustiveness is required (OE0203 at ox check): a final _ arm, or full coverage of the scrutinee’s enum variants when statically known. The same desugar serves every value position — fn bodies, rule-body comparison operands (compiled to CompiledExpr::If in the reasoner), and mutate let RHS / update values / return values / require guards (evaluated by the existing Term::IfExpr arm of resolve_term_to_value); the Lean evalExpr gains the matching .matchExpr arm (evalMatchArms / patternMatchesConst).

Statement-position match (arms running effects) is now realized the same way — no Operation::Match: it lowers to a right-nested Operation::If chain over the arm blocks’ operation sequences (match s { P1 => { ops₁ }, P2 => { ops₂ }, _ => { ops₃ } }If(s == P1) { ops₁ } else { If(s == P2) { ops₂ } else { ops₃ } }), reusing the value desugar’s pattern classification and exhaustiveness (same constant subset, same OE0203 / OE1319 sinks; or-patterns expand to consecutive conditions sharing the arm’s operations; a wildcard-only match splices its arm unconditionally behind a hidden Operation::Let of the scrutinee, so an aborting scrutinee — a missing-field projection, say — aborts exactly as in the chained case, matching Lean’s evaluate-scrutinee-first order). Scrutinee Terms are pure, so per-link re-evaluation is effect-free. One scoping caveat: the Lean matchStmt/ifStmt semantics scope arm/branch-local bindings, while the Rust runtime threads one flat environment and currently leaks them past the match/if (pre-existing, recorded drift — #196). The enabling parser change threads an in-mutate-body flag so blocks at any nesting depth (match arms, if branches, for bodies) admit the full mutate statement set — which also fixed update not parsing inside if branches; blocks in value position (a let RHS block, a value-if branch, a value-match arm) refuse effectful statements loudly (OE1321) since value lowering reduces a block to its tail expression. On the Lean side, Stmt.matchStmt gains direct execution semantics (evalMatchStmtArms: ordered first-match, the selected arm’s block runs for effect, return propagates), intended-equivalent to the If-chain by construction. Stmt.matchStmt / Stmt.ifStmt are NOT yet removed as Amendment 1 ordered: the A1 encoding (exprStmt over Expr.block arms) needs block evaluation inside evalExpr, which merges the expression/statement interpreter cliques and requires evalExpr to carry control flow for return propagation — exactly the merged-clique re-mechanization A1 itself anticipated; doing it halfway would regress return-in-branch. The removal stays tied to that re-mechanization; the drift is documented at both constructors and in Argon.Runtime.MutationSemantics. OE1318 (Module::unsupported_mutation_forms) remains the gate for the genuinely unexecutable statement forms — emit now parses (EMIT_STMT) and lowers to Operation::Unsupported so it is refused there rather than as a generic parse error.

RFD 0016 — Numeric tower: exact by default

  • State: committed
  • Opened: 2026-06-05
  • Decides: the runtime representation and arithmetic semantics of the numeric tower — specifically that Real is exact (arbitrary-precision rational), Decimal/Money are exact, and machine floats (f32/f64) are the explicit opt-in inexact types; how the reasoner’s value domain represents numbers; and that aggregate folds (sum/avg/min/max) are exact. Resolves the “finer breakdown of the tower … deferred to a follow-up RFD” note in §17.1.

Question

§17.1 lists Real as “real number; runtime-chosen representation (defaults to IEEE 754 f64)” and defers the finer tower (rationals, fixed-point) to a follow-up RFD. But the substrate is already ahead of the book: the slice-4 mutation semantics (Argon/Runtime/MutationSemantics.lean:85) models Value.real : Rat — an exact rational — with exact parseDecimal, toRat, and evalRatBinary. Meanwhile the reasoner’s catalog value domain has no numeric beyond Int (a Real/Decimal/Money literal round-trips as opaque CBOR), so an aggregate like sum(r.value for r in …) where value: Real cannot fold at all.

What is Real’s representation, what does the reasoner store, and is avg exact? And which wins — the book’s “f64” or the Lean’s exact rational?

Context

  • Domain. Argon’s flagship workloads are finance and legal (the residential-lease / accounting overlay). For that domain IEEE-754 f64 is a bug generator: 0.1 + 0.2 ≠ 0.3, summed payments drift, and cumulative-satisfaction checks (e.value <= sum(r.value …)) become subtly wrong at the boundary. Exactness is a correctness requirement, not a nicety.
  • The Lean already chose exact. MutationSemantics.lean models Real as Rat deliberately (“the spec’s arithmetic is exact”). Per the project’s own rule — where the book disagrees with the Lean on something the Lean covers, the Lean wins; the book is a bug — §17.1’s “f64 default” is the bug.
  • avg wants a field. avg over a Real collection is Σ / n. Over rationals this stays exact (rationals are closed under division); over fixed-precision decimals or floats it rounds. Exact Real is what makes avg mathematically clean.
  • The reasoner gap. oxc-reasoning’s Value has Int(i64) but no exact Real/Decimal; the fold is Int-only. This blocks the overlay’s Met rule (the motivating case) and any Real/Money aggregate.

Decision

  1. Real = exact, arbitrary-precision rational. Numerator/denominator big integers; no precision ceiling; no implicit rounding. This is the canonical Real for the data/ontology substrate.
  2. Decimal and Money are exact. Decimal is arbitrary-precision base-10; Money is a currency-tagged Decimal. Both share the exact-rational carrier in the reasoner (a Decimal is a rational whose denominator is a power of ten); the currency tag and display scale are metadata on top. Money arithmetic (§17.1, D-069) is unchanged in typing; only its representation becomes exact.
  3. Floats are the explicit opt-in inexact tier. f32/f64 live in std::math::primitive (already the case) and are the only inexact numerics. Real never silently becomes a float. The implicit-widening chain keeps Int → Real (exact ⊆ exact) but the f32 → f64 → Real step is removed — a float reaches Real only via an explicit, lossy to_real-style conversion (a float is not exact, so widening it into the exact tier must be a visible choice).
  4. The reasoner value domain carries exact numerics. oxc-reasoning’s Value gains an exact-rational variant; Real/Decimal/Money literals materialize to it (replacing the opaque-CBOR/f64 path). Mirrors MutationSemantics.Value.real : Rat.
  5. Aggregate folds are exact. sum/min/max/avg fold over the exact domain: integer-exact when all operands are Int, else promoted to exact rational; avg stays exact (Σ/n as a rational). This mirrors MutationSemantics.foldAggregate.
  6. §17.1 is corrected to describe Real as exact arbitrary-precision rational (not f64), and the deferral note is resolved by this RFD.

Rationale

  • Correctness-first for the domain. A financial/legal substrate that silently rounds is unfit for purpose; exact-by-default makes “the numbers are right” the default, with floats available when a modeler explicitly wants approximate/scientific compute.
  • The Lean is the source of truth. Adopting exact rationals aligns the reference and the implementation to the already-mechanized MutationSemantics; it is reconciliation, not invention.
  • avg exactness is a concrete, checkable win unavailable under float or fixed decimal.
  • Implementation. The Rust reasoner uses num-rational::BigRational over num-bigint (MIT/Apache-2.0, on the workspace allow-list) — arbitrary precision, exact division, deterministic Ord/Hash over normalized components. Not rust_decimal (96-bit fixed; would diverge from the Lean Rat carrier and cap precision).

Alternatives considered

  • Real = f64 default (status quo §17.1). Rejected: float drift is a correctness bug for the domain; contradicts the Lean.
  • Real = fixed-precision Decimal (e.g. rust_decimal, 96-bit). Rejected: caps precision, rounds division (so avg is inexact), and diverges from the Lean’s Rat carrier.
  • Defer (keep folding Int-only). Rejected: blocks the motivating Met rule and every Real/Money aggregate; the deferral was already taken once in §17.1 and this RFD is its resolution.

Follow-ups

  • L5 — exact avg + a Reasoning/Aggregate.lean fold theorem (the interval/fold characterization; closes the Lean item of issue #52).
  • R4oxc-reasoning exact Value variant + exact encode_tuple + exact sum/min/max/avg fold mirroring MutationSemantics.foldAggregate; oxc-runtime literals → exact value (replace the opaque-CBOR path).
  • A future RFD may add custom-precision fixed-point / refinement-driven width selection (the remaining tail of §17.1’s deferral); not needed for exact-by-default.

RFD 0017 — Refinement classification: where (primitive) vs iff (defined)

  • State: committed
  • Opened: 2026-06-05
  • Decides: whether a concept’s refinement predicate (<: Parent where { P }, Refinement) is a necessary condition only (membership asserted; the predicate is an invariant) or a necessary-and-sufficient condition (membership derived; the substrate auto-classifies). Today every refinement is implicitly the latter. This RFD splits the surface so the modeler chooses explicitly: where { P } is primitive (description-logic ; necessary-only; membership asserted), iff { P } is defined (DL ; necessary-and-sufficient; membership derived). Confirms dyn remains reserved exclusively for runtime trait objects (Built-in type forms, Out of scope (v0)). Relates to RFD 0006 (refinement-determined classification was cited there as canonical), RFD 0007 (three-valued refinement membership under OWA), RP-007 (spec/research/RP-007-narrowing-under-mutation.md; value-dependent narrowing soundness).

Question

concept Adult <: Person where { self.age >= 18 } — is an arbitrary Person with age >= 18 automatically an Adult, or must Adult-membership be asserted, with the predicate merely constraining who may be one?

Today the answer is “automatically.” The runtime computes extent(Adult) = { x : iof(x, Person) ∧ predicate(x) } (compiler/crates/oxc-runtime/src/lib.rs:1986-2008, the “refinement-honesty pass”); the book states it outright — “the refinement is the substrate’s source of truth for membership… the substrate derives the iof classification automatically” (mutate, constraint 2 on insert iof) — and rejects explicit insert iof(x, Adult) with OE0211. That is the DL defined-class () reading, and it is the only reading the surface can express. There is no way to say “validate this predicate on every Adult, but membership is conferred, not inferred” — the DL primitive-class () reading, which is the common case in real ontologies.

Auto-classification by default, with no opt-out, is the wrong default for three reasons (Rationale). The question: what surface distinguishes the two, and what does each mean across the extent query, insert iof, construction, and mutation?

Context

The DL distinction. A primitive class C ⊑ D ⊓ P states necessary conditions: every C is a D satisfying P, but a D satisfying P is not thereby a C — membership is asserted (SubClassOf in OWL). A defined class C ≡ D ⊓ P states necessary and sufficient conditions: a D satisfies P iff it is a C — membership is inferred by the reasoner (EquivalentClasses; classification/realization). In real ontology engineering the overwhelming majority of named classes are primitive; defined classes are the deliberate minority written specifically to drive inference. Defaulting every where to defined inverts that.

What the substrate mechanizes vs. specifies. The Lean models the refinement predicate as a decidable fragment (spec/lean/Argon/Decidability/Fragment.lean) but leaves the instance-level value predicate D2Pred opaque (Fragment.lean:80-103: “we do not formalize the internal structure of QF-LIA formulas”). mutate emits only explicit assertIof/retractIof effects (spec/lean/Argon/Runtime/MutationSemantics.lean:104-115). The defined-class realization (iof(x,C) ↔ iof(x,parent) ∧ P(x)) exists as book prose + Rust runtime, not as a Lean theorem. So this is a clean point to fix the surface: the substrate has not committed to “all refinements are defined” — only the prose and one runtime pass have.

The corpus is entirely defined classes. Every where in a runnable example declares the refinement, never asserts iof to the refined type, and queries the derived extent: FullTime/Manager (examples/refinement_employment), ActiveAdult (examples/multi_field_refinement), Adult/Felon/SpecialClass feeding defeasible can_vote (examples/legal_norms_can_vote), FullTime over time (examples/temporal_promotion), Adult as-of (examples/temporal_as_of_surface). These are defined classes; migrating them whereiff is correctness, not churn. The sole where test that does not auto-classify hand-asserts alice iof Adult (compiler/crates/oxc-oxbin/tests/end_to_end_program.rs) — the natural primitive case.

No write-time enforcement exists today. The predicate is purely a query-time filter; construction, insert iof, and update perform no validation (oxc-runtime/src/lib.rs: Operation::Construct, Operation::InsertIof, Operation::Update). OE0210/OE0211 are declared in grammar.toml but never emitted. So a primitive where needs new enforcement machinery (its predicate must do something), and a defined iff needs OE0211 finally wired.

Decision

1. Two clause keywords; identical syntax, opposite membership semantics

where-clause ::= ('where' | 'iff') '{' refinement-pred (',' …)* '}'

The predicate grammar (refinement-pred ::= D1Pred | D2Pred, Refinement) is unchanged. The keyword alone selects the membership semantics:

  • where { P } — primitive (DL ). P is a necessary condition. Membership is asserted (by construction or insert iof). extent(C) is the set of entities asserted iof C (or iof a subtype) — identical to an unrefined subtype’s extent. P is enforced as an invariant at every membership-write point; it never widens the extent.
  • iff { P } — defined (DL ). P is a necessary-and-sufficient condition. Membership is derived: extent(C) = { x : iof(x, parentᵢ) ∧ P(x) } over C’s <:-ancestors-including-self — the current runtime behavior, unchanged. Manual assertion is forbidden.

2. insert iof / construction / mutation, per kind

operationwhere (primitive)iff (defined)
extent(C) queryasserted members (∪ subtypes); no predicate filteriof(ancestor) ∧ P, derived
insert iof(x, C)permitted; P(x) checked → OE0212 if violatedrejected OE0211 IofInsertOnRefinedType
insert C { fields }construct + assert iof C; P(fields) checked → OE0212construct + set fields; classification derived from P
update x set { f = v } where some where-C ∋ x constrains fre-check POE0212 if violatedn/a (membership re-derived at next query)

OE0211 (declared, previously unemitted) is narrowed to iff concepts — you cannot assert membership of a defined class because its membership is the predicate. For where, asserting membership is exactly how you become a member, so insert iof is permitted and the predicate gates it.

3. New diagnostic: OE0668 RefinementInvariantViolated

A membership-write that would place an entity in a primitive where-concept whose predicate it does not satisfy is rejected at runtime, surfaced as Result<_, Diagnostic> (the mutate-rejection channel mutate already promises). Three-valued, OWA-aligned (World assumptions (CWA / OWA), RFD 0007): reject only on positive evidence of violation (P evaluates to definite false); unknowninformation absence, i.e. a referenced field with no recorded value — permits the write. A primitive invariant blocks the demonstrably-bad, not the merely-unproven — symmetric with iff’s rule that unknown does not grant membership.

Amendment (2026-06-11, audit ts-01 / PR #272). The original clause folded unevaluable (a v0.1-unsupported form or a type-mismatched operation) into unknown-permits. That fold was the ts-01 critical: predicates over Real/Decimal/Money/Date were “unevaluable” to the v0.1 evaluator and therefore silently never enforced. The ratified semantics split the bucket: unknown = missing field only (OWA information-absence; permits where, withholds iff membership). A predicate that cannot be evaluated — unsupported form, malformed wire data, type-mismatched comparison — is a loud error (RefinementUnevaluable at runtime; OE0660 refuses unsupported forms at build time), never a silent permit or a silently-empty extent. Three-valuedness is for the world’s incompleteness, not the evaluator’s.

4. dyn is reserved exclusively for trait objects

Confirmed (your call (c)). dyn is and stays the keyword for deferred runtime trait objects (&dyn Trait / Box<dyn Trait>, Built-in type forms, Out of scope (v0)) — dynamic dispatch + type erasure, orthogonal to whether membership is derived. The classification axis uses where/iff; the dispatch axis uses dyn. They may co-occur on a future concept and must not share a keyword.

5. Substrate scope

The Lean surface AST (ConceptDecl) and storage body (Storage.AxiomBody.ConceptDeclBody) carry the primitive/defined discriminator now — a shared @[language_interface] inductive RefinementKind, drift-gated against the Rust RefinementKind mirror — and the two membership semantics are documented in the substrate docstrings. The realization theorem for iffiof(x,C) ↔ iof(x,parent) ∧ P(x), requiring the instance population + value-environment that State C A does not yet model — is not mechanized here; it is the value-dependent-membership case that RP-007 §1.2 hazard 2 / §4.5 scopes as open. It is left as a follow-up (RP-007-adjacent, #40), not claimed as done. This is honest staging, not a stub: the surface, elaboration, storage, and runtime are complete and proven-to-run (parser, elaborator, and five end-to-end runtime tests); the deep substrate theorem is research, exactly as RP-007 is.

Rationale

Why split at all (against auto-by-default). (1) Practice: most real classes are primitive; the common case should be the plain keyword. (2) No spooky inference: silent derivation of facts is un-Rust-like (the house aesthetic default); a modeler should opt into the reasoner minting memberships. (3) Soundness blast radius: value-dependent auto-classification is the source of RP-007’s hardest open hazard (a narrowing if x: VerifiedAdult invalidated by update x set { age = 10 }). Making it opt-in (iff) makes that hazard opt-in — the obligation narrows to exactly the concepts that asked for derivation.

Why iff. It is the membership biconditional: iof(x, Adult) ↔ iof(x, Person) ∧ age ≥ 18 reads directly as “Adult iff Person and age ≥ 18.” Necessary-and-sufficient is the literal meaning of “if and only if.” No other candidate (defined, :=, ) is as self-documenting at the use site, and it touches neither dyn nor = (taken by metaxis typed domains, Meta-calculus atom).

Why where for primitive. Rust’s where is a bound — a necessary constraint on a type, never a definition (fn f<T>() where T: Clone). Reusing it for “necessary condition on members” aligns with the Rust/Cargo aesthetic default and with DL . A where-refined concept reads as “a Person, further constrained to age ≥ 18” — a constraint, not a definition.

Why three independent principles agree (practice, no-spooky-inference, soundness-containment) plus two surface alignments (Rust where-bound, iff-as-biconditional) is why (a) — which keyword is primitive — has a correct answer rather than a coin-flip: where = primitive, iff = defined is over-determined.

Alternatives

  • Keep auto-by-default; add a keyword for primitive. Rejected: inverts ontology practice, keeps the spooky default, and leaves the RP-007 hazard pervasive. Also incompatible with choosing iff (which must be the defined form — it claims sufficiency).
  • Operator distinction <:+where vs =. (Adult = Person where {…}.) Mirrors DL / exactly but a one-character carrier of enormous semantic weight is dangerous, and = already introduces metaxis typed domains.
  • defined modifier on the concept. (pub defined kind Adult ….) Familiar to Protégé users but detached from the clause and heavier; loses the at-the-predicate self-documentation iff gives.
  • Reuse dyn (dyn where). Rejected per call (c): collides with trait objects, and “dynamic dispatch” ≠ “derived membership.”
  • Enforce primitive where purely via a separate check rule (status quo workaround: drop the predicate, add a constraint rule). Rejected as the only mechanism: it detaches the invariant from the concept’s identity and gives no surface to the necessary/sufficient choice. where-as-invariant and check-rules coexist; the former is a membership invariant local to the concept, the latter a global constraint.

Consequences

  • Migration. All 8 example where sites → iff; the corpus tests, CLI pipeline tests, and runtime unit test that assert derived extents update accordingly. Primitive-where behavior (no auto-classify, asserted membership, OE0668 on insert-iof / construct / update, OE0211 on iff insert-iof, atomic rejection) is covered by five new end-to-end runtime tests. The oxbin round-trip fixture stays where-compatible.
  • Wire format. ConceptDeclBody gains a classification discriminator; canonical-CBOR field order updated; decode_concept_decl back-compat: absent discriminator ⇒ iff is not assumed — pre-split artifacts are rebuilt (Argon is pre-1.0, in-process; no stored-artifact compatibility burden).
  • OE0211 semantics change. Previously declared-but-unemitted “refined type”; now emitted, and only for iff. Any future code keying on “has refinement predicate ⇒ reject insert iof” must key on “is iff.”
  • New runtime enforcement path (OE0668 RefinementInvariantViolated) for primitive where at construction / insert iof / dependent update.
  • Rigidity (OE0210) remains out of scope here: it gates insert iof on anti-rigidity (a UFO meta-property carried as meta_property events, not in the ontology-neutral ConceptDeclBody). Still declared-but-unenforced after this RFD; a separate rigidity-enforcement effort owns it. Noted so the two insert iof gates (rigidity, classification) are not conflated.

Open questions

  • iff realization soundness (follow-up, RP-007-adjacent #40): mechanize iof(x,C) ↔ iof(x,parent) ∧ P(x) in Lean, which requires extending the state model to carry the value environment — the RP-007 §4.5 sub-problem. Until then iff derivation is runtime-correct but not substrate-proven (parity with the pre-RFD status quo).
  • update-time invariant scope. v0.1 re-checks a primitive where predicate only against the directly mutated entity/fields. Transitive invalidation (a mutation to entity y that changes an aggregate a where-predicate on x reads) is not chased; the predicate fragment (OE0660) forbids the aggregate/subquery forms that could create such coupling, so this is currently vacuous — revisit if the fragment widens.
  • Should iff construction (insert C { … }) be sugar or an error? Decided: sugar (construct + set fields; classification derived). Revisit if it proves confusing that a constructed iff-C with predicate-violating fields silently is not in extent(C).

RFD 0018 — Production reasoner: the incremental DBSP engine

  • State: accepted — partially implemented (WFS executor + Engine::evaluate shipped; DBSP fork / persisted-IVM not yet)
  • Opened: 2026-06-05
  • Decides: the concrete realization of RFD 0003’s DBSPExecutor — the data model, evaluation engine, storage model, provenance, and invalidation that take Argon’s reasoner from a correct demo (cold, non-incremental, nested-loop semi-naive) to production-grade (“working and usable” at scale). Resolves RFD 0003’s deferred open question (“Stratum boundary policy with retractions → defer to the DBSP integration RFD”). Fixes the scope cut (RP-009 §4) and answers RP-009’s open questions (§9). Scopes the ARS substrate research record (vault ars-substrate R-3.1–R-3.8) down to what we build now vs design-for vs defer.

This RFD is the architecture decision record for the production reasoner. It is Lean-first where it touches semantics (the engine conforms to spec/lean/Argon/Reasoning/; divergence is a bug). It commits a plan, not code; its Phase 0 is a de-risking spike that gates the central engineering bet before any irreversible code lands.


Question

RP-009 mandates: bring the rule engine from correct MVP to production-grade, because it is the foundation the entire language sits on. RFD 0003 settled how multiple backends compose under one Engine/TierExecutor interface, named DBSPExecutor as the incremental recursive-tier backend, and explicitly deferred the engine itself — its data model, its IVM/retraction semantics, its storage and invalidation — to “the DBSP integration RFD.” This is that RFD.

Concretely: What is the production engine? — (1) the value/data model that lets a bilattice (Truth4) truth domain ride an incremental dataflow substrate that needs an abelian group; (2) the evaluation substrate (build vs fork vs wrap, and which); (3) how facts are stored and read at scale (the event log is the source of truth, but a cold per-query re-materialization is O(database) and dies at scale); (4) how derived state is incrementally maintained, persisted, and invalidated; (5) how provenance composes with incrementality; (6) how it conforms to the Lean; and (7) the scope cut + build order that gets us there correctly the first time, designed so the features we’ll need soon (defeasibility, metric-temporal, well-founded semantics over cycles) integrate neatly without a rewrite.


Context

Current state (verified against origin/main @ b7a24bc)

The engine is a sound, well-tested, semi-naive stratified-Datalog evaluator — correct on the Datalog + stratified-NAF + exact-rational-aggregate fragment, at small scale. It is not production-grade. The load-bearing gaps:

  • Cold + non-incremental. oxc-runtime::query_derive calls materialize_predicates(module) (a full scan of all IofAssertion/RelationTuple/IndividualPropertyAssertion events into a fresh RelationCatalog) then evaluate_to_fixpoint from scratch — every query. No derived state persists across queries; no incremental update on mutation. Every query is O(all events) + O(rules × facts × iterations), cold. This is the headline scaling failure.
  • Nested-loop joins, decode-per-tuple. extend_bindings/unify iterate every tuple of a relation and decode_tuple (CBOR) it inside the innermost join loop; Relation<Vec<u8>> = BTreeMap<CBOR-full-tuple, weight>. No join-key index, no arrangement (arrangement_body is reserved + inert). The dominant cost is O(iterations · rules · |prior| · |rel|) CBOR decodes.
  • Executor split. The live path is the free fn executor::eval::evaluate_to_fixpoint; SemiNaiveExecutor::execute is a stub returning an empty catalog; the RFD-0003 TierExecutor trait and the DataFusion-shaped logical/physical/optimizer layers are orphaned scaffolding.
  • Generation counter wired to the wrong cache. oxc-storage-pg has runtime_generations (bumped atomically with appends, invalidating a projection cache), and oxc-serve reads it — but only to memoize the hydrated Store (raw replayed events), not derived reasoning state. The §19.6 runtime-Salsa db (OxcRuntimeDatabase/EventLogInput) does not exist (oxc-runtime doesn’t depend on salsa). So there is no derived-state invalidation today.
  • Correctness items. Modal box/diamond erase to the inner atom — unsound for anti-rigid types (the Lean proves box(anti_rigid) → false in StaticDischarge.lean). The 1000-iteration cap is a hard Err (not silent), but is a literal at every call site, not a configured convergence policy.

What already works and must stay green: stratified Datalog + NAF; exact-rational sum/count/min/max/avg/count_distinct folds (RFD 0011/0016); the keystone end-to-end tests (oxc-runtime/tests/keystone.rs); Apt-Blair-Walker stratification (compile/stratify.rs, Tarjan SCC); and — partially — a Governatori three-stratum defeasible evaluator and per-standpoint federated Truth4 evaluation.

What the substrate requires (Lean-first conformance bar)

The only fully-mechanized reasoning semantics is strict-stratified perfect-model evaluation (Reasoning/Fixpoint.lean: Cat1-monotone to fixpoint, then Cat2-NAF once, per axis in topological order; Theorems terminate/unique/stable, zero sorry). Consequences for the engine:

  • Order-independence (stratified_fixpoint_unique): within-stratum rule/axis order is semantically irrelevant ⇒ any strategy converging to the same perfect model is conformant — DBSP, semi-naive, indexed, all sound. Engine choice is a performance decision, not a correctness one.
  • NAF order-sensitivity (cat2Apply_sublist): Cat2 extension is monotone only under List.Sublist; the engine must preserve relative Cat2 rule order across deltas.
  • Acyclicity is necessary (Necessity.lean): cyclic axis-dependency ⇒ non-unique. Cycle rejection (OE1309) is correct, not conservative.
  • Modal: box(anti_rigid) → false is proven and must be honored (or the case honestly refused).
  • WFS-by-default, #[brave] stable models, the defeasibility proof-tag engine, DatalogMTL operators, and aggregate OWA-intervals are book-promised but not Lean-backed — they are the “design-for / defer” set, not the conformance floor.

What the north-star application needs (RP-009 §4, the residential-lease + accounting overlay)

A shallow, narrow rule program (≈28 derive rules, one true positive recursion — BreachedAt over the propositional-content tree, with stratified not Met — one NAF pipeline). Its load-bearing needs: recursive stratified Datalog with field-navigation joins; aggregates in derive bodies incl. aggregate-comprehension where with rule-atom/predicate filters; stratified NAF; function application + projected-field terms as rule-atom arguments; an Allen-interval + date-arithmetic builtin; forall; denial constraints. It does not today exercise WFS-over-cycles, standpoints, or modal operators, and it models legal exceptions monotonically (conditional propositional content), not via rule defeat. But defeasibility and metric-temporal reasoning are confirmed near-term needs — design for them now, build them next.

Prior research: input, re-derived (not authority)

Per AGENTS.md the vault is research, not authority — the decision below stands on this RFD’s own rationale; the prior record is reconciled and re-derived, not deferred to. The vault ars-substrate program produced a decision record (R-3.1–R-3.8): a two-strategy architecture — DBSP for the bottom-up recursive tier (R-3.3, Feldera dbsp / differential-dataflow), SLG (chalk-engine-shaped) for top-down WFS/upper tiers (R-3.1), bilattice answers via the AFT pair construction (R-3.2), Salsa above both (R-3.4), provenance as a side-track (R-3.6). Four validation passes against that record surfaced the decisive fact:

The single most load-bearing unproven claim — recursive WFS riding DBSP via coupled alternating-fixpoint circuits, which the research itself says has no published precedent — is co-extensive with the WFS-over-cycles feature we are deferring. Everything our scoped target needs (positive recursion, stratified NAF, stratified aggregates, defeasibility’s Maher three-stratum compilation, stratified-NAF DatalogMTL) sits in DBSP’s proven, standard fragment.

So “build it correctly once” is more achievable than RP-009 implies: the scary part is the part we defer, and the AFT pair-encoding keeps the door open to add it (via SLG) without a data-model rewrite.


Decision

A new module realizing RFD 0003’s DBSPExecutor, plus the storage/invalidation/provenance substrate around it. Twelve decisions:

D1 — Value model: AFT pair-encoded Z-sets (the keystone)

Each Truth4 atom is a pair of ℤ-weighted Z-sets: tt (told-true / evidence-for support) and tf (told-false / evidence-against support), in the Belnap evidence-pair encoding (is = (1,0), not = (0,1), CAN/U = (0,0), BOTH = (1,1); a coordinate is present iff its support weight is non-zero). Each stream is a standard Z-set over an abelian group, so DBSP’s incremental machinery (chain rule, distinct, recursion via δ₀/∫) applies per-stream unmodified. Bilattice operations are per-coordinate: info-meet ⊗ = pointwise AND/min; info-join ⊕ (federation → BOTH) = pointwise OR/max; truth meet/join = the Belnap 4×4 table per atom; negation swaps the two streams (the sole cross-stream coupling). This gives, for free: K3 per-standpoint (⊕ statically unreachable within a standpoint; a clash is a diagnostic), FDE BOTH only at the federation boundary — matching the mechanized K3-per-standpoint / FDE-at-federation split (Foundation/Bilattice.lean, Standpoint/Federation.lean; per-standpoint state in Reasoning/State.lean) exactly. Cost ≈ 2× monotone Datalog (3× with symmetric T/F provenance).

Lean gate (D1), discharged. The value-level correspondence is mechanized in Standpoint/PairEncoding.lean (zero sorry/sorryAx; audited): encode/readoff form a bijection, and neg = stream-swap, = pointwise-OR, = pointwise-AND, plus federate evaluated in the pair representation reads back to the canonical Truth4 fold (readoff_pfederate). Mechanizing this corrected the encoding: the operations above (swap / pointwise OR-AND) hold under the evidence-for/against labels here, not the Fitting consistent-pair labels (T = (1,1), …) an earlier draft printed — under which neg is not a pure swap and is not pointwise-max. (This is the for/against form the Phase-0 spike already used.) The ℤ-multiplicity→support bridge for the assert-only fragment, and the retraction case, are the separate D5 obligation.

This is the keystone because it is also the shared substrate for the deferred SLG kernel (R-3.1): SLG’s answer-subsumption over a 2-D lattice is literally this pair. Adding WFS later is a new fixpoint strategy over the same state — not a rewrite.

D2 — Three time axes, kept strictly separate

  • Transaction/circuit time = the IVM stream index (each kernel commit is a Z-set delta: +1 assert / −1 retract, paired by axiom_id).
  • Valid time (VT) = a payload column on the Z-set key, never the circuit clock. Derived VT = intersection of body-atom VTs; derived TT = materialization tx-time.
  • Transaction time (TT) = the durable audit axis (tx_from/tx_to) for “AS OF” reads.

The tuple representation is interval-aware from day one even though metric-temporal ships later (D11), because retrofitting intervals is a rewrite. Metric-temporal reasoning is a separate DRedMTL maintainer over interval-set deltas (no engine implements interval-Z-sets), not a modified DBSP circuit.

D3 — Engine: fork Feldera dbsp (own it; do not wrap, do not fork differential-dataflow)

Revised by RFD 0021 D6. The in-memory reasoner adopts the DBSP model (every operator a pure Z-set→Z-set function, so incrementality is an additive outer loop) but does not fork Feldera’s dbsp crate: its flat binary-join Z-sets are the wrong substrate for the WCOJ / factorization / BYODS representations 0021 builds on. The Phase-0 spike below still stands as evidence that the model decouples latency from DB size; what changed is the substrate it runs on.

We fork Feldera dbsp (Apache-2.0) — the Lean-verified Z-set algebra itself — and own it under oxc-reasoning::executor::dbsp. We do not fork differential-dataflow/timely: DD’s value is its distributed/multi-worker machinery, which we would immediately strip on a single-node per-tenant engine. (This refines RFD 0003’s DBSPExecutor row, which said “differential dataflow / timely” — that was the stale default.) Strip: timely exchange/workers/progress-tracking, distributed coordination, any SQL frontend. Keep: the sorted Z-set traces/arrangements and the operator set (map, filter, join, antijoin = stratified NAF via distinct(I₁ − I₁⋈I₂), distinct, consolidate, iterate). Build on top: the D1 pair-encoding, the D2 interval payload, the D5 provenance side-track.

From kora-reason-rl (MIT/Apache, Argon-family — kora-reason-rl is itself “forked from the Orca kernel”) we borrow (lift into our tree, BTreeMap-convert, attribute), not depend on: its bit-parallel Warshall transitive-closure fast path (tc.rs/bitmatrix.rs) as a tier-dispatched operator; its forward/recursive derivation-tree provenance; its stratification (as a cross-check of ours); and its DatalogMTL→time-guarded-Datalog compilation approach (for D11). We skip its incremental.rs (DRed — over-delete+rederive, strictly inferior to DBSP exact deltas, falls back to full re-materialize on additions) and its hand-rolled datafrog_eval.rs (except as a differential-test oracle). This realizes RFD 0003’s “vendor Kora, own it” decision, scoped to the genuinely-reusable pieces.

D4 — Storage: CQRS — event-log write model + persisted, graph-optimized read model

Adopt the event-sourcing / CQRS split explicitly:

  • Write model = the append-only event log (axiom_events) — the single source of truth (audit, time-travel, bitemporal). It is the Z-set delta stream the engine consumes. Append scales; scanning to re-materialize is what dies — and that is exactly what D6/D7 eliminate.
  • Read model = a persisted, indexed, graph-optimized materialized representation — the durable form of the DBSP arrangements (D7). In Phase 1 it is in-memory; Phase 4 persists it so cold-start does not replay the whole log. Its layout borrows graph-database storage techniques (CSR / index-free adjacency, native edge stores) for traversal-heavy relations — Argon’s data is a graph (individuals + relation-edges).

This commits the scaling contract: no O(database) operation on any hot path, at any scale — mutation→query is O(Δ) (D6); cold-start is O(read the persisted read model), not O(replay log) (Phase 4). It aligns with the spec §20 reserved CQRS catalog tables and the StorageBackend “per-tenant LSM, XTDB-inspired, recency-sharded” slot. The StorageBackend trait is a clean seam now (Phase 1) so the persisted read model slots in without a reasoner rewrite, even though its implementation is Phase 4.

D5 — Provenance: dual-track (mandatory)

Provenance cannot ride the IVM Z-set stream (PosBool is an idempotent semiring with no additive inverse — Amsterdamer 2011 — so it breaks DBSP’s chain rule; answer-subsumption and variant dedup also erase per-derivation identity). So: the Z-set stream carries counting-multiplicity (ℕ) for IVM; why-provenance lives in the separate append-only derivation log (the existing D-097 PosBool-DNF column on axiom_events), joined by the erasing homomorphism ℕ[X] → PosBool[X]. Cheap (O(1)/conjunct: append-on-assert, remove-by-witness-on-retract, empty-DNF ⇒ retracted); bilattice = per-coordinate (separate T-witness and F-witness DNFs); each conjunct carries its own VT interval. Commit dual-track from day one — it is nearly free given the event log exists, but retrofitting onto a single-track answer table is expensive.

D6 — Incrementality + invalidation: Salsa above, generation-driven, one coherent path

Salsa sits above the engine (session granularity, per-(tenant, fork); the engine owns its internal incrementality). Build the §19.6 runtime OxcRuntimeDatabase with EventLogInput + the u64 generation counter as Salsa inputs, route query_derive through it, and unify it with oxc-serve’s hand-rolled runtime_cache so there is exactly one invalidation path: event-log delta → DBSP circuit (warm arrangements) → Salsa session cache, with the generation bump (already atomic with appends in oxc-storage-pg) as the single invalidation signal. “Warm derived state across queries” (Phase 1) is the prerequisite milestone; “warm across restarts” (Phase 4, via D4’s persisted read model) follows.

D7 — Tuple/index layout: typed, sorted arrangements on InternalId

Replace BTreeMap<CBOR-Vec<u8>, weight> + decode-per-tuple with typed, decode-once, partial-key-indexable arrangements (Materialize-style immutable sorted batches + trace) keyed on the existing InternalId (8-byte NonZeroU64, [kind:8 | partition:16 | sequence:40], range-scannable). All layouts are sorted (B-tree / arrangements / interval-trees), honoring the determinism-by-observability rule (RP-006) and the BTreeMap-not-HashMap convention. Automatic index selection (Subotić-style bipartite matching, ~500 LoC) is a compile-time pass portable from the Souffle literature (“steal the ideas, don’t port the C++”).

D8 — Conformance harness: Lean-first + dual-oracle differential testing

The correctness target is the strict-stratified perfect model. The DBSP engine is differential-tested against (a) the retained semi-naive evaluator as an independent oracle and (b) the Lean semantics on generated stratified programs, in addition to keeping the ~600 workspace tests + keystone.rs green throughout. Implement the proven modal discharge (box(anti_rigid) → false) statically; refuse the rest.

D9 — Semi-naive is retained as oracle + cold/one-shot fallback (not retired)

DBSP subsumes semi-naive (its recursive operator is semi-naive internally; a circuit fed the database as one delta-from-empty computes the same bottom-up fixpoint), so the algorithm is never lost. The standalone semi-naive evaluator is kept as: (a) the independent differential oracle (D8) — validating the new engine against an independent implementation is the correct practice; and (b) a registered cold/one-shot fallback executor in the RFD-0003 dispatch (it wins only for query-once-tear-down workloads where arrangement maintenance is pure overhead). It is not grown — all expressivity (D11) lands once, in DBSP. One rule-IR + one Z-set semantics + one RelationCatalog exchange, two backends — not “build twice.” This realizes RFD 0003’s “DBSP preferred; SemiNaive stays as fallback + reference implementation.”

D10 — Executor unification

Collapse the live-eval / TierExecutor-stub split: DBSPExecutor becomes the real recursive-tier path through the RFD-0003 dispatch (registered first, ahead of SemiNaiveExecutor); fold compile/stratify’s stratification into the PhysicalPlan strata so the physical plan is the real execution unit; either move evaluate_to_fixpoint’s body into SemiNaiveExecutor::execute or delete the trait method’s stub. Fix the stale eval.rs “naive” header and replace the literal 1000 caps with a configured convergence/divergence policy (divergence stays a first-class, explained Err).

D11 — Scope cut (RP-009 §4)

Legend: BUILD-NEXT = ships on the stratified bottom-up core in this effort’s own later phases (no new kernel); BUILD-SOON = the next major arc after this effort (the SLG kernel), reached through the RFD-0003 dispatch seam; DEFER = not foreclosed, no current design work.

  • IN (this effort): recursive stratified Datalog (joins, field-navigation, collection iterators, comparisons); full in-body aggregates incl. aggregate-comprehension where with rule-atom/predicate filters; stratified NAF; function application + projected-field terms as rule-atom arguments; Allen-interval + date-arithmetic builtins; forall; denial constraints (assert ⇒ error); occurrence-typing type-tests; modal discharge soundness; indexing; incrementality; durable+warm derived state; observability; the scaling contract to 10M facts.
  • DESIGN-FOR-NOW, BUILD-NEXT (ride the stratified bottom-up core; no SLG kernel): defeasibility (Maher three-stratum at the closure tier; proof tags +Δ/−Δ/+∂/−∂ as derived-predicate labellings; defeater chains in the provenance side-track); metric-temporal (DatalogMTL compiled to time-guarded interval-Datalog; a separate DRedMTL maintainer over interval deltas).
  • DESIGN-FOR-NOW, BUILD-SOON (the next major arc, via the RFD-0003 seam): WFS-over-cycles — the SLG kernel (R-3.1). OE1309 stays reject-with-a-good-error now, architected as the future dispatch-to-SLG trigger. Coupling: ambiguity-propagating defeasibility = WFS-of-translation (Maier–Nute; +∂‖ = well-founded-true), so the SLG arc serves both WFS and the richer defeasibility.
  • DEFER, don’t foreclose: full Kripke modal; FOL/SMT (unsafe logic); KoraExtensionExecutor (DL via std::owl); standpoint-translation hardening; per-tenant eviction (a 10⁹ / ~2 TB-provenance concern); #[brave] stable models.

D12 — Phased plan, with a gating spike (no phase rebuilds a prior phase)

  • Phase 0 — de-risking spike (GATE before committing the fork). On a forked/wrapped dbsp: a bilattice-pair-encoded recursive rule (the overlay’s Met-style rule, or reachability) with stratified NAF, over bitemporal-interval-keyed tuples. Acceptance: a post-mutation query does not re-materialize — incremental mutation→query latency decouples from total DB size (latency ∝ |Δ|, not |DB|); cross-stream pair-encoding throughput is acceptable on a real rule mix. If the public dbsp API proves too lossy for the pair-encoding or the interval payload, that is the signal to fork internals (which we do anyway).
    • RESULT — PASSED (2026-06-05). Ran on stock dbsp 0.277 (rustc-1.92-compatible), release, 1 worker. (A) recursive transitive closure swept 1K→1M components (10K→10M derived facts): full re-eval grew ≈linearly (9.4 ms→5.78 s) while the steady-state incremental step stayed flat at ~400–850 µs — at 10M facts a mutation propagates in <1 ms vs 5.78 s full (~6,800×), ratio growing with DB size. The decoupling holds. (C) a valid-time [lo,hi) interval carried as a payload column (interval-intersection join) preserved the same flat incremental step (~390–863 µs) — VT rides as data, not the clock (§D2 validated; MTL can compile to interval-Datalog on this base). (B) the AFT pair-encoding (two ℤ-streams) + stratified NAF (antijoin) computed correctly at 1M (active = 500K, T/F disjoint within source, BOTH only at federation) with pair-encoding overhead 1.46× — under the ~2× the construction predicts (§D1 validated). Three Path-A frictions surfaced and reinforce D3 (fork & own): (i) upstream MSRV churn (latest dbsp needs rustc 1.93; we used 0.277); (ii) relations >65,535 records silently read empty without a storage backend configured; (iii) .output() (delta mailbox) reads empty at scale — must use .accumulate_output() (materialized snapshot). A vendored fork eliminates all three by giving us the spine/storage/read-model directly. (Harness + full results live at .local/spikes/dbsp-phase0/local-only / gitignored: a throwaway Path-A measurement harness, not shipped and not independently checkable from this tree. The tables and method above are the auditable record; reproduction is three cargo run --release -- {A,C,B} … lines against dbsp = "=0.277.0".)
  • Phase 1 — the build-once engine core. Forked dbsp IVM + D1 pair-encoding + D7 arrangements (on InternalId) + D10 executor unification + D5 dual-track provenance carriers + D6 runtime-Salsa/generation wiring (unified with serve’s cache) + the D4 StorageBackend seam. Conformance: D8 differential testing. Lean gate (Lean-first): the D5 retraction homomorphism (commutes-with-deletion) and the D1 pair-encoding↔bilattice correspondence are mechanized before the incremental-retract and cross-stream-negation paths they underwrite are trusted (see Consequences/Lean). Bench: mutation→query vs DB size (the headline).
  • Phase 2 — expressivity on the real engine (unblocks the overlay end-to-end; closes RP-008’s operational-evaluator debt as a by-product): aggregate-comprehension rule-atom/predicate filters, function application in rule bodies, projected-field rule-atom args, Allen/date builtins, modal discharge soundness.
  • Phase 3 — design-for features: defeasibility (proof-tag propagation) and metric-temporal (interval-Datalog + DRedMTL), each Lean-first + reject/accept tests.
  • Phase 4 — scale + ops: persisted graph read model (D4) + restart-warmth (checkpoint vs snapshot-replay), observability (structured logging/metrics/slow-query), resource limits, concurrency model, the benchmark suite to the 10M-fact target, IAM in oxc-serve. (Per-tenant eviction enters here only if the scale target rises.)

Each phase is a CI-gated PR sequence, complete + tested + benchmarked — no hollow features; a thing is done only when it runs and is proven.


Rationale

Why DBSP at all (vs indexed-semi-naive-with-caching). The product thesis is “the data system IS the reasoner”: a mutation must make a subsequent query cheap. That is incremental view maintenance, and DBSP is the provably-incremental, Lean-verified Z-set IVM substrate the codebase already committed to (runtime/relation.rs is “the day-one commitment to DBSP-shaped data”; arrangement_body is reserved). Indexed-semi-naive-with-caching gets indexing but not principled incrementality; it would be a stepping-stone we’d replace — i.e., build twice.

Why fork dbsp, not differential-dataflow, and not wrap. Single-node per-tenant means DD’s distributed/timely machinery is surface we’d strip, not value we’d keep; dbsp is the algebra itself with no distribution layer and a verified core (matching the Lean-first bar). Owning the fork (vs wrapping) is required to thread the pair-encoding, interval payloads, and provenance through the operators — and to honor the house constraints by fencing unsafe/HashMap behind a deny.toml-reviewed boundary rather than inheriting them opaquely.

Why the scope is safe to build correctly once. The only unproven piece in the ARS record (recursive-WFS-on-DBSP) is exactly the WFS feature we defer to the SLG kernel; everything in D11-IN is DBSP’s proven fragment. The AFT pair-encoding (D1) is what makes the deferral non-binding: WFS/SLG and the richer defeasibility slot in over the same state, via the same RFD-0003 dispatch seam, with no data-model rewrite. So designing-for-soon costs us a clean trait seam (D6/D10) and a value model (D1) we want regardless — not speculative engine work.

Why CQRS / dual representation. A single append-only log is a fine write model but a terrible read path at scale (re-materialization is O(DB)). Separating a persisted, indexed, graph-optimized read model (the durable DBSP arrangements) from the event-log write model is the standard event-sourcing answer, it is what the spec §20 already reserves, and it is what makes the no-O(DB)-on-any-hot-path contract achievable at all scales.

Why dual-track provenance, from day one. It is forced by three independent facts (group-vs-semiring impossibility, answer subsumption, variant dedup); it is nearly free given the event log already carries the PosBool-DNF column; and retrofitting it onto a single-track answer table later would be a rewrite of the hot path.

Why keep semi-naive. Validating the new engine against an independent implementation — not against itself — is the correct way to earn “completely correct.” Retiring the reference implementation to save tidiness is the wrong trade for a foundation. DBSP subsumes it, so this costs nothing in duplicated expressivity.


Alternatives considered

  • Indexed semi-naive + cross-query caching (no IVM). Smaller, stays in the BTreeMap world, gets indexing — but delivers caching, not principled incrementality, and would be replaced by DBSP. Rejected as a stepping-stone we’d build twice. (Its good ideas — arrangements, auto index selection — are absorbed by D7.)
  • Fork differential-dataflow (the ARS-record default Path B). Rejected: its distinguishing value is distributed/timely scale-out we don’t need; we’d fork-then-amputate. dbsp is the cleaner thing to own single-node.
  • Wrap Feldera dbsp as a black-box dependency (ARS Path A). Rejected as the product path (kept only as a throwaway Phase-0 measurement harness): wrapping can’t thread the pair-encoding/interval/provenance through operator internals, and drags in unsafe/HashMap behind an API we don’t control. “Build correctly once” + the fork-and-strip preference favor owning it.
  • DRedc as the shipping recursive substrate, DBSP as a v0.3 migration (ARS W5.S2/D-103). This existed as the safe fallback precisely because recursive-WFS-on-DBSP was unproven. Since we stay strict-stratified (where DBSP is proven), DRedc would be a stepping-stone we’d replace — rejected. (This reconciles the R-3.3-vs-D-103 internal inconsistency in the ARS record for our scope.)
  • Single-track provenance (on the answer row / on-stream). Structurally impossible under the bilattice + group structure (D5 rationale). Rejected.
  • Build recursive-WFS-on-DBSP now (the novel coupled-fixpoint construction). Unproven, no published precedent; the ARS record itself names it the gating risk. Rejected for this effort — WFS goes to the SLG kernel via the seam.
  • Event-log only (no persisted read model). The status quo; O(DB) re-materialization; dies at scale. Rejected (D4).
  • Retire semi-naive. Loses the independent oracle and the cold/one-shot fallback. Rejected (D9).

Consequences

  • Code structure. New oxc-reasoning::executor::dbsp (the forked, owned engine) registered first in the RFD-0003 dispatch; SemiNaiveExecutor implemented for real and retained as oracle/fallback; the orphaned logical/physical/optimizer scaffolding either wired into the real path or removed; oxc-runtime gains a salsa dependency and the OxcRuntimeDatabase; a StorageBackend trait seam for the future persisted read model; tc.rs/provenance/MTL-compile borrowed from kora-reason-rl under attribution.
  • Dependencies. The vendored dbsp fork enters via compiler/deny.toml allow-list + an explicit constraint review; unsafe is minimized and fenced (our new code stays #![forbid(unsafe_code)]); HashMap is permitted only in the vendored fork where it cannot affect observable order, justified in this RFD. num-rational/num-bigint already present (RFD 0016).
  • Lean (Lean-first for new semantics; evaluation-strategy obligations trail). AGENTS.md puts substrate semantics in the Lean-first lane, so the new obligations split by kind:
    • Gates the code it underwrites (Lean-first — these are new semantics not covered by the forward perfect-model theorems): (a) the ℕ[X]→PosBool[X] homomorphism commuting with deletion under DBSP’s chain rule (D5) — incremental retract is genuinely new semantics (stratified_fixpoint_unique is the forward model only), so it is mechanized before Phase 1’s incremental-retract path is trusted; (b) the D1 pair-encoding ↔ mechanized bilattice correspondence (representation adequacy for cross-stream negation = stream-swap, and ⊕-only-at-federation = Foundation/Bilattice.lean/Standpoint/Federation.lean) — mechanized before Phase 1 relies on the cross-stream coupling.
    • Trails implementation (evaluation strategy — reduces to the already-proven perfect model, sound for any converging strategy by stratified_fixpoint_unique): the operational RuleIR→extent evaluator (closes RP-008).
    • All anchored on porting Bogaerts–Cruz-Filipe 2024 (AFT-in-Coq) to Lean 4.
  • Conformance. The ~600 tests + keystone.rs stay green throughout; new differential + per-feature tests per phase.
  • Workflow. Branch off origin/main (this RFD is on rfd/0018-production-reasoner); per-PR CI-gated; commit at checkpoints; no “done” without a running/benchmarked proof.
  • Spec. Resolves RFD 0003’s deferred retraction/IVM open question; refines its DBSPExecutor row (Feldera dbsp fork, not DD/timely). The reference (spec/reference/src/{17,19,20}) follows once the design lands.
  • Relationship to RFD 0017 (refinement classification). RFD 0017’s two refinement kinds map cleanly onto this engine, with no scope expansion: a defined (iff) refinement is a derived-membership ruleiof(x, C) :- iof(x, parentᵢ), P(x) — i.e. an incrementally-maintained view (a mutation touching P(x) updates C-membership as a Z-set delta; this is the IVM win applied to classification), and is already covered by D11-IN’s recursive stratified Datalog. A primitive (where) refinement is a write-time invariant (OE0668 RefinementInvariantViolated) — a constraint check at the mutation boundary (Cat3-shaped), not a derived relation. RFD 0017 explicitly defers the iff realization theorem (iof(x,C) ↔ iof(x,parent) ∧ P(x)) to RP-007-adjacent work; this engine is its operational home, and that theorem is part of the D8 / RP-008 Lean conformance debt.

Open questions / tracked-future

  • The Phase-0 spike — PASSED (2026-06-05). Decoupling (∝|Δ| not |DB|) confirmed to 10M facts; AFT pair-encoding + stratified NAF correct at 1M with 1.46× overhead; VT-as-payload preserves incrementality. The three Path-A frictions found (MSRV churn; >65k-record relations need a storage backend; .output() reads empty at scale, must use .accumulate_output()) all reinforce D3 — fork & own. See the Phase-0 RESULT block in §Decision/D12.
  • AS OF tx-time = X × Salsa backdating — prototype with the Phase-1 Salsa layer; fallback = disable backdating for AS OF queries (ARS R-W5S2-04).
  • ℕ[X]→PosBool[X] commutes with deletion under DBSP’s chain rule (ARS R-W5.S3-4) — new semantics; gates Phase 1’s incremental-retract code (Lean-first; see Consequences/Lean). Not “tracked-future” — a Phase-1 prerequisite, listed here for visibility.
  • Persisted-read-model layout — a research item: graph-DB storage internals (CSR / index-free adjacency, native edge stores, triple-store indexing) → the Phase-4 read-model design.
  • PG schema canonicalization — spec §20.3 (TSTZRANGE+GIST, generated columns, JSONB derivation) vs the shipped migrations (INT8 ns columns, projection-index tables, TEXT derivation). Decide canonical before Phase 4 scaling.
  • The SLG kernel arc (WFS + ambiguity-propagating defeasibility) — the next major effort after Phase 2; its own RFD, building on D1’s pair-encoding and the D10 dispatch seam.
  • Per-tenant eviction — tracked, not built; enters when a tenant exceeds ~100 GB (provenance ≈ 2 TB/tenant at 10⁹ is the binding constraint); recommended composition = snapshot-at-τ₀ + continuous provenance compaction + tenant archival.
  • Cross-stream operator throughput for the pair-encoding — RESOLVED by the Phase-0 spike: measured at 1.46× at 1M facts, under the ~2× the construction predicts.

RFD 0019 — Mutation write-path correctness: construction, identity, read-your-writes, and exact values

  • State: accepted — partially implemented (RC1/RC2/RC4 landed; required-fields and within-body read-your-writes remain)
  • Opened: 2026-06-06
  • Decides: the surface and runtime semantics that take Argon’s write path — insert/construct → mint identity → persist fields and relations as ABox events → read them back within the same body and on reload — from silently incorrect to correct, durable, and Lean-conformant. Records the decisions taken on the design questions in RP-010 §6/§11. Refines RFD 0015 (the mutate body surface — insert disambiguation), and connects to RFD 0001 (identity), RFD 0006 (IndividualPropertyAssertion), RFD 0007 (required/missing fields under OWA), and RFD 0016 (exact Real). Its read/execution-path counterpart is RFD 0018 (RP-009); this RFD owns the write side and coordinates at the seams (§Consequences).

This RFD is the architecture decision record for the mutation write path. It is Lean-first where it touches semantics — the engine conforms to spec/lean/Argon/Runtime/MutationSemantics.lean (+ MutationFreshness.lean, AggregateExact.lean); divergence is a bug. It commits a plan and a set of surface-semantics decisions, not code. The full verified evidence lives in RP-010; this RFD states the decisions and why.


Question

Argon can parse and type a rich ontology and can reason over facts at small scale, but the write path is silently incorrect: real ontologies produce no usable instance data. Concretely (verified against origin/main @ 967cc03; see RP-010 §1 for the file:line evidence):

  • RC1 — positional insert Concept(...) does not construct. The parser dispatches purely syntactically (L_PAREN ⇒ relation-tuple op, L_BRACE ⇒ struct/construct op), never consulting whether the head resolves to a concept or a relation. So insert Person(name) lowers to a relation-tuple assertion that mints no individual, persists no fields, and lands under NameRef::DEFAULT (a garbage relation id). In let position it is totally silent: lower_let_stmt (mutate_lower.rs:271) only binds an INSERT_STRUCT_OP, so let p = insert Person(name) emits zero ops and binds nothing — and a later unbound p then collapses to a blake3("individual:{name}") content-hash id (oxc-runtime/src/lib.rs:3874), identical across runs. The brace form insert Type { f = v } is correct.
  • RC2 — no read-your-writes; navigation fields invisible. A Term::Proj reader in a mutate body reads committed (pre-mutation) state, not this body’s own buffered writes, so coll = coll + [x] clobbers. And from <rel>.endpoint navigation-view fields — which do resolve in the derive/query read path — are invisible inside mutate bodies.
  • RC4 — exact Real mishandled. The rule-body aggregate fold is exact BigRational (RFD 0016), but the mutate/term value path is f64-based: as_f64 has no Value::Real arm, so mutate-expression arithmetic/aggregates error on exact-Real operands.
  • §1.5 — required fields unchecked. No construction-time field-coverage check exists; insert Type { … } with missing required (incl. inherited) fields silently creates an incomplete individual. The relevant diagnostics (OE1908, OE1014) are defined-but-never-emitted.

What surface and runtime semantics make this path correct — and which of the “silent-wrong” behaviors become loud?

Context

The substrate is ahead of the implementation here. The Lean mechanizes a denotational mutate interpreter (Runtime/MutationSemantics.lean, 734 lines): a typed-literal insert mints a fresh IndividualId and emits an iof assertion plus per-field IndividualPropertyAssertions; a relation insert emits a RelationTuple; atomicity is structural (a failing run is Except.error, which carries no effects). MutationFreshness.lean proves mutate_run_fresh_ge (every minted id exceeds every committed one). AggregateExact.lean proves the folds are exact over Rat. So the correct behavior is mechanized; the Rust write path simply does not match it. This RFD’s job is to (a) settle the few surface-semantics calls the Lean does not pin down, and (b) commit the build order that makes the Rust conform.

The applied pressure is concrete: the residential-lease + accounting overlay (sharpe-ontology, ~5.2k LOC of .ar) cannot create a single faithful instance graph today — its entire Create* surface uses positional concept-insert, which silently persists nothing. This RFD is the prerequisite for the overlay (and therefore the production reasoner, RFD 0018) having real data to operate on.

Decision

1. Construction is brace-only; positional concept-insert is a hard error (RC1)

There is exactly one construct syntax: insert C { field = value, … }. The two surface forms are disjoint by bracket, and the bracket is the semantics:

  • insert C { … }construct. Mints a fresh IndividualId, asserts iof(id, C), and persists every supplied field (scalar, list, nested-individual, relation-typed, inherited) as ABox events. Value-producing: let p = insert C { … } binds p to the new individual.
  • insert R(args)relation-tuple assertion, only when R resolves to a relation. Emits a RelationTuple under R’s id.
  • insert C(args) where C resolves to a concepthard error (a new diagnostic, tentatively OE0212, finalized in grammar.toml at implementation): “positional insert of a concept constructs nothing; use the brace form insert C { … }.” The elaborator already has the module’s concept/relation index; it resolves the head and rejects rather than emitting a garbage tuple.

This kills the silent misroute, keeps the surface unambiguous (parens = relation, braces = construct), and avoids overloading parens to mean “construct sometimes.” The cost is an overlay migration (positional concept-inserts → brace form), which is mechanical and one-time.

The unbound-Var blake3 fallback (resolve_term_to_value:3874) is deleted: an unresolved Term::Var in a value position is a hard error, never a silently-minted content-hash id.

2. Identity: fresh monotonic surrogate, confirmed; no content-hash minting (RC1, RFD 0001)

The AtomicU64 surrogate minter is correct and stays. Distinct constructions get distinct ids; replay correctness comes from the event log replaying already-minted ids (MutationFreshness.mutate_run_fresh_ge is the backbone). A content/key-addressed “explicit identity key” (upsert/merge-on-key) is a future feature, explicitly out of scope for v1; if/when added it is opt-in, never the default. The only id-minting path is the surrogate counter.

3. Read-your-writes within the body (RC2)

A read of a field or collection inside a mutate body reflects this body’s own prior writes, not just committed state. So account.records = account.records + [r] accumulates correctly, and construct-then-read works. The value resolver consults the in-body buffered-write overlay (st.collections and the pending property/iof writes) before falling back to committed storage. Commit remains atomic at the body level (buffer → flush; a failing run flushes nothing — matching the Lean’s Except.error atomicity).

4. Navigation-view fields are computable in mutate bodies (RC2)

from <rel>.endpoint projections (and multi-hop chains like pair.book.account.records) resolve inside a mutate body exactly as they do in the derive/query read path (which already supports multi-hop projection — proven by keystone_met_integration). This removes the overlay’s parameter-passing workaround. The mutate evaluator materializes the navigation view on read (read-path parity), under the same world-assumption semantics.

5. One exact value model end to end (RC4, RFD 0016)

There is a single value model: exact BigRational for the exact tower (Real/Decimal/Money), with f64 reserved for explicitly-float types. Value::Real flows through the mutate/compute value path — as_f64/eval_binary and the mutate-path aggregate helpers (aggregate_sum/aggregate_extremum/aggregate_avg) gain a Value::Real arm and stay exact (no f64 promotion on the exact path). This is validated differentially against AggregateExact.lean. So require { value == sum(record.value …) } with value: Real evaluates exactly.

6. Required-field coverage is validated at build and at construction (§1.5, RFD 0007)

Construction with missing required (incl. inherited) fields is rejected with a real diagnostic:

  • Build-time (where statically knowable): a checker pass over insert C { … } against C’s field schema (walking <: for inherited required fields) emits a build diagnostic. The diagnostic-code reconciliation (OE0207 vs the existing OE1908/OE1014) is settled during implementation; the existing defined-but-unemitted codes are wired or replaced, not left dead.
  • Runtime: a construct-time guard catches the dynamically-unknowable cases at the mutate-rejection channel.

Required fields are CWA-at-construction even under concept-level OWA (an individual you are building now must satisfy its required structure), per RFD 0007’s intent distinctions; three-valued/OWA subtleties apply only where a field’s intent is epistemic/optional.

7. Loud failure is a deliverable (§1.7)

Every form that cannot execute correctly becomes a hard error or a build-time diagnostic, never a silent no-op or garbage write: positional concept-insert (§1), unbound-Var id (§2), construct with missing required fields (§6). Making these loud is part of the work, not a follow-on.

Rationale

  • Bracket-as-semantics is the least surprising surface. Once { } is the construct form (it already is, and it is correct), letting ( ) also construct — disambiguated only by a name lookup the reader must perform in their head — is the ambiguity that produced RC1 in the first place. Disjoint brackets mean a modeler (and the parser) can tell construct from relation-assertion locally, without resolving the head. The hard error on concept-(...) turns the one genuinely-ambiguous case into a teachable diagnostic.
  • The substrate already says so. The Lean InsertForm distinguishes typedLiteral (construct, mints identity) from relation (tuple, yields unit). Brace-only construction maps cleanly onto typedLiteral; positional-relation onto relation. The reject rule is the surface honoring a distinction the substrate already draws.
  • Read-your-writes is the transactional intuition every modeler brings; committed-only reads make in-body accumulation silently wrong, which is exactly the failure mode we are eliminating.
  • Exactness must be uniform or the numeric tower (RFD 0016) is a half-truth: a value that is exact in a rule body but lossy in a mutate expression is a latent correctness bug at the read/write seam.
  • Required-field enforcement at build is the earliest, loudest signal; the runtime guard covers what build cannot see. Silent incomplete individuals are the kind of garbage-in that defeats the reasoner downstream.

Alternatives

  • (RC1) Semantic disambiguation — positional insert Concept(...) constructs. The head resolves to a concept ⇒ Construct; to a relation ⇒ tuple. This was the initially-recommended option (more ergonomic; no overlay migration). Rejected in favor of brace-only: it overloads ( ) to mean two different things depending on a name lookup, keeps two construct syntaxes, and pushes head-resolution into a load-bearing position in the parser/elaborator. Brace-only is the more principled and locally-readable surface; the overlay migration is a bounded one-time cost.
  • (RC2) Committed-only reads. Simpler evaluator; rejected because it makes coll = coll + [x] silently truncate — a silent-wrong behavior this RFD exists to remove.
  • (§6) Runtime-only required-field check. Simpler, but defers feedback to execution; rejected in favor of build-time + runtime so statically-knowable omissions fail at ox build.
  • (RC2 nav fields) Reject navigation fields in mutate bodies. Keeps scope small but entrenches the overlay’s param-passing workaround and creates a read-path/write-path asymmetry; rejected in favor of parity.

Consequences

Phased implementation (each phase a complete, CI-gated PR, proven on a running example — no hollow features):

  1. RC1 — construct vs tuple + identity. Brace-only construction; positional concept-insert → hard error (OE0212); let x = insert C { … } binds; delete the unbound-Var hash fallback. Reconcile/extend MutationSemantics for the chosen surface (the reject is a surface rule; confirm the Lean models brace-construct + relation-tuple and add the positional-reject note). Proof: a keystone test constructs distinct individuals with all fields present and round-trips; the overlay’s Create* ops migrate to brace form and persist real data.
  2. RC2 — read-your-writes + navigation fields. Buffered writes feed in-body reads (scalar + collection); from/multi-hop projections compute in mutate bodies; resolve the update target: Type annotation requirement. Proof: update account set { records = account.records + [r] } accumulates across a for; the overlay’s Materialize* ops work without the direct-account workaround.
  3. RC4 — exact Real unification. Value::Real through as_f64/eval_binary/mutate aggregates. Proof: require { value == sum(record.value …) } with value: Real passes; differential test vs AggregateExact.lean.
  4. §6 — required-field validation. Build-time checker pass + runtime guard; diagnostic-code reconciliation. Proof: reject/accept tests; the overlay’s temporal entities enforce begin/end.
  5. Loudness sweep + reload fidelity. Remaining silent-wrong paths become diagnostics; round-trip/replay tests assert event-log fidelity for every emitted event kind.
  6. Overlay integration capstone. The lease Create*/Materialize*/Recognize* mutations build a faithful instance graph; Met/BreachedAt derives fire over real data (read side coordinated with RFD 0018).

Seams with RFD 0018 (RP-009). The two term evaluators (oxc-serve::eval_compute_term, oxc-runtime::resolve_term_to_value) are un-unified; RC4 and the reasoner’s compute work both touch them. Exact-Real plumbing touches oxc-reasoning::compile::Value, shared with the reasoner. The events this write path emits must be exactly what materialize_predicates reads. Ownership boundary on the shared evaluator is agreed with the RP-009 effort before refactoring shared code; the read/execution path (incrementality, indexed joins, function-application in derive bodies, tier coverage, modal soundness) is RFD 0018’s, not this RFD’s.

Drift. Any new/changed @[language_interface] shape (a CoreIR Operation, an event variant) updates the Lean inductive and the oxc-protocol mirror in lockstep (cargo xtask check-drift). The OE0212 reject is a diagnostic, not a wire shape — no drift impact.

Reference + Lean. The surface change (brace-only construct; positional-concept reject) lands in the reference (07-rules.md §7.5 + appendix-c-diagnostic-codes.md) and is reconciled with the Lean surface, per the surface-change workflow (RFD + reference → Lean → code).

Open questions

  1. update target: Type annotation. Today update target set { … } hard-errors; the annotation is required. Should it be inferred from the binder’s type (RC2 scope)? Leaning: infer where statically known, keep the annotation optional.
  2. Diagnostic-code reconciliation for required fields. OE0207 (spec-planned, nonexistent) vs OE1908 (IntrinsicPropertyMissing) vs OE1014 (RequiredFieldUnasserted) — which is the build-time code, which the runtime channel? Settled in the §6 phase against grammar.toml.
  3. property_id_for_field interning. The blake3("field:{Type}::{field}") field-key hash is a correct-but-stand-in for a real interned NameRef (RFD 0001). Promote to interned ids as part of the loudness/fidelity phase, or defer?
  4. Term-evaluator unification ownership. Who owns the unified term evaluator across the write path (this RFD) and the compute/read path (RFD 0018)? Agree the boundary before refactoring (§Consequences seam).

RFD 0020 — The runtime data engine: a composable query + reasoning pipeline

  • State: accepted — partially implemented (Phase 1 #98 landed; Phase 2 tracking issue #100 open)
  • Opened: 2026-06-06
  • Decides: the engine architecture of Argon’s runtime — the composable LogicalPlan → optimizer → PhysicalPlan → tiered execution pipeline that makes the runtime a full-blown, highly-optimized graph/knowledge database which (a) serves arbitrary ad-hoc queries and mutations (engine-configured), (b) maintains derived state incrementally as a reasoner, and (c) is the substrate the compiler/type-checker draws on — one composable IR, several physical backends, without becoming three incompatible engines or one monolith that compromises each role. This is the umbrella that frames RFD 0003 (the tier-dispatch seam), RFD 0018 (the recursive-tier read executor — the DBSP engine), and RFD 0019 (the write path), positioning each within the whole.

This RFD is the architecture decision record for the runtime engine. It is Lean-first where it touches reasoning semantics — the IR’s meaning conforms to spec/lean/Argon/Reasoning/ and the D1 pair-encoding correspondence (Standpoint/PairEncoding.lean); divergence there is a bug. But most of this RFD — the pipeline structure, physical operators, optimizer, storage layout — is engine architecture and ergonomics, which the Lean does not mechanize (per AGENTS.md scope) and which is settled from first principles here. It commits a plan, not code; orca-era decisions (D-NN) are cited only as corroborating prior experience, never as authority.


Question

Argon’s runtime is not “a reasoner with a storage backend.” It is a graph/knowledge database whose distinguishing feature is that the data system and the inference engine are the same system (oxc-reasoning/src/lib.rs:4-11: “Argon’s reasoner IS the data system… queries are sinks… the storage layer IS the reasoner’s state”). It must simultaneously be:

  1. A world-class graph database — accepting arbitrary ad-hoc queries and mutations (when the engine is configured to allow them), with traversals, pattern matching, aggregation, and recursion, optimized to compete with purpose-built graph stores at scale;
  2. An incremental reasoner — maintaining derived predicates (rules, bilattice/AFT, stratified NAF, aggregates; later WFS/defeasibility/MTL) over the same facts, incrementally on mutation (RFD 0018);
  3. The compiler/type-checker’s substrate — subtyping, refinement (where/iff), occurrence typing, and structural checks are queries over the type/ontology graph.

What is the engine architecture that serves all three without forcing the wrong shape on any of them? Concretely: what is the shared IR, what are the physical execution substrate(s), how do queries / rules / checker-goals / mutations relate, how is it optimized, how is data laid out at scale, and how do RFD 0003/0018/0019 compose inside it?


Context

Current state (verified against origin/main @ e7711a4)

  • The composable pipeline is designed, not wired. oxc-reasoning already declares the DataFusion-shaped stack: LogicalPlan (logical/mod.rs: Scan/Filter/Map/Join/AntiJoin/Distinct/Recurse/Sink), an OptimizerRule trait + an empty pipeline (optimizer/mod.rs), PhysicalPlan = Vec<Stratum> (physical/mod.rs), and an Engine dispatching Box<dyn TierExecutor> per stratum by tier (lib.rs:97-119). The module docs state the intent explicitly: “mirrors DataFusion’s ExecutionPlan pattern” and (compile/mod.rs) LogicalPlan is “the future surface for optimizer rules… when the optimizer materializes it will rewrite LogicalPlan → optimized → CompiledRule.”
  • The MVP took a shortcut around it. Today query_derive (oxc-runtime/src/lib.rs) goes AtomIR → CompiledRule → evaluate_to_fixpoint(&[CompiledRule], …) directly (6 call sites), bypassing LogicalPlan/optimizer/PhysicalPlan/TierExecutor. SemiNaiveExecutor::execute is an empty stub; the real (semi-naive) evaluator lives as free functions in executor/eval.rs. The whole RFD-0003 dispatch layer is currently orphaned — by staging, not by design error.
  • No ad-hoc query IR. Queries today are declared rules; there is no query expression/IR distinct from rules, and no general ad-hoc surface.
  • The checker does not yet use the reasoner. oxc-check is pure syntax-driven; the only shared artifact is the Tier enum. Role (3) is a goal, not wired fact.
  • Storage is naive. Relations are BTreeMap<CBOR-tuple, i64-weight> (runtime/relation.rs), decode-per-tuple in the join loop; no index-free adjacency / CSR / columnar / arrangements (the arrangement_body slot in .oxbin is reserved + inert).

What the roles demand (and what the references teach)

  • Graph-DB rolekuzu is the playbook: a strict bind → logical-plan → optimizer (visitor passes) → physical-mapper (1:N) → vectorized processor pipeline; factorization (flat vs unflat column groups → compact storage for many-to-many path patterns); multiway INTERSECT (worst-case-optimal joins) for cyclic patterns; CSR adjacency + columnar storage with semi-mask / predicate pushdown; clean extension hooks for pluggable operators. oxigraph adds index-permutation storage (SPO/POS/OSP), lazy iterator (volcano) evaluation, and the “a query is just a rule with a distinguished head” identity.
  • Reasoner rolekora teaches staged filtering (told-subsumers → EL-saturation → DL-saturation → tableau → FOL-escalation) and “absorption is a logical→physical lowering”; nous teaches the anti-patterns to avoid: rules-as-Rust-enums (rules must be data/IR), phases hardcoded into a loop (phases must be stratification metadata), and direct state mutation (rules must produce Z-set deltas, not mutate).
  • The physical insight — the vault’s Materialization Wall: for TC/1000, the bit-parallel computation is ~5 ms but converting the answer into joinable tuples is ~1100 ms (99.5%). The optimal compute representation (bitmatrix, CSR) differs from the optimal join representation (sorted tuples); converting between them dominates. The fix is BYODS — make the evaluator polymorphic over relation representations so the compute rep is the relation. This generalizes RFD 0018’s “arrangements (D7)” into a principle.
  • The IR principle — a reasoning logical IR is declarative, set-oriented, monotone, fixpoint-oriented (not SSA); FlowLog’s “explicit relational IR per rule, recursive control separated from the logical plan” is the right shape.
  • The optimizer trajectory — DataFusion’s own path: rule-based passes first; Cascades (memo + transformation/implementation rules + cost model; CMU’s optd is a Rust Cascades for DataFusion) when the search space justifies it.

Decision

A composable, multi-tier query+reasoning engine. Twelve decisions:

D1 — The runtime is a graph/knowledge database; reasoning is a capability within it

The product is a database: a durable, queryable, mutable store of individuals and relation-edges, with reasoning (derived predicates) as a first-class capability over the same data — not a bolt-on. Every other decision serves “world-class graph database that also reasons,” not “reasoner that also stores.” This reframes RFD 0018: the incremental reasoner is the engine’s view-maintenance subsystem, one tier among several.

D2 — One composable logical IR; three front-ends lower into it

LogicalPlan (a relational + graph + recursive algebra, D5) is the single shared surface. An ad-hoc query, a declared rule, and (at the boundary) a compiler/type-checker goal all lower to the same LogicalPlan: a rule adds a Recurse (fixpoint) node; a query adds a Sink/projection; a checker-goal is a bounded query over compile-time relations; a mutation is a write node (D11). This is why one engine can “serve all three” — they share the IR, like Substrait/DataFusion’s LogicalPlan is backend-agnostic. CompiledRule is reclassified as the physical lowering of a rule body (the mapper’s output), with LogicalPlan the optimizable form.

D3 — Two physical substrates, one IR (from first principles)

The IR is shared; the physical execution substrate is not one engine. Compile-time checking runs on Salsa-tracked memoized functions (demand-driven, per-definition invalidation, the rustc/rust-analyzer regime). Runtime queries/reasoning run on the DBSP/Z-set IVM engine (event-stream-driven, per-(tenant, fork) invalidation, RFD 0018). They meet at a boundary — compile artifacts feed the runtime; provenance composes — not in one executor. First-principles justification (not deference to orca’s D-09/D-10): the two halves have fundamentally different change regimes (source edits vs fact mutations), granularity (per-definition vs per-tuple), and lifetime (a build session vs a persistent multi-tenant store). A single physical substrate would force batch-IVM semantics onto fine-grained incremental type-checking, or Salsa’s recompute-on-demand onto a streaming fact firehose — compromising one half. The shared logical IR + tier ladder + provenance model is what unifies them; the physical backends are chosen per role.

D4 — The pipeline stages, strictly separated

front-end (parse → bind → type) → LogicalPlan → optimizer → physical mapper (1:N) → executor. Strict stage boundaries with typed IRs between them (kuzu’s discipline; and the vault’s canonical-pipeline-architecture post-mortem found that conflating analysis stages was the direct cause of a 4× diagnostic-count divergence — separation is a correctness property, not just hygiene). Each stage is independently testable; extension hooks (planner/mapper) allow pluggable operators and backends without editing the core.

D5 — The logical operator algebra

LogicalPlan extends the present relational core with graph-native and mutation nodes:

  • Relational: Scan, Filter, Map/Project, Join, AntiJoin (stratified NAF), Distinct, Aggregate, Union, Sink.
  • Graph-native: Extend (single-hop edge traversal), PathExtend (variable-length / recursive path), Intersect (multiway / worst-case-optimal join for cyclic patterns).
  • Recursive: Recurse (least-fixpoint — the rule-evaluation operator; “rules are the execution unit” lives here).
  • Mutation: Insert, Delete, Set, Merge (D11; the write path’s logical surface, coordinating with RFD 0019).

Graph-native nodes desugar to joins + Recurse for correctness, but the optimizer can lower them to native physical traversal operators (index-free adjacency, factorization) when the storage rep supports it — the kuzu performance win. The IR stays declarative / set-oriented / monotone / fixpoint-oriented.

D6 — BYODS: physical relations are polymorphic over representation

A physical Relation is an interface (membership, key-scan, range-scan, join-key iteration), not a fixed BTreeMap<CBOR, weight>. Representations coexist behind it: sorted arrangement on InternalId (RFD 0018 D7, the join workhorse), bitmatrix (transitive closure — kora-reason-rl’s bit-parallel Warshall), CSR index-free adjacency (graph traversal), factorized (D7), and virtual/lazy (generate tuples on demand). The optimizer/mapper picks the rep per relation; the engine never pays the Materialization Wall — the optimal compute rep is the relation, served directly to downstream operators. This subsumes and generalizes RFD 0018’s “arrangements.”

D7 — Factorization is in scope for v1

kuzu-style factorized query processing — flat vs unflat column groups, FLATTEN operators inserted by a rewriter pass, factorized intermediate results — is part of v1, not deferred. It is the difference between linear and Cartesian memory for the many-to-many graph patterns a knowledge graph is made of; deferring it would mean rebuilding the physical layer later. It is built early, alongside the Z-set/arrangement baseline, and reconciled with the Z-set model (a factorized Z-set is a compressed multiplicity-carrying batch).

D8 — The optimizer: rule-based visitor passes now, Cascades later

The optimizer is a chain of visitor-based rewrite passes (the OptimizerRule trait, made real): predicate/projection pushdown, join reordering (cardinality-guided), magic-sets / demand transformation (Datalog — restrict bottom-up rule evaluation to the query’s demand), stratum merging, factorization rewriting (D7), and backend/tier dispatch. This matches DataFusion’s shipping design. Cascades (optd-style memo + transformation/implementation rules + cost model over ontology/graph statistics) is the planned successor once the plan search space (graph join orders × representation choice × backend choice) outgrows hand-ordered passes — adopted then, not now.

D9 — Tier dispatch with pluggable executors (generalizes RFD 0003)

The 7-tier classifier (classifier/mod.rs) routes physical (sub)plans to executors, each owning its own incrementality over the shared relation catalog: Salsa-tracked functions (compile-time, structural/closure checking), semi-naive / DBSP (recursive runtime tier — RFD 0018), SLG (WFS-over-cycles — deferred), Kora (DL/expressive tier — embedded behind the seam, kora’s staged saturation/tableau), SMT (FOL under unsafe logic). Tier is compile-time metadata on rules/plans (not a runtime profile flag — the nous anti-pattern). This is RFD 0003’s TierExecutor seam, generalized from “reasoner backends” to “any physical-plan backend.”

D10 — Storage: CQRS, and the event-log-as-sole-store risk

The store splits write model (the append-only axiom_events log — source of truth, audit, bitemporal time-travel; the Z-set delta stream RFD 0018 consumes; the write path RFD 0019 produces) from read model (a persisted, indexed, graph-optimized materialization — CSR adjacency / columnar / factorized, the durable BYODS physical relations). The event log must not sit on any hot read path. A single append-only log is an excellent write/audit model but a catastrophic primary read store at scale (re-materialization is O(database); per-query log replay dies) — so the persisted read model is the primary served store, the log is checkpointed/compacted behind it, and cold-start reads the read model, not the log. (This is the storage-performance concern raised in discussion, made a hard contract.) Cross-refs RFD 0018 D4 (which this generalizes from the reasoner’s arrangements to the whole database’s read model) and the spec §20 CQRS catalog.

D11 — Ad-hoc queries and mutations are first-class (the database API), config-gated

The engine accepts arbitrary queries and mutations at runtime, not only declared rules/procedures — when the engine is configured to permit it (a deployment may restrict to declared operations for safety/perf). Ad-hoc queries are LogicalPlan trees built at runtime (D2/D5); ad-hoc mutations are write nodes producing Z-set deltas that the IVM engine maintains derived state against (D5/D10; semantics owned by RFD 0019). This is the read/write API surface of the database — gating is an engine policy, not a language restriction (tenancy/IAM stay in the serving layer per AGENTS.md).

D12 — Relationship to RFD 0003 / 0018 / 0019, and the build order

  • RFD 0003 (backend dispatch) is the TierExecutor seam — subsumed and generalized by D9.
  • RFD 0018 (DBSP engine) is the recursive-tier runtime read executor — the physical backend for Recurse/IVM. Correctly scoped; this RFD is the layer above it.
  • RFD 0019 (mutation write-path correctness) owns the write semantics; D5/D10/D11 host its logical surface and storage contract.
  • Build order: the “executor unification” work is no longer a standalone refactor — it is Phase 1 of this RFD: route the runtime through the Engine/dispatch over the proven rule path (with Recurse as the operator), make SemiNaiveExecutor::execute real, de-magic-number convergence, fix the stale headers. Then: Phase 2 — the LogicalPlan/optimizer/physical-mapper made real (the pipeline wired end-to-end) + BYODS reps; Phase 3 — graph-native operators + factorization + the persisted read model; Phase 4 — ad-hoc query/mutation surface + Cascades when justified. RFD 0018’s own phases (the DBSP engine) proceed in parallel as the recursive-tier executor.
    • As-built realization: RFD 0021. Phase 1 (dispatch through Engine::evaluate), Phase 2 (indexed + persistent-arrangement joins, WCOJ, BYODS/CSR reps, the SIP body-reorder optimizer), and the reasoner half of Phase 3 (graph-native joins + factorization) are now landed in oxc-reasoning; 0021 records that engine as built. The persisted read model + cross-query IVM (the remaining half of Phase 3) is still open — 0021 D6/D7 show why the operator discipline already in place makes it an additive layer rather than a rewrite.

Rationale

Why composable, not monolithic. Three roles with different consumers and change regimes cannot be served well by one hand-rolled evaluator (nous proved the failure mode: hardcoded phases, rules-as-enums, an EL++ ceiling). A composable IR + pluggable optimizer passes + pluggable executors is exactly how DataFusion serves dozens of embeddings and kuzu serves graph workloads — and it is what lets a new capability (a new operator, a new tier backend, a new physical rep) land without rewriting the engine.

Why one IR but two substrates. Unifying the logical layer is what makes the three roles coherent (one algebra, one tier ladder, one provenance model). Unifying the physical layer would be a category error: compile-time checking and runtime fact-streaming have different change granularity and lifetime; the right incremental machinery differs (Salsa vs DBSP). Share the meaning; specialize the mechanism.

Why BYODS / never materialize. The Materialization Wall is empirical (99.5% of TC time is representation conversion). A world-class graph database cannot pay that. Polymorphic relations let TC stay a bitmatrix, traversal stay CSR, joins stay sorted arrangements — each served through one interface, none converted.

Why factorization now. Knowledge graphs are many-to-many. Flat tuple materialization of path/pattern results is Cartesian; factorization is linear. It is structural to the physical layer, so it is cheaper to build in than to retrofit — hence v1.

Why CQRS with the log off the hot path. Event-sourcing gives audit, time-travel, and a clean IVM delta stream — but a log is a write model. Serving reads from it is O(database). The persisted, indexed read model is the only way to hit world-class read latency at scale; the log earns its keep on the write/audit side.

Why ad-hoc, gated. A database that only runs pre-declared procedures is a stored-procedure engine, not a database. Ad-hoc queries/mutations are the product; gating is an operational policy for deployments that want it.


Alternatives considered

  • Keep the MVP shortcut (no pipeline); grow the direct rule evaluator. Rejected: it cannot host ad-hoc queries, graph-native operators, an optimizer, or pluggable backends without becoming the monolith nous warns against; it is the stepping-stone we’d replace.
  • One physical engine for both compile-time and runtime. Rejected (D3 rationale): forces the wrong incremental regime on one half.
  • Relational-only IR; treat graph queries as sugar over joins. Rejected for v1’s graph-DB ambition: loses the native-traversal / factorization / WCO-join performance that defines a graph database (D5/D7).
  • Materialize everything into sorted tuples (no BYODS). Rejected: the Materialization Wall (D6).
  • Event-log as the primary read store (no persisted read model). Rejected: O(database) reads; dies at scale (D10) — the explicit storage concern.
  • Declared-queries-only (no ad-hoc). Rejected: not a database (D11).
  • Cascades optimizer from day one. Deferred, not rejected: rule-based passes are sufficient until the search space justifies a memo/cost-model engine (D8).

Consequences

  • Code structure. oxc-reasoning’s logical/optimizer/physical/executor modules become the real pipeline (not scaffolding); the runtime routes through Engine/dispatch instead of the bare free function; a physical Relation trait (BYODS) replaces the fixed BTreeMap<CBOR,weight>; new graph-native + mutation logical nodes; a factorized-batch physical layer; a persisted read-model store behind a StorageBackend seam.
  • Spec. This RFD frames RFD 0003/0018/0019; the reference (spec/reference/src/{17,19,20}) gains an engine-architecture chapter once the design lands. RFD 0018’s “the engine” framing is contextualized as the recursive-tier executor.
  • Lean / conformance. The IR’s reasoning semantics conform to spec/lean/Argon/Reasoning/ + the D1 pair-encoding correspondence; the pipeline structure / operators / optimizer / storage are engine architecture (outside the Lean’s mechanized scope per AGENTS.md) and are conformance-tested against the semi-naive oracle + the Lean (RFD 0018 D8). The optimizer’s rewrites must be semantics-preserving — a differential-test obligation (each pass: optimized plan ≡ unoptimized plan on generated inputs).
  • Workflow. Branch off origin/main; per-PR CI-gated; the executor-unification slice (Phase 1) is the first PR; no “done” without running/benchmarked proof; commit at checkpoints.
  • Performance posture. The scaling contract (RFD 0018 D4) is hereby a database contract, not just a reasoner one: no O(database) operation on any hot path, at any scale, for queries or mutations or reasoning.

Open questions / tracked-future

  • Event-log compaction / checkpointing design — how the read model is kept primary and the log is compacted/archived off the hot path (the storage-performance concern); interacts with RFD 0018 D4 + the spec §20 schema. A research item before Phase 3.
  • Factorization ↔ Z-set interplay — the precise representation of a factorized, multiplicity-carrying, possibly bilattice-pair-encoded batch (D7 × RFD 0018 D1/D5). Needs a concrete data-model design before Phase 3.
  • The checker-uses-the-reasoner boundary — when/how oxc-check starts issuing LogicalPlan goals (subtyping / refinement / occurrence) executed on the Salsa substrate; what the shared-IR contract between compile-time and runtime looks like in code. Role (3) is design-for-now; the seam is D2/D3, the wiring is later.
  • Cost model / statistics source — what cardinality/selectivity statistics the optimizer (and eventually Cascades) consumes, and how they are maintained incrementally over a mutating graph.
  • Ad-hoc safety / config-gating model — the engine-configuration surface for permitting/restricting ad-hoc queries and mutations (resource limits, allowed operators, tier ceilings); a Phase-4 design coordinated with the serving layer (RFD 0014).
  • WCO-join scope — how far to take worst-case-optimal / multiway joins (kuzu’s Intersect is star-pattern-restricted; general WCO is more) for cyclic graph patterns.

RFD 0021 — The reasoner execution engine (as built)

  • State: committed
  • Opened: 2026-06-06
  • Decides: the concrete query-execution engine of oxc-reasoning — the realization of RFD 0020’s Phase 2 (executor unification, the join engine, the optimizer) and the reasoner-side of Phase 3 (graph-native operators, factorization). It records the decisions settled by building: the execution model, the join algorithms, the optimizer passes, the Map operator, the factorization layer, the physical-relation layer, the incrementality discipline, and the correctness methodology. It also records two framing corrections that building surfaced — to RFD 0018 D3 (“fork Feldera”) and to the meaning of “full BYODS.” Built and merged across PRs #98, #102, #103, #104, #105, #108, #111, #114, #116, #118, #121, #122, #124 (and the forall triage #120).

This RFD records what was built and why. RFD 0020 is the umbrella vision; RFD 0018 is the incrementality plan; this is the engine that exists on main. It is Lean-first where it touches reasoning semantics: the executor conforms to the strict-stratified perfect model (spec/lean/Argon/Reasoning/Fixpoint.lean) and is held to it by differential testing against the semi-naive oracle. The rest — join algorithms, physical layout, optimizer, factorization — is engine architecture the Lean does not mechanize (per AGENTS.md scope), settled from first principles.


Question

RFD 0020 set the composable-engine vision but, at the time, the pipeline was “designed, not wired”: LogicalPlan/optimizer/PhysicalPlan/TierExecutor were orphaned, SemiNaiveExecutor::execute was a stub, the live path was AtomIR → CompiledRule → evaluate_to_fixpoint directly, storage was decode-per-tuple BTreeMap, and there were no graph-native operators, no computed-term evaluation, and no factorization.

How is the reasoner’s query-execution engine actually realized — execution model, join algorithms, optimizer, computed terms, factorization, physical-relation layer, and the incrementality discipline — and what concrete decisions did building it settle?


Context

  • RFD 0020 (umbrella) chose: one composable IR; graph-native operators (D5); BYODS (D6); factorization in v1 (D7); a rule-based optimizer (D8); tier dispatch (D9); CQRS storage (D10).
  • RFD 0018 (incrementality) chose: DBSP as the recursive-tier engine, originally forking Feldera dbsp (D3); AFT pair-encoded Z-sets (D1); arrangements on InternalId (D7); semi-naive as oracle (D9). Its Phase-0 spike de-risked IVM (<1 ms incremental at 10 M facts).
  • The constraint that shaped every slice: a correctness-first, no-placeholder, no-hollow discipline — each optimization had to be the genuine mechanism (not a known-incomplete shortcut), validated against an independent oracle, with a correct fallback for the cases it doesn’t yet handle.
  • A coordination seam with the parallel write-path track: the RelationCatalog public API (get/get_mut/ensure/insert/iter + the CatalogEntry.relation field). The reasoner owns the internal eval/join/representation; the write path consumes via the API.

Decision

D1 — Execution model: CompiledRule is the executed form; the “operator pipeline” is the set of operators it evaluates (#98)

The semi-naive CompiledRule evaluator (executor/eval.rs) is the executed form, routed through Engine::evaluate + ConvergencePolicy dispatch (the RFD 0003 seam made real; the six magic 1000s removed; SemiNaiveExecutor::execute real). RFD 0020’s “operator pipeline” (D2/D4) is realized as the set of operators the executor evaluatesPredicate (join), Comparison (filter), Naf (anti-join), Aggregate, Compute (Map) — not a separate operator-tree interpreter. Why (build-correctly-once): an operator-tree LogicalPlan that round-tripped back to the CompiledRule executor would be throwaway scaffolding the moment an operator-tree executor arrives; the optimizable LogicalPlan IR is reserved for that executor when graph-native physical operators + factorization genuinely demand it. The dead logical/physical/optimizer scaffolding stays reserved, not wired.

D2 — The join engine: a Free-Join hybrid over a polymorphic physical layer

Joins dispatch by body shape, all anchored to the binary binding-extension evaluator as the differential oracle:

  • Indexed joins (#102) — arrangement-by-bound-key, tuples decoded once at build time. Replaced the O(|prior| × |rel|) decode-per-tuple nested scan. Output ordering byte-identical → a pure drop-in.
  • Persistent arrangements (#104) — arrangements cached across a stratum’s semi-naive iterations, evicted after each merge for exactly the relations whose extent changed (merge_into_state is the sole mutation point). Staleness is structurally impossible; stable relations (EDB, earlier strata) build their index once.
  • Worst-case-optimal join (#114, time-optimal #116) — for pure positive-predicate cyclic bodies (the triangle), a variable-at-a-time Generic Join over per-atom prefix tries: at each variable, drive the multiway intersection from the smallest candidate set and probe the others by membership (#116 — the leapfrog discipline that makes it O(M log N), not O(N²); #114 alone was memory-optimal but time-O(N²)). Binary stays for acyclic/linear-recursive bodies — the Free-Join hybrid. Gated by atoms ≥ distinct vars (perf-only; WCOJ is correct for any conjunctive body). Differential-tested identical to binary.
  • BYODS / CSR index-free adjacency (#118) — the arrangement is polymorphic (enum { Bucketed, Csr }, RFD 0020 D6). A binary relation keyed on a single column (the edge-traversal pattern) builds a CSR adjacency (sorted sources + contiguous neighbours); probing is a binary search + a contiguous slice. Built ephemerally from the canonical BTreeMapCatalogEntry.relation is unchanged, so coordination-free.

D3 — The optimizer: SIP reorder + projection-collapse (#103, #105, #122)

In Engine::evaluate, each rule body is reordered (optimizer/reorder.rs): filters first, then the most-constrained predicate (most already-bound / constant argument positions), then aggregates, with a cardinality tie-break (smaller non-rule-head relation first; a rule-head relation’s pre-eval size is unknown → deferred, never falsely “smallest”). Safety is structural — a greedy that only ever places an atom whose consumed variables are already bound (a correct over-approximation excluding NAF/aggregate-locals). Semantics-preserving.

Projection-collapse (#122) — eager projection-pushdown in the naive pass: after each atom, variables that are now dead (bound so far, but not in the head and not referenced by any later atom) are cleared and the binding set deduped, so a projected-away fan-out (HasBoth(p) :- hasPhone(p,ph), hasEmail(p,e) → stays at |distinct p|, never |ph|×|e|) collapses without enumeration. Result-preserving.

D4 — The Map operator: computed terms (#108, #111)

CompiledExpr + CompiledAtom::Compute evaluate computed scalar terms (exact-BigRational arithmetic with Int-collapse, comparisons → Bool, &&/||) — what CompiledTerm (variable/constant only) could not represent, the gate behind the issue-#56 “won’t evaluate” wall. The drift-gated AtomIR::Compute substrate node (Lean + protocol, the write-path track) lowers via compile_expr. The Map operator is linear → trivially incremental. Non-scalar terms (field projection, application) are a loud compile error, never a silent drop.

D5 — Factorization: the f-representation for the high-value cases (#121, #122, #124)

Realized for independent-factor bodies (groups sharing no body-local variable), computed over the factorized form rather than the enumerated cross-product:

  • count (#121) = per-group sizes.
  • projection-collapse (#122) — see D3 (the main-eval analog).
  • value folds (#124) — a value fold whose single projection variable lives in one group folds over that group; the others scale (sum: × ∏ other sizes) or gate (min/max/avg/count_distinct: repetition-invariant, gated on the others being non-empty).

Connected bodies, constant/outer-bound projections, and nested factorization stay on the correct flat path — real follow-ups, not silent shortcuts. Every path is differential-tested identical to the flat fold. This is the genuine f-representation: the general FBindings representation (deferred) would compute these the same way — these generalize, they are not placeholders.

D6 — Incrementality is an additive outer loop, not a rewrite — the DBSP model, not a Feldera fork (revises RFD 0018 D3)

Every operator is a pure Z-set → Z-set function: linear (commutes with the delta operator — Map, Filter, projection) or with an explicit delta-form over arrangements (the join product rule, antijoin’s signed-weight form). The substrate already is the incremental substrate: Relation = BTreeMap<tuple, i64-weight> is a Z-set, the persistent arrangements are DBSP’s integrated indexed state, and the semi-naive delta loop is the join product rule applied across iterations. Therefore cross-mutation IVM is an additive outer loop (integrate/differentiate at the boundaries + delta-seeded recursion), never an operator rewrite. Holding this discipline as each operator is built is what guarantees it.

This revises RFD 0018 D3 (“fork Feldera dbsp”) → “adopt the DBSP model; build our own operators.” Feldera’s flat binary-join Z-sets are the wrong substrate for the factorization and WCOJ that RFD 0020 D7 commits to for v1; grafting them onto Feldera is swimming upstream. We build our own operators on the DBSP model and keep Feldera + the semi-naive evaluator as differential oracles. RFD 0018’s Phase-0 spike still stands — it de-risked IVM-as-approach, not Feldera-the-codebase.

D7 — Cross-query reuse needs the persisted read-model, not a CatalogEntry.relation rep-swap (corrects the “full BYODS” framing)

The catalog is rebuilt per query (query_derive → materialize_predicates → RelationCatalog::new(); the Store holds no materialized catalog; query results are cached, the catalog is not). So making CatalogEntry.relation a persistent dense-InternalId representation buys nothing across queries — it dies with the catalog — and within-query reuse is already captured by the persistent arrangements (#104) + CSR (#118). The genuine cross-query-reuse and incrementality win is to persist the materialized read-model on the Store and maintain it incrementally on mutation — the CQRS persisted read-model + IVM (RFD 0020 D10, RFD 0018 Phase 4) — a storage-and-write-path-coordinated effort, not a reasoner representation change.

D8 — Correctness methodology: the independent oracle

Every optimization is validated against an independent implementation that must produce identical results: the binary/flat evaluator is the differential oracle for indexed joins, WCOJ, CSR, projection-collapse, and factorization; the semi-naive evaluator is the oracle for the eventual DBSP/IVM. Library code has no unwrap/expect/panic; BTreeMap/BTreeSet only; every PR is fmt + clippy clean and cargo nextest-green. This is why the engine could be transformed under load without regressions, and why a structurally-impossible-staleness or byte-identical-output argument backs each slice.


Rationale

  • Build-correctly-once over speculative IR (D1). The optimizable operator-tree LogicalPlan is real architecture, but wiring it to round-trip through the proven CompiledRule executor would be scaffolding discarded the moment an operator-tree executor lands. Optimizing the executed form and deferring the tree to its real consumer is the honest sequencing.
  • The oracle is the load-bearing safety property (D8). WCOJ, CSR, factorization, and projection-collapse are intricate and easy to get subtly wrong; anchoring each to the binary/flat evaluator turned “is it correct?” into a test. (#116 exists because the oracle-and-complexity analysis caught that #114 was memory-optimal but time-O(N²).)
  • The Z-set discipline is what keeps incrementality cheap to add (D6). Because the substrate is already a Z-set with arrangements, and every operator is a differentiable Z-set function, IVM is a wrapping, not a rewrite — which is precisely why forking Feldera (whose model can’t carry factorization/WCOJ) is the wrong trade.
  • Facts beat framings (D7). “Full BYODS for cross-query reuse” sounded right until the per-query catalog rebuild was verified; surfacing that prevented a large, coordinated, marginal-value rep-swap.

Alternatives considered

  • Wire the operator-tree LogicalPlan interpreter now. Rejected (D1): the LogicalPlan → CompiledRule round-trip is throwaway once an operator-tree executor exists, and the round-trip for aggregates/NAF is complex; defer the tree to its real consumer.
  • Fork Feldera dbsp (RFD 0018 D3 as written). Rejected (D6): flat binary-join Z-sets are the wrong substrate for factorization/WCOJ; adopt the model, build our own operators.
  • “Full BYODS” = make CatalogEntry.relation a persistent rep. Rejected (D7): the catalog is rebuilt per query, so it delivers no cross-query reuse; the real win is the persisted read-model.
  • WCOJ everywhere / always-on factorization. Rejected: WCOJ regresses acyclic bodies; factorization helps only specific shapes. Both are gated, with the proven path as the default and fallback.

Consequences

Built and on main: a query engine that is expressive (computed terms), optimized (SIP + cardinality reorder, indexed + persistent arrangements, projection-collapse), worst-case-optimal on cyclic graph patterns, index-free-adjacency-capable (CSR), factorized (count / projection / value-folds), and incrementality-ready (every operator a pure Z-set function). All correctness-first; the forall over-derivation was triaged to the lowering with a validated count-equality fix recipe (#120, handed to the write-path track).

Deferred (each a real follow-up, not a gap in what’s built):

  • delta-path projection-collapse (recursive rules) and nested factorization;
  • the general FBindings representation flowing through the eval — gated on real-workload evidence (no real models exist yet, so the winning-shape frequency is unknown);
  • the persisted read-model + IVM (D7) — the cross-query-reuse and incrementality payoff, the next big coordinated effort;
  • WFS / SLG for recursion-through-negation (OE1309 under strict stratification) — a real-model driver (RFD 0018, Gustavo’s breach calculus);
  • cardinality statistics for cost-based / per-stratum reorder.

Coordination: the RelationCatalog public-API seam stays the boundary with the write-path track; the catalog-owned (persistent) BYODS representation, when built, is the coordinated slice (touches CatalogEntry.relation).


Open questions / tracked-future

  • When does the general FBindings representation earn its cost? It is the uniform home for factorization, but it is the largest operator-model change; building it ahead of a real workload that exhibits the fan-out shapes would be over-engineering. Decide when real models exist.
  • The persisted read-model + IVM — the design (CQRS read model on the Store, generation-driven invalidation, incremental maintenance with retraction) is RFD 0020 D10 / RFD 0018 Phase 4; it is the next major, coordinated effort and warrants its own RFD when started.
  • Recursion-through-negation — needs WFS (the SLG kernel, RFD 0018 D11); strict stratification correctly rejects it (OE1309) today.

RFD 0022 — Package-path addressing (pkg) and the build evaluability gate

  • State: committed
  • Opened: 2026-06-07
  • Decides: two surface/contract decisions settled by building the real-package-layout module-resolution work — (1) how a path addresses modules, including the self-reference anchor and restricted visibility (pkg:: / pub(pkg), not crate:: / pub(crate)), and (2) that ox build refuses to emit an artifact containing a rule the runtime cannot evaluate, rather than silently dropping it. Built and merged across PR #128 (resolver anchoring, re-export propagation, pub(pkg), the build gate). Relates to Modules and the loudness stance of RFD 0019.

This RFD gives a home to two decisions the module-resolution PR made that change the surface (cratepkg) and the build contract (warn→error). Per AGENTS.md surface changes route through an RFD; these are recorded as built.


Question

The corpus examples were hand-flattened into a single namespace, so module resolution had never been exercised on a real nested package. Two questions surfaced when it was:

  1. How does a path name a module — and how does a package refer to itself? Rust uses crate:: for self-reference and pub(crate) for package-wide visibility. Argon is not Rust; it has packages, not crates. What is the canonical self-anchor and restricted-visibility spelling?
  2. What does ox build do with a rule the runtime cannot evaluate? The lowering admits rule shapes the executor’s compile_rule then refuses (a forall/exists quantifier → OE1315, not <aggregate> → OE1313, an unsupported aggregate kind → OE1312, …). Such a rule is silently dropped from evaluation.

Context

Resolution was filesystem-relative to the importing file, with no notion of the package root: pkg::a::b, super::, and any reference from a file deep in the tree all failed; only flat-relative paths from the package root happened to work (where relative coincides with absolute). The real overlay emitted ~190 OE0101 errors as a result.

Separately, ox build lowered every rule, loaded the artifact, and warned (non-fatally) on rules the runtime couldn’t evaluate — then wrote the artifact anyway. A consumer querying such a model gets wrong (under-derived) answers with no runtime error, the build warning easy to miss.


Decision

D1 — Path addressing: pkg is the sole self-anchor; no crate

A qualified path’s leading segment selects a root (Name resolution):

  • pkg — the current package’s root. The only way a package refers to itself; a package never names itself by its own package name. pkg::a::b::X names X in module a::b. Rename-safe: changing the package’s name in ox.toml doesn’t break internal paths.
  • self — the current module; super (repeatable) — an ancestor module.
  • A dependency package name ([dependencies]), and the always-available std root.
  • Otherwise the leading segment resolves against the scope chain (a submodule of the current module, or a use-imported name).

Restricted visibility is pub(pkg) (package-wide), not pub(crate). There is no crate keyword anywhere in the surface. pub(<anything-but-pkg>) is a parse error (OE0001) — it is not silently widened to pub.

pub use … ; / pub use … ::*; re-export (transitively); a plain use is a private import and does not re-export. Under the v0 world-assumption simplification pub(pkg) re-exports identically to pub (package boundaries aren’t yet modeled as a visibility cut).

D2 — ox build refuses to emit an un-evaluable artifact

If any rule in the lowered program is one the runtime’s compile_rule refuses, ox build fails and writes no artifact (it re-runs the runtime’s own rule compiler as the oracle, before write_oxbin). A built .oxbin therefore evaluates every rule it contains, or it does not exist.


Rationale

  • One obvious self-anchor. Supporting both pkg:: and the package’s own name would make every self-reference a silent style choice and blur the inside/outside boundary (a reader couldn’t assume a package-name path is a dependency). pkg:: is unambiguous and rename-safe; the package name stays the dependent-facing absolute path.
  • crate means nothing in Argon. The unit of distribution is a package ([package] in ox.toml); borrowing Rust’s crate vocabulary would be a false cognate.
  • Loud over silently-wrong (D2). A knowledge system that returns a plausible-but-wrong answer is worse than one that refuses — the same instinct as RFD 0019. A dropped rule is a silent under-derivation; refusing the artifact moves the failure to build time where it’s visible. The full examples corpus builds clean under this gate, so no real model relied on the warn-only behaviour.

Alternatives

  • Support both pkg:: and package-name self-reference (referential transparency: a symbol’s absolute path is identical inside and outside). Rejected: the cosmetic upside is outweighed by two-ways-to-say-it and rename-fragility; the external absolute path is still expressible.
  • Keep pub(crate) (Rust-familiar). Rejected: crate is not an Argon concept.
  • Warn, don’t fail, on un-evaluable rules (the prior behaviour). Rejected: it ships silently-incomplete artifacts.
  • A new diagnostic code for the visibility error. Deferred: reusing OE0001 with a clear message is sufficient; a dedicated code can come later if needed.

Consequences

  • Visibility::pubPackage (Lean Syntax/Decl.lean), Visibility::Package (Rust oxc-db), and the book §3.1/§3.4 are aligned on pub(pkg). There is no oxc-protocol Visibility mirror, so the rename is maintained by hand, not the drift gate.
  • self:: as a path-start segment does not yet parse (self is a lexer keyword for self.field); resolver support is in place. Accepting self/super/pkg as first-class path-root keywords is a follow-up.
  • Build-time refusal currently keys on the runtime rule compiler; an un-evaluable rule fails the whole build (no partial artifact). This is intentional for v0.

Open questions

  • Should pub(pkg) become a true visibility cut (distinct from pub) once package boundaries are modeled, rather than the v0 Package == Public simplification?
  • Should the unknown-pub(...) rejection get its own diagnostic code (vs the reused OE0001)?
  • Should the build gate ever support a partial/--allow-unevaluable mode for iterative authoring, or is whole-program evaluability the permanent contract?

RFD 0023 — Reflective TypeRef: type-as-value in the meta-calculus

  • State: committed
  • Opened: 2026-06-07
  • Decides: the substrate’s type-as-value facility — a reflective sort TypeRef whose values are references to declared types, the bounded form TypeRef<C>, the runtime/wire carrier, and the first-class (value-polymorphic) forms of the four reflection intrinsics (meta/iof/specializes/ extent). This is the realization of RP-003 GAP-1 (“expose the reflection predicates as first-class so library code can pass/count/quantify over type-values”). It is Lean-first: the foundation landed in spec/lean/Argon/CoreIR/Term.lean (Term.typeRef) and spec/lean/Argon/MetaCalculus/Reflect.lean (sorts, lattice, the iof/specializes/extent relation semantics) before the Rust layers.

This RFD records a settled design (see .local-staged discussion). It does not introduce any higher-order theory into the core: MLT / Potency / ML2 remain std::* libraries (RFD 0009, RP-003); TypeRef is the neutral substrate they build on.


Question

Ontological modeling stores references to types as values: actionType: TypeRef, roleType: TypeRef, relatorType: TypeRef (110+ sites in the sharpe-ontology overlay). Today such a field/param does not resolve (no TypeRef sort), and a mutation parameter that should accept a declared TBox class reference ("residential_lease::core::Lessor") is instead validated as an ABox entity reference (#i123) and rejected. Separately, RP-003 established that for any higher-order theory to be a library, the substrate must expose iof/specializes/extent/meta as first-class predicates over type-values (GAP-1), which the IR does not yet do (iof is a syntactic typeTest, meta compares against an Ident, specializes/extent have no IR at all).

What is a type-as-value in Argon — its sort, its bounded form, its runtime/wire representation, and the first-class form of the reflection intrinsics — without leaking any higher-order theory into the core?

Context

  • Meta-calculus (§4) already specifies the four reflection intrinsics and a 3-level tower (meta(Person)==kind, meta(kind)==Metatype, Metatype sealed/self-instantiating); RP-003 §10 (resolved) canonicalized the argument sorts as Entity (value-position) and type (type-position) and made the intrinsics substrate-scope (no use). The book uses these but the substrate never realized first-class, value-polymorphic forms — that is the gap.
  • Prior thinking pre-figures this and retires the central risk. The vault’s RP-001/D-132 MLT-parent-theory campaign already adjudicated Girard’s paradox: the reflective layer is a predicative, stratified universe of codes (hypothesis-2-type-theoretic/s2-MLT-mapping.md §9.4), not an impredicative Type:Type. A TypeRef value is a handle into the closed declared catalog (à la OWL-2 punning / Java Class<?> / Haskell TypeRep), so iof/extent are predicates over that catalog — no self-membership at a fixed level.
  • Carrier already exists. The runtime Value::Name(NameRef) (“a declared symbol reference”) is content- addressable (RFD 0001), bitemporal-safe, total-Ord (Z-set keys), and round-trips CBOR↔path-string — the legacy EntityRef::Concept / unified-EntityId idiom. No new Value variant is needed.
  • Overlay evidence. The overlay’s type-valued fields are consumed only by == equality today; the bounded need (“a reference to one of a set of declared subtypes of X”) shows up as the current enum workaround — i.e. exactly a bounded TypeRef<C>.

Decision

D1 — A reflective sort TypeRef

TypeRef is the sort whose values are references to declared types (concept / construct / relation / metatype). It is unsealed. The lattice:

Metatype  <:  TypeRef  <:  Entity

Entity is the universal sort of all entities (individuals + type-references); a type is an entity (so it can be classified by a higher-order type — MLT higher-order). Because TypeRef is unsealed, Metatype remains the only sealed primitive (preserving the redesign’s “Metatype is the only sealed primitive” commitment — this is not an amendment). Chosen name TypeRef (over core Type) avoids the four-way collision with Top, std::mlt::star::Type, UFO Type_, and a vocabulary’s own pub type Type, and avoids reopening the delexicalization that removed a Type keyword.

D2 — Bounded TypeRef<C> is a refinement, not a core type-former

TypeRef<C> ≝ { t: TypeRef where specializes(t, C) } (RFD 0017, where = primitive/asserted). TypeRef == TypeRef<Top>. Subtyping is covariantTypeRef<A> <: TypeRef<B> iff A <: B — which follows from the refinement ({t|t<:A} ⊆ {t|t<:B}), so no new structural subtyping rule is added. Membership is three-valued under OWA (per RFD 0007): specializes(t,C) unknown ⇒ not a member (success requires definite-true). Powertype / order / categorization semantics stay in std::mltTypeRef<C> carries none of them.

D3 — Runtime/wire carrier: Value::Name(NameRef)

A TypeRef value is a NameRef handle to a declared type (no new Value variant). On the wire, the SDK sends a qualified class-path ("residential_lease::core::Lessor"); the runtime resolves it to the type’s NameRef. coerce_json_arg_for_type gains one arm for TypeRef-typed params: path → resolve → Value::Name, short-circuiting before the ABox entity-ref path. Equality (==) already works. The TS SDK (oxc-gen) adds a class-path wire shape + serializer branch distinct from the existing #i… / entity-ref path.

D4 — First-class, value-polymorphic reflection intrinsics (GAP-1)

The four intrinsics accept/produce TypeRef values (a type-position argument is a Term — a Term.typeRef literal or a bound variable of sort TypeRef), so library code can quantify/count over types:

meta(x: Entity)                  -> TypeRef      // immediate classifier; amends book §4's `-> Metatype`
iof(x: Entity, t: TypeRef)       -> Bool         // sugar:  x : T
specializes(t1: TypeRef, t2: TypeRef) -> Bool    // sugar:  t1 <: t2
extent(t: TypeRef)               -> Set<Entity>

IR lowering convention (matches the temporal-operator precedent of reserved-head predicates — no new AtomIR constructors, so the Admittance/tier proofs are untouched):

  • iof(x,t)AtomIR.predicate ["iof"] [x, t]; specializes(t1,t2)predicate ["specializes"] [t1, t2].
  • extent(t)Term.app (var "extent") [t]; meta(x)Term.metaCall x (returns a TypeRef value).
  • The closed/literal sugar x : T keeps AtomIR.typeTest; x :: T keeps AtomIR.metaEq (fast path). The reasoner evaluates the reserved-head reflection atoms against the catalog’s iof/specializes graph.

D5 — Lean is canonical; the foundation landed first

Term.typeRef : Path → Term (the type-as-value primitive, @[language_interface]) and MetaCalculus/Reflect.lean (the sorts; reflectiveLeq proven a preorder so Subtyping.subtypeOf decides Metatype<:TypeRef<:Entity; ReflectCatalog with specializes proven a preorder, iof, and extentOf with its characterization x ∈ extentOf u t ↔ x ∈ u ∧ iof x t). The reflective sort edges are folded into the catalog’s <:-closure builder (not OR-ed at query time — a disjunction of two preorders is not transitive).

Rationale

  • Neutral substrate, theory as library — matches the meta-calculus pattern one layer up (RP-003): the core gains only “a value can reference a declared type” + first-class reflection; order/categorizes/ power_type_of live in std::mlt. The BFO/non-UFO smoke test still passes (a vocabulary uses TypeRef/meta with zero UFO/MLT in scope).
  • Sound by construction — codes-universe, not impredicative universe (D-132 / s2 §9.4). TypeRef unsealed keeps Metatype the lone sealed primitive.
  • Minimal blast radius — reuses Value::Name (carrier), RFD 0017 refinement (TypeRef<C>), the reserved-head-predicate IR convention (no AtomIR/tier-proof churn), and D-116’s precedent that a type-position may resolve to a handle (D-116 explicitly rejected “wrap a type in a synthetic concept”, validating a genuine sort).
  • Overlay-correct== over Value::Name works immediately; bounded TypeRef<C> is exactly the enum-workaround need, done principledly.

Alternatives considered

  • Core sort named Type (demote std::mlt::star::TypeOrderlessType). Most ergonomic word, but a four-way name collision and reopens the delexicalization wound; and Type with Metatype <: Type would amend “Metatype is the only sealed primitive.” Rejected for TypeRef.
  • Entity + metatype-as-sort only (no dedicated reflective sort; RP-003 §10 canonical Entity/type). Most minimal, but : type reads as a keyword and conflates the generic metatype with the universal type-reference sort; a dedicated unambiguous TypeRef is clearer craft.
  • First-class generic Type<C> core type-former (covariant primitive). Adds a powertype-flavored core form and reproves variance; the refinement route (D2) is more neutral and reuses machinery.
  • Runtime-only coercer fix (accept a class-path for any concept-typed param, no sort). Smallest, but a non-design — pushes the type/individual distinction into ad-hoc coercion. Rejected.
  • MLT-order-indexed Type@n / potency in core. This is precisely what neutrality forbids in the core.

Consequences

  • New surface: TypeRef, TypeRef<C> as type expressions; Term.typeRef IR; meta/iof/specializes/ extent value-polymorphic. Book §4 amended (meta -> TypeRef); RP-003 §10 type-position sort named TypeRef (lowercase type remains the generic metatype). SUMMARY/Appendix updated.
  • Drift gate: Term.typeRef is @[language_interface] — the Rust oxc-protocol Term mirror gains a matching TypeRef variant (arity 1 over the name/path id).
  • Lean module inherits Classical.choice at the metalevel (D-132 OQ3) — already used in Foundation/Truth4, not new debt.
  • TypeRef must stay crisply distinct in spelling/scope from Top, std::mlt::star::Type, and any vocabulary’s Type_ (D-073) — no respelling that blocks a vocabulary declaring its own.

Open questions

  • Codomain scopeTypeRef references concept/construct/relation/metatype; the intrinsics stay category-errors on struct/enum data values (§4). (Recommended: include relations.)
  • extent(TypeRef<C>) — enumerate all subtypes of C (symmetric with extent(Metatype)). (Recommended: yes.)
  • Narrowing — should t: TypeRef narrow to TypeRef<K> after iof(t, K) (occurrence typing)? Deferred.

Implementation status (layered, each checkpoint green)

  1. Lean foundationTerm.typeRef + MetaCalculus/Reflect.lean; lake build green (1034 jobs), 0 sorry (commit a84d9e3).
  2. Protocol + drift — mirror Term::TypeRef in oxc-protocol; document Value::Name carrier; drift green.
  3. Resolver + checker — resolve TypeRef/Entity; fold the reflective edges into the <:-closure; TypeRef<C> refinement + covariance; 3-valued OWA membership.
  4. Instantiate — lower the four intrinsics to the value-polymorphic atoms (sugar preserved; fast-path closed types).
  5. Reasoner — evaluate the reserved-head reflection atoms over the catalog graph.
  6. Runtime + wire — the coerce_json_arg_for_type TypeRef arm (path → NameRefValue::Name).
  7. SDK (oxc-gen) — class-path wire shape.
  8. Spec + overlay — §4 amendments; overlay migration where “any type-reference” is meant.

RFD 0024 — Allen interval algebra as a library (std::allen), not substrate operators

  • State: committed
  • Opened: 2026-06-08
  • Decides: Allen’s interval algebra is a standard-library theory (std::allen), written in pure .ar over the scalar Date/Duration value layer (#159) — not reserved infix operators in the substrate. The currently-committed-but-unbuilt Allen operators (Syntax/Operators.lean::AllenOp, Syntax/Expr.lean::RuleAtom.allenAtom, the tier:expressive grammar in book §7.3.1, and OE0713 AllenOverMTLDerived) are removed.

This is the temporal counterpart of the principle RFD 0009 / RP-003 already apply to higher-order theories: the substrate stays ontology-neutral; specific theories are libraries. MLT, UFO, and BFO are std::* packages, not language features. Allen’s interval algebra is a theory of time, and the same rule applies. Settled in discussion (the .ar value layer it rests on landed first, by design).


Question

Argon currently carries Allen’s 13 interval relations two ways at once: as reserved substrate operators (infix a before b, mechanized in Lean, drift-gated, tier-classified) and, implicitly, as something a modeler would otherwise define over interval boundaries. The substrate form is committed but unbuilt — no parser, elaborator, or executor path. With the scalar Date/Duration value layer now real (#159), an Allen relation is definable as an ordinary rule over {startsOn, endsOn} boundaries. So:

Does Allen’s interval algebra belong in the substrate (reserved operators), or as a library over the value layer?

Context

  • What is committed today (unbuilt). AllenOp enumerates 12 relations (before/after/meets/metBy/overlaps/overlappedBy/during/contains/starts/startedBy/ finishes/finishedBy — note it omits Allen’s 13th, equals) at spec/lean/Argon/Syntax/Operators.lean:79; the surface atom RuleAtom.allenAtom : FieldPath → AllenOp → FieldPath at Syntax/Expr.lean:222; the field-path allen-op field-path grammar at tier:expressive in book §7.3.1 (07-rules.md:127-150); and OE0713 AllenOverMTLDerived (Allen applied to an MTL-windowed predicate — ill-typed). All @[language_interface] and drift-gated; none of it is parsed, elaborated, or evaluated.
  • Allen reduces to boundary comparisons. a before b ≡ a.endsOn < b.startsOn; a meets b ≡ a.endsOn == b.startsOn; a equals b ≡ a.startsOn == b.startsOn ∧ a.endsOn == b.endsOn; and so on for all 13. Each is a conjunction of chronological comparisons on Date boundaries — exactly what the value layer of #159 now evaluates correctly (and which, pre-#159, the reasoner did as a silent lexicographic string compare).
  • The substrate’s stated posture. Argon is “ontology-neutral (UFO/MLT/BFO are stdlib theory packages, not language features)” (AGENTS.md). The substrate provides value computation (Money/Decimal/Date arithmetic, comparison); theories are libraries. Allen is a theory.
  • Allen is not the only interval algebra. There are variants — point vs. proper intervals, open vs. closed, fuzzy/probabilistic Allen, the coarser INDU and convex-relation algebras. A reserved operator set privileges one (and the committed set is already incomplete: no equals). A library is choosable and extensible.

Decision

  1. std::allen is a library, pure .ar, over #159. It models intervals and defines the 13 Allen relations (and any convenience dispatch) as ordinary derive rules over Date boundaries. It requires nothing from the engine beyond the scalar value layer that already shipped.
  2. Remove the reserved Allen operators from the substrate. AllenOp, RuleAtom.allenAtom, the §7.3.1 grammar, the tier:expressive Allen classification, and OE0713 are deleted. They are unbuilt and have no users, so this is a no-cost reversal taken now while it is free (no migration, nothing to break).
  3. No infix sugar. Library Allen is call/UFCS syntax (a.before(b) or before(a, b)), not infix a before b. For a niche qualitative algebra this is an acceptable — arguably clearer — trade for a smaller, neutral substrate. (Argon does not offer user-defined infix operators, and adding them for one library is not justified.)

The std::allen library (sketch)

Built on #159’s Date/Duration values + chronological comparison. An interval is modeled as a concept with two Date boundaries (a real entity, so its fields project in rule bodies; an opaque inline struct would not):

// std::allen (or std::time::allen)
pub kind TimeInterval { startsOn: Date, endsOn: Date }

pub derive before(a: TimeInterval, b: TimeInterval)  :- a.endsOn < b.startsOn;
pub derive meets(a: TimeInterval, b: TimeInterval)   :- a.endsOn == b.startsOn;
pub derive during(a: TimeInterval, b: TimeInterval)  :- b.startsOn < a.startsOn, a.endsOn < b.endsOn;
pub derive equals(a: TimeInterval, b: TimeInterval)  :- a.startsOn == b.startsOn, a.endsOn == b.endsOn;
// … the remaining relations + inverses, all boundary comparisons.
  • No relation-name magic. A user-declared pub rel AllenHolds(...) stays ordinary data; it gains meaning only if the model defines it from these rules (pub derive AllenHolds(a, Before, b) :- before(a, b);). The substrate never special-cases the name AllenHolds, nor sniffs {startsOn, endsOn} field names.
  • dateInterval / shift are library helpers; until the fn return-of-constructed-value surface matures, the robust form is to shift a boundary inline in the comparison — e.g. the workflow’s `allenAfter(timeInterval
    • relativeTime, dateInterval(checkDate))is(iv.startsOn + relativeTime) > checkDate (after ≡ a.startsOn > b.endsOn; a point interval's endsOn` is the date itself).
  • The library can be complete (all 13 relations incl. equals) and may offer an allenHolds(a, rel, b) dispatch over an AllenRelationType value — neither requires substrate support.

What is removed / changed

A surface change, so Lean-first (per the AGENTS.md workflow: surface decision → Lean → reference → drift mirror → parser/grammar):

  • Lean: delete AllenOp (Syntax/Operators.lean) and RuleAtom.allenAtom (Syntax/Expr.lean); drop the Allen arm from the tier classifier (Decidability/) and any Expr/@[language_interface] references.
  • Reference: remove the Allen grammar + prose from §7.3.1 (07-rules.md:127-150) and Allen mentions in §6/§15/crash-course; document std::allen in §15 (stdlib) instead.
  • Drift / Rust mirror: drop the AllenOp mirror in oxc-ast and its drift-gate entry; the parser/grammar never recognized the operators, so there is little Rust to remove.
  • Diagnostics: retire OE0713 AllenOverMTLDerived (it only exists to police an Allen×MTL interaction that no longer has substrate operators). Leave a tombstone in grammar.toml per code-allocation hygiene, or reclaim the number — decide at implementation.

Non-goals

  • Not the temporal reasoner. This is orthogonal to DatalogMTL metric operators (since/until/ever), bitemporal valid-time, and temporal modal operators — the “temporal operators” tracked separately. Allen here is qualitative interval relations as value-level boolean rules.
  • Not interval types in the substrate. The substrate gains no Interval type; an interval is a library concept with two Date fields.
  • No infix operators, no fuzzy/INDU variants in scope (those are further libraries if wanted).
  • DateTime/Time intervals and calendar-relative durations are out of scope — they ride the #159 follow-ons (sub-day resolution, P1M/P1Y).

Alternatives considered

  • Keep Allen in the substrate (status quo). Rejected: it privileges one interval theory, is library- definable with zero engine support, and the committed set is already incomplete. Mechanizing + drift-gating a theory the substrate doesn’t need is debt.
  • Ship Allen as built-in stdlib functions (allenBefore, …) wired into the engine. Rejected: same neutrality problem one layer down, and it would fork the algebra against the reserved operators. A pure-.ar library is cleaner and needs nothing special.
  • Leave the operators reserved-but-unbuilt. Rejected: a dangling surface that will fork against std::allen the moment the library exists; removing it now (unbuilt, no users) is the cheapest it will ever be.

Sequencing

  1. (Done — prerequisite) Scalar Date/Duration value layer + chronological comparison, #159.
  2. Remove the reserved Allen operators (Lean → reference → drift mirror → grammar), per What is removed.
  3. Ship std::allen as a pure-.ar package over #159.

Related: RFD 0009 (theories-as-libraries), RFD 0016 (the value tower this builds on), RP-003 (substrate neutrality). The today()/now() evaluation-context for date-relative rules is a separate RFD (deliberately out of scope here).

RFD 0025 — check discharge: vocabulary-staged compile-time and runtime constraint checking

  • State: accepted — implementation in flight
  • Opened: 2026-06-10
  • Decides: the complete semantics of the check rule mode — when and where a check discharges, what the => Diagnostic { … } payload is, what severity means at runtime, and how violations surface. Settles the question Gustavo Guizzardi and Tiago raised (“is a check compile-time or runtime?”) and Ivan’s original conception (checks as user-authored compiler diagnostics) into one model. Design discussion: .local/research/checks/DESIGN-2026-06-10.md.

Prior state (verified at 8deb583): checks parse and lower to RuleMode::Check Cat3 rules, the => Diagnostic expression is discarded at lowering, the payload shape is unvalidated, and no discharge path exists — RuleMode::Check has zero evaluation consumers. The body is evaluable (ox derive <oxbin> <CheckName> returns the violation set), so this RFD is the harness, payload, and contract around an engine that already computes the hard part.


D1 — One construct; the body’s vocabulary determines the discharge site

A check is a denial rule over the well-founded model: the body is the violation pattern, the payload is the per-violation report. The author never chooses a mode; discharge is staged by what the body reads — the third instance of an established pattern (refinement where staging D1Pred/D2Pred; modal static discharge):

Body vocabularyDischarge
Catalog-level — every head param and body variable is reflective-sorted (TypeRef, TraitRef, or Metatype; amended by RFD 0026 D6 — a free TraitRef variable must not demote a conformance check to instance-level); atoms read declaration structure (specializes, iof over metatypes, implements per RFD 0026)ox check / ox build / LSP. The catalog is closed at build; evaluation is total and final there.
Instance-level — any variable ranges over individualsRuntime, at transaction boundaries and on demand — and also at build, over whatever EDB the package itself declares (pub facts and seeded declarations are build-visible).

The classification rule is crisp and checkable: a check is catalog-level iff every variable in head and body is reflective-sorted (TypeRef, TraitRef, or Metatype). Mixed bodies are instance-level. (Amended by RFD 0026 D6, which introduces TraitRef; the original rule read “TypeRef-sorted”.)

#[static] (convention, Ivan 2026-06-10): compile-time-intent checks carry #[static] by convention. Discharge is still computed from the vocabulary; the attribute (a) makes intent legible at the declaration and (b) turns accidental instance-vocabulary drift into a hard error (new OE: static check reads instance vocabulary) instead of a silent reclassification to runtime. There is no #[runtime] twin — a catalog-level check evaluated at build is simply done.

D2 — Runtime Error semantics: guard on the delta

An Error-severity instance check rejects any mutation that creates new violations: violations(post) ∖ violations(pre) ≠ ∅ ⇒ abort, atomically (RFD 0015 whole-body atomicity — nothing flushes), with the rendered diagnostics returned to the caller. Precedent: OE0668 (where-invariant) and OE0211 already reject writes this way; user checks generalize that gate.

  • Delta, not absolute state. Pre-existing violations (e.g. a new artifact’s stricter rules over old data) are reported through the observe channel but never block writes — absolute semantics would brick every subsequent mutation on a store with one legacy violation.
  • Post-state realization: committed state + the transaction’s buffered events, read through an overlay view — the runtime realization of exactly the overlay semantics Runtime/MutationSemantics.lean already models for mutate bodies. No speculative store clone, no compensating events; the guard runs before flush.
  • v1 computes violations(pre)/violations(post) by evaluating the check predicates on both views and set-differencing; incremental maintenance of violation relations is explicitly the RFD 0018 persisted-IVM follow-on (the delta is what semi-naive computes natively).

D3 — Severity drives blocking

Error guards (D2); Warning/Info observe only. #[observe] on an Error check opts out of guarding (report-only errors are legitimate during migration). Severities are the closed set Severity::{Error, Warning, Info}.

D4 — K3: fire on is only

Check bodies evaluate over the WFM in K3. A violation fires iff definitely derived (is); undefined (can) does not fire. Surfacing can-grade violations (e.g. at Info) is deferred. Documentation must state the OWA reading plainly: not statute(c.cite) is NAF — “no derivably known statute” — not a claim about reality.

D5 — The Diagnostic { … } payload is check-surface syntax, not a user value

Diagnostic, Severity, and the field set are interpreted by the compiler (like #[strict] strengths) — no user-space Diagnostic type exists or is needed. Validated at ox check:

  • severity: — exactly Severity::Error | Severity::Warning | Severity::Info (nominal).
  • code: — string literal, namespaced: must contain :: (e.g. "Lease::E001"), and the OE/OW prefixes are reserved for the compiler (extends #150’s hygiene rule to user space).
  • message: — string literal, or format!("…{}…", args) with positional {} only; each argument must resolve against the body’s bindings (variables / field chains), checked like any body term. Named/spec’d interpolations ({name}, {:?}) are refused loudly.
  • Unknown fields are errors; at: is reserved (refused with a “reserved for span attribution” note) until the LSP consumes it.
  • All three fields are required. (Amended by RFD 0026’s 2026-06-11 amendment (check members may pin their severity), issue #230: when the check implements a trait member whose signature pins a severity — check Member(Self) => Severity::…;severity: may be omitted and inherits the pin; a divergent restatement is OE0676. code: and message: stay unconditionally required.)

format! becomes a real (dedicated) expression form in the parser — the §13 macro system is not implied; format! is recognized structurally, the way count { … } is.

D6 — Delivery: the diagnostic stream now, the Diagnostics sink contract forward

Semantically a fired check is an emission to a reserved typed sink Diagnostics: Diagnostic (§7.7.1) — that contract is recorded here so the sink machinery subsumes delivery when it lands, and checks become its first real producer. Until then, delivery is direct:

  • Static discharge → the build/LSP diagnostic stream: violations render exactly like compiler diagnostics, under the check’s own user code, at ox check and ox build (build does not fail on user Error checks over declared EDB? — it does: a firing Severity::Error check at build is a build failure, same as any other error diagnostic; Warning renders and passes).
  • Runtime discharge → mutation results carry rejected-guard diagnostics; dispatch responses (oxc-serve) gain a diagnostics section for observe-channel violations (amended 2026-06-11: the shipped wire key is diagnostics, not the originally drafted $diagnostics — the $ sigil is the reserved internal-relation namespace, the wrong register for a JSON response key); ox query renders them; the violation set is queryable on demand.

D7 — Wire and drift

RuleDeclBody gains additive fields: the lowered diagnostic template (severity, code, message parts as literal/argument segments) and the #[static]/#[observe] markers. Lean @[language_interface] carriers align; the drift gate covers the new shapes. Existing .oxbin artifacts predate any consumer of these fields; no migration concern.

Non-decisions (tracked, out of scope here)

  • Artifact-upgrade pre-existing-violation reporting moment (load-time observe pass) — open.
  • can-grade violation surfacing (D4 deferral).
  • Per-standpoint checks; check members in traits (companion trait design — their discharge follows this RFD unchanged once monomorphized).
  • Incremental violation maintenance (RFD 0018 phase 2).
  • ox check --runtime <store> verb cosmetics; ox derive <CheckName> stays the debug surface.

RFD 0026 — Trait rule members: clause-union dispatch, conformance, and the implements intrinsic

  • State: accepted — implementation planned
  • Opened: 2026-06-10
  • Decides: the complete semantics of the trait atom’s contents — what may be declared inside pub trait and provided by impl Trait for Type, how rule members evaluate (clause-union with a static coverage gate), how fn/mutate members dispatch (receiver-resolved), the conformance obligations (completeness, orphan, no-overlap, supertraits), the reflective conformance surface (TraitRef, implements), and member naming. Settles issues #202/#203/#204 (Gustavo’s trait report) and the ArgUFO // TODO: implement trait feature demand. Design discussion: .local/research/traits/DESIGN-2026-06-10.md. Companion: RFD 0025 (check discharge; merges first — see Sequencing) — trait check members monomorphize into ordinary RuleMode::Check rules whose discharge follows 0025 unchanged, and this RFD amends 0025 D1 (see D6).

Prior state (verified at 8deb583): trait/impl declarations parse and reach the wire as shapes; bodies are swallowed by eat_balanced and lowered as Vec::new() (§12.2), so a rule member written inside an impl silently never exists — check and build stay green (#202). No calling convention, no conformance checking, no way to test conformance in a rule body (#204). The book promises only fn signatures in traits; rule members are new surface. The parser accepts <: for supertraits (grammar.rs::trait_decl) while STATUS documents : — a pre-existing three-way drift this RFD resolves (D5.5).


Question

A trait that carries nothing is a name. The recorded design lineage (redesign charters; the five-atom settlement) made the trait atom Argon’s behavioral contract: concepts say what something is; traits say what something can do. For a language whose behavior is mostly rules, “what something can do” must include rules — Gustavo’s pattern:

pub trait Adulthood {
    derive Adult(Self)
}

impl Adulthood for USPerson {
    derive Adult(p: Self) :- p.age >= 18
}

pub derive IsAdult(p: USPerson) :- Adult(p)

What does Adult mean as a predicate, who provides its clauses, what may call it, what guarantees conformance, and how does a program ask whether a type implements a trait?

D1 — Rule members evaluate by type-guarded clause union; dispatch is derivation

A trait declares the member signature; each impl contributes a clause. The impl above elaborates to the ordinary rule

derive Adulthood::Adult(p: USPerson) :- p: USPerson, p.age >= 18

(the head-parameter annotation injects the membership guard through the existing type-test lowering, which closes correctly over <: since #199). A second impl for another type adds a second clause with the same head — Datalog unions same-head rules natively. No new evaluation machinery exists or is added: monomorphized member rules flow through the classifier, stratifier, the #171 evaluability gate, and the reasoner as ordinary RuleDecl events.

This is not a departure from Rust dispatch — it is what dispatch becomes in a bottom-up engine. The fixpoint evaluator has no call sites; an atom is a join. “Select the body by the value’s runtime type” (Rust dyn) transposes to “select the clause whose type guard the individual satisfies.” The no-overlap rule (D5.3) keeps clause selection single-valued per declared type: at most one impl covers any declared sortal type, statically checked. Per individual, Argon’s multiple classification admits one residual the static check cannot close (an individual instantiating two <:-incomparable covered types); its semantics is defined, not accidental — see D5.3.

Wherever a Rust-static reading would compile at all (the argument’s static type covered by exactly one impl), clause union gives identical answers. The programs where it gives more are exactly the polymorphic rules the feature exists for:

impl Adulthood for USPerson     { derive Adult(p: Self) :- p.age >= 18 }
impl Adulthood for GermanPerson { derive Adult(p: Self) :- p.age >= 18, p.has_residence }

pub derive CanVote(p: Person) :- Adult(p), p.registered   -- heterogeneous, per-subtype

Rust expresses that with bounded generics (<T: Adulthood>) or dyn — machinery v0.1 defers (OE0667). Clause union delivers it with what the engine already does.

Specialization and inheritance are rejected (Ivan, 2026-06-10): traits are Rust-exact. No “more-specific impl wins” — overlapping impl targets are an error (D5.3), and any future priority-between-clauses story belongs to defeasibility, not dispatch. Traits form no subsumption lattice and never enter the concept lattice.

D2 — Two planes: all five member forms, split by how they are consumed

PlaneMember formsSemantics
Rule plane (joined)derive, check, queryClause union (D1). A check member monomorphizes to an ordinary RuleMode::Check rule per impl; discharge, payload, severity, and delivery follow RFD 0025 unchanged. Query members: below.
Invocation plane (called)fn, mutateSingle resolution by receiver. fn members resolve statically per §12.4 (inherent impls, then trait impls; UFCS Trait::foo(x); bare x.foo() when unambiguous). A mutate member (mutate close(self, reason: String); impls provide RFD 0015 imperative bodies) monomorphizes per impl; invocation dispatches on the receiver individual’s actual type to the unique covering impl — see D5.3 for the ambiguous-receiver refusal — through the existing mutation-dispatch surface (CLI / serve / SDK, suffix-aware per #180) extended with receiver selection. Mutations do not call mutations today; member mutates do not change that.

Query members, precisely. The trait-side signature is the full §7.4 query signature: query Ident '(' member-params ')' '->' TypeExpr ';'. The impl provides a full §7.4 body (select … from …); the trait signature fixes the parameter sorts, the return type, and thereby the projection shape every impl must produce. Self is admitted in parameter positions only; Self in the return type is refused (OE0675) — a return-position Self makes the union endpoint’s type a union over impl targets, which needs union types (#184) or bounded generics (V1); tracked as an explicit non-decision. With a Self-free return, both consumptions are well-typed: in rule bodies the member’s head participates in the §7.3.1 vocabulary like any query head; as a dispatch endpoint, Trait::member(args) returns the union of the per-impl results (each clause guarded by its target).

Member signatures in the trait must mention Self in at least one parameter position (else the member is not about the implementing type and belongs at module level — the diagnostic says exactly that). Multiple Self positions are legal; every Self position is guarded/substituted.

Out of scope, refused loudly (not silently): trait-side default bodies (a default rule body needs structural bounds on Self to typecheck against fields — V1 bounded-generics territory, OE0667), generic members, bounded generics, return-position Self, per-standpoint impls (recorded open question; impls are standpoint-global), associated type/const (already reserved).

Grammar scope (corrects a §5.4 conflict found in review): the member grammar above is the item grammar for trait impls (impl Trait for Type). Bare impls (impl Type { … }) keep §5.4’s item set unchanged (any declaration kind, including rel and associated items); conformance obligations D5.1/D5.2 apply to trait impls only.

D3 — The coverage gate: bare atoms require full coverage; partiality requires an explicit guard

Clause union’s one honest weakness against Rust: in Rust, calling a method on a type with no impl is a compile error; under naive union, a member atom over an uncovered type is just false — the silently-vacuous class this project exterminates on sight.

Coverage, defined. Coverage is computed over the workspace-closed catalog using the metacalculus sortality metaxis (§4.1): a declared type S is instantiable iff S is sortal (non-sortals — categories, mixins — carry no identity principle and admit no direct instances: every individual is classified through some sortal, so non-sortals need no covering themselves; their sortal descendants do). A static type T is fully covered by a trait’s impl set iff every instantiable declared S ⊑ T (including T itself when sortal) satisfies S ⊑ targetᵢ for some impl target. This is a finite catalog scan — decidable at ox check/ox build.

The gate:

  1. A bare member atom Adult(p) requires p’s static type to be fully covered. Not fully covered ⇒ OE1327, whose help names the uncovered instantiable types and offers both fixes — add the impl, or write the guard.
  2. Partial coverage is legal only under an explicit conformance guard: implements(meta(p), Adulthood), Adult(p). Dispatch that can fail is always visible in the source; uncovered individuals are excluded by the guard, not by silence.
  3. A member atom over a type with no covering impl anywhere is always OE1327 (the atom can never fire; the guard would be vacuous too — the help says so).

Guard semantics under multiple classification. meta(p) is multi-valued (one row per <:-minimal classifier, §4.4). The guard is existential by constructionmeta is a relation and the conjunction is a join: implements(meta(p), Tr) holds iff some minimal classifier of p is covered. That is the intended reading: the member can fire for p exactly when some covered classification applies, and the clause that fires is the one whose guard that classifier satisfies.

D4 — Naming: Trait::member qualified heads; catalog keyed by qualified path

  • The member predicate is one name: pkg::mod::Adulthood::Adult (the trait’s module owns it). Per-clause rule identities qualify further by impl target, so clauses are distinct events with one shared head.
  • The rule catalog’s runtime keying moves from bare short name to qualified path, with short-name lookup retained as the unambiguous-suffix resolver (the #180 pattern applied to rules). This fixes the pre-existing cross-module derive Foo collision class as a forced move.
  • Call forms in rule bodies: Adulthood::Adult(p) (qualified — always legal; qualified predicate atoms get wired by this work); Adult(p) (bare — legal when exactly one provider of that name/arity is in lexical scope; ambiguity is an error naming the candidates, mirroring §12.4’s collision rule). No postfix p.Adult() sugar (Ivan, 2026-06-10): it buys spelling only and collides with field-access space (p.Adult is OE0204 territory). Method-call syntax arrives with the invocation plane, where §12.4 already promises it for fns.
  • OE0223/OE0204 become trait-aware: when an unresolved name matches a trait member in scope, the help states the member, its trait, and the legal call forms.

D5 — Conformance obligations (elaboration-time, not check rules)

All catalog-closed, all enforced at ox check and ox build; none depend on the RFD 0025 runtime path:

  1. Completeness — a trait impl provides every member its trait declares. OE0670.
  2. Extraneous / mismatched member — a trait impl provides a member the trait doesn’t declare, or with a different signature (arity, parameter types, return type, plane). OE0671. (Bare impls are exempt — they have no contract; §5.4 governs them.)
  3. No-overlap (coherence) — two impls of one trait are rejected (OE0673) when their targets are <:-comparable or share a declared common descendant (both catalog-decidable). The second arm is what multiple classification demands: impl T for Person + impl T for Customer with a declared pub kind Employee <: Person, Customer would put every Employee under two clauses. The worked counter-example for the first arm: impl Adulthood for Person + impl Adulthood for USPerson is OE0673 — the natural “default + override” pattern is rejected by design (no specialization; write disjoint targets, or one impl whose body branches). Residual, defined: an individual dynamically classified under two <:-incomparable covered targets with no declared common descendant (pure multiple classification) cannot be excluded statically under OWA. Semantics: in the rule plane, clauses are independent sufficient conditions — both may fire and the union is their disjunction (well-defined, documented; not an error). In the invocation plane, an ambiguous receiver is a loud runtime refusal naming both impls — never an arbitrary pick.
  4. Orphan ruleimpl Trait for Type must live in the package declaring Trait or the one declaring Type (§12.4, previously reserved). OE0672.
  5. Supertraits as requires-constraintsimpl Sub for T demands impl Super for T exist (Rust-exact; not inheritance). OE0674. Surface token: supertraits are spelled : (pub trait Repaintable: Drawable). Honestly stated: (a) the parser today implements <: (and the shared supertype_clause also admits the specializes keyword — both removed for traits in slice 0) for trait supertraits (grammar.rs::trait_decl), while STATUS.md documents : — STATUS was wrong about what’s built; (b) : in concept headers means MLT instantiation — a distinct, non-conflicting context (a trait header is never a concept header; Rust likewise overloads : by position). Slice 0 switches the trait production to :, makes <: after trait Ident a parse error with a fix-it (no in-repo source uses it), and corrects STATUS. <: stays the concept-lattice operator exclusively.
  6. Self disciplineSelf resolves only inside trait/impl bodies (trait: the obligation’s type parameter; impl: the target type); misuse, a trait member signature with no Self parameter position, or return-position Self (D2) is OE0675.

Member forms whose implementation slice has not landed are refused at parse with OE1326 (TraitMemberNotYet) — the gate that makes #202’s silent swallow structurally impossible from slice 0 onward.

D6 — Reflective conformance: TraitRef and implements

  • TraitRef — a new reflective sort parallel to TypeRef (RFD 0023): values are handles into the closed declared trait catalog, carrier Value::Name. Deliberately not folded into TypeRef — traits stay off the concept lattice. TraitRef does not join the Metatype <: TypeRef <: Entity chain; it is a sibling sort under Entity.

  • implements(t: TypeRef, tr: TraitRef) -> Bool — the fifth reflection intrinsic, materialized as the RT-closed reserved-head relation $implements from ImplDecl events (the $iof/$specializes pattern, #132) with two closures folded in: supertrait closure (implements(T, Sub) → implements(T, Super) — the D5.5 constraint’s logical consequence, not inheritance) and target upward-coverage along <: (an impl for Person covers USPerson), coherent with #199.

  • implements is exempt from OE0212 (MetaArgUnbound): unlike the type-position arguments of meta/iof/specializes/extent, both positions of implements may be free — $implements is a finite catalog-closed relation and enumeration is the intended use (free t: all implementing types; free tr: all traits of a type; instance-level composes with meta(x), #135). §4.4’s intrinsic invariants are amended accordingly (the “type-position argument is a TypeRef value” sentence and the OE0212 rule both carve out implements; its second argument is TraitRef-sorted). Being catalog-closed also makes not implements(…) stratification-safe. An implementors(Trait) -> Set<TypeRef> expression-plane intrinsic rides the same materialization.

  • implements is available in both planes: rule atoms (including NAF) and boolean expressions (if/match scrutinees in fn/mutate bodies, executing since #192/#194).

  • Amendment to RFD 0025 D1 (applied to 0025’s text when both RFDs are on main; 0025 merges first): the catalog-level classification sentence

    a check is catalog-level iff every variable in head and body is TypeRef-sorted

    becomes

    a check is catalog-level iff every variable in head and body is reflective-sorted (TypeRef, TraitRef, or Metatype)

    so that trait-conformance checks — Gustavo’s PersonImplementsAdulthood — classify catalog-level and discharge at ox check through 0025’s harness, #[static] by convention. This RFD builds the vocabulary; 0025 owns discharge. Without the amendment, a free tr: TraitRef variable would misclassify the check as instance-level.

D7 — Wire, drift, and what does NOT change

  • Member rules are emitted as ordinary RuleDecl/ComputeDecl events — the runtime and reasoner load them with zero changes. No new AxiomKind.
  • ImplDeclBody.items (already Vec<CborValue>, “rule_decl-shaped”) carries the member rules’ identities/provenance for tooling and conformance — not evaluation. TraitDeclBody.methods carries member signatures (name, arity, parameter types, return type, plane). Both fields exist today as empty vecs; the wire was built for this (§12.2’s stated intent).
  • Lean Syntax/Decl.lean already models TraitDecl.items/ImplBlock.items : List Decl — the Lean was ahead; Rust catches up. Stale Lean doc annotations the token change invalidates (TraitDecl.supers “via <:”; Substrate/Trait.lean/Conditional.lean citing “spec §13”) are corrected in the Lean PR. Drift gate: TraitAtom/TraitDecl/ImplBlock shapes unchanged; the member-signature carrier aligns by the usual @[language_interface] discipline.

Lean obligations (before the Rust slices, per workflow)

  1. Monomorphization conservativity — elaborating a trait/impl catalog adds a finite concrete rule set; the extended program’s well-founded model restricted to the old vocabulary is unchanged, and stratification is preserved given D5.3. Restates the legacy parametric-rules result on the modern Reasoning/Datalog/ spine. The load-bearing theorem.
  2. Coherence instantiation, by construction — a nominal impl target is a TraitBound in Conditional.lean’s sense: bound.satisfied T := T ⊑ target. The obligation is to build that instantiation and prove: the D5.3 catalog check (no <:-comparable targets, no declared common descendant) implies unique applicability over declared sortal types, whence coherent_of_unique_applicable applies. The per-individual multiple-classification residual (D5.3) is outside the theorem’s scope and documented as such — the theorem speaks about types, not individuals.
  3. Conformance decidability — completeness/orphan/overlap/coverage/implements are finite catalog scans; structural tier (§10 ladder).
  4. Substrate/Trait.lean member-signature model mirroring TraitDeclBody.methods (+ the doc fixes from D7).

Sequencing and implementation plan (each slice lands whole and loud)

Merge order: RFD 0025 (checks) → this RFD (which then applies the D6 amendment text to 0025) → Lean PR → slices. The 0025 cross-links in this RFD and §12 resolve when 0025 lands.

  • Slice 0 — real trait-item/impl-item parsing for trait impls (kills eat_balanced; bare impls keep §5.4 parsing), scoped Self, supertrait token <:: (parse error + fix-it for <:; STATUS corrected), OE1326 gating of every not-yet-landed member form, OE0675.
  • Slice 1 — rule plane end-to-end: elaboration (Self substitution, guard injection, RuleDecl emission, items/methods population), qualified-path naming + qualified atoms + bare-call resolution, conformance gates OE0670–OE0674, coverage gate OE1327 (full-coverage form; the partial-coverage guard escape activates in slice 2 with implements), trait-aware OE0223/OE0204. Keystones: Gustavo’s program verbatim; multi-impl union; NAF over a member; recursion through a member; cross-module impl; the OE0673 counter-examples (comparable targets; declared-common-descendant).
  • Slice 2TraitRef, $implements, implements in both planes, partial-coverage guard rule, the 0025-D1 amendment in force (reflective-sorted classifier), examples/trait_contracts/ (corpus-pinned, including a #[static] conformance check).
  • Slice 3 — invocation plane: fn members + static dispatch (§12.4), mutate members + receiver dispatch (ambiguous-receiver refusal) through CLI/serve/SDK, oxc-gen emission for member endpoints.

Diagnostics allocated

CodeNameSite
OE0670ImplMemberMissingcheck/build (elaboration)
OE0671ImplMemberExtraneouscheck/build (elaboration)
OE0672OrphanImplViolationcheck/build (elaboration; §12.4’s reserved code, now real)
OE0673ImplTargetsOverlapcheck/build (elaboration)
OE0674SupertraitUnsatisfiedcheck/build (elaboration)
OE0675SelfMisusecheck/build (parse/elaboration)
OE0676ImplMemberSeverityDivergescheck/build (conformance; 2026-06-11 amendment)
OE1326TraitMemberNotYetparse-time capability gate
OE1327TraitMemberUncoveredcheck/build (coverage gate)

Renumbering note (slice 1): this RFD originally allocated OE1323 for TraitMemberUncovered, but RFD 0025 (which merged first) took OE1323–OE1325 for its check-diagnostic payload codes (MalformedCheckDiagnostic / CheckCodeNamespace / CheckMessageArgUnbound); the coverage gate ships as OE1327.

Amendment (2026-06-11) — check members may pin their severity (issue #230)

The final-validation audit (aa7a7d0e) probed two impls of one check member declaring divergent severities: Limited::OverLimit as Error on Truck (mutations rejected) and Warning on Crane (mutations pass). D5.2 compares plane/arity/param-types/return only, so the trait could not pin the blocking semantics of its own obligation. Decided (issue #230, option a): a trait-side check member signature MAY pin its severity with the payload-arrow suffix, mirroring the check payload form —

pub trait Limited {
    check OverLimit(Self) => Severity::Error;
}

Only the severity is pinnable — code: and message: stay per-impl (a full Diagnostic { … } payload at the trait side is refused, OE1323). Pinning is optional; an unpinned member keeps the per-impl freedom above (documented status quo, not an error). Semantics when pinned:

  • An impl’s member-check payload may omit severity: — it inherits the pin (the ergonomic point). OE1323’s “all three fields required” relaxes to: code: + message: required, severity: required unless trait-pinned.
  • Restating the same severity: legal (harmless).
  • Stating a different severity: OE0676 ImplMemberSeverityDiverges at ox check/ox build (conformance pass, collect-all), naming the trait, the member, the pin, and the divergent severity. Rationale: a contract whose blocking behavior varies by implementor is a weak contract.
  • #[observe] on an impl member whose pinned severity is Error remains legal (observe is a discharge-mode opt-out, not a severity change — RFD 0025 D3).

Wire: TraitMemberSig gains severity: Option<DiagnosticSeverity> (additive, serde-default; always None for non-check members). Lean: Argon.Substrate.Trait.TraitMemberSig.severity, with the conformance gate Argon.TypeSystem.Conformance.SeverityPinned joining the Conformant conjunction (the OE0671 model comparison becomes severity-blind SigShapeEq; severityPinned_invariant states the implementor-invariance headline).

Non-decisions (tracked, out of scope)

  • Bounded generics / conditional impls / default member bodies / return-position Self — V1 (OE0667 stands; the Conditional.lean theory is ready for it; return-position Self also waits on union types, #184).
  • Per-standpoint impls (vault open question; impls are standpoint-global).
  • Postfix predicate sugar p.Adult() (revisit with the invocation plane if wanted).
  • Modeling guidance is reference material, not normative: a classification (Adult) is a phase/iff concept; a trait is a capability/obligation contract — §12.8.

RFD 0027 — The meta-property plane: axis bindings, catalog tiers, value-position resolution, and substrate-neutral modifiers

  • State: committed — implemented end-to-end (S0 #276, S1+S2 #283, S3 #287, S4 = this arc’s final PR; bugs #242/#243/#229 closed)
  • Opened: 2026-06-11
  • Decides: how metaxis declarations and metatype axis bindings become real — validated at elaboration, persisted on the wire, materialized as catalog relations, and queryable in rule bodies (meta(t).rigidity == rigidity::anti_rigid); one unified name-resolution pass for value positions in rule bodies (axis values, enum constants, individuals, qualified names — closing a family of silent mis-evaluations); metatype-tier $meta/$iof rows (settles #229); a Value::Symbol carrier shared with enum constants (fixing enum type-erasure); and the substrate-neutral modifiers abstract and fixed that replace every compiler read of user axis vocabulary (the OE1327 "sortality"/"sortal" magic strings, the §7.5 dynamic-iof gate, §10.2 static-discharge rigidity, RP-004 VT-persistence defaults). Settles audit findings ufo-01/ufo-02/ufo-03/ufo-04 and issue #148’s constants half; motivated by the first external user collision (Tiago’s ArgUFO overlay, 2026-06-11). PR #209 parked citing a planned “RFD 0027 ambient-vocabulary cleanup” that was never written; this RFD takes the number and completes that program.

Prior state (verified at aa7a7d0e): metatype axis bindings parse and are then droppedlower_metatype_decl emits axes: Vec::new() (oxc-instantiate/src/lower.rs:2870; same for metarels at :2897); the metaxis value set {anti_rigid < semi_rigid < rigid} is persisted nowhere (MetaxisDeclBody.value_type hardcoded Null, lower.rs:2844-2845); there is zero validationpub metatype weird = { nonexistent_axis::bogus_value }; passes ox check and ox build clean. $meta/$iof materialize individual-tier rows only (oxc-runtime/src/lib.rs:3923-4040, gap note at :4012-4014); meta(Person) == kind silently derives nothing (#229). In rule bodies, any multi-segment path lowers to a variable named by the joined path (atom_lower.rs:1649-1655 path_term), so rigidity::anti_rigid trips OE1303 as an “unbound variable” — and the same resolver hole makes enum constants work on comparison-RHS only, individuals in predicate-argument position silently match everything, and qualified predicate atoms silently derive zero rows. The trait coverage gate decides instantiability by token-scraping the literal strings "sortality"/"sortal" from metatype declarations (oxc-driver/src/trait_conformance.rs:398-399) — an unqualified match any module’s axis can satisfy, and a one-letter typo silently disables the gate. The book Note (04-meta-calculus.md:44) claims the language “ships” the rigidity/sortality/identity_provision axes.


Question

pub metaxis and the metatype binding form exist so that vocabularies can classify their own types along their own dimensions — the meta-calculus is atom 1 of the language. The book already commits to the semantics: axis values “are not labels — the reasoner reads them” (crash-course §1), and the storage chapter commits axis values as (axis, target, value) meta_property events (“No UFO axis is a column”, 18-storage.md:7). The Lean storage model carries them (Storage/AxiomBody.lean:96-132). The implementation honors none of it.

The first external user hit the gap within a day of the trait arc shipping. Tiago’s ArgUFO overlay — UFO built as user-space vocabulary, exactly what the no-ambient-vocabulary doctrine (#208, #213) prescribes — writes:

pub derive isAntiRigid(t: TypeRef) :-
    meta(t).rigidity == rigidity::anti_rigid

and gets OE1303 naming rigidity::anti_rigid as an unbound variable. No phrasing works: the constant doesn’t resolve, the metatype tier isn’t materialized, and the binding isn’t even in the artifact. Meanwhile the compiler itself does read axis vocabulary — by magic string — in the one place it needs an answer (OE1327 instantiability), which is the same bug class the §3.4 gate (#213) eliminated one level down.

Two questions, then. (1) What is the correct end-to-end design for declared meta-properties — declaration, validation, persistence, materialization, query surface? (2) Where is the boundary between user axis vocabulary and substrate semantics — what may the compiler read?

Design lineage

The vault records both questions being answered before, halfway:

  • argon-ontology-neutrality/rigidity-removal-storage (sub-RFC, accepted 2026-04-30): tore the built-in Rigidity/SortalDependence enums out of the legacy kernel — “the largest remaining UFO leak in nous” — replacing them with a generic per-target meta_properties: BTreeMap<AxisId, Vec<MetaValue>>, with MetaValue::Symbol interned per (axis, value-name) pair. The (axis, target, value) event shape descends from this. But the cluster kept legacy helpers (is_rigid()/is_sortal()/provides_identity()) that read the axes by string — the storage went neutral, the semantics did not. The OE1327 magic strings are that compromise carried forward.
  • Beyond-OntoClean research (vault scratch, 2026-04-18): the useful meta-property space is open-ended — Fine’s essence-vs-modality refinement of rigidity, unity as a parameterized family (topological/morphological/functional/intentional), Mizoguchi role-ness and context-dependence, Lowe individuation-dependence, BFO’s dependence typology. No finite axis list a substrate ships can be right. Rigidity/sortality are formal-ontology-neutral (UFO/BFO/DOLCE all use them) but they are still ontological commitments — and the substrate does not need them; it needs their operational shadows.

This RFD completes the neutrality program: the 04-30 sub-RFC neutralized the data; this RFD neutralizes the semantics.


D1 — One value-position name-resolution pass

Every term position in a rule body (predicate arguments, comparison operands both sides, compute operands, head arguments) resolves names through a single pass, in priority order:

  1. Bound rule variable (appears in a positive body atom or is a head parameter).
  2. Enum constantStatus::Active, qualified or imported.
  3. Axis valuerigidity::anti_rigid, where the first segment resolves to a visible pub metaxis and the second to a value in its declared domain.
  4. Type reference — single-segment and qualified type names (TypeRef constant).
  5. Declared individual — bare or qualified (alice, people::alice).

Refusals are loud:

  • A multi-segment path that resolves to nothing is a hard error (new OE), never a variable.
  • A bare identifier used both as a rule variable and resolving to a declared constant is a hard ambiguity error naming both candidates (the lesson of Rust’s bindings_with_variant_name, promoted from lint to refusal): rename the variable or qualify the constant.
  • The existing path :: Ident metaEq sugar (07-rules.md:128) applies only when the left side is a bound term, after constant resolution fails to claim the path — the resolution order above is the documented disambiguation for A::B (already flagged ambiguous at 07-rules.md:155).

This single pass closes five verified symptoms at once: axis values as OE1303 “variables” (Tiago); enum constants failing on comparison-LHS while working on RHS; x == alice refused (#148); knows(x, carol) silently treating carol as a wildcard and over-deriving (the worst member — silent wrong results, zero diagnostics); and qualified individuals refused. Qualified predicate atoms deriving zero rows (rule compile keys joined paths, catalog keys short names) is the predicate-position sibling fixed in the same slice.

D2 — Declarations become real: wire + validation

  • MetaxisDeclBody persists the domain: unordered(values), chain(values) (declaration order is the order; the book’s a < b chains), or typed(TypeExpr, refinement?). The Lean AxisDomain shape (MetaCalculus/Axis.lean:38-48) already models this; the Lean storage mirror does not and gains it.
  • MetatypeDeclBody.axes / MetarelDeclBody.axes are populated with resolved bindings: axis = qualified NameRef (post-#213 discipline — the wire does not carry bare text), value = symbol (enumerated domains, bound with ::) or literal (typed domains, bound with =, e.g. weight = 1.0; :: vs = follows the existing grammar and the RFD 0017 note that = belongs to typed domains).
  • Validation at elaboration (new diagnostics, meta-calculus OE19xx range): unknown axis; value not in the axis’s declared domain (or literal fails the typed domain’s refinement — the OE0606 precedent); axis tier mismatch (for type axes bind only on metatypes, for rel only on metarels); duplicate axis in one declaration (OE1903, already specified in Wellformed.lean).
  • Per-target assertions ride the existing MetaProperty event ((axis, target, value) — the channel MLT’s #[order(N)] already emits), now actually consumed (D3).
  • Lean storage mirrors for MetaxisDeclBody/MetatypeDeclBody/AxisBinding are added; the Rust doc-comment claiming “Mirrors Argon.Storage.AxiomBody.AxisBinding” currently cites a declaration that has never existed and becomes true.

D3 — Catalog tiers: metatype-tier $meta/$iof and the $axis relation (settles #229)

  • The runtime Module indexes concept_id → metatype_id at load (ConceptDeclBody.metatype_id is already resolved on the wire since #213; it currently has zero non-test readers).
  • $meta and $iof gain metatype-tier rows: (type, metatype) for every declared type, plus the tower the book’s worked examples promise (meta(Person) == kind, meta(kind) == Metatype, 04-meta-calculus.md:95-99). Catalog-closed ⇒ NAF-safe, the same justification as $implements. This also unblocks MLT’s CL-rules (iof over types) as a side effect.
  • A new catalog relation $axis(target, axis, value) materializes the effective axis assignments: metatype-level bindings (D2) unioned with per-target MetaProperty assertions, per-target assertions taking precedence, functional per (target, axis) — a conflicting pair is a load-time error, not a silent choice.
  • Valence: single-valued per (target, axis). The legacy design admitted Vec<MetaValue> (the vault’s mode metatype binding two nature values); in the IS/CAN/NOT calculus that case is correctly “the metatype does not determine the value” — a CAN, i.e. no binding — rather than two simultaneous ISes. Multi-valued axes are a recorded non-decision: revisit with a concrete consumer, against the vault lineage. Cross-axis condition clauses from the legacy engine likewise stay out until someone needs them.

D4 — The query surface

  • meta(t) over a type evaluates via the metatype-tier $meta join (it already lowers correctly; the rows now exist).

  • Sort-directed projection. base.name where base is reflective: a TypeRef-sorted base resolves name as a declared field on the type (the documented meta(bar).x walk-up, 05-constructs.md:158-173 — unchanged); a Metatype-sorted base resolves name as a visible metaxis and lowers to a $axis join. No overload collision: fields live on types, axes live on metatypes. Tiago’s rule works as written:

    pub derive isAntiRigid(t: TypeRef) :-
        meta(t).rigidity == rigidity::anti_rigid
    

    t binds via the catalog $meta join; .rigidity joins $axis; the right side is a constant (D1). meta(t).rigidity == r with r free binds r. An unbound axis (the metatype binds nothing for it) yields no row — ordinary Datalog absence.

  • Chain comparisons. For chain domains, </<=/>/>= compare by declared position (crash-course §1 promises exactly this). Comparing symbols of different owners, or of an unordered domain, is a loud compile-time error where statically known and a loud runtime refusal otherwise — never an enum-variant-order fallback.

  • Type-tests over reflective sorts are catalog atoms. t : TypeRef ranges over all declared types; m : Metatype over all declared metatypes (the predicate forms of the promised extent(TypeRef)/extent(Metatype), 04-meta-calculus.md:109). Both bind positively, catalog-closed. This legalizes Tiago’s second attempt rather than refusing it. x : Entity stays refused for now, with a diagnostic that names the supported sorts and the catalog-atom binding idiom (its extent spans individuals ∪ types and needs its own design).

D5 — Value::Symbol: one carrier for axis values and enum constants

A new runtime value sort:

#![allow(unused)]
fn main() {
Value::Symbol { owner: NameRef, name: SmolStr, ord: Option<u32> }
}
  • Identity is (owner, name) — the interned-(axis, value-name) design from the 04-30 sub-RFC. ord is populated from chain domains at build (None for unordered/enum owners); equality ignores it; ordered comparison requires Some on both sides and equal owners, else refuses loudly.
  • Enum constants migrate to the same carrier. Today’s enum runtime carrier is a type-erased CBOR tag ({tag: "<variant>"}) under which two enums sharing a variant name compare equal — a live defect this RFD fixes rather than duplicates. Pre-release, no artifact compatibility is owed; the corpus regenerates.
  • Full sort cost, paid in full (no hollow rendering): CBOR encoding in the event log; CLI render_value renders owner::name; serve JSON renders a typed envelope with the qualified owner path (the #182 pattern); oxc-gen emits branded TS types per owner with exact (de)serialization; Lean wire mirror + drift coverage.

D6 — Substrate-neutral modifiers: abstract and fixed

The compiler never reads a user axis name. Anywhere. The substrate’s three genuine needs reduce to two ontology-neutral, PL-precedented bits, declared as modifiers:

Substrate consumerTodayBecomes
OE1327 trait-coverage instantiability"sortality"=="sortal" token scrapeabstract — no direct instances; abstract types are exempt from impl-coverage obligations
§7.5 runtime re-classification gate (insert/delete iof on an existing individual)rigidity::anti_rigid per the book Note; unenforcedfixed — classification decided at construction; insert/delete iof against a fixed-introduced type refuses loudly
§10.2 static check discharge; RP-004 valid-time iof persistence defaultsrigidity (Lean Rigidity inductive; design docs)the same fixed bit — fixed ⇒ membership constant ⇒ static discharge sound, VT-persistent
  • Placement. Both modifiers attach to metatype declarations (pub abstract fixed metatype category = { … };) — the metatype is the behavior bundle — and abstract is additionally allowed per-type (pub abstract type Vehicle { … }, the UML/PL convention). No override semantics: a type introduced by an abstract metatype is abstract, full stop.
  • Polarity: dynamic by default, fixed is the opt-in restriction. The §7.5 gate governs re-classification of existing individuals, not construction; for a data-systems language, membership churn is the normal case and is today’s behavior throughout the examples. The restriction is the constraint you declare — the same posture as check. (The keyword is fixed; sealed was rejected as colliding with the closed-hierarchy meaning in Kotlin/Scala/C#.)
  • Consequences for the book. The Note at 04-meta-calculus.md:44 (“the language ships the rigidity/sortality/identity_provision axes”) is deleted. Anti-rigid admission “through the axis assignment” is rewritten: admission comes from the absence of fixed; identity_provision has zero substrate consumers and becomes pure user vocabulary like everything else. ArgUFO writes pub abstract fixed metatype category = { sortality::non_sortal, rigidity::rigid }; — the modifiers carry the behavior, the bindings carry their ontology, and importing a package can never change mutation semantics because its author named an axis rigidity.
  • StaticDischarge.lean’s Rigidity inductive (:38-50) is re-grounded as the Kripke-semantics justification of fixed (rigid designation = fixed classification), not as a privileged axis.

D7 — Lean obligations

Surface settled (this RFD); per the workflow table the substrate semantics go Lean-first:

  1. Canonical model = the ternary axis relation (target, axis, value) with per-(target, axis) functionality — the (axis,value)-point instantiation of State C A cannot express chains or typed domains; the relation reading covers all three domains uniformly. This resolves the deferred wiring note in IsCanNot.lean:48-50 (the A-instantiation becomes a derived view of the relation).
  2. Chain-order semantics for symbol comparison; refusal semantics for cross-owner/unordered comparison.
  3. Catalog-closure of $meta (both tiers), $axis, and the reflective-sort extents, with the NAF-safety corollary — the #221 (Catalog.conformant_iff) pattern, including a decidable checker and non-vacuity witnesses on a concrete catalog.
  4. Decidability: axis atoms, metatype-tier reflection atoms, and reflective-sort type-tests are D1-tier (catalog joins); the classifier admits them.
  5. Modifier semantics: fixed ⇒ the mutation-boundary refusal theorem (no iof delta on fixed-introduced types in any successful mutation — extends the RFD 0015/0019 mutation semantics); abstract ⇒ the OE1327 oracle reads a wire flag (decidable, no string predicate); the static-discharge soundness hypothesis re-stated over fixed.
  6. Wire mirrors (D2) under @[language_interface], acknowledging the known drift-gate limitation that structures are prose-aligned — the mirrors are still written, and the lying comment dies.

D8 — Diagnostics inventory

New codes in the meta-calculus range (numbers assigned at implementation against the live catalog; the trait arc’s OE1322→OE1326 renumbering is the cautionary precedent): unknown axis in binding; axis value not in domain / literal fails typed-domain refinement; axis tier mismatch; unresolvable multi-segment path in rule value position; variable/constant ambiguity; cross-owner or unordered symbol comparison; insert/delete iof against fixed; instantiating an abstract type; Entity type-test refusal (help names the supported idioms). Amended help texts: OE1303’s “variable(s)” wording when the offender is an unresolved path (point at the path, not at range restriction); OE0223 on reflective sorts (now legal per D4 — the remaining refusal case is Entity).

Alternatives considered

  • std-shipped well-known axes (std::meta::rigidity, …): rejected. Re-creates the #208/#213 leak one level up — a blessed package whose names carry compiler semantics — and the beyond-OntoClean record shows no finite list is right. The book Note that implied this dies.
  • Per-value marker attributes on axis declarations (#[dynamic_classification] on anti_rigid): rejected — the substrate bit is per-value in that encoding, the declaration site becomes clutter, and importing a vocabulary still changes substrate behavior.
  • Keep the magic strings: rejected; it is the audited bug (ufo-01), behaviorally confirmed — a typo silently disables a soundness gate.
  • Multi-valued axes (Vec<MetaValue>): deferred with the K3 ‘can’ reading as the principled alternative; see D3.
  • Static-by-default polarity for classification mutability: rejected for data-systems pragmatics — it makes the common case ceremonial and breaks every existing example; the ontological reading is recovered exactly by declaring fixed.
  • A separate axis-value sort distinct from enum constants: rejected; two symbol-like sorts with different equality and rendering rules is incoherence by construction, and the enum carrier needed the fix anyway.

Sequencing

  1. S0 — D1 resolution pass + the two filed silent bugs (individual-as-wildcard, qualified predicates). Independent of everything else; converts silent-wrong to loud immediately.
  2. S1 — D2 wire + validation + Lean storage mirrors.
  3. S2 — D3 catalog tiers ($meta/$iof metatype tier, $axis), closing #229.
  4. S3 — D4 query surface + D5 Value::Symbol (including the enum migration), full rendering chain.
  5. S4 — D6 modifiers + consumer migration (OE1327, §7.5 gate, static discharge, VT defaults)
    • the book rewrite (Note deletion, §7.5, §12.4, crash-course).
  6. Lean (D7) leads each slice’s semantics per the workflow table; the mutation-gate theorem lands with S4.

Each slice is loud-complete on its own: no slice ships a parsed-but-inert surface.

Relationship to existing issues

  • #229 — settled by D3 (metatype-tier rows) + D4 (the classifier admits catalog-level bodies).
  • #148 — the constants half is settled by D1; the equality-binding half stands.
  • #209’s “RFD 0027” parking reference — this document.
  • Audit register: ufo-01 (magic strings → D6), ufo-02 (hollow wire → D2/D3), ufo-03 (phantom Lean mirrors → D2), ufo-04 (the boundary ruling → D6), gs-09 (axis sugar diagnostics → D1/D8), plus the enum type-erasure finding (→ D5).
  • #150 — the new codes land catalog-first; no raw-string emissions.

RFD 0028 — Defeasibility redesign: honest heads, the defeat-directive plane, and strategy as a compilation scheme

  • State: accepted — implementation planned
  • Opened: 2026-06-11
  • Decides: the complete replacement of the §7.8 strength-attribute surface (#[strict]/#[defeasible]/#[defeater]/#[priority], pub priority) with honest-head rules plus a defeat-directive plane — unmarked rules stay strict/classical, #[default] marks an overridable rule, #[defeats(target(args))] declares the attack, #[label(name)] gives a clause an identity — with head-level, clause-level, and trait-qualified targeting all in v1; the strategy-as-compilation architecture: the core language owns four strategy-neutral hooks (rule identity in the catalog, defeat edges on the wire, a transform slot between lowering and stratification, a provenance channel), and Governatori-with-explicit-superiority is strategy #1, specified as its compilation to the core stratified/WFS semantics; proof tags (+Δ/−Δ/+∂/−∂) finally surfaced through the provenance channel; and a loud migration — no silent aliasing. Settles the #244 design thread (including both correction comments: the grammar-clause rejection and the final lock) and stage 2 of #245. Hard prerequisite: the directive registry (audit dc-01). This RFD leads the arc per the workflow table — language surface: RFD + reference draft → Lean → code.

Prior state (verified at d26dc626): the attribute walker maps the strength triple onto RuleDeclBody.rule_strength (oxc-instantiate/src/lower.rs:145-147; wire field oxc-protocol/src/storage.rs:784; Lean mirror RuleStrength in Locality/DefeasibleExtraction.lean under @[language_interface]), and a fused strength-stratified evaluator computes final[H] = strict[H] ∪ (defeasible[H] \ defeater[H]), keyed by head name, blocking by exact head-tuple match (oxc-runtime/src/lib.rs:4810-4823). #[priority(N)] is silently dropped — the audit executed the repro (sf-02, probe p18: a program written to §7.8’s priority semantics computes different conclusions with zero diagnostics; #245) — and the pub priority { } superiority blocks §7.8 documents don’t parse at all: book-ahead prose presented as available. Unknown attributes are silently ignored across the board (audit dc-01: #[defeasable] passes ox check and silently strictens the rule). The whole §7.8 diagnostics block — OE0411–OE0414 — is phantom: zero presence in the catalog or the compiler (audit dc-03). The wire proof_tag field exists (storage.rs:1283, “Governatori-Rotolo 4-slot proof tag”) but lowering always writes None (lower.rs:6755) and its only live writer is fork promotion squatting it with promoted_from:{fork} (oxc-serve/src/lib.rs:2568).


Question

We lost a morning to three lines of the canonical example (examples/legal_norms_can_vote/norms.ar):

#[strict]     pub derive can_vote(p) :- SpecialClass(p);
#[defeasible] pub derive can_vote(p) :- Adult(p);
#[defeater]   pub derive can_vote(p) :- Felon(p);

The third rule spells the head it denies. §7.8 itself defines a defeater as A ⇝ ¬B — blocks B without asserting either — yet the surface writes can_vote(p) :- Felon(p). Everyone reads it as “felons can vote.” The attribute silently inverts the polarity of the head at a distance, which is unacceptable as a modeling surface — and the book even ships a phantom diagnostic (OE0413 DefeaterAssertsConclusion) acknowledging the confusion it documents.

Four defects, one design (#244):

  1. The defeater writes the head it denies — the polarity flip lives in an attribute, invisible at the head.
  2. No rule identity, no targeted defeat — attack resolution is head-name-only; the only priority knob is a global integer that is the wrong abstraction (magic numbers don’t compose across modules or packages) and is silently discarded today; the pub priority blocks are unimplemented prose.
  3. No surface notion of a default rule#[defeasible] is logician-speak; modelers think in defaults and exceptions.
  4. Exceptions aren’t compositional — an exception must be written as the attacked predicate, in its terms; it can’t live in another module or impl under its own honest name. Post-#217 the rule catalog is qualified-path keyed, so the addressing substrate for cross-module targeting now exists.

Two questions, then. (1) What is the right surface — how does a modeler write defaults, exceptions, and attacks so that every rule reads true? (2) What does the core language own, versus what belongs to a particular flavor of defeasible reasoning — Governatori-style defeasible logic is one strategy among several (default logic, courteous LP, argumentation, ASP preferences), and the substrate must not privilege it.

D1 — Honest heads: a rule derives exactly what its head says

Unmarked = strict. A bare derive rule is classical, exactly as today — a Datalog program keeps meaning what it always meant, and strict conclusions cannot be overridden. #[default] marks an overridable rule (the Rust-specialization precedent — default fn — an established PL concept carrying exactly the right reading: this clause holds unless something more specific displaces it). An exception is an ordinary rule with its own honest head; the attack is carried by a directive (D2), never by the rule’s syntax. The canonical example becomes:

// strict = unmarked: special-class members vote, period
pub derive can_vote(p) :- SpecialClass(p);

// the overridable default
#[default]
#[label(adult)]
pub derive can_vote(p) :- Adult(p);

// the exception: an honest head, and the attack as a directive
#[defeats(can_vote(p))]
pub derive disenfranchised(p) :- Felon(p);

Every rule now reads true: special-class members can vote; adults can vote by default; felons are disenfranchised, and that disenfranchisement defeats the default. The pure defeater — block without asserting anything anyone consumes — is recovered as the degenerate case: a #[defeats(…)] rule whose head no other rule or query reads. Heads never lie; the polarity flip is gone because there is no flip.

Numeric priority is subsumed, not replaced like-for-like: lex specialis becomes the specific rule defeating the general clause’s label — an explicit, resolution-checked, cross-package-stable edge instead of a pair of magic integers that only mean something relative to each other.

D2 — The attack is a directive, not grammar

Defeat edges are meta-level — statements about rules, not conditions in them. The #244 body sketched a trailing defeats … clause after the rule body; that sketch is rejected (first correction comment): trailing position reads as part of the body, conflating the object level (what the rule derives) with the meta level (which other rules it displaces). The right surface is the directive plane above the rule — Argon’s established home for argument-bearing compiler directives (the §13.5 MLT relational decorators already resolve type arguments and emit classification wire events; this is the same shape with rule-and-variable arguments instead of type arguments).

No new grammar keywords. A default modifier keyword was also rejected: default and defeats are strategy vocabulary (D6), and strategy vocabulary must not be privileged in the core grammar — the same neutrality reasoning as the no-ambient-vocabulary doctrine (#208), applied to reasoning strategies. The directives:

DirectiveOnMeaning
#[default]a derive rulethis clause is overridable; it survives unless an applicable attacker blocks it
#[defeats(target(args))]a derive rulewhen this rule’s body fires, it blocks the targeted conclusion for the bound tuples (D3)
#[label(name)]a derive rulegives the clause an identity, referenced as head.label; duplicate labels per head refuse

Hard prerequisite: the directive registry (audit dc-01) lands before or with these directives. Today an unknown attribute is silently ignored — #[defeasable] silently strictens a rule — and with #[defeats] carrying legal meaning, a typo must never silently change what a norm program concludes. Unknown or malformed directives refuse loudly; the registry is the gate.

D3 — Targeting: head-level, clause-level, trait-qualified — all in v1

All three targeting forms ship in v1; there is no interim subset (locked: no shortcuts).

  • Head-level#[defeats(can_vote(p))]: attacks every #[default] clause of that head.
  • Clause-level#[defeats(can_vote.adult(p))]: attacks exactly the clause labeled adult. head.label is the reference form; labels are per-head identities (D2).
  • Trait-qualified#[defeats(Vote::can_vote(p) @ A)]: attacks a trait member’s clause at a given impl target, using the post-#217 qualified catalog naming (Trait::member @ Type).

Targets are resolution-checked at elaboration (goto-def-able): an unresolvable target — no such head, no such label, no such qualified member — refuses loudly.

v1 scope (amended 2026-06-13, PR #354). Defeat-edge resolution is file-local in v1: a #[defeats] target resolves against the rule catalog of the module that declares the attack, and a target naming a head/label/member outside that file refuses as unresolvable (OE0716). This is consistent with D7’s structural guarantee — selection is per module, so a connected defeat graph is single-strategy because edges resolve within the module that declared them. It narrows the cross-package aspiration sketched above for the trait-qualified grain (“a regulation package can defeat a clause it does not own”): the addressing substrate (post-#217 qualified catalog naming) exists, but workspace-scoped resolution requires lifting the defeat pass out of per-file elaboration into a combined-artifact pass — a build-pipeline change deferred so it does not ride alongside the two correctness fixes this PR lands. Cross-module composition through honest heads (strategy-independent) is unaffected and works today. Tracked: #362. Rationale recorded per the locked-design amendment discipline (AGENTS.md “RFDs record settled designs”).

Directive arguments resolve against the decorated rule’s variables (head + body). The argument binding ties the attacker’s tuples to the target’s: #[defeats(can_vote(p))] on disenfranchised(p) :- Felon(p) blocks can_vote exactly for the p the attacker derives — per-tuple blocking, not head-wide suppression. An argument name that does not bind in the decorated rule is a loud error, never a fresh variable — the #242 lesson: a name silently treated as a fresh variable is how knows(x, carol) matched everything. The same single resolution discipline as RFD 0027 D1 applies inside directive argument position.

D4 — Attack discipline

  • Strict conclusions are unattackable. A #[defeats] target that resolves to a head or clause not marked #[default] is a loud error. Adding rules to a classical program can only add conclusions; defeat exists only where overridability was declared.
  • Defeat-graph cycles are refused loudly in v1. The graph is over resolved rule identities (clauses and heads), known at elaboration; acyclicity is decidable at build time (D10). Cyclic attack structures are exactly where the well-behaved compilation stories diverge; v1 refuses rather than picking one silently.
  • Defeated defeaters are legal. A #[defeats] rule may itself be #[default] and be the target of another attack — the exception to the exception — subject to the cycle gate.
  • Ambiguity blocking is retained (current behavior): a blocked tuple is simply absent from the head’s extent; it does not propagate a third truth value downstream. Ambiguity propagation is a different strategy (D7), not a switch on this one.
  • Team defeat, recorded explicitly: survival is support-based, per tuple. A tuple is in the head’s extent iff some clause not attacked on that tuple derives it — clause-level targeting blocks only the labeled clause’s contribution; head-level targeting blocks all #[default] clauses. An unbeaten teammate keeps the conclusion. This is the reading clause-union forces, and it is recorded here as the chosen semantics rather than left implicit; stricter team-defeat variants belong to future strategy vocabularies.

D5 — Strategy = compilation scheme, not engine mode: the four hooks

The architectural decision (locked): a defeasibility strategy is a compilation scheme onto the core semantics, never an engine mode. The engine — classifier, stratifier, reasoner — stays strategy-blind. The core language owns exactly four strategy-neutral hooks:

  1. Rule identity surviving lowering into the catalog. rule_id and qualified_path already survive (post-#217); #[label] extends identity to the clause grain. Identity is catalog-addressable — the same addressing that makes targets resolution-checked.
  2. Defeat edges as resolved wire metadata in the .oxbin. Each edge: attacker identity, resolved target set, argument binding. Resolved at elaboration — the wire never carries bare text to be re-resolved downstream (the #213 discipline).
  3. A transform slot in the pipeline, between lowering and stratification. The strategy compilation consumes the declared program + markers + edges and emits a core stratified/WFS program. Everything downstream of the slot sees ordinary rules.
  4. A provenance channel on derived tuples (D8) — the strategy maps its proof statuses onto it; the channel itself is strategy-neutral.

A strategy is then a quadruple: (directive vocabulary, compilation scheme, tag mapping, correctness proof). The RuleStrength wire field and its Lean mirror retire with the surface triple — hook 1 supersedes the strength field: once the artifact’s defeasibility metadata carries default markers, labels, edges, and the strategy id (D6), a wire-level strength is no longer a hook but a strategy-internal derivative, reconstructed inside the transform where the compilation needs it (the retirement is carried as an explicit Lean obligation, D10.6).

Module extraction (§3.5) operates pre-transform, over labeled rules + defeat edges — which is exactly the granularity Locality/DefeasibleExtraction.lean already mechanizes (defeat_complete_preserves over rule identities and a superiority relation). The hooks were, in this sense, already proven before they were named.

D6 — Strategy #1: Governatori with explicit superiority, as its compilation

The first (and v1 only) strategy is Governatori-style defeasible logic with explicit superiority and ambiguity blocking, specified as its Maher-2021-style compilation onto the core stratified/WFS semantics. §7.8 already commits that “no separate reasoner is invoked; the existing stratified-fixpoint machinery is reused” — this RFD promotes that from implementation note to definition: the meaning of a #[default]/#[defeats] program is the meaning of its compiled core program. Superiority is the explicit edge set (D3); there is no implicit priority anywhere.

The strategy id is recorded in the .oxbin defeasibility metadata. v1 programs get it implicitly (the exact identifier is assigned at implementation); the field exists from day one so that artifacts are honest about which compilation gave them their meaning, and so that per-module strategy selection (D7) has a place to land.

The existing fused strength-stratified evaluator may survive only as a fast path under the oracle obligation (D10): it must agree with the compiled program on every input, checked by the RFD 0018 differential discipline. If it can’t be kept in agreement, it dies; the compiled program is the semantics either way.

D7 — Strategy evolution: per-module vocabularies, editions, and the tier ladder

  • Future strategies arrive as use-imported macro-vocabulary packages once the macro atom matures (§13 V1): a package exports its directive vocabulary plus its compilation scheme. Selection is per module, which structurally guarantees that a connected defeat graph is single-strategy — edges resolve within the module that declared them, and cross-module composition happens only through honest heads, which are strategy-independent.
  • Migration off the implicit default is edition-shaped: a deprecation window plus a mechanical migration tool (the rustfix precedent). No flag-day; no silent reinterpretation of existing programs.
  • Cost containment: a strategy whose compilation needs more than stratified/WFS does not get a new engine — it lands on the §9 tier ladder: the #[brave]/stable-model tier or the ARS external-solver layer (§17). Strategy choice can change a program’s tier; it can never change the engine.

D8 — Provenance: the proof-tag channel, un-squatted

The Governatori proof tags — definitely provable, −Δ definitely refuted, +∂ defeasibly provable, −∂ defeasibly refuted — are finally surfaced on derived tuples through the provenance channel (hook 4). The tag mapping is strategy-owned: strategy #1 maps its compiled strata onto the four tags; a future strategy maps its own statuses. The §7.8 query surface for tags (match over /+∂/is unknown) stays as specified; this RFD supplies the channel it was always waiting for.

The wire proof_tag field is to be un-squatted: its only current writer is fork promotion stuffing promoted_from:{fork} into it (oxc-serve/src/lib.rs); fork lineage gets its own field, and proof_tag carries proof tags.

S3 status (amended 2026-06-13, PR #354). Proof tags are surfaced at query time today — Store::query_derive_explained computes each surviving tuple’s / +∂ tag from the compiled strata (the user-visible ox derive --explain surface, §7.8 tag queries). No proof tag is persisted to AxiomEvent.proof_tag (the comptime lifter writes None), so the fork-promotion squat does not collide with any proof-tag writer yet — but it is still a wrong use of the field. Moving fork lineage to its own field touches the @[language_interface] AxiomEvent wire shape (its Lean mirror, the oxbin codec, the Postgres column, and ~28 struct constructions), so the un-squat is deferred out of this correctness PR rather than landed beside the F1/F2 transform fixes. Tracked: #363.

D9 — Migration: loud deprecations, the §7.8 rewrite, diagnostic fates

  • #[strict], #[defeasible], #[defeater], #[priority], and pub priority become loud deprecation errors pointing at the new forms. No silent aliasing — a norm program’s meaning never changes without the author seeing it. #245’s stage 1 (the interim loud refusal of #[priority]) is independent and lands first; this RFD is stage 2, removing the attribute for good.
  • §7.8 is rewritten around the hooks/strategy split: the strength-attribute prose, the #[priority] section, and the pub priority blocks all die; the chapter specifies honest heads + the directive vocabulary as strategy #1 over the four hooks.
  • examples/legal_norms_can_vote is migrated to the D1 form — the canonical example must read correctly, since misreading it is what started this.
  • Diagnostic fates (all four are phantoms today — audit dc-03 — so nothing is removed from the catalog, only from the book): OE0413 (DefeaterAssertsConclusion) is moot — no rule can assert what a defeater denies, because no rule spells a head it denies. OE0412 (PriorityNotIntegral) dies with #[priority]. OE0414 (MixedStrengthAtHead) dissolves — strict and #[default] clauses sharing a head is the intended idiom (the special-class rule next to the adult default), not a hazard. OE0411’s concern (defeat cycle) survives as the new cycle-refusal code, under a fresh number (D11; no reuse).

D10 — Lean obligations

Surface settled (this RFD); per the workflow table the substrate semantics go Lean-first, leading each implementation slice:

  1. Transform-correctness theorem. The compiled core program, evaluated under the existing stratified/WFS semantics, realizes the declared defeasible semantics: a per-tuple defeasibleWarranted-style specification over labeled rules + defeat edges (the DefeasibleExtraction.lean shape, lifted from rule grain to tuple grain with argument bindings) agrees with the compiled program’s model.
  2. Fused-evaluator agreement. If the strength-stratified evaluator is retained as a fast path, it agrees with the compiled program as oracle on all inputs — the RFD 0018 differential-oracle discipline (semi-naive = oracle precedent).
  3. Defeat-graph acyclicity is decidable — decidable checker plus non-vacuity witnesses on a concrete program (the #221 conformant_iff pattern).
  4. Compatibility with defeat-aware module extraction (§3.5). defeat_complete_preserves already operates over rule identities + superiority edges, pre-transform; restate it over the new edge carrier and prove extraction-then-transform agrees with transform-then-extraction on the extraction signature.
  5. Narrowing soundness re-keyed. The existing restriction — only narrowings established by strict rules are preserved under defeasible attack (TypeSystem/Soundness/Defeasibility.lean) — is restated over #[default]: the counterexample and the strict-only soundness theorem carry over unchanged in substance.
  6. Wire mirrors under @[language_interface] for the defeasibility metadata (labels, edges, strategy id); the RuleStrength mirror retires with the surface triple.

D11 — Diagnostics inventory

All codes proposed; numbers assigned at implementation against the live catalog (the OE1322→OE1326 renumbering is the cautionary precedent; #150’s catalog-first discipline applies — no raw-string emissions). The 04xx range is proposed for the defeasibility plane: it is unallocated in appendix C’s range table, and the phantom OE0411–OE0414 die rather than being reused — no number reuse, per registry hygiene.

  • Unknown directive — the registry gate itself (dc-01; attribute subsystem, 07xx range): an unrecognized or malformed #[…] refuses loudly.
  • Unbound directive variable — a #[defeats] argument that binds in neither the head nor the body of the decorated rule; names the offender and the rule’s bound set; never a fresh variable.
  • Unresolvable defeat target — no such head, no such label on that head, no such qualified trait member.
  • Defeats a strict conclusion — the target resolves to a head/clause not marked #[default].
  • Defeat-graph cycle — names the cycle, rule by rule.
  • Duplicate label per head — two clauses of one head labeled identically.
  • Deprecated strength attribute#[strict]/#[defeasible]/#[defeater]/#[priority] or pub priority; the help text shows the new form for the specific case (migration, D9).

Alternatives considered

  • Trailing defeats … grammar clause (the #244 body sketch): rejected — it reads as part of the rule body, conflating the object level with the meta level; defeat edges are statements about rules, and the surface must say so (first correction comment).
  • default / defeats as grammar keywords: rejected — strategy vocabulary must not be privileged in the core grammar; the substrate stays neutral between defeasibility strategies exactly as it stays neutral between ontologies (#208’s reasoning, one level up). The directive plane carries the vocabulary; the grammar carries nothing.
  • Numeric #[priority(N)] + pub priority superiority blocks: removed — global integers are non-compositional across modules and packages (whose 10 beats whose 7?), and both are dead in practice: the attribute is silently discarded (sf-02, #245) and the blocks never parsed. Explicit edges subsume the use cases: lex specialis is the specific rule defeating the general clause’s label; derived superiority (lex posterior over enactment dates) belongs to a future strategy vocabulary that derives edges, not to core.
  • Engine-mode pluggable strategies (a strategy enum the reasoner switches on): rejected — the engine stays strategy-blind; compile-to-core keeps one trusted kernel and one mechanization target. The evidence the hooks suffice for the known strategy space: grounded argumentation semantics is the well-founded semantics of the argument meta-program (Dung 1995); courteous logic programs compile to LP with NAF (Grosof 1997); Governatori-style defeasible logic compiles to three strata of stratified Datalog (Maher 2021). Each is a quadruple over the same four hooks.
  • Head-level-only targeting in v1: rejected — no interim subsets (locked: do the full thing). Clause-level and trait-qualified targeting are where cross-module exceptions live — defect 4 — and the addressing substrate (post-#217 qualified catalog naming) already exists; shipping head-level-only would re-create the compositionality gap this RFD exists to close.

Recorded non-decisions

  • Trait-side strength pinning (the #240 shape): whether a trait can pin its members’ overridability, and whether one impl’s clause can be #[default] while another’s is strict — open, recorded in #244; nothing in this design forecloses it.
  • Ambiguity propagation and stricter team-defeat variants — future strategy vocabularies, not switches on strategy #1 (D4, D7).
  • Strategy-package format — the exact shape of a use-imported strategy vocabulary awaits the macro atom (§13 V1); only the hooks contract is fixed now.
  • Temporal proof tags (the 4-slot Governatori-Rotolo schema) — stay deferred exactly as §7.8 already records.

Sequencing

Each slice is loud-complete on its own — no slice ships a parsed-but-inert surface — and the Lean obligations (D10) lead each slice’s semantics per the workflow table. One deliberate deviation from the “RFD + reference draft” pairing: the §7.8 rewrite waits for S4 rather than landing with this RFD — rewriting the chapter now would describe an unimplemented surface, repeating the exact book-ahead-of-code failure (pub priority, OE0411–OE0414) this RFD retires; until S4, §7.8 carries a superseded-pending marker pointing here.

The slices:

  1. S1 — hooks. Rule identity and #[label] through lowering into the catalog; defeat edges as resolved wire metadata; the directive registry lands here as the prerequisite (dc-01) — unknown directives refuse loudly before any new directive exists to typo.
  2. S2 — vocabulary + transform. #[default]/#[defeats] resolution and validation (D3/D4 gates); the strategy transform in the pipeline slot; the compiled program is the semantics, with the fused evaluator retained only under the oracle check (D6, D10.2).
  3. S3 — provenance. Proof tags through the channel; proof_tag un-squatted (fork promotion gets its own field); the §7.8 tag-query surface lights up.
  4. S4 — migration. Loud deprecation errors for the old surface; legal_norms_can_vote migrated; §7.8 rewritten around the hooks/strategy split; the phantom OE041x block dies with it.

Relationship to existing issues

  • #244 — settled by this RFD, incorporating both correction comments (directive plane over grammar clause; the final lock).
  • #245 — stage 1 (interim loud refusal of #[priority]) is independent and lands first; stage 2 is settled here: the attribute and the pub priority prose are removed for good.
  • Audit dc-01 — the directive registry is the hard prerequisite (S1); dc-03 — the phantom OE0411–OE0414 citations die in the §7.8 rewrite; sf-02 — the executed priority-discard repro is resolved by removal, not implementation.
  • #242 / #243 — the loud-unbound rule for directive arguments and resolution-checked qualified targets are those lessons, applied at the directive plane (D3).
  • #240 — trait-side pinning stays open; recorded non-decision.
  • RFD 0010 (negative facts) — orthogonal: defeat blocks, it does not assert negative catalog facts.
  • RFD 0026 / #217 — qualified member naming is the targeting substrate for Trait::member @ Type defeat targets.
  • RFD 0018 — the differential-oracle discipline governs the fused evaluator (D10.2).
  • #134 — the WFS mechanization catch-up; the transform-correctness theorem (D10.1) lands against the same Reasoning/Datalog layer.

RFD 0029 — Derived values and aggregate terms: body-level binding, aggregate sources, rounding

  • State: accepted — implemented in this PR
  • Opened: 2026-06-12
  • Decides: the quantitative-modeling wall of the v0.2.1 program — that a rule can derive a computed numeric value and compare two aggregates. One mechanism: a body-level binding atom x = expr (single =, distinct from the comparison ==) that binds a fresh variable to the value of expr, where expr ranges over bound vars, literals, field projections, arithmetic over the exact tower, and aggregate expressions. Aggregate sources extend the existing comprehension form with comma-separated body atoms and admit relation atoms as sources; the brace form stays count/exists-only. Grouping is the outer bound variables (the standard Datalog reading). A rounding builtin family (round, round_half_even, trunc) is added to rule-body expressions and the fn/compute plane, banker’s rounding the money default. The exact-Decimal mutate-body status note is killed (the path already routes through the exact canonical core). The CLI/demo harness accepts decimal arguments for Decimal/Money/Real params. Settles register items R-B2, R-B3, R-B4, R-M4, R-M5, R-M7, and the harness half of R-B10.
  • Prior art: Datalog/Soufflé assignment (x = expr binds; range-restriction over the computed value) — Soufflé’s = constraint and arithmetic functors are the direct precedent ([Jordan, Scholz, Subotić, Soufflé: On Synthesis of Program Analyzers, CAV 2016]; the Soufflé manual’s “Assignments” and “Aggregates” chapters). Stratified-aggregate semantics follow Faber–Pfeifer–Leone 2011 (the aggregated predicate must live in a strictly-lower stratum).
  • Layer: language surface — RFD + reference draft → Lean → code (workflow table). The reasoner is the layer where Rust leads (AGENTS.md); the Lean obligation is recorded for #134’s catch-up scope, not mechanized here.

1. The problem

On origin/main @ v0.2.0, tax and accounting are not authorable. The domain-modeling lane proved it against the binary:

  • A rule head cannot carry a computed value. derive Tax(t, owed) :- appliesTo(b, t), owed == t.income * b.rate refuses with OE1303 — == is a filter over already-bound operands, never an assignment, so owed is never positively bound (R-B2).
  • Two aggregates cannot be compared. The double-entry invariant sum(debits) == sum(credits) is inexpressible: a comprehension aggregate parses only as the RHS of a field-path comp-op ___ comparison; on the LHS or in leading position it is OE0001 (R-B3).
  • Aggregates have no relation source and no grouped form. sum(amt for amt in rel(e, _, amt)) refuses (the comprehension source must be a materialized collection field); group by … having refuses with OE0007 (R-B4, R-M7).

Three adjacent gaps compound it: the §7 status claims mutate-body arithmetic is Real/f64 with “exact Decimal pending” (R-M4); there are no rounding primitives in any plane (R-M5); and the demo/CLI harness rejects a decimal mutation argument outright (R-B10, harness half).

2. The design — one mechanism

2.1 Body-level binding: x = expr

A new rule-body atom form binds a fresh variable to the value of an expression:

pub derive Tax(t, owed) :- appliesTo(b, t), owed = t.income * b.rate;

x = expr (single =) is assignment, distinct from x == e (double =, a filter). expr ranges over: bound variables, literals, field projections (t.income), arithmetic over the exact tower (a * b, a + b, exact /), nullary/other intrinsics, and aggregate expressions (§2.2).

This is the established PL concept — Datalog/Soufflé assignment. It is not a new IR constructor: the binding lowers to the existing AtomIR::Compute { result, expr } (the RFD 0020 D5 Map operator), whose result variable the range-restriction safety check already treats as a binding source (a Compute result is bound once its input variables are bound, closed to a fixpoint). The work is purely in the parser (recognize the = atom shape) and the lowering (emit Compute, relationalizing any Proj/aggregate sub-terms exactly as the existing comparison-operand hoist does).

Freshness and range restriction. The LHS x must be a FRESH variable, and the binding must be range-restricted. Two distinct refusal paths, never a silent Null:

  • Freshness (OE1335 BindingLhsAlreadyBound). If x is already bound — by a prior positive predicate atom, a head parameter bound elsewhere, a projection, an aggregate result, or an earlier binding — the = was silently degrading into an equality FILTER (x joined against the computed value) rather than a binding, collapsing the two readings of = (bind vs. compare) with no diagnostic. It refuses, naming x: use == to compare, or pick a fresh name. This is the path a rebind x = x + 1 takes when x is otherwise bound — the LHS is not fresh.
  • Range restriction (OE1303 RuleNotRangeRestricted). x = expr binds x positively iff every variable in expr is itself positively bound. An unbound RHS variable, or a pure self-reference / cycle where the result variable is bound by nothing else (x = x + 1 as x’s only binder; x = y, y = x), leaves x unbound under the binding fixpoint and refuses as unsafe. OE1303’s wording is extended to name the binding form.

So x = x + 1 refuses via OE1335 (freshness) when x is already bound elsewhere, or via OE1303 (range restriction) when x has no other binder — the two codes name two genuinely different errors. The freshness check runs first, so the freshness diagnostic wins when both could apply.

Why x = expr, not aggregate-on-LHS comparison sugar. a = sum(...), b = sum(...), a == b already solves the leading/LHS aggregate position (R-B3) with the same mechanism. We do not add a second spelling (sum(...) == sum(...) as direct comparison sugar): one mechanism, one spelling (house rule — nothing silently dropped, nothing redundantly admitted). A user who writes sum(...) == sum(...) directly is hoisted by the existing aggregate-operand relationalization (the aggregate becomes a Compute-bound var), so it also works — but the canonical, documented form is the binding.

2.2 Aggregates as bindable expressions

An aggregate call is a bindable expression:

pub derive trial(acct, bal) :- acct: Account,
    debits  = sum(e.amount for e in Entry, posted(e, acct), e.side == "D"),
    credits = sum(e.amount for e in Entry, posted(e, acct), e.side == "C"),
    bal     = debits - credits;

Because the aggregate binds a variable, comparing two aggregates is just comparing two bound variables (R-B3): a = sum(...), b = sum(...), a == b.

2.3 Aggregate sources — extend the comprehension, don’t add a form (R-B4)

The existing comprehension sum(e.value for e in S where φ) is extended so the body after the source is a comma-separated list of additional body atoms, replacing the single where φ:

agg( proj for binder in Source [, atom]* )
  • Source may be a type extent (for e in Entry) or a relation atom is expressed as one of the trailing atoms (posted(e, acct)).
  • The trailing atoms are ordinary rule-body filter atoms (predicates, comparisons, type tests, membership) lowered with the same machinery as the outer body.
  • Variables from the outer rule body are visible inside the comprehension. That visibility is the grouping (§2.4).

The legacy single-where form (… where φ) stays accepted as sugar for one trailing atom, so every existing example keeps parsing.

Bindings are NOT a trailing-atom form (decided under the no-hollow rule). An earlier draft of this enumeration listed nested bindings as a trailing-atom kind. They are not implemented and are removed from the design: the comprehension cond is a boolean-filter chain (right-nested &&), which has no assignment shape, and a fresh variable introduced inside an aggregate sub-scope is not safety-checked there (the outer range-restriction and freshness checks do not descend into aggregate bodies — a recorded conservative gap). Admitting a half-checked binding inside the fold would open a new silent-unsafe surface, and the useful shape is already expressible without it: sum(w for u in T, rel(t, u), w = u.v) is exactly sum(u.v for u in T, rel(t, u)) (project into the fold directly), and anything richer binds in the OUTER body and aggregates the bound variable. So a binding x = expr in a trailing position is refused with a directed hint, OE1336 BindingInComprehension, naming both rewrites — rather than a generic parse error from the unconsumed =. The comparison == is a real trailing filter and is unaffected.

The brace form stays count/exists-only. sum(expr){atoms} does not parse as a value aggregate; it refuses with a directed hint pointing at the comprehension form (OE1331). We do not add sum(expr){…} — one spelling for value aggregates (the comprehension), one for cardinality (the brace). This keeps the brace’s existing count { p in Person, R(a, p) } membership-join shape (tracked separately under #308) the only brace surface.

2.4 Grouping = the outer bound variables (R-M7)

Grouping is the standard Datalog reading: the group key is exactly the set of outer rule-body variables free in the aggregate. pub derive balance(acct, s) :- acct: Account, s = sum(e.amount for e in Entry, posted(e, acct)); groups per acct — the aggregate is evaluated once per binding of the outer variables, and acct appears in the aggregate body (posted(e, acct)), so each acct gets its own fold. This already matched the reasoner’s evaluation model (the aggregate sub-pipeline is seeded with the outer binding); the binding atom makes the result nameable so it can flow to the head or a second aggregate.

The SQL-ish group by … having keeps refusing (OE0007) but now with a directed hint showing the binding-form equivalent — a group by acct having sum(x) > 0 becomes acct: Account, g = sum(x for …), g > 0. We do not build group by: the Datalog grouping is the spelling, and a second one would be a silent-redundant surface.

Empty-group semantics — a deliberate split. When a group folds over no rows, the aggregators divide by role, and this is the documented behavior the double-entry example relies on:

  • sum / count / count_distinct emit a value for the empty group0. An additive/cardinality fold has a well-defined identity (the empty sum is zero, the empty count is zero), so the grouped row is produced with that identity.
  • min / max / avg drop the row — they have no value over an empty set (no least/greatest element; the mean is 0/0), so no grouped row is produced rather than fabricating one.

This split is load-bearing for the double-entry invariant (§4): sum(debits) == sum(credits) must catch an entry with credits but no debits. Because sum emits 0 for the empty debit side, the comparison is 0 == credits — which fails and flags the entry as unbalanced. Were sum to drop the empty group, the row would vanish and the imbalance would pass silently. The behavior is fixed in fold_aggregate (compiler/crates/oxc-reasoning/src/executor/eval.rs) and pinned by a test asserting BOTH halves (empty_group_split_sum_count_emit_zero_minmaxavg_drop).

2.5 Semantics and stratification

  • Stratification. An aggregate over a derived predicate requires the aggregated predicate stratified strictly below the rule (Faber–Pfeifer–Leone). This is already enforced: an in-SCC aggregate edge is recursion-through-aggregation and refuses loudly with OE1317 (issue #174). No change.
  • WFS interaction (#250). Aggregates evaluate over the definitely-true extent. If the aggregated atom set draws on a relation with a non-empty well-founded-undefined companion ($undefined::R), the fold would silently treat undefined as false — the #250 leak. We refuse the rule loudly at runtime for now (OE1332 / ReasoningError::AggregateOverUndefined) rather than fold undefined-as-false. #250 is the designed follow-up (three-valued aggregate intervals); this RFD does not close it, it stops the silent leak.

2.6 Exact-Decimal mutate-body path (R-M4)

The §7 status note (“mutate-body arithmetic is Real/f64; exact Decimal pending”) is false at HEAD and is removed. Mutate-body arithmetic (require guards, let/field expressions) already routes through eval_binary_values — the canonical exact core (RFD 0016) that the #272/#277 work made the single arithmetic path: pure-Int stays checked Int, any Real/Decimal operand promotes to exact BigRational, / is the exact field operation, no f64 in the arithmetic path. Money (carried as a structured CBOR value) folds through the same rational core. We delete the stale note and flip the §7 status row to ✓/✓, and add a money-arithmetic regression that proves 0.1 + 0.2 == 0.3 exactly in a mutate body.

2.7 Rounding (R-M5)

A builtin function family, available in both rule-body expressions and the fn/compute plane, exact-tower in/out (Decimal stays Decimal, never via f64):

BuiltinMeaning
round(x)nearest integer, ties away from zero
round(x, n)nearest multiple of 10^-n, ties away from zero
round_half_even(x, n)nearest multiple of 10^-n, ties to even (banker’s rounding)
trunc(x, n)toward zero at 10^-n

round_half_even is the money default — financial rounding rounds half to even to avoid the upward bias of half-away-from-zero. Documented in the book’s money section. All four operate on BigRational and return an exact value (integral results collapse to Int, as elsewhere in the tower). round / round_half_even / trunc are reserved builtin names: they resolve by surface name in both the rule plane (compile_expr) and the fn/compute plane (resolve_term_to_value), so a user-defined fn round does not shadow them. A wrong-arity call is OE1333 (RoundingBuiltinArity).

2.8 CLI/demo harness decimal arguments (R-B10, harness half)

demo.toml mutation args accept decimal values for Decimal/Money/Real params, two spellings:

  • A quoted decimal string via an explicit table form { decimal = "0.22" } / { money = "100.50" } — exact, parsed directly to the rational carrier (never via f64). This aligns with serve’s coerce_money_arg convention (a Money amount is a decimal string).
  • A bare TOML float (rate = 0.22) — converted via its shortest round-tripping decimal representation (the same value the user typed), then parsed exactly. The conversion is documented: a bare float is read as the shortest decimal that round-trips to that f64, so 0.22 becomes exactly 22/100, not the f64 artifact 0.2200000000000000011….

The serve-side wire half of R-B10 (decimal coercion at the HTTP boundary) belongs to arc2 and is not touched here.

3. Diagnostics

New codes (allocated next-free in the 13xx rule band, grammar.toml as truth, cargo xtask gen):

CodeNameMeaning
OE1331ValueAggregateBraceFormA value aggregate (sum/min/max/avg) was written in brace form sum(expr){…}; directs to the comprehension form sum(expr for x in S, …).
OE1332AggregateOverUndefinedAn aggregate folds over a relation with well-founded-undefined atoms; refused rather than silently treating undefined as false (#250 leak guard). The refusal is whole-relation, not per-group.
OE1333RoundingBuiltinArityA rounding builtin (round/round_half_even/trunc, §2.7) was called with the wrong number of arguments.
OE1334BindingLhsNotSimpleVarA binding atom x = expr (§2.1) has a non-fresh-variable LHS — a dotted projection (x.f = e) or a ::-qualified path (m::x = e); a binding’s LHS must be a bare identifier.
OE1335BindingLhsAlreadyBoundA binding atom x = expr (§2.1) names an LHS x already bound by a positive predicate, a head param, a projection, an aggregate result, or an earlier binding; the = would silently act as an equality filter. Use == to compare, or pick a fresh name. (Freshness path; see §2.1.)
OE1336BindingInComprehensionA binding x = expr appears as a comprehension trailing atom (§2.3), which is not a trailing-atom form; directs to project into the fold directly or bind in the outer body.

The Rust error variant for OE1333 is RuleCompileError::RoundingBuiltinArity, renamed from BuiltinArity to match the catalog name (only the rounding builtins reach this arity check).

Extended:

  • OE1303 (RuleNotRangeRestricted) — wording extended to name the binding form x = expr as a binding source and as a consumer of its RHS variables.
  • OE0007 (GroupByNotExecuting) — message extended with the binding-form equivalent hint.

4. Worked example: double-entry accounting

examples/double_entry_v0 is the journey’s exact wall made a running proof: accounts, journal entries with debit/credit lines, the balance invariant sum(debits) == sum(credits) as a check, and a trial-balance query that sums per account. It is corpus-pinned (an integration test asserts its extents) and demo.toml-driven.

5. Lean obligations

The reasoner is the layer where Rust leads (AGENTS.md): well-founded semantics, factorized aggregates, and the join machinery execute in Rust ahead of the Lean fixpoint. The binding atom and aggregate-source extension are evaluation-layer changes that ride the existing AtomIR::Compute / AtomIR::Aggregate constructors — already in the @[language_interface] wire shape — so no drift-gate change is required. Mechanizing the body-binding and grouped-aggregate semantics is added to issue #134’s catch-up scope (the Reasoning/Datalog/ layer), not mechanized in this PR.

6. Disposition against the register

ItemDisposition
R-B2 (derived value head)x = expr binding atom → AtomIR::Compute; head carries the bound var. LHS freshness enforced (OE1335) — a rebind-as-filter refuses loudly.
R-B3 (compare two aggregates)a = sum(…), b = sum(…), a == b via the binding atom.
R-B4 (aggregate sources)Comprehension extended: comma-separated trailing filter atoms + relation-atom sources; brace value-aggregate refused (OE1331). Trailing bindings are not a form — refused with a directed hint (OE1336); project into the fold or bind in the outer body.
R-M4 (exact mutate arith)Already exact via eval_binary_values; stale §7 note removed; money regression added.
R-M5 (rounding)round/round(_,n)/round_half_even/trunc builtins, exact tower, banker’s-default.
R-M7 (grouped aggregation)Grouping = outer bound vars (Datalog); group by … having refuses with binding-form hint.
R-B10 (harness half)demo.toml accepts { decimal/money = "…" } and bare floats (shortest-decimal).

RFD 0030 — Package dependencies ([dependencies], path deps v1)

  • State: committed
  • Opened: 2026-06-12
  • Decides: how an Argon package declares a dependency on another package and consumes its pub surface — the mechanism that makes vocabulary publishing real. A vocabulary team ships a package (e.g. argufo); a modeler in a separate package declares [dependencies] argufo = { path = "../argufo" }, writes use argufo::kind;, and uses argufo’s pub metatype kind as a declaration keyword in its own catalog. Built as the Wave-A arc7-packaging work of the v0.2.1 program; closes register blocker R-B1 and majors R-M1 (use-import diagnostics) and R-M12 (manifest honesty). Builds D1 of RFD 0022 (which reserved “a dependency package name ([dependencies])” as a resolution root) and the §3.3/§3.4 manifest/resolution surface. Relates to Modules and Build.

This RFD records, as built, the dependency mechanism the book described aspirationally (§3.3 [dependencies], §3.4 dependency-package roots, §16 the ox orchestrator’s package graph). v1 is path dependencies only; the registry/version/git surface is recognized and refused loudly, reserved for a later RFD.


Question

Until now ox.toml parsed only [package]/[project]/[schema], and the resolver registered only the embedded stdlib (std::*) plus intra-package paths. The two-package vocabulary journey — the central use case for an ontology-modeling language — was impossible: every vocabulary and every model that used it had to live as sibling modules in one package. The book documented the opposite in detail, “as if real.”

  1. How does a package name and resolve a dependency? What [dependencies] shape, what resolution semantics, what happens across the package boundary for use, qualified paths, and — the acceptance test — dependency-provided introducers (pub metatype / pub metarel)?
  2. What about the registry/version surface the book showed? ufo = "1.0" is a version requirement against a registry that does not exist. Accept it silently (the old failure mode), or refuse it?
  3. What is honest about an ox.toml the compiler does not fully consume — unknown sections, [lattice].max_tier?

Context

The substrate already composes a multi-file package into one artifact: the driver’s pre-elaboration sweep builds a WorkspaceSymbols table over every reachable module, the §3.4 introducer gate (resolve_metatype_introducer/resolve_metarel_introducer) resolves a declaration keyword against the pub metatype/pub metarel declarations visible in the workspace, and the resolver (oxc-resolver) resolves use/qualified paths over the workspace’s file set. The embedded stdlib is folded into that same file set via synthetic <stdlib>/std/<pkg>/root.ar paths (oxc_instantiate::stdlib_source_files). A dependency package is the same shape as the stdlib: a set of pub-surfaced modules folded into the consumer’s workspace.

So the mechanism is not a new subsystem. It is: parse the dependency, load its modules, give them a package-qualified namespace, fold them into the workspace exactly like the stdlib, and teach the resolver to treat the dependency package name as a path root. Everything downstream — the introducer gate, use, qualified paths, module extraction, the .oxbin — composes for free, because all of those already operate over the workspace.


Decision

D1 — Manifest: [dependencies], path deps only

[dependencies]
argufo = { path = "../argufo" }
  • The key is the dependency name the consumer uses as the path root (argufo::Person, use argufo::kind;). It must equal the dependency package’s own [package].name — a mismatch is refused with OE1241, naming both the declared key and the found package name. (A rename-on-import surface is a possible later extension; v1 keeps the package’s published name authoritative, as Cargo does by default.)
  • The only supported source is path = "<relative path to a package directory>". The path is resolved relative to the consumer package directory; it must point at a directory containing an ox.toml.
  • A shorthand string requirement (argufo = "1.0") or any of the registry/VCS keys (version, git, branch, tag, rev, registry) is recognized and refused with OE1240, naming the feature (“registry/version dependencies are not yet supported; cite RFD 0030”) rather than silently ignored. This is the loud-refusal discipline: a documented input is never silently dropped.

D2 — Resolution: a dependency is a package-qualified namespace folded into the workspace

For each declared path dependency, the workspace loader:

  1. Loads the dependency package (its own ox.toml + entry + reachable module closure), exactly as it loads the consumer package.
  2. Re-homes the dependency’s modules under synthetic VFS paths <dep>/<name>/… and assigns each a package-qualified module path rooted at the dependency name: the dependency’s root module becomes module argufo, its submodule endurant becomes argufo::endurant, and so on.
  3. Folds the dependency’s source files into the consumer’s Workspace file set and its modules into the package’s reachable-module list — the same fold the stdlib already gets.

The resolver (oxc-resolver) recognizes a leading path segment equal to a registered dependency name as a root (§3.4): argufo::Person and use argufo::kind; resolve into the dependency’s <dep>/argufo/… namespace, the way std::core::rel resolves into <stdlib>/std/core/…. A dependency root is detected from the synthetic-path pattern — a leading segment argufo is a dependency root iff the dependency’s root file <dep>/argufo/root.ar is in the workspace file set — so no separate dependency-name registry is threaded onto the Workspace input (the same way the stdlib is recognized by <stdlib>/std/<pkg>/root.ar). Inside a dependency, pkg:: anchors at that dependency’s root, so a multi-file dependency’s internal package-absolute paths resolve within its own subtree. Only the dependency’s pub (and pub use-re-exported) surface is visible to the consumer; module visibility rules (§3.1) are unchanged across the boundary.

Because the dependency’s pub metatype/pub metarel declarations are in the workspace symbol table, the §3.4 introducer gate resolves them transparently: after use argufo::kind;, a consumer declaration pub kind Person { … } resolves kind to argufo::kind and elaborates as a concept under that metatype — the acceptance property. A dependency-shipped catalog check (a pub check over iof/meta/specializes) runs on the consumer’s catalog at the consumer’s ox check, because the dependency’s rules and the consumer’s individuals share one elaborated event stream. That is what makes a vocabulary package real, and it is tested (see Consequences).

Transitive dependencies resolve recursively (a dependency’s own [dependencies] are loaded into the same workspace). Cycles are refused loudly with OE1242, naming the cycle path. Two declared dependencies resolving to the same canonical directory are one package (de-duplicated, not an error). The same dependency name resolving to different canonical directories is refused with OE1243, naming both paths.

D3 — Artifact: dependency modules embed into the consumer’s .oxbin (monolithic, v1)

Dependency modules are elaborated into the same event stream as the consumer’s modules and embed into the consumer’s single .oxbin, exactly as the stdlib embeds today (§16.3). v1 is one monolithic artifact per ox build; the per-package .oxc cache + workspace merge (§16.1) remains the reserved v0.2+ shape. Because the dependency’s elaborated content participates in the event stream, module/content hashes (§16.5) incorporate it: a change to a dependency changes the consumer’s artifact_hash.

D4 — No lockfile for path dependencies

Path dependencies are unlocked (Cargo precedent — a path dep is whatever is at that path now). There is no ox.lock for a path-only dependency graph. ox.lock is reserved here for the registry/git story (D1’s refused surface): a versioned/VCS dependency graph needs a lockfile to pin resolved versions; a path graph does not.

D5 — Use-import diagnostics (R-M1): loud, never silent

A use that does not resolve is refused loudly:

  • use a::b::C; where the path does not resolve emits OE0103 (UnresolvedUseImport) at the use itself, with a did-you-mean suggestion over the names visible in the target namespace (the dependency’s pub surface, a sibling module, an intra-package path). Previously a broken use was accepted clean and the only error was a misleading downstream OE0605 far from the cause.
  • A glob use pkg::*; / use argufo::*; resolves correctly (it already does, via the re-export-aware module_exports) — it brings in the target’s pub surface. A glob whose prefix does not resolve is the same OE0103 as a named import. A glob is never a silent no-op.

D6 — Manifest honesty (R-M12)

  • An unknown ox.toml section or key (anything outside the recognized [package]/[project]/[schema]/[dependencies]/[lattice]/[standpoints] surface, or an unrecognized key within them) emits a Cargo-style OW1240 (UnusedManifestKey) warning naming the section/key — it no longer parses with serde silently dropping it.
  • [lattice].max_tier is wired to the §10 tier classifier: it sets the artifact’s tier ceiling, and a declaration whose classified tier exceeds the ceiling is refused at ox check/ox build with OE1230 (TierCapExceeded, previously reserved). The ceiling string is validated against the seven-tier ladder names; an unknown tier name is OW1240.

Rationale

  • A dependency is the stdlib shape. The most robust, lowest-surface-area design reuses the fold-into-workspace machinery the stdlib already proves. The introducer gate, use resolution, qualified paths, module extraction, and the .oxbin all already operate over the workspace; a dependency that lives in the workspace inherits all of them with no new code path. This is why the acceptance property (imported introducers + imported checks fire on the consumer’s catalog) falls out rather than being special-cased.
  • Path-first, registry-refused. Path deps are the v1 vocabulary-authoring need (a Sharpe team’s packages live in one tree). A registry is a distribution-infrastructure project (provenance, resolution, lockfiles) orthogonal to the language. Recognizing-and-refusing the registry keys keeps the manifest forward-compatible and honest: the book can show the shape, and a user who writes it gets a feature-named refusal, not a silent no-op.
  • No lockfile for path deps matches Cargo and the “no ceremony you don’t need” instinct: a path graph is fully determined by the filesystem.
  • Loud manifest, loud imports. Both R-M1 and R-M12 are instances of the program’s silent-accept blocker class. A mistyped import or a typo’d manifest key that the compiler silently swallows is a trap; a warning/refusal that names the thing is debuggable.

Alternatives

  • A separate dependency resolver pass that produces per-package .oxc and a workspace merge (the full §16.1 orchestrator). Deferred: it is the right end state for incremental builds and a registry, but it is a large build-system project; folding into one workspace is correct for v1 and keeps the artifact monolithic (§16.3’s stated v0.x shape).
  • Allow version/git to parse and warn, resolving via path anyway. Rejected: a version requirement that silently resolves to a path is exactly the silent-wrong the program forbids — the user thinks they pinned a version.
  • Rename-on-import (argufo = { path = "…", package = "ufo_foundational" }). Deferred: a useful ergonomic, but v1 keeps the published [package].name authoritative (mismatch = OE1241), matching Cargo’s default.
  • A lockfile for path deps. Rejected (Cargo precedent): a path graph has nothing to lock.

Consequences

  • ox.toml now has a [dependencies] table and recognizes [lattice]/[standpoints]; unknown keys warn (OW1240). [lattice].max_tier is enforced (OE1230, no longer reserved).
  • No change to the Workspace salsa input: dependency roots are detected from the synthetic <dep>/<name>/root.ar path pattern in the file set (mirroring stdlib’s <stdlib>/std/<pkg>/…), so Workspace::new’s signature is unchanged.
  • New diagnostics (allocated in grammar.toml, generated via cargo xtask gen): OE0103 UnresolvedUseImport, OE0104 GlobImportUnsupported (reserved — glob currently resolves; held for a future feature-named refusal of an unsupported glob shape), OE1240 DependencyVersionUnsupported, OE1241 DependencyNameMismatch, OE1242 DependencyCycle, OE1243 DuplicateDependencyName, OE1244 DependencyPackageLoadFailed, OW1240 UnusedManifestKey. OE1230 TierCapExceeded is wired (was reserved).
  • The two-package journey is the living proof: examples/vocab_pkg_v0 (a UFO-shaped vocabulary package with pub metatype kind/category, a pub metarel, and a pub check catalog rule) and examples/vocab_consumer_v0 (a separate package, [dependencies] vocab_pkg_v0 = { path = ".." }, declaring pub kind Person via the dependency’s kind, with the dependency’s catalog check firing on the consumer’s catalog). Both are corpus-pinned and run in the examples harness; GO-journey (a) of the v0.2.1 release gate.

Open questions

  • When the registry/git story lands (its own RFD), ox.lock and the per-package .oxc + workspace merge (§16.1) become live; the path-dep fold described here stays as the unlocked fast path.
  • Should a future rename-on-import key (package = "…") be added once a registry makes the published name vs. local name distinction load-bearing?
  • Should pub(pkg) become a true visibility cut at the dependency boundary (RFD 0022 open question) — i.e. should a dependency’s pub(pkg) items be invisible to a consumer (today pub(pkg) == pub)? v1 keeps the RFD 0022 simplification.

RFD 0031 — The relation-constraint plane + meta-property completion

  • State: committed
  • Opened: 2026-06-12
  • Decides: how a vocabulary author expresses and the elaborator enforces the relation-level compile-time constraints an ontology needs — completing the meta-property and reflection surface the book promised but left unbuilt. Concretely: (1) metarel endpoint-metatype verification (§4.3 “the elaborator verifies the relation’s endpoint metatypes match the metarel’s positions” — #311); (2) relation-signature reflection$rel/$arm catalog atoms so a package-shipped check can quantify over declared relations, their classifying metarel, and endpoint types (#312); (3) per-target axis overrides keyed on a vocabulary’s own axis name (RFD 0027 D2 — R-M9); (4) reflection-intrinsic category errors on struct/enum carriers (§4.4.2 — R-M10); (5) the metaxis typed-axis where refinement parse (§4.1 — R-M11); and (6) relation bracket-cardinality enforcement (§4.3/§5 — #310). Built as the Wave-C arc4-metaproperty work of the v0.2.1 program. The macro atom is explicitly out of scope (its own later design discussion). Builds on RFD 0027 (the meta-property plane, whose D2 per-target plane and D4 reflection surface this completes) and RFD 0023 (reflective TypeRef). Relates to Meta-calculus and Constructs.

This RFD records, as built, the relation-constraint surface the readiness register (.local/research/readiness-2026-06-12) named as the vocabulary-authoring blocker half of the production bar: a vocabulary team (Gustavo/Tiago, building ArgUFO) cannot write the relation-level compile-time constraints their ontology needs — “an ability must inhere via an aspect, not a kind” is unenforceable, and there is no reflection over relations to write such a constraint as a package-shipped check. The substrate the meta-property plane (RFD 0027) built for types is here extended to relations, plus the residual meta-property gaps (R-M9/M10/M11) closed.


Question

RFD 0027 built the meta-property plane for the type tier: metaxes, metatypes, abstract/fixed modifiers, the $meta/$iof/$axis catalog relations, sort-directed meta(t).axis projection. It left the relation tier half-built and three meta-property gaps open:

  1. Metarel endpoints are unverified. pub metarel mediation(mediator: relator, mediated: kind) declares position metatypes, but pub mediation Bad(a: Vehicle, b: Person) — where Vehicle is kind-sorted, not relator-sorted — was accepted clean. The §4.3 promise (“the elaborator verifies the relation’s endpoint metatypes match the metarel’s positions”) was prose only; the position metatype names were parsed and discarded (never persisted on MetarelDeclBody). This is the core ask: an ability must inhere via an aspect, not a kind, and nothing enforced it.

  2. No reflection over relations. $iof/$meta/$axis/$implements reflect over types and traits; nothing reflects over relation arms. A vocabulary package could not write a check that quantifies over declared relations (their classifying metarel, their endpoint types) — so the metarel-endpoint discipline, even once enforced by the compiler, could not be extended or audited by package-shipped rules.

  3. Per-target axis overrides have no surface. RFD 0027 D2 specified per-target MetaProperty assertions (“metatype-level bindings unioned with per-target assertions, per-target taking precedence”), but the only emitter was the hardcoded MLT #[order(N)] magic string. A vocabulary declaring metaxis rigidity for type { … } gave consumers no way to override rigidity on one specific concept. In-body { rigidity::semi_rigid } → OE0001; the attribute form → OE0705.

  4. Reflection on struct/enum is silently clean. §4.4.2 promises a category error (“calling meta() on a struct/enum-declared value is a category error … emits a diagnostic at elaboration”). meta(p) == Point over a struct checked clean — no diagnostic existed.

  5. The metaxis where-refinement does not parse as printed. §4.1’s pub metaxis weight for type = Real where _ > 0.0; → OE0001 (the typed-domain type_expr greedily consumed the trailing where as a refined-type refinement expecting { … }). Only the braced = Real where { _ > 0.0 }; parsed.

  6. Relation bracket cardinalities are silently ignored. pub rel R(...) [1..1] [0..*]; parsed into a CARDINALITY token-soup node that no lowering read (#310). A documented modifier, honored nowhere.


Decisions

D1 — Metarel endpoint-metatype verification (#311)

The metarel’s position metatypes become real on the wire. MetarelDeclBody gains position_metatypes: Vec<Option<NameRef>> — one entry per declared endpoint, the resolved metatype NameRef named in that position (relator, kind, …), or None for a bare-typed position (metarel material(kind, kind) names a metatype per position; a position naming a primordial or a generic param binds None and is unconstrained). The generic stdlib pub metarel rel<E1: metatype, E2: metatype>(E1, E2) binds None at every position — it “accepts any endpoint metatypes” (§4.3), so it imposes no endpoint constraint, exactly as documented.

At relation lowering, the elaborator verifies. For a relation pub <metarel> R(a: A, b: B) classified by a metarel with position metatypes [Some(m₀), Some(m₁), …]:

  • each endpoint concept A’s declared metatype is resolved (the concept’s introducing classifier, already captured by the workspace symbol sweep), and
  • if the endpoint’s metatype is not the position metatype, the relation is refused with OE0631 (MetarelEndpointMetatypeMismatch), naming the position, the expected metatype, the endpoint concept, and its actual metatype.

A None position imposes no constraint. An endpoint whose type is a primordial (Int/String/…) or whose metatype cannot be resolved is not refused here (it is unconstrained or already refused by the §3.4 introducer gate) — OE0631 fires only when both the position metatype and the endpoint metatype are known and incompatible.

Elaboration path (v0). Metatypes are flat in v0 — there is no declared metatype <: graph — so the comparison is metatype short-name identity (MetarelDeclBody.position_metatypes carries the resolved NameRefs for the runtime; the elaboration-time gate compares the short names the workspace catalog resolves both endpoints to). It is still an identity comparison, not a magic string: the compiler never branches on a particular metatype/axis word (the §3.4 / RFD 0027 D6 doctrine — the compiler never branches on user vocabulary). The sub-metatype tolerance (an endpoint metatype that is a descendant of the position metatype satisfies the constraint) is reserved — it activates when metatype subtyping lands; until then the flat case is exact identity. This holds across the dependency boundary: a relation declared in a consumer package against a metarel imported from a dependency is verified against the dependency’s published position metatypes (the cross-package OE0631 path, #311).

D2 — Relation-signature reflection: $rel and $arm (#312)

Two new catalog-closed reflection relations, in the established $iof/$meta/$axis style (abstract reflection primitives, never ontology vocabulary):

  • $rel(r: TypeRef, m: TypeRef) — for every declared relation r (a TypeRef value naming the relation), m is its classifying metarel. Catalog-closed: one row per declared relation.
  • $arm(r: TypeRef, pos: Int, t: TypeRef) — for every declared relation r, pos is a 0-based arm position and t is the endpoint type declared at that position. One row per (relation, position).

Surface spelling mirrors the type-tier reflection sugar: rel(r, m) and arm(r, pos, t) are admitted in any rule body without a use (the rel/arm heads lower to the reserved-head predicates $rel/$arm, exactly as iof$iof). Both are first-class and all positions may be free — enumeration is the intended use, the same justification as $implements. A package-shipped check can now quantify:

// "every endpoint of a `characterization` relation must be aspect-sorted or the bearer kind"
pub check BadCharacterization(r: TypeRef) :-
    rel(r, characterization), arm(r, 1, t), meta(t) == kind, not meta(t) == aspect
    => Diagnostic { ... };

rel here is the vocabulary’s metarel name in value position (a TypeRef), not the stdlib std::core::rel introducer — they are distinct (one is a metarel name used as a value, the other the generic introducer keyword). The reflection head spelled rel(...)/arm(...) is the abstract primitive; resolution distinguishes the value-position metarel reference from the head.

D3 — Per-target axis overrides (R-M9, RFD 0027 D2 realized)

A concept declaration may carry per-target axis assignments in its body, in the same axis::value / axis = literal spelling a metatype body uses:

pub kind Person { rigidity::semi_rigid }          // override the metatype's rigidity binding
pub kind Worker { age: Int, rigidity::semi_rigid } // mixed with field declarations

An axis::value (or axis = literal) clause in a concept body lowers to a per-target MetaProperty event (axis, target=concept, value) — the same channel MLT’s #[order(N)] already emits, now reachable from a generic, vocabulary-named surface. The clause is validated exactly like a metatype binding (axis resolves to a visible pub metaxis whose for targets include type; value in domain — OE0622/OE0623/OE0624; duplicate — OE0625). The effective $axis relation unions metatype-level bindings with these per-target assertions, per-target taking precedence (RFD 0027 D3 — functional per (target, axis), OE0629 the load-time backstop).

The compiler never reads the axis name. The override mechanism resolves the axis to its NameRef and validates against the declared domain of whatever metaxis the vocabulary declared; a string-match on rigidity (or any axis/value name) appears nowhere. A vocabulary that declares metaxis foo for type { a, b } gets per-target foo::a overrides for free, with zero compiler changes — the mechanism is axis-name-agnostic by construction.

D4 — Reflection-intrinsic category error on struct/enum (R-M10)

meta/iof/specializes/extent applied to a carrier that is a struct- or enum-declared type (language-level data, not an ontologically-classified concept) is a category error, refused at elaboration with OE0632 (ReflectionOnUnclassified) — the feature-named diagnostic §4.4.2 promised, parallel to OE1016 (Truth4OfOnStruct). The check fires when the type-position argument of a reflection intrinsic statically resolves to a struct/enum declaration; reflection over concepts (the metatype-classified tower) is unaffected.

D5 — The metaxis where-refinement parse (R-M11)

The typed-domain metaxis body parses = TypeExpr ('where' refinement)? where the refinement is a bare predicate (_ > 0.0, self > 0.0) terminated by ;, or the braced { … } form. The parser stops type_expr from greedily consuming the trailing where: the metaxis-decl rule parses the base type expression without the refined-type where-suffix, then handles where itself (either a { … } block or a bare predicate to ;). Both printed spellings now parse and the refinement lowers into the typed-domain AxisDomainBody::Typed { refinement } already on the wire.

D6 — Relation bracket-cardinality enforcement (#310)

The [lo..hi] cardinality brackets parse into a structured Cardinality { lo, hi } (lo: u32, hi: Option<u32>* = None) per arm, persisted on RelationDeclBody.

  • Max-caps are CWA-checkable and enforced at the write path: an insert/update that would make an entity participate in more than hi tuples of relation R in the constrained position is refused with OE1014 family (the closed-world cardinality gate), feature-named OE1341 (RelationCardinalityExceeded). Max-caps are checkable at ox check/build only when statically decidable; the live enforcement is at write/serve.
  • Min-cardinalities need the evaluation channel. A [1..1] lower bound is an existence requirement that, under the closed-world default, would refuse an entity that does not (yet) participate — but staged construction (classify now, relate later) makes a build-time refusal wrong, and the field-access-time / evaluation-channel emitter that would surface staged incompleteness is RFD 0007’s design and not built in v0 (the same disposition as OE0207’s staged-construction note). Decision: enforce max-caps loudly; min-cards are recognized, validated for well-formedness (lo <= hi), persisted, and refused-or-deferred explicitly — a non-zero lo that cannot be statically discharged emits the reserved OW1342 (RelationMinCardinalityDeferred, a build-time note-severity diagnostic naming the unenforced bound) rather than silently accepting it as enforced. No silent-ignore: the bracket is honored (max) or explicitly flagged-as-deferred (min). Full min-card-under-OWA enforcement is the recorded follow-on, gated on RFD 0007’s evaluation channel.

This composes with D1: the metarel constraint plane (endpoint metatypes) and the cardinality plane together are the relation-level compile-time constraint surface.

D7 — mode un-reserved; dead-reservation sweep (#309)

mode is removed from the lexer keyword list — it lexes as IDENT, so pub metatype mode { … } (the most important UFO vocabulary word) is admitted. The documented graph-traversal mode-spec ::= 'mode' ('walk' | …) surface (§7.4) is unbuilt (allowlisted OE0001, arc6-debt); when it lands it recognizes mode contextually in the role-step position (lex-as-IDENT, match-by-text), the same discipline type/rel already use — no reserved keyword needed. The sweep also un-reserves ordered, a vestigial reservation with zero grammar consumers and no documented surface (order — distinct keyword — backs order by). The other zero-consumer reserved words (walk/trail/acyclic/simple/shortest/union/with/upsert/detach/…) stay reserved: each backs a documented future surface that refuses feature-named-or-OE0001 by design (the coverage allowlist’s burn-down class), so un-reserving them would let a modeler shadow a planned keyword.

D8 — Teaching hints (#313)

Three diagnostic-message improvements, no new codes:

  • A comma-separated refinement constraint list (where { a > 0, b < 10 }) gets a directed hint: “constraints combine with && — write a > 0 && b < 10” (one spelling; comma is not sugar).
  • OE0660 (bare field name in a refinement) gains “did you mean self.<field>?”.
  • The non-existent pub kind X : T = { field: value } declaration shape gets a directed hint naming the two real alternatives (pub kind X : T { field: … } for a typed instance, or a separate pub fact/construction).

Diagnostics inventory

CodeNameTier
OE0631MetarelEndpointMetatypeMismatchmeta-calculus (06xx) — D1
OE0632ReflectionOnUnclassifiedmeta-calculus (06xx) — D4
OE1341RelationCardinalityExceededruntime/write gate (13xx) — D6 max-cap
OW1342RelationMinCardinalityDeferredbuild composition (13xx) — D6 min-card, warning-severity

Out of scope

  • The macro atom — its own later design discussion.
  • Multi-valued axes — RFD 0027’s recorded non-decision stands.
  • Full min-cardinality-under-OWA enforcement — gated on RFD 0007’s evaluation channel; D6 enforces max-caps and explicitly flags deferred min-cards.
  • Cross-module relation-subsumption parent resolution — unchanged from RFD 0005.

Wire / drift-gate note

D1 (position_metatypes) and D6 (Cardinality) add fields to MetarelDeclBody / RelationDeclBody. These are storage-mirror shapes; the @[language_interface] drift gate covers the syntax inductives, not the storage bodies (the storage mirror is a documented known-limitation of the gate, RFD 0027 D9 §note). The Lean storage mirrors gain the fields in the catch-up; the Rust wire is canonical for these runtime-facing bodies.

RFD 0032 — oxup manages editor-extension installation

  • State: committed
  • Opened: 2026-06-13
  • Decides: how the Argon editor integration (the VS Code extension today; Neovim / Vim / Emacs later) is installed and kept in sync with the active toolchain — by oxup, abstracted over editors, rather than hand-installed. Closes the “users must hand-install the VS Code extension” gap. Builds on RFD 0013 (the oxup manager + the argon.sharpe-dev.com CDN) and RFD 0013 (the oxup manager + dist layout).
  • Implements: a new oxup extension (alias ext) subcommand; an EditorIntegration abstraction; a CDN asset path for the .vsix; auto-wiring from oxup init / oxup update.

This RFD records, as built, the editor-extension story. The first cut shipped the VS Code family (VS Code, Cursor, VSCodium, VS Code Insiders) via an --install-extension CLI; Neovim, Vim, and Emacs (#393) now install by file placement — the embedded plugin tree plus a managed config block in the user’s init file — since those editors have no install-CLI contract. Nothing is silently skipped (the no-hollow-features house rule).


Question

Today the Argon VS Code extension (editors/vscode/, id argon-lang.argon) is built by release.yml’s build-vsix job and attached to the GitHub Release. A user who wants it must find the .vsix, download it, and run code --install-extension by hand — there is no version coupling to the toolchain they installed, and nothing refreshes it when they oxup update. We already own the install story for the toolchain (oxup install fetches a version-matched, sha256-verified artifact from the CDN). The editor extension should ride the same rails.

  1. What is the command surface? One subcommand, abstracted over editors, so vim/neovim/emacs can slot in without a new top-level verb.
  2. How does the extension version stay coupled to the toolchain? A user on stable 0.2.1 must get the 0.2.1 extension, not “latest”.
  3. Where does the asset live and how is it verified? Same discipline as the toolchain fetch: immutable versioned CDN path, sha256 sidecar, fail-closed.
  4. What happens for editors we don’t yet support? Loud refusal or silent skip?

Decision

1. Command surface — oxup extension (alias ext)

oxup extension install   [--editor <vscode|cursor|codium|code-insiders|code-server|neovim|vim|emacs>] [--archive <path.vsix>] [--extensions-dir <dir>]
oxup extension uninstall [--editor <name>] [--extensions-dir <dir>]
oxup extension list
  • install (no --editor): auto-detect every installed VS Code-family editor (by its CLI on PATH) and install the extension matching the active toolchain version into each. With --editor, target exactly one. With --archive <path.vsix>, install a local .vsix (offline / a freshly built extension) instead of fetching from the CDN.
  • uninstall: remove argon-lang.argon from the detected (or --editor-named) editors.
  • list: show detected editors and, for each, the installed argon-lang.argon version (or “not installed”).

2. Editor abstraction — EditorIntegration

An enum Editor with a small behavioral surface (oxup/src/extension.rs):

methodmeaning
name() -> &strthe --editor key (vscode, cursor, codium, code-insiders, code-server, neovim, vim, emacs)
detect() -> Option<PathBuf>the editor’s CLI on PATH, or None
install_argv(vsix) -> Result<Vec<OsString>>the exact argv to install a .vsix
uninstall_argv() -> Result<Vec<OsString>>the exact argv to uninstall argon-lang.argon

The VS Code family maps vscode→code, cursor→cursor, codium→codium, code-insiders→code-insiders, code-server→code-server; detect by that CLI on PATH; install via <cli> --install-extension <vsix> --force; uninstall via <cli> --uninstall-extension argon-lang.argon.

code-server (browser-hosted, server-side VS Code — Daytona/ODE sandboxes) is a full member of the family: it honors the same --install-extension/--uninstall-extension contract. A server sandbox usually has only code-server on PATH (no desktop code), so it is in the auto-detect order — but last, after the desktop editors, so a desktop editor wins when both are present. Because the running code-server instance is launched with an explicit --extensions-dir, a fresh code-server --install-extension would otherwise land in the default dir; the optional --extensions-dir <DIR> argument on install/uninstall is appended to the editor argv to target the dir the live server actually reads. The flag is accept-and-passthrough for the desktop CLIs too (VS Code’s code supports it), optional, and omitted by default (the CLI’s default dir). code-server has no macOS .app bundle, so its detection is PATH-only (no bundle fallback).

Neovim, Vim, Emacs have no --install-extension CLI, so they install by file placement (oxup/src/editor_plugin.rs, #393): the plugin sources (editors/nvim/, editors/emacs/) are embedded into the oxup binary, placed into the editor’s native package dir, and the user’s init file gets a managed config block between begin/end sentinels that loads the plugin and points ox lsp at the active toolchain.

  • Neovim$XDG_CONFIG_HOME/nvim/pack/argon/start/argon (auto-loaded by Neovim’s built-in packages), managed block in init.lua.
  • Vim~/.vim/pack/argon/start/argon (the syntax + filetype-detection floor; the Lua LSP client needs Neovim 0.8+), managed block in ~/.vimrc.
  • Emacs~/.emacs.d/argon on load-path, managed block in ~/.emacs.d/init.el.

The block is idempotent and non-destructive: re-running rewrites only the region between the sentinels (one block, never duplicated), leaving hand-written config untouched; uninstall strips the block and removes the placed tree. The sentinels are commented in the init file’s own language (-- for Lua, " for Vimscript, ;; for Lisp) so the line is never a syntax error. The install_argv / uninstall_argv CLI helpers still refuse these editors loudly — they have no CLI contract — and redirect to the file-placement path; nothing is a silent no-op.

Version coupling for file-placement editors. There is no CDN .vsix to address. Instead the coupling is structural: oxup ships from the same release pipeline as the toolchain and carries the same version, so the embedded plugin (including its generated version.lua / argon-version.el stamp) matches the toolchain oxup installs, and oxup update (which self-updates oxup and re-runs the install) refreshes it. The managed block additionally points ox lsp at the active toolchain’s ox, so the editor always talks to the matching language server. Auto-wire stays VS-Code-only: oxup init/update never write into a user’s init.lua/init.el unbidden; file-placement editors install only on an explicit --editor.

The placement plan is constructed and asserted in tests over a temp tree without invoking a real editor; only the write/create_dir_all/remove side effects run at the CLI boundary.

3. Asset source + version coupling

The extension version tracks the toolchain version. In release.yml’s build-vsix job, the resolved $version is stamped into editors/vscode/package.json version before vsce package, and the artifact is named argon-<version>.vsix. publish-dist uploads it (plus a bare-hex .sha256 sidecar) to:

s3://argon-dist-sharpe/editors/vscode/<version>/argon-<version>.vsix
                       /editors/vscode/<version>/argon-<version>.vsix.sha256
CDN: https://argon.sharpe-dev.com/editors/vscode/<version>/argon-<version>.vsix

oxup extension install (no --archive) resolves the active toolchain’s concrete version (read from the installed toolchain’s manifest.toml version, so a stable channel maps to the real 0.2.1), fetches editors/vscode/<version>/argon-<version>.vsix, sha256-verifies it against the sidecar (same fail-closed discipline as the toolchain fetch), writes it to a temp file, and hands that path to the editor CLI’s --install-extension. The .vsix is also still attached to the GitHub Release (the secondary download path).

The CDN base URL is the existing dist_base_url() ($OXUP_DIST_URL, default https://argon.sharpe-dev.com), so a mirror / smoke host is honored end to end.

4. Auto-wire from init / update

After the toolchain is placed:

  • oxup init (non-minimal) detects installed VS Code-family editors and installs/refreshes the matching extension. --no-extension skips it; --minimal already skips it (it doesn’t fetch a toolchain at all). If no editor is detected, print a quiet one-line note — not an error.
  • oxup update refreshes the extension for the channel it updated, but only when the version changed: the installed extension version is recorded in settings.toml ([extension] installed_version), and update re-installs only if the new toolchain version differs.

A failed extension install during init/update is a soft failure (warn, don’t abort): the toolchain is what matters; the extension can be installed later with oxup extension install.

5. Editor-support matrix

EditorStatus
VS Code (code)supported
Cursor (cursor)supported
VSCodium (codium)supported
VS Code Insiders (code-insiders)supported
code-server (code-server)supported — server-side VS Code (Daytona/ODE); --extensions-dir for the live server dir
Neovim (neovim)supported — file placement into nvim/pack/argon/start/argon + managed init.lua block (#393)
Vim (vim)supported — syntax + ftdetect floor into ~/.vim/pack/... + managed .vimrc block (#393)
Emacs (emacs)supported — .el package into ~/.emacs.d/argon + managed init.el block (#393)

Why this shape

  • One verb, editor-abstracted. A single extension subcommand with an Editor enum keeps the vim/neovim/emacs work a matter of filling in install_argv, not adding CLI surface. The no---editor auto-detect mirrors how a user expects “install the extension” to just work across whatever VS Code-family editors they have.
  • Version coupling over “latest”. Stamping the toolchain version into the .vsix and addressing it at an immutable /editors/vscode/<version>/ path means a pinned toolchain gets a matching extension, and the CDN path is 1-year-cacheable like the toolchains. This avoids a “latest extension against an old toolchain” skew once the extension grows toolchain-coupled behavior (LSP protocol versions, server flags).
  • Reuse the fetch discipline. The .vsix fetch reuses dist_base_url() and the same sha256-verify-before-use path as the toolchain, so there is no second, weaker download path.
  • Loud, not silent, for unsupported editors. Recognizing vim/neovim/emacs and refusing with a specific pointer (and an issue number) is the house rule: a no-op that pretends to work is worse than an honest “not yet.”

Out of scope / deferred

  • Windows. v0.2 is macOS + Linux only; the VS Code CLIs exist on Windows but the rest of the oxup layout is unix-only (RFD 0013).
  • Marketplace / Open VSX publish. Argon is Sharpe-internal; the .vsix is distributed via the private CDN + the GitHub Release, not a public marketplace.

RFD 0033 — The ad-hoc query and mutation surface

  • State: accepted — implemented
  • Opened: 2026-06-14
  • Decides: that arbitrary, not-pre-declared (ad-hoc) queries and mutations are a first-class, default-on capability of the Argon runtime — submitted as source text at request time, parsed, lowered, and executed against the loaded module — with a deployment opt-out that restricts a server to declared invocables only. Establishes that the generic submission path is the substrate, and the declared pub query / pub mutate forms are a thin named wrapper over it — not the only door. Builds on RFD 0014 (the serving surface), RFD 0015 (the mutate body / Operation IR), RFD 0020 / RFD 0021 (the Engine/CompiledRule evaluation seam), and RFD 0022 (the build evaluability gate, whose runtime analogue this RFD must define).
  • Implements (as built): the query-provider Schema interface (oxc_types::Schema) with two parity-gated backends — WorkspaceSchema (build-time, over ASTs) and oxc_runtime::ModuleSchema (runtime, over a loaded .oxbin); the checker (oxc-check) fully routed through it; the runtime frontend (oxc-parser/oxc-check/oxc-instantiate now linked into oxc-runtime); Store::eval_{query,mutation}_source (parse → full type-check → lower → run, ill-typed bodies refused and never run); the POST /v1/{query,mutation}/adhoc HTTP surface + ox query --eval CLI; the AdhocPolicy opt-out (default-on); and the build-vs-runtime agreement gate (oxc-runtime/tests/adhoc_agreement.rs) asserting byte-identical diagnostics + lowered IR. The persisted-projection-cache / IVM materialization arc is a separate follow-on, out of this RFD’s scope.

Question

A data system you cannot query ad hoc is not a database. Argon’s design intent — stated repeatedly and recorded since 2026-05-29 — is that the runtime accepts arbitrary queries and mutations at request time, not only the “stored-procedure” pub query / pub mutate declarations that lower into .oxbin. The declared forms are meant to be a convenience layer over a generic ad-hoc path. A deployment may turn ad-hoc off (lock down to declared-only) for safety, but that is a gate you enable, not a default-closed wall.

Today that path is unbuilt at the edges, and — separately — the project’s own notes and one prior analysis have repeatedly mis-described it as “rejected by design.” It is not. This RFD settles:

  1. What the ad-hoc surface is (wire shape, CLI shape, semantics), for queries and mutations together.
  2. How a body submitted as source text is compiled at runtime, given that the compiler frontend is not currently linked into the serving binary.
  3. The resolution context: how names and types in an ad-hoc body resolve against the loaded module rather than a build-time Salsa workspace.
  4. How much type-checking an ad-hoc body receives (answer: the full amount), and what decidability-tier admittance applies at runtime (answer: build-gate parity by default, configurable).
  5. The security model: default-on, the deployment-level opt-out, and affordance parity — ad-hoc is governed by the same uniform capability scoping as declared invocation, never an ad-hoc-specific leash.

Context

The framing matters because it has been wrong. The corrected, code-verified picture:

The reasoner is rule-as-data, and the compile step already runs at request time. In Store::query_body_dispatch (compiler/crates/oxc-runtime/src/lib.rs:67996824) the runtime decodes a query’s AtomIR body + head Term, calls oxc_reasoning::compile::compile_rule(short, &head, &atoms) at dispatch time, pushes the fresh CompiledRule onto the module’s rules, and runs Engine::evaluate(&rules, &mut catalog, …). The engine consumes &[CompiledRule] as plain data; it has no notion of “pre-declared.” The only thing tying this to a declaration is the source of atoms/head: find_query_decls(name) looks them up from .oxbin-loaded QueryDeclBodys rather than from the request.

The mutation interpreter is already general and decl-agnostic. Store::run_body_op (oxc-runtime/src/lib.rs:3548+) interprets an Operation sequence (InsertIof, InsertTuple, Update, For, If, Return, … — oxc-protocol/src/core_ir.rs:389) and does not take the MutationDecl; the decl is consulted only for argument validation and capability checks at the boundary. The storage write methods (emit_iof_assertion, emit_relation_tuple, emit_individual_property_assertion) are origin-agnostic.

So the constraint is not semantic. It is three concrete wiring gaps:

  1. No request field for a body. DispatchDescriptor is { qualified_path, args, return_type } (oxc-serve/src/lib.rs:2671); resolution is query_decls.get(qualified_path) → 404 if absent. There is nowhere to put a body. (The runtime refusal ARGON_RUNTIME_UNSUPPORTED_QUERY_BODY at oxc-serve/src/lib.rs:6749 is a narrower executor gap — field projections in bodies are not yet executable — not an ad-hoc policy.)

  2. The compiler frontend is not linked into the serving binary. oxc-serve and oxc-runtime depend on oxc-reasoning, oxc-protocol, oxc-oxbin (+ storage) — and not oxc-parser, oxc-check, oxc-instantiate, oxc-resolver, or oxc-db (verified in both Cargo.tomls). The runtime can compile pre-lowered IR but cannot turn source text into IR.

  3. Name/type resolution is build-time. The frontend’s full type-checker (oxc-check) is bound to a Salsa OxcDb/Workspace/resolver.

The fourth fact reshapes the whole design and is why this is tractable:

Lowering is already decoupled from Salsa. oxc_parser::parse(source_text: &str) -> Parse (oxc-parser/src/lib.rs:44) is standalone — string in, parse tree out, no DB. Rule-body lowering is body_to_atoms_ctx(list: &SyntaxNode, ctx: &LowerCtx) -> Vec<AtomIR> (atom_lower.rs:49), and LowerCtx (expr_lower.rs:116) resolves names through plain closuresresolve_type: &dyn Fn(&str) -> Option<NameRef>, plus enum-variant and field-optionality resolvers — not Salsa. In oxc-instantiate/src/lower.rs, every &dyn OxcDb use is parse_file(db, file): the DB’s only job in the lowering data path is to produce the parse tree.

The genuinely Salsa-heavy component is oxc-check (reference resolution + full type inference via resolve_path(db, workspace, file, …) and lower_type_expr(…)), and it runs separately, after lowering. So “decouple frontend lowering from Salsa” splits into two very different tasks:

  • (a) Lowering is already call-site-decoupled. The work is to build a LowerCtx whose closures are backed by the runtime Module / .oxbin catalog instead of the build-time file pre-pass. Small.
  • (b) Type-checking is Salsa-bound. Reproducing it at runtime — or deciding ad-hoc bodies get lighter validation — is the large, separable decision.

What the runtime already exposes for (a): Module (oxc-runtime/src/lib.rs) carries concept_id, concept_id_by_short_name, relation_id, ancestor_concept_ids_including_self, resolve_predicate_key, resolve_rule_name, resolve_mutation_invocable, symbol_path, and (today private) declared_field. The .oxbin declaration bodies (oxc-protocol/src/storage.rs) carry field declarations with type expressions, refinement predicates, relation arg concepts/cardinalities, and query/mutation parameter types — encoded as CBOR. The information needed to back the LowerCtx closures exists; Module simply doesn’t yet expose a resolution surface over it (notably: resolving names inside a CBOR-encoded TypeExpr, field-type lookup, and a parameter catalog).

Decision

Adopt a two-tier surface, with the generic path as substrate and declared decls as a wrapper.

Tier A — the generic submission substrate

A submitted body flows through the same runtime seam declared invocables already use:

  • Queries: (head Term, Vec<AtomIR>)compile_rule → appended to module rules → Engine::evaluate → rows. (This is literally the query_body_dispatch path with the IR sourced from the request instead of find_query_decls.)
  • Mutations: Vec<Operation> (+ params) → the existing run_body_op interpreter, under the same atomicity, read-your-writes, and delta-guard contract as declared mutations (RFD 0015 / RFD 0019).

Tier A is reachable in two framings, in priority order:

  1. Source text (the product surface): the request carries an Argon query/mutation body string. The runtime parses and lowers it (Tier B) to the IR above, then runs it. This is what ox query '<body>', a REPL, and an /v1/query HTTP endpoint use.
  2. Pre-lowered IR (the substrate boundary): the IR itself is the unit Tier A executes. It is the internal contract the source-text path compiles down to, and declared decls already produce it. Whether IR is also a public client surface is left open deliberately (§Open) — it is a performance/optimization question (a precompiled/prepared-statement analogue), and the answer should be whatever is correct once the prepared-body / caching design is worked out, not a guess made here. Note that IR submitted directly would bypass the type-checker, so if exposed it must carry its own validation story — another reason to settle it with the performance design rather than now.

Declared pub query/pub mutate become wrappers: their dispatch resolves a name to stored IR and then enters the same Tier A execution. No second engine path.

Tier B — runtime parse + lower + check (the resolution contract)

Parsing is the easy part: oxc_parser::parse(source_text: &str) -> Parse is already standalone (no DB). The hard part — resolving and type-checking the body against the loaded schema — is solved by a single proven pattern, not by carrying source and not by a second checker.

The query-provider pattern (decision #3, refined 2026-06-15). Across every mature separately-compiled language — rustc (.rmeta as a query provider: tcx.type_of(def_id) is answered from local HIR or by decoding metadata, dispatched only by local-vs-extern), Go (go/typesImporter), OCaml/GHC/SML/Scala/F# (rehydrate serialized data into the same Env / TyThing / StaticEnv / typed-tree the checker already consumes) — the dominant, unanimous design is one type-checker whose environment access is an interface, answered either from source (local) or from already-resolved serialized facts (imported / loaded). Nobody re-elaborates the dependency’s source; nobody forks the checker. The PL-theory framing is the same (external prior art, cited as ideas not authority): F-ing modules’ “signatures are views over the kernel’s type structure, not a parallel type system,” and the .olean / .ttc interface-file precedent that the serialized environment is the type-checking source-of-truth.

Concretely for Argon:

  • Introduce a Schema interface — the narrow set of environment-access operations the frontend actually performs: resolve a name to a declared concept/relation/struct/enum; a concept’s fields and their types; subsumption/parent edges; relation argument arities and types; enum variants; query/mutation parameter types; and each concept’s world assumption (CWA/OWA) (so three-valued OWA refinement checking can’t silently diverge — a substrate-research caveat).
  • oxc-parser (standalone), oxc-instantiate body-lowering (already (&SyntaxNode, &LowerCtx), no DB), and oxc-check all resolve through Schema. The build-time backend answers from the Salsa workspace / ASTs (today’s code, behavior unchanged); the runtime backend answers from the loaded module. The inference and lowering logic is shared and untouched — only the environment-access surface is abstracted. This is the rustc local-vs-extern split, not a rewrite of the type system.

The runtime backend reads a projection over the event log — it serializes nothing new. This follows from how Argon storage works today (verified in current code, not assumed): storage is a single append-only axiom_events log (oxc-protocol’s AxiomEvent; the axiom_events table in oxc-storage-pg), and Module already builds its concept/relation/field indexes from that event stream at load. So the runtime Schema is a reader over the catalog projection the Module already builds from declaration eventsnot an embedded copy of source and not a separate schema section. The data it needs (resolved field TypeExprs, parent ids, relation arg types, params, refinement predicates) is already in the .oxbin decl bodies. We do not add a redundant representation of facts the log already holds; we expose them through the interface.

The artifact-identity and drift-guard machinery already exists; the Schema backend keys on it. The separate-compilation literature is unanimous that cross-boundary type identity must be a persistent content hash (rustc DefPathHash + StableCrateId; SML content-derived PIDs), never a structural match or an allocation-order stamp. Argon’s .oxbin already implements this: per-section BLAKE3 content hashes and a composition signature (oxc-oxbin/src/composition_signature.rs, content_hash.rs, section.rs), a multi-axis version preamble with strict-producer/liberal-consumer gating checked at the load site before any body section (versioning.rs; reader.rs), and a load-time tier gate (validation.rs layer1_validOE1204). So the boundary is already guarded two ways — a hard version/format header (deterministic refusal of an incompatible artifact) plus content fingerprints over the sections. The runtime Schema backend identifies its schema by the loaded module’s composition signature and section hashes; nothing new is invented here.

The genuine residual is narrower than “no identity”: artifact-level identity is solid, but it is not yet threaded to per-event / per-symbol identity inside the store — the storage-side gap where module_id is effectively constant, so two schemas’ symbol ids can collide at the event level (a known storage defect). Schema resolution must carry module/artifact identity down to per-symbol resolution; fixing that is shared with the storage-identity work, not additive to it.

Type-checking: full, no shortcuts (decision #1)

An ad-hoc body receives the same, complete type-checking a declared body gets — name resolution, reference checking, full inference — via the same oxc-check logic, now resolving through Schema. There is no “lighter validation” tier and no unchecked-but-executed path; a half-checked ad-hoc surface would be exactly the hollow feature the house rules forbid.

Parity is enforced as a canonical-input contract + agreement test — the same discipline the repo already runs at the Lean↔Rust boundary (the @[language_interface] drift test in oxc-protocol, where one logical contract is checked across two representations). Schema is the only way the frontend may touch the environment — no caller reaches around it to the AST or the catalog (make-illegal-states-unrepresentable) — and a CI agreement test asserts that the same body checked against the build-time and runtime Schema backends yields byte-identical diagnostics and identical lowered IR. Drift is a defect, gated like any spec↔code drift.

Decidability-tier admittance (decision #2)

By default an opted-in deployment admits the same tier ceiling as the build evaluability gate (RFD 0022) — ad-hoc bodies are held to the identical decidability bar as declared ones. The load-time tier gate that enforces parity already exists (oxc-oxbin/src/validation.rs layer1_valid, refusing max_tier_claimed beyond the runtime’s capability with OE1204); an ad-hoc body’s classified tier is checked against the same ceiling. The ceiling is intended to be configurable per deployment (a server may set a lower ad-hoc ceiling for untrusted callers) — that per-call/lenient mode is not yet built (today’s gate is artifact-level strict) — but the default is parity, and a deployment may not silently admit more than the build gate would.

Security: affordance parity, deployment-level control only

The governing principle (decision #4): ad-hoc queries and mutations have the same affordances as everything else. Ad-hoc is not a hobbled subset of the declared surface — it is the surface, with declared forms as the named convenience layer over it. We do not special-case what ad-hoc may express, read, or write relative to a declared invocable. The Postgres test applies: a system you cannot freely query and mutate is not a useful system.

Control is therefore deployment-level, applied uniformly, never an ad-hoc-specific leash:

  • Ad-hoc submission is on by default. A deployment may opt out to restrict to declared invocables only (lock-down), or run read-only (a normal database posture, not an ad-hoc penalty) — these are the same kinds of switches any database exposes.
  • Whatever capability / RBAC / tenant / fork / standpoint scoping exists applies equally to declared and ad-hoc invocation. An ad-hoc mutation that a caller’s capabilities permit is exactly as permitted as the equivalent declared mutation.
  • One capability exception — forget. Physical erasure (forget) is gated on the build-time #[allow_forget] grant, which is a declaration-site capability. A runtime-submitted body has no declaration site and so cannot confer it on itself; an ad-hoc forget is therefore refused (OE0730). This is not an ad-hoc-specific leash on affordance — it is that a request cannot forge a build-time capability grant (the same reason an ad-hoc body cannot, say, mark itself #[brave]). A declared #[allow_forget] mutate still erases; an ad-hoc body cannot. We record this as the deliberate exception to the otherwise-unqualified parity rather than pretend parity is total (originally this section asserted no Forget gate at all — that was the bug, not the code).
  • This is orthogonal to the generic-entity-write denial (POST /v1/entities → 404, oxc-serve/src/lib.rs:9565): that is an untyped-blob REST shape, a different axis. Ad-hoc writes go through the typed mutate/Operation mechanism with the full mutate affordance set. The two must not be conflated.

Forward compatibility: heterogeneous stores (keep this seam clean)

The stated future is specialized stores — relational / columnar / blob — that are “part of the Argon knowledge graph,” queried uniformly, with per-data placement configured in ox.toml. That design is not settled here, but this RFD must not foreclose it. Two principles, grounded in current-repo design intent (RFD 0020) and external prior art (database catalog/connector SPIs; the BYODS work, Sahebolamri et al., OOPSLA 2023):

  • Schema stays strictly store-agnostic. Schema answers type questions only; it must never know where bytes live. Physical placement is a separate layer — RFD 0020’s BYODS (D6: a physical Relation is an interface; representations coexist) plus the RuntimeStorageBackend seam, selected per-relation by ox.toml placement. This is the OBDA shape (data stays in place, queried through the ontology; ox.toml placement is the R2RML analogue), and the catalog/connector SPIs (Calcite Schema/Table.getRowType, Trino ConnectorMetadata) confirm the split: the engine owns the type system; sources map into it and never own planner type semantics.
  • The ad-hoc path lowers to LogicalPlan, not to a single in-memory catalog. RFD 0020 D2 already decided that ad-hoc queries, declared rules, and the type-checker goal all lower to one shared LogicalPlan (the IR scaffolded but currently dead in oxc-reasoning/src/logical/). Lowering ad-hoc bodies to that IR — rather than hard-wiring the current materialize_predicates pull-everything-into-memory model — is what keeps the surface multi-store-ready by construction. When pushdown arrives it follows the proven contract: an optimization never an obligation, negotiated as (handle-that-absorbed-work, remainder) with the residual always re-checkable in-engine (Trino/FDW), capability modeled as binding patterns (a blob/KV store can’t free-scan, TSIMMIS), and shippability gated on determinism + identical both-sides semantics.

This RFD is, in effect, the realization of RFD 0020 D11 (“ad-hoc queries and mutations are first-class … gating is an engine policy, not a language restriction”); its new contribution is the runtime-frontend mechanism (the Schema query-provider, content-addressed identity, parity discipline) that D11 left unspecified.

Rationale

  • Reuse over reinvention. The execution substrate (compile-at-dispatch for queries, the general Operation interpreter for mutations) already exists and already runs at request time. Tier A is mostly routing: let the IR come from a request. This is why “ad-hoc is impossible by design” was always wrong.
  • Lowering is already where we need it. Because parse is DB-free and LowerCtx is closure-based, the runtime lowering path is a Module-backed resolver + a dependency edge — not a rewrite of lowering.
  • One frontend, no drift. Reusing oxc-instantiate lowering and oxc-check type-checking against a Module-backed context (rather than runtime-only reimplementations) keeps build-time and runtime behavior identical, honoring the spec↔code drift discipline. Byte-for-byte diagnostic agreement is the acceptance test.
  • Full parity, no shortcuts. Ad-hoc bodies are type-checked exactly as declared bodies are (decision #1) and hold the same decidability ceiling by default (decision #2). A partially-checked ad-hoc surface would be a hollow feature; we do not ship one.
  • Ad-hoc is the surface, not a sandbox. Declared forms are sugar over the generic path; ad-hoc has full affordance parity (decision #4). Control is deployment-level and uniform, never an ad-hoc-specific restriction.
  • Default-on matches the product. Locking down is a deployment choice, not the substrate’s posture.

Alternatives considered

  1. Declared-only forever (status quo). Rejected: contradicts the stated design intent; “a database you can’t query ad hoc isn’t a database.”
  2. Source text only, IR never public. Likely, but not decided here: whether IR is also a public (prepared-statement-style) surface is folded into the performance/caching design (decision #3, §Open) so the answer is the correct one rather than a guess.
  3. A separate runtime-only frontend fed by an .oxbin catalog (decision-#3 option B). Rejected: faster to stand up but creates a second lowering/checking path that drifts from the build-time one — the exact failure mode the intent-node/drift-gate discipline exists to prevent.
  4. Ship ad-hoc with reduced/“lighter” validation first, full type-checking later. Rejected (decision #1): a half-checked surface is a hollow feature. Full oxc-check parity is in scope from the start, which is what pulls the checker into the runtime frontend.
  5. A special capability leash on ad-hoc writes (extra gates on Update/retract because they are ad-hoc). Rejected (decision #4): ad-hoc has affordance parity; control is uniform and deployment-level. The lone exception is forget, refused for ad-hoc — but that is not a leash on affordance, it is that forget’s #[allow_forget] capability is conferred at a declaration site a request doesn’t have, so the request can’t forge it (see Security).
  6. A generic untyped entity-write endpoint (POST /v1/entities). Rejected/kept-absent: ad-hoc writes belong to the typed mutate/Operation mechanism, not an untyped blob surface.

Consequences

  • New runtime dependencies: oxc-serve/oxc-runtime gain the frontend — oxc-parser, oxc-instantiate, and (per decision #1) oxc-check / oxc-resolver / oxc-types, once their environment access is routed through Schema. This is a substantial change to the runtime’s relationship to the frontend (the runtime/AGENTS.md “the reasoner was not built here” tombstone framing and the oxc-runtime/oxc-serve intent nodes all need updating). Introducing Schema as the sole environment-access contract — with the build-time backend over ASTs and the runtime backend over the event-log projection — is the bulk of the engineering and lands as its own arc before the surface is wired.
  • Artifact identity + drift guard already exist; per-symbol identity is the residual. Artifact identity (composition signature + per-section content hashes) and the version/tier load gates are already built (oxc-oxbin: composition_signature.rs, content_hash.rs, versioning.rs, validation.rs). The runtime backend reuses them. What remains is threading that identity to per-event/per-symbol resolution (the storage-side module_id collision gap) so two schemas’ symbol ids can’t alias — shared with the storage-identity fix, not additive.
  • New Schema-backing Module surface: name/type/parameter/world-assumption/refinement resolution over the CQRS catalog projection (additive; the facts are already in the .oxbin decl bodies — no new serialized representation, no embedded source).
  • New wire + CLI surface: a generic submission request shape and ox query '<body>' / REPL entry (exact shapes in the implementing PRs).
  • Spec/Lean: per the repo workflow, this is language-surface — RFD + reference draft → Lean → code. The reference (spec/reference/) gains an ad-hoc-submission section; the Lean substrate is unaffected in its semantics (an ad-hoc rule is just a rule), but the storage/runtime contract may need to record that evaluation admits request-sourced rules, and the security/opt-out posture should be described where the serving contract lives.
  • The “rejected by design” framing is retired in code comments, AGENTS nodes, and project memory.

Open questions

Decisions #1–#4 are settled above, and the resolution mechanism is settled as the query-provider Schema interface (one checker, build-time backend over ASTs, runtime backend over the event-log projection — the rustc/Go model). What remains genuinely open:

  1. The exact Schema operation set. The minimal trait surface (it must cover name→declaration resolution, field/param types, subsumption edges, enum variants, world-assumption, and refinement metadata) and how much it reuses the indexes Module already builds (concept_ids, relation_signatures, etc.) vs. adds. Identity/fingerprint is not open — the artifact already carries it (composition signature + section hashes); the backend keys on that. Lazy per-name materialization (the Idris .ttc pattern) is a future optimization, not needed for v1 since Module already eagerly indexes the (small) schema.
  2. The performance / prepared-body design (decision #3). The load-bearing open thread: compile-caching of recurring ad-hoc bodies (keyed by body hash + composition signature — the content-hash machinery already exists), whether a public prepared-IR fast-path is the correct surface, and how Salsa incrementality is reused at runtime. The IR-submission question is answered here, not in isolation.
  3. Materialization model. Ad-hoc reads today inherit the full in-memory materialize_predicates build (oxc-reasoning; SemiNaiveExecutor). The intended replacement — a content-addressed, generation-invalidated projection cache (the read-model section is already reserved in .oxbin and invalidation exists in oxc-storage-pg get_projection_cache, but it is not populated; the DBSP/IVM executor is drop-in-ready but gated) — is a real forward arc. The ad-hoc path should target that Engine/projection-cache seam rather than entrench the full-scan, and this overlaps the external/foreign-relation (“market oracle”) thread.
  4. Standpoint / fork / bitemporal scoping. Ad-hoc bodies need the same as_of / standpoint / fork context as declared dispatch; query_body_dispatch currently refuses across-standpoint parameterized bodies (oxc-runtime/src/lib.rs:6787). The ad-hoc path must reach full parity here, so that refusal is a gap to close, not a boundary.

RFD 0034 — Source text encoding and the Unicode lexical policy

  • State: committed
  • Opened: 2026-06-14
  • Decides: that Argon source is UTF-8 and that identifiers are Unicode per UAX #31, comments and string/char literals admit arbitrary UTF-8, and operators/punctuation/keywords stay ASCII (modulo the established // typeset aliases). Records two safety/correctness items as documented fast-follows: NFC normalization at the name-resolution layer (canonical equivalence) and a mixed-script confusable warning (UAX #39). Also records the module-file membership rule (Rust semantics: only mod/use-reachable files are part of a package) and its loud counterpart, OW0710.
  • Implements: §2.1/§2.3 of the reference; the lexer change in oxc-lexer; the reachable-closure workspace build in oxc-workspace; OW0710 (OrphanModuleFile) flipped from reserved to live.

This RFD records, as built, two adjacent lexical-layer decisions that surfaced together while diagnosing a real authoring incident: a tenant ontology package whose editor lit up with “unsupported non-ASCII character” diagnostics pointing at obviously-valid, pure-ASCII rule files.


Question

  1. Encoding. §2.1 already declared source UTF-8, but §2.3 defined identifiers as [A-Za-z_][A-Za-z0-9_]* (ASCII only), and the lexer rejected any non-ASCII byte outside string literals — including in comments. So an em-dash in a // comment was a hard lexer error. What is the real policy?
  2. Identifiers. Should identifiers be ASCII-only, or full Unicode? If Unicode, with what normalization, and how do we keep visually-confusable homoglyphs from silently denoting different names?
  3. Module membership. A .ar file sitting in a package’s source tree but declared by no mod was being lexed, parsed, and checked — and (through Salsa accumulation during cross-module name resolution) its lex errors bubbled up and were misattributed to sibling files. Is a non-mod-reachable file part of the package?

Decision

1. Source is UTF-8; non-ASCII is admitted in identifiers, comments, and literals

  • Comments (//, ///, //!, /* */) and string/char literals admit arbitrary UTF-8. (The lexer already scanned these byte-by-byte; the policy is now explicit and tested.)
  • Operators, punctuation, and keywords are ASCII. The only non-ASCII operator forms are the recognized typeset aliases (U+2291 → <:), (U+22A4 → Top), (U+22A5 → Bot). A non-ASCII codepoint in operator position is still a hard error (OE0001), now reported at the correct file and codepoint.

2. Identifiers are Unicode (UAX #31)

An identifier starts with a XID_Start character or _ and continues with XID_Continue characters (unicode-ident, the rustc-grade table). The ASCII subset is the common case and the recommended style. The token text is the raw source slice, byte-for-byte — see the lexer constraint below.

This follows the Rust/Cargo aesthetic (Rust accepts Unicode identifiers per UAX #31) and keeps the substrate ontology-neutral: a vocabulary authored in a non-Latin script is first-class.

Lexer constraint — token text must equal the source bytes. The parser rebuilds the rowan green tree from token text and derives every node’s text_range() by accumulating token byte-lengths. If the lexer rewrote an identifier’s text (e.g. folding a de-normalized spelling to NFC), the tree’s offset space would diverge from the raw-source offset space that the checker, the LSP LineIndex, and miette all index against — shifting every downstream span. So canonicalization does not happen in the lexer; the token carries the source bytes verbatim.

NFC normalization — fast-follow. Canonical equivalence (precomposed é U+00E9 vs e+combining-acute) should hold: two such spellings ought to denote the same name. Per the constraint above, that belongs at the name-resolution / interning layer (normalize the name key, not the token text) — the rust-analyzer model. Name comparison is currently spread across the resolver, checker, and elaborator on raw .text(), so doing this correctly is its own focused change. Until it lands, identifiers are matched by their exact source bytes (an NFD and an NFC spelling of the same glyphs are distinct names).

Confusable safety (UAX #39) — fast-follow. Permitting arbitrary scripts reopens the homoglyph surface (Latin A U+0041 vs Cyrillic А U+0410 read identically). The decided mitigation is a warning, not a refusal: a mixed-script confusable identifier is reported so the confusion is loud, never silent. It needs the unicode-security / unicode-script tables and a deny.toml license allowance, so it lands as a focused fast-follow. Until then, cross-script confusables are not yet flagged.

3. Module membership is the mod/use-reachable closure (Rust semantics)

A .ar file is part of a package iff it is reachable from the package entry through a chain of mod/use declarations — exactly as a .rs file is part of a Rust crate only when a mod brings it in. A sibling file no chain reaches is not compiled, not checked, not linted, and cannot contribute diagnostics.

The compiled workspace is therefore built from the reachable closure alone. Leaving unreachable files in the workspace was the root cause of the misattribution in the Question: checking a reachable file transitively parsed every workspace file during name resolution, and an unreachable file’s lex/parse diagnostics bubbled through Salsa accumulation onto whichever reachable file triggered the parse.

Loud counterpart — OW0710 (OrphanModuleFile), now live. Rust silently ignores an unreferenced source file (the IDE hints at it); Argon’s loud-not-silent doctrine and the already-reserved §3.1 code argue for surfacing it. We emit OW0710 as a warning (the build stays green, matching Rust’s non-fatal treatment) at ox check/ox build, naming each on-disk .ar under the schema root that no mod/use chain reaches. This is the diagnostic that would have immediately explained the incident (“rel_example.ar is not part of this package”).


Consequences

  • Vocabulary and model packages may use Unicode identifiers (matched by exact source bytes today; NFC canonical equivalence is the fast-follow above).
  • A scratch/tutorial .ar left in a package’s source tree no longer breaks the build with misattributed errors; it is ignored and surfaced as OW0710.
  • The confusable warning is owed; until it lands, a mixed-script identifier is accepted silently.
  • No change to operators/keywords; // aliases preserved.

RFD 0035 — The composable operator-tree execution pipeline

  • State: discussion
  • Opened: 2026-06-15
  • Decides: the realization of RFD 0020’s composable pipeline (D2/D4/D9) that RFD 0021 D1 deliberately reserved“the optimizable LogicalPlan IR is reserved for [an operator-tree] executor when graph-native physical operators + factorization genuinely demand it.” That consumer has arrived (RFD 0036: foreign-source federation + a relation-valued compute operator + a federation-split optimizer rewrite, none of which has a home in the current CompiledRule-direct path). This RFD builds the operator tree as the single shared lowering target for every front-end, a tree-level optimizer, a physical mapper, and a generalized operator-call executor contract — while preserving the proven semi-naive evaluator (RFD 0021) as the physical operator for the recursive/conjunctive core. It also introduces the relation-valued (table) operator IR — the relation→relation node absent from CompiledAtom today — co-designed with RFD 0029’s aggregate surface.

This RFD is co-designed with RFD 0036 (heterogeneous stores), which is its forcing consumer; 0035 is the engine layer, 0036 is the store layer that lands on it. It is Lean-first where it touches reasoning semantics — the executed meaning conforms to spec/lean/Argon/Reasoning/ (Fixpoint.lean, Datalog/Compiled.lean) and is held there by the differential oracle (RFD 0021 D8); the pipeline structure, operators, optimizer, and mapper are engine architecture the Lean does not mechanize (per AGENTS.md scope), settled from first principles here. It commits a plan, folded into the implementing PRs per the repo’s discussion-first practice.

Reconciled with the performance / distribution / consensus research campaign (2026-06-15). D4/D6/D7/D8 below are updated to record the campaign’s findings: the columnar content-addressed segment + IVM maintainer is the primary read-model (D7, a priority inversion — the architecture was already correct, only its sequencing was set without performance data); the analytical/columnar tier is Argon’s own native vectorized engine, not a delegated one (D4/D6 — Argon is the high-performance engine, never a “dumb” forwarder); and the IVM↔oracle equivalence joins the frozen-EDB theorem as a named obligation (D8). The campaign is research and decides nothing; these edits are the cut, settled in discussion.


Question

Argon’s runtime engine has, by deliberate staging, two halves that don’t meet. The reasoner (oxc-reasoning) is a fast, correctness-first semi-naive evaluator over CompiledRule — indexed + persistent arrangements, worst-case-optimal joins on cyclic bodies, CSR index-free adjacency, factorized aggregates, all anchored to a differential oracle (RFD 0021). Above it sits a designed-but-unwired operator-tree pipeline (logical/, optimizer/, physical/, runtime/operators.rs) that RFD 0021 D1 chose not to wire, on a “build-correctly-once” argument: a LogicalPlan that merely round-trips back into the CompiledRule executor is throwaway scaffolding until a real operator-tree executor exists.

That argument was right, and it carried an explicit trigger condition: the tree gets built when graph-native physical operators + factorization genuinely demand it. RFD 0036 is that demand, and sharper than anticipated:

  • a foreign-source scan is a new leaf the optimizer must rewrite filters/projections into (pushdown);
  • federation-split (absorbed, remainder) is a tree rewrite with no home in a flat rule body;
  • a relation-valued compute operator (e.g. k-means over a columnar securities master) reads a relation and returns a relation — no CompiledAtom variant expresses this (the five variants are Predicate, Comparison, Naf, Compute = scalar map, Aggregate = relation→scalar; verified compile/rule.rs:233-283);
  • binding-limited foreign sources need magic-sets/demand — a tree-level transformation.

What is the execution pipeline that hosts all of this — for declared rules, ad-hoc queries, checker goals, and mutations alike — without throwing away the proven evaluator, and without becoming the monolith nous warned against?


Context

Verified current state (against origin/main @ 1bdfa16)

  • LogicalPlan is orphaned, relational-core only. logical/mod.rs:21Scan/Filter/Map/Join/AntiJoin/Distinct/Recurse/Sink, each Tier-tagged; Filter’s predicate is opaque CBOR “pending the CoreIR expression interpreter.” No graph-native, mutation, compute, or foreign nodes. Never instantiated by any front-end (oxc-reasoning/AGENTS.md:24-32 landmine note).
  • The front-ends bypass it entirely. All compilation goes AtomIR → compile::compile_rule → Engine::evaluate directly — verified at ~11 sites in oxc-runtime (checks.rs:628,644; lib.rs:1211,6807 + the .evaluate( sites 6819/7002/7086/7113/7147/7306/7402/7484/7527/7813/7867). Engine::evaluate (oxc-reasoning/src/lib.rs:144) reorders each body (SIP) and dispatches to a TierExecutor.
  • TierExecutor::execute is whole-program. executor/mod.rs:67-85execute(&self, rules: &[CompiledRule], catalog: &mut RelationCatalog, policy: ConvergencePolicy), writes derived facts into the catalog in place. SemiNaiveExecutor is the only real impl (covers Structural/Closure/Recursive); SLG/DBSP/SMT/Kripke/Kora are docstring stubs.
  • The optimizer that runs is one pass over CompiledRule. optimizer/reorder.rs — SIP/bound-set reorder + cardinality tie-break (RFD 0021 D3); it tracks bound: BTreeSet<VariableIdx> (reorder.rs:61) and consumes catalog sizes (reorder.rs:189-195). The OptimizerPipeline over LogicalPlan (optimizer/mod.rs) is part of the dead family.
  • runtime/operators.rs (map/filter/join/antijoin/distinct/integrate/differentiate) is the dead Z-set operator vocabulary — the semi-naive loop implements joins inline; it marks the future IVM boundary.
  • The catalog is rebuilt per query. query_derive → materialize_predicates → RelationCatalog::new(); the Store holds no materialized catalog (RFD 0021 D7). A CatalogEntry carries a tier + a Relation<Vec<u8>> = BTreeMap<Vec<u8>, Weight> (catalog/mod.rs:70, runtime/relation.rs).
  • The freeze discipline already exists — the wall clock is frozen for the duration of evaluate_to_fixpoint (eval.rs:170-174); stable relations’ arrangements are held across iterations (eval.rs:232-235).

What RFD 0021 established (and we keep)

RFD 0021 D1 made CompiledRule the executed form and the “operator pipeline” the set of operators the evaluator runs, deferring the operator-tree executor to its real consumer. D6 holds every operator a pure Z-set→Z-set function so IVM is an additive outer loop. D8 makes the semi-naive/binary evaluator the differential oracle for every optimization. These are load-bearing and survive intact.


Decision

Build the operator-tree pipeline RFD 0021 D1 reserved, as the orchestration+optimization layer above the preserved evaluator. Eight decisions.

D1 — LogicalPlan becomes the single shared lowering target (RFD 0020 D2 realized)

Every front-end — ad-hoc query, declared rule, compiler/type-checker goal, and mutation — lowers into one LogicalPlan. The ~11 direct compile_rule → Engine::evaluate call sites are replaced by lower-to-LogicalPlan → optimize → map → execute. CompiledRule is reclassified as the physical form of a Datalog-fixpoint sub-plan (the mapper’s output for Recurse/conjunctive nodes), not a front-end target. This is precisely the inversion RFD 0021 D1 said to perform “when a consumer demands the tree”: the tree is no longer scaffolding because it now carries front-ends a flat rule body cannot (foreign scans, table operators, federation rewrites, checker goals).

D2 — The evaluator is preserved as the fixpoint physical operator; the tree is coarse-grained

The proven eval.rs (WCOJ, CSR, persistent arrangements, factorization, oracle-validated) is not reified into per-join boxed operators. It is the physical implementation of a Recurse/conjunctive Datalog node. The operator tree reifies inter-operator / source / compute / recurse / sink structure; within a Datalog-fixpoint node the fused evaluator runs unchanged. Rationale: fine-grained reification would regress the tight semi-naive loop and dissolve the WCOJ/CSR/factorization fusion the oracle proved correct — discarding RFD 0021’s investment for no gain. Coarse reification honors D1’s “no throwaway” and keeps the engine.

D3 — The frozen-EDB materialization discipline is the composition mechanism

A non-Datalog sub-plan — a foreign scan (RFD 0036 D3), a relation-valued table operator (D4) — is realized by materializing its result into the RelationCatalog as a frozen EDB, after which the fixpoint operator consumes it natively. This generalizes the existing today()-pin (eval.rs:170-174) and stable-arrangement (eval.rs:232-235) discipline from a scalar/relation to any externally-produced relation, and it is the reason cross-source joins need no new physical join operator in v1 — the foreign/computed slice becomes an ordinary CatalogEntry and the existing evaluator joins it as it joins any EDB.

Soundness. The mechanized fixpoint operators range over Interp Atom = Set Atom and never inspect provenance — TP (Datalog/Program.lean:73) and gamma (the GL-reduct least model, Program.lean:107); the compiled engine’s immediate-consequence step equals TP over its grounding via the bridge theorem fire_eq_TP (Datalog/Compiled.lean:232), so provenance-freedom carries to the executed form. A frozen externally-produced atom is therefore semantically indistinguishable from a native one. The only Lean obligation is a semantics-preservation theorem — “a frozen slice injected as an EDB yields the same model as a native EDB of the same extent” — statable against Fixpoint.lean/Compiled.lean with no new machinery (a net-new C12 deliverable; the static case is immediate, the computed case — a frozen relation whose production is itself an inner fixpoint — is the one that needs the statement, see D4 and RFD 0036 D4).

D4 — The relation-valued (table) operator IR, co-designed with RFD 0029

Introduce the missing lowering target: a logical node (Apply / TableOp: relation(s) → relation) and a matching physical contract. It is the home for graph algorithms, windowing/ranking, and foreign analytical compute (RFD 0036’s k-means). It is co-designed with RFD 0029’s aggregate surface: an aggregate (relation→scalar) is the degenerate codomain of a table operator (relation→relation); the two surfaces share one design so a second seam cannot drift from the first. This is a language-level capability (table operators are wanted independently of federation), not federation plumbing.

A table operator’s physical realization is routed by the mapper (D6) to a tier executor; its result is materialized as a frozen EDB (D3). Determinism gate (net-new): a table operator admitted into a fixpoint position must be deterministic-given-its-frozen-inputs. The gate has two faces, and the distinction is load-bearing: for an in-engine operator it is statically checked (the engine sees the operator’s definition); for an opaque foreign compute provider Argon cannot verify determinism by inspection — it is contract-asserted (the connector declares it, as Soufflé functor-purity is author-asserted), and an operator that does not declare determinism is refused in a fixpoint position (RFD 0036 D3). Non-deterministic production (k-means training; any stochastic operator) runs outside the fixpoint and contributes only a frozen, content-addressed artifact; the deterministic re-entry (predict/assign) is what enters the fixpoint (the train/predict split — RFD 0036 D3). A non-deterministic operator in a fixpoint position is refused, never silently admitted (C9). The well-posedness order is fixed: this atom kind gates the executor contract (D6) and the freeze theorem (D3) — there is nothing to specify for an operator that cannot be named in the IR.

The analytical tier is Argon’s own native engine, not a delegated one (updated per the performance campaign, 2026-06-15). The table-operator / analytical-tier executor — vectorized columnar scan / filter / join / aggregate over the segment read-model (D7) and over foreign columnar sources — is built as Argon’s own native physical operators (extending RFD 0021’s engine). An off-the-shelf engine (DataFusion, DuckDB, Polars) is not Argon’s analytical engine; at most its TableProvider is a connector interface shape (RFD 0036 D3), or a clearly-temporary operational bridge — never the permanent execution engine. Two grounded reasons our own engine is forced, not merely preferred: (1) the segment must carry PosBool(M) why-provenance + the Governatori proof_tag + a BitemporalExtent inline (RFD 0036 D7), which every off-the-shelf columnar engine — provenance-free — structurally cannot; (2) the campaign’s measured result is that vectorized-vs-compiled is a small constant and the real cliff is representation (columnar), so a native vectorized engine lands within a small constant of DuckDB/DataFusion while owning the whole stack (provenance, the 4-axis segment, the Lean-conformant fixpoint). The CP3 cut is non-negotiable in either case: a SQL-style linear recursion engine cannot host Argon’s stratified semi-naive fixpoint, so Recurse/AntiJoin/Distinct/WFS never leave the native core.

D5 — The optimizer moves onto the tree

The live SIP/bound-set reorder (reorder.rs, over CompiledRule) is lifted to operate over LogicalPlan, preserving its bound-set propagation (bound: BTreeSet<VariableIdx> — the binding-pattern substrate RFD 0036 D3/D4 consume) and its cardinality tie-break (RFD 0021 D3). New passes land as semantics-preserving tree rewrites: predicate/projection pushdown (lifting RFD 0021 D3’s projection-collapse to the logical layer), magic-sets / demand transformation (the bounded-demand substrate RFD 0036’s binding-limited foreign sources need), federation-split (RFD 0036 D3 — folding Filter/Map into a ForeignScan leaf with a three-valued verdict), and stratum split + tier assignment. The OptimizerRule / OptimizerPipeline traits (optimizer/mod.rs) are made real. Every pass is a semantics-preserving rewrite, enforced as a differential-test obligation (D8): optimized plan ≡ unoptimized plan on generated inputs.

Hard prerequisite — the logical-layer expression interpreter. LogicalPlan::Filter carries its predicate as opaque CBOR today (logical/mod.rs:28, “pending the CoreIR expression interpreter”). Predicate pushdown, federation-split, and table-operator predicates all require a predicate the optimizer (and a connector — RFD 0036 D3 apply_filter) can inspect; an opaque blob cannot yield a three-valued pushdown verdict. So building the logical-layer expression interpreter that replaces the opaque CBOR is a sequencing prerequisite for these passes — not a deferrable open question. It lands before federation-split (RFD 0036 D3) is more than a stub.

D6 — The physical mapper (1:N) + the generalized operator-call executor contract

A physical mapper lowers an optimized LogicalPlan to a PhysicalPlan — per-node physical-operator choice + per-node executor assignment. The whole-program TierExecutor::execute(&[CompiledRule], …) (executor/mod.rs:79) generalizes to an operator-call / sub-plan dispatch so that a Recurse/conjunctive node routes to the semi-naive (later SLG) executor; a Scan to a catalog read and a ForeignScan to a connector (RFD 0036 D3); a table-operator node to a compute/analytical tier executor (RFD 0036 D3). Tier stays compile-time metadata on nodes (the Tier tag LogicalPlan already carries), never a runtime profile flag — the nous anti-pattern (RFD 0020 D9). The existing SemiNaiveExecutor is the first physical executor under the generalized contract; the SLG/DBSP/SMT/Kripke/Kora stubs slot in unchanged in shape. This is RFD 0003’s TierExecutor seam, generalized from “reasoner backends” to “any physical-plan node.”

Placement as a permanent per-workload router (updated per the performance campaign, 2026-06-15). Per-node executor assignment composes with [placement] (RFD 0036 D6): a node’s workload is routed to the store/engine that best serves it. Argon’s own engine is the first-class default; a specialized external store/engine is chosen when it is genuinely optimal for that workload (sub-ms operational point-lookup, a >TB columnar source Argon does not own). This dual stance — Argon is a first-class engine and a permanent orchestrator over heterogeneous stores — is permanent architecture, not scaffolding (RFD 0036 Decision lead): different workloads require different stores now and forever, while Argon itself stays a high-performance engine that never pawns off its own core.

D7 — The columnar content-addressed segment + IVM maintainer is the primary read-model; the frozen path is its correct fallback

Every operator stays a pure Z-set→Z-set function (RFD 0021 D6), so IVM is an additive outer loop — and that purity is exactly what makes the read-model cheap to wire and what keeps it bit-identical to the oracle.

The primary read-model (updated per the performance campaign, 2026-06-15). The persisted read-model is a columnar, content-addressed, immutable segment, maintained incrementally by the currently built-but-dead DBSP operators (runtime/operators.rsintegrate/differentiate/distinct, zero forward-path callers today) so that a mutate produces a delta, not a full catalog rebuild (today every mutate evicts the cache and re-runs the whole fixpoint). This is the convergence point of the campaign’s single-node-execution, storage, and IVM findings — one artifact seen from three angles — and it is the highest-leverage performance work, single-node-meaningful before any S3 or distribution exists. The single-node cliff the campaign measured is row-at-a-time over BTreeMap + per-tuple CBOR decode, which a columnar segment captures most of independent of execution model; so the segment is columnar (decode-once, keep-decoded, keyed by content_id). Retraction (statute sunsets, corrections) is mandatory for Argon and is what forces a real IVM algorithm — the algebra is settled by the prior IVM trilogy (DRedc / two-semiring DBSP; the maintenance loop + segment contract is the work, not the algebra; see RFD 0036 D7/D9). This is Argon’s own engine’s read-model, built natively (D4/D6), not delegated.

The frozen path is its correct fallback, not the headline. Lower → optimize → map → execute with non-Datalog inputs frozen-materialized per query (D3) is the correct fallback the segment is maintained against (the RFD 0021 D8 “genuine mechanism + correct fallback” discipline). What would be hollow is a LogicalPlan that round-trips into the old path, or federation that only works on an inert read-model — we ship neither. (An earlier draft of this RFD called frozen-per-query “the complete mechanism” and IVM “a named subsequent optimization”; the campaign inverts that priority while keeping the architecture — the Z-set purity here is precisely what makes the inversion free.)

D8 — Correctness methodology carries over: the differential oracle is load-bearing

The semi-naive/binary evaluator stays the differential oracle (RFD 0021 D8). Every optimizer pass (D5) and every physical mapping (D6) is diff-tested identical to the unoptimized/oracle path on generated inputs; the pipeline’s own correctness is a test, not an argument. No optimization ships without the oracle. Library discipline unchanged: no unwrap/expect/panic, BTreeMap/BTreeSet only, fmt + clippy clean, cargo nextest-green.

The IVM↔oracle equivalence obligation (added per the performance campaign, 2026-06-15). The incremental read-model maintainer (D7) introduces one net-new proof obligation, the CP3 hinge: maintaining the read-model M under a committed delta Δ must yield exactly the least fixpoint over the original EDB extended by Δ

maintain(M, Δ) ≡ lfp T_P (E ⊎ Δ)

— provenance- and time-free, on every mutation. The maintainer’s internal (time, diff, iteration) bookkeeping is maintenance state, not meaning; distinct projects the timestamped trace down to the reference Set Atom semantics (Program.lean:73). v1 discharge is the differential oracle: the maintainer’s output is diff-tested identical to a full semi-naive recompute over generated mutation sequences (the same discipline that proves wcoj ≡ binary). The Lean-level theorem joins the frozen-EDB preservation theorem (D3) as a named obligation, statable against Fixpoint.lean/Compiled.lean. No prior IVM system has this theorem because none maintains against an external reference semantics; provenance is preserved through the maintainer by the two-semiring split, so the distinct collapse and the provenance DNF do not fight (RFD 0036 D7).


Rationale

  • The trigger condition is met, not invented — and this RFD stands on its own. The table operator (D4) is wanted independently of federation — graph algorithms, windowing/ranking, ML all need a relation→relation node that CompiledAtom lacks — so 0035 has standalone motivation even while RFD 0036 is still in discussion. RFD 0021 D1 reserved the operator tree for the consumer that genuinely needs it; RFD 0036 is a (sharp) consumer, but not the only justification. Building it now is the staging RFD 0021 designed for — not a reversal.
  • Preserve the engine, build the layer above it (D2/D3). The freeze-into-catalog discipline lets the operator tree be an orchestration/optimization layer that produces frozen EDBs, leaving the proven fused evaluator as the fixpoint physical operator. We get the tree’s expressiveness without discarding RFD 0021’s WCOJ/CSR/factorization/oracle investment.
  • One IR, several physical backends (D1/D6). Unifying the logical layer is what lets one engine serve rules, ad-hoc, checker goals, and mutations coherently; specializing the physical backends (semi-naive, connector, compute tier) is what keeps each role fast. Share the meaning, specialize the mechanism (RFD 0020 D3).
  • The table operator is a language gap, not a federation gap (D4). Relation→relation operators (graph algorithms, windows, ML) have no lowering target today; designing the IR coherently with RFD 0029’s aggregates is the honest fix and prevents a second drifting surface.
  • Correctness stays a test (D8). Anchoring every pass and mapping to the oracle is what made RFD 0021 transformable under load without regressions; the same discipline is why the pipeline can be built correctly-once.

Alternatives considered

  • Wire a LogicalPlan that round-trips into the CompiledRule executor. Rejected — RFD 0021 D1’s original reason holds: throwaway the moment a real operator-tree executor exists. We build the executor (D6), not a round-trip.
  • Fine-grained operator reification (every join a boxed operator; runtime/operators.rs made live for evaluation). Rejected (D2): regresses the fused semi-naive loop and dissolves the WCOJ/CSR/factorization fusion. runtime/operators.rs stays the IVM-boundary vocabulary (D7), not the query-evaluation path.
  • Keep the direct compile_rule path; bolt federation onto it. Rejected: a flat rule body cannot host a foreign-scan leaf, a federation-split rewrite, a table operator, or a checker goal — it is exactly the monolith nous warns against.
  • Treat the persisted read-model + IVM as a someday-optimization. Rejected after the performance campaign (D7): the columnar content-addressed segment + IVM maintainer is the primary read-model and the highest-leverage work; frozen-per-query is its correct fallback, not the headline. The architecture (pure Z-set so IVM is additive) was already right; only the priority was wrong.
  • Delegate the analytical tier to an off-the-shelf engine (DataFusion/DuckDB as Argon’s execution engine). Rejected (D4): forced by the inline-provenance segment requirement and unjustified by perf (vectorized-vs-compiled is a small constant). Off-the-shelf engines are a connector shape or a temporary bridge, never Argon’s own engine.
  • Cascades optimizer from day one. Deferred (RFD 0020 D8): rule-based tree passes (D5) suffice until the plan search space justifies a memo/cost-model engine.

Consequences

  • Code structure. logical//optimizer//physical/ become the real pipeline; the front-end call sites in oxc-runtime re-point to lower-to-LogicalPlan; reorder.rs is lifted onto LogicalPlan; TierExecutor generalizes to operator-call dispatch; a new table-operator logical node + CompiledAtom/physical contract lands (D4); SemiNaiveExecutor becomes the fixpoint physical operator under the generalized contract. eval.rs and its oracle stay.
  • Lean / conformance. The IR’s reasoning semantics conform to spec/lean/Argon/Reasoning/; the frozen-EDB semantics-preservation theorem (D3) is a net-new, statable obligation. Pipeline structure/optimizer/mapper are engine architecture (outside mechanized scope) and are conformance-tested against the semi-naive oracle (D8).
  • Spec / reference. An engine-architecture chapter (spec/reference/src/{17,19}) lands once the pipeline is built; RFD 0021’s “as-built engine” framing is contextualized as the physical layer beneath this logical layer.
  • Coordination. The RelationCatalog public-API seam (RFD 0021) stays the boundary with the write-path track; RFD 0036 lands its connector/compute/store layers on D3/D4/D6.

Open questions / tracked-future

  • The exact table-operator IR shape and its coherence with RFD 0029 — the logical node, the CompiledAtom variant, and the shared aggregate↔table-operator design. The novel core; gates the executor contract and the freeze theorem (D4).
  • The generalized operator-call executor contract signature — how TierExecutor moves from whole-program to sub-plan/operator dispatch without losing the per-stratum stratification it does internally today.
  • Checker goals as LogicalPlan — whether oxc-check issues bounded LogicalPlan goals in this RFD or is designed-for (RFD 0020 D3 role 3 is design-for-now; the seam is D1).
  • Cost model / statistics — cardinality/selectivity over a mutating graph for the reorder + future federation-split + eventual Cascades (RFD 0021 left this open; RFD 0036’s foreign sources have no in-engine cardinality, sharpening it).
  • The magic-sets / demand interface RFD 0036’s binding-limited foreign sources consume (D5) — its precise shape (demand-stratify vs monotone bounded re-consultation) is settled with RFD 0036 D4.
  • Logical-layer expression interpreternot an open question but a stated prerequisite (D5): the opaque-CBOR LogicalPlan::Filter.predicate must become an inspectable expression before pushdown / federation-split / table-operator predicates work. What remains genuinely open is only its expression coverage (which operators/forms the logical layer interprets vs. defers).
  • The IVM maintainer’s checkpoint cadence (D7) — how often a mutation mints a new immutable segment: per-mutation (segment churn + GC pressure) vs batched (the in-memory materialization must then survive restart some other way, reintroducing a durability seam). A genuine open the loop surfaces; settled with RFD 0036 D9.
  • Enforcing the CP3 cut if an off-the-shelf engine is ever used as a bridge (D4) — whether confining it to non-fixpoint plans is a structural guarantee (fixpoint operators unrepresentable in the lowered sub-plan) or advisory Tier metadata; and the provenance-injection step a foreign analytical result needs to re-enter the provenance-carrying fixpoint (the frozen-foreign-EDB marker, RFD 0036 D7).

RFD 0036 — Heterogeneous and specialized data stores

  • State: discussion
  • Opened: 2026-06-15
  • Decides: how specialized / heterogeneous stores — a columnar analytical store (a >TB “market oracle” securities master), a blob store, a DynamoDB/KV store — become part of the Argon knowledge graph and are queried through it, alongside the default store (in-memory / Postgres). Establishes three distinct patterns (foreign federation, external-valued attributes, persistence-backend swap), the connector SPI, the mapping & placement surface, the recursion×federation discipline, the world-assumption-as-tier-input rule, and the provenance/freshness contract. Built on RFD 0035 (the operator-tree pipeline — its forcing consumer and substrate), RFD 0033 (the store-agnostic Schema query-provider and the ad-hoc surface), and RFD 0020/RFD 0021 (the engine).
  • Grounded in: a six-track literature campaign (polystore/federation, OBDA/VKG, pluggable persistence, recursion×federation, compute pushdown, consistency/provenance) whose decision-ready briefing this RFD selects from. External systems and PL theory are cited as evidence, never authority; current-repo code is cited at verified anchors.
  • Depends on: RFD 0033 (PR #401) — the store-agnostic Schema query-provider this RFD’s C1 rests on. Merge #401 first; on this branch the 0033-*.md links are forward-referential by design, not dangling.

This RFD is co-designed with RFD 0035: 0035 is the engine layer the foreign data flows through, 0036 is the store layer. Per the repo workflow this is language-surface + engine architecture (RFD + reference → Lean where a new semantic notion appears → code); the only net-new semantic obligation is the frozen-foreign-EDB preservation theorem (RFD 0035 D3), expressible against spec/lean/Argon/Reasoning/.

Reconciled with the performance / distribution / consensus research campaign (2026-06-15). D2/D3/D4/D7/D9/D10 are updated to record the campaign’s findings: the read-model is the primary path as a columnar content-addressed immutable segment maintained by IVM (D9), the >TB analytical bar is met by pushdown + push-compute-to-data + Argon’s own vectorized streaming rather than a BTreeMap freeze (D4), the segment carries provenance inline via a two-semiring maintainer (D7), the write spine is a scalar root pointing at an immutable content-addressed manifest (D9), and the financial path is three workloads with Argon as the read/OLAP DB beside a federated ledger (D10). The governing principle below frames all of it. The campaign is research and decides nothing; these edits are the cut.


Question

Argon’s value is the ontology + reasoning layer over data. Some of that data is too large, or too workload-specialized, to live as Argon axiom events: a >TB columnar securities master built for analytics; a blob store for documents; a KV store for point lookups. The intent — stated since the ad-hoc work began — is that such stores are part of the knowledge graph and queryable through Argon naturally, joined against native facts and reasoned over, without copying their data into Argon’s log.

The forcing example: “flag every security in the same k-means cluster as a known-distressed security, where clusters are computed over the market oracle’s return vectors.” distressed/1 is a small native relation; returns(...) lives in the columnar store; cluster_of(...) is computed by the store’s own engine. This single query exercises every hard axis: foreign data in a native shape, a relation→relation compute operator, a federated join, recursion-adjacency, world assumptions, and cross-store provenance.

What is the design by which heterogeneous stores join the knowledge graph — the connector contract, the placement/mapping surface, the recursion discipline, the world-assumption handling, and the provenance/freshness model — built correctly and completely, with no hollow path?


Context

What RFD 0035 provides

The operator-tree pipeline: LogicalPlan as the shared lowering target (D1), the frozen-EDB materialization discipline (D3 — a non-Datalog sub-plan’s result is materialized into the catalog as a frozen EDB the evaluator joins natively), the relation-valued table operator IR (D4), the tree optimizer with pushdown + magic-sets/demand + federation-split (D5), and the generalized operator-call executor (D6). 0036 attaches its connector/compute/store layers to these seams. Without 0035 there is no place to attach (verified: the live path is AtomIR → compile_rule → Engine::evaluate; LogicalPlan is orphaned).

What the substrate already provides (verified, not assumed)

  • AxiomEvent already is the native provenance/freshness token (oxc-protocol/src/storage.rs:1508): content_id (BLAKE3 — C10) + the four scope axes (tenant/fork/standpoint/module — C8) + a bitemporal extent + a proof_tag (defeasibility) + a derivation (PosBool(M) DNF why-provenance). Federation adds one leaf, not a new token.
  • Per-concept world assumption is already mechanizedWorldAssumptionMap, Locality/Cwa.lean, with cwa_owa_transfer proven (CWA-true ⇒ OWA-true; the reverse proved unsound), and diagnostics OE0901/OW0902 reserved-but-unbuilt. Argon is ahead of deployed OBDA here (uniformly OWA; Ontop never implemented closed predicates).
  • The freeze discipline is in the engine (eval.rs:170-174 clock pin; 232-235 stable arrangements) — RFD 0035 D3 generalizes it.
  • No store config exists. ox.toml (oxc-workspace/src/lib.rs:160-168) parses package/project/schema/dependencies/lattice only. The .oxbin/pg projection cache exists but is inert (None everywhere; oxc-storage-pg get/put_projection_cache tested, never called by the evaluator).
  • A RuntimeStorageBackend trait exists (oxc-runtime/src/lib.rs:2890) — a sync, in-process replay seam; PgStorage is a separate, async, CQRS-shaped durable layer reached by hydrate-then-replay (it does not implement that trait).

Decision

Governing principle — Argon is a first-class engine and a permanent orchestrator (both, forever). Two things are true at once and neither subsumes the other. (1) Argon itself is a high-performance database engine — never “dumb,” never a thin forwarder, never pawning off its own/core performance to another DB; the end-state includes Argon’s own custom engine, and for data Argon owns and reasons over, Argon’s own engine does the work. (2) Argon is also a permanent orchestrator/federator over heterogeneous external stores — now and forever, even after that engine exists — because different workloads genuinely require different stores (sub-ms operational KV, >TB columnar analytics, blob stores, time-series). This is first-class permanent architecture, not scaffolding to outgrow. The router between them is [placement] (D6): per-workload, which store serves it — Argon’s own engine the first-class default, a specialized external store chosen when genuinely optimal. The only thing that lessens over time is the current degree of reliance on externals (and outsourcing the durability backend for Argon’s own log, à la Datomic); the orchestration capability is permanent. “Don’t pawn off” is therefore narrow: it forbids delegating Argon’s own core performance and forbids Argon being a dumb passthrough — it does not mean retreating from external stores.

D1 — Three patterns, named and kept separate

PatternWhat it isDriving exampleSeam
P2 — Foreign federation (centerpiece)Foreign data in its native shape, mapped into the ontology, queried through Argon, never copied into the logthe market oracleconnector SPI (D3) + frozen-EDB (RFD 0035 D3)
P3 — External-valued attributesA property’s value is a content-addressed handle; bytes live in a blob storedocument on an individualRef<Blob> (D8)
P1 — Persistence swapArgon’s own event-log/read-model lives in a chosen durable backendlog in DynamoDBthin durable seam (D9)

Conflating them is rejected: P2 data has no axiom-event semantics (no bitemporality, polarity, standpoints, defeat tags); forcing those onto it is the trap. P1 is an Argon-own-model persistence concern; P3 is a typed value with a remote byte-store.

D2 — Foreign data is a virtual extensional predicate, outside the log, frozen per query; read-only in v1

A foreign relation is a virtual EDB produced on demand by a connector and materialized once into the catalog as a frozen EDB for the query’s duration (RFD 0035 D3). Argon stores only the mapping + a connection contract, never the foreign data. The axiom-event log stays source-of-truth for native facts; the foreign store for its own; they meet at query time. Foreign stores are read-only in v1 — and this is the correct steady-state architecture, not a limitation: per-entity ACID + idempotent cross-entity messaging is the right shape (Helland); 2PC is an anti-availability protocol that only works inside one store (Gray/Lamport). Cross-store write transactions stay out of scope, with the sole admissible exception being P3’s non-transactional content-put (D8). This is the realization of RFD 0020 D11 (“ad-hoc queries/mutations first-class … gating is engine policy”) extended across the federation boundary.

The BTreeMap freeze is for small slices and is not the >TB performance path (updated per the performance campaign, 2026-06-15). Materializing a slice into an in-memory BTreeMap CatalogEntry is correct for a small federated result (or as the fallback), but it is the O(database) cost CP2 forbids for a >TB store — and you cannot freeze a >TB slice into a BTreeMap. The >TB analytical bar is met by never moving the data: push filters/projections/aggregates down (D3), run analytical compute in-store where the store can (the k-means train/predict split — only the small result returns), and for what must run in Argon, stream it through Argon’s own vectorized engine (RFD 0035 D4) over columnar segments/sources — never a BTreeMap ingest. Only the small result re-enters as a frozen EDB to join native facts. Argon provides the performance; a “dumb” store provides only bytes — Argon is never the dumb layer (Decision lead).

D3 — The connector SPI: per-operation, three-valued pushdown verdict + binding patterns, object-safe

A foreign store implements an object-safe ForeignRelation trait (held as Arc<dyn> in a CatalogEntry, so one catalog holds heterogeneous connectors — DataFusion’s Arc<dyn TableProvider> precedent):

  • schema() — answers type questions into Argon’s vocabulary; never owns type semantics. Schema stays store-agnostic (C1, RFD 0033) — the connector sees the IR, never Schema.
  • binding_patterns() — capability as {b,f}^n adornments (C4; Rajaraman–Sagiv–Ullman): ff… free-scannable, bf… requires a bound key (a blob/KV store cannot free-scan). Consumed by the optimizer’s bound-set propagation (the same bound: BTreeSet<VariableIdx> reorder.rs already tracks, lifted to LogicalPlan — RFD 0035 D5).
  • apply_filter(&LogicalPlan) -> (handle, Absorption) with three-valued Absorption ∈ {Exact, Inexact, Unsupported} (DataFusion TableProviderFilterPushDown): Exact = no re-check; Inexact = the source prunes but the engine re-applies the whole predicate (so the residual is not the set-complement of the absorbed work); Unsupported = engine does it. Under Inexact the residual is the original typed predicate, so the type-identical residual demand (C2/C3) is met automatically.
  • scan(handle, demand) -> stream — lazy; a bounded binding-pushed slice via the b positions in demand (never a free count on the hot path — C5).

Rejected: the delegated-subplan-rewrite shape (datafusion-federation: the connector ships its own optimizer rule and self-determines the federated fragment). More expressive and a cleaner IR fit, but the engine cannot independently cost or type-check what the connector absorbed opaquely — irreconcilable with the type-identical-residual demand (C2/C3), which is non-negotiable for Argon. The async connector is reconciled with the sync fixpoint by the freeze rule: await the slice once, materialize it as a frozen EDB, iterate sync (RFD 0035 D3). Relation-provider and compute-provider are distinct seams — a relation provider negotiates binding patterns; a compute provider is a table operator (RFD 0035 D4) routed to an analytical-tier executor (RFD 0035 D6). The market-oracle k-means is the latter: train (non-deterministic) runs outside the fixpoint and emits a frozen content-addressed model artifact; predict/assign (deterministic given the model) re-enters as a frozen EDB. Compute determinism at the foreign boundary is contract-asserted (the connector declares it; undeclared compute operators are refused in a fixpoint position — RFD 0035 D4), since Argon cannot verify foreign code by inspection.

Connector verdicts are a trust boundary — two distinct dimensions, with different defenses; do not fuse them. A connector that wrongly returns Exact suppresses the in-engine re-check and yields silently wrong answers — the one outcome a correctness-first engine cannot tolerate (collation, NULL handling, numeric coercion are the classic mismatches). The differential oracle (RFD 0035 D8) is load-bearing for in-engine passes but structurally cannot test a connector — it has no foreign data. The two dimensions:

  • Data completeness (false negatives — a source omits rows it should return). Irreducibly trusted in both Exact and Inexact, because re-applying the predicate only re-filters the rows that came back — it can never recover omitted ones. “Always re-check” buys exactly nothing here. This is the Postgres-is-trusted dimension: a connector is, irreducibly, a trusted data source for the relations it serves.
  • Verdict honesty (false positives — a source claims Exact but returns rows that fail the predicate). Not irreducible — this is the connector’s code, not the source’s data, and it is closable.

Three measures follow from the split:

  • Inexact-by-default. An Exact claim is honored — i.e. allowed to suppress the residual re-check — only from a connector that has passed a conformance harness for the operations it claims; otherwise pushdown is treated as Inexact and the engine re-applies the whole predicate. This removes the engine-introduced footgun (honoring an unverified guarantee).
  • Audit mode is the “always-re-check” configuration. It re-runs every Exact verdict against the residual and flags divergence — run in CI / dev / canary, where the re-check is free, rather than taxing production. This captures everything a blanket “no Exact ever” would buy on the verdict-honesty dimension, at zero steady-state cost — the federation analogue of the WCOJ-soundness bug the Lean↔Rust conformance framework caught (1bdfa164f).
  • Honest scope. A perpetual production re-check would pay a hot-path tax to defend only verdict-honesty — the dimension audit already closes for free — while leaving data-completeness, the irreducible hole, exactly as open. So Inexact-default + earned-Exact + audit-in-CI is the calibrated answer, not “always re-check.” Conformance raises confidence in the completeness trust; it does not abolish it.

The store taxonomy — one SPI, heterogeneous roles (updated per the performance campaign, 2026-06-15). The same ForeignRelation SPI accommodates stores with very different capabilities because binding_patterns() negotiates them: a point-lookup-only KV store answers bf (needs a bound key), a free-scannable columnar store answers ff. Concretely: DynamoDB plays up to three distinct roles — the outsourced CAS / durability backend for Argon’s own log (D9), an operational point-lookup tier, and a federated source; DuckDB / Parquet / Arrow is a >TB columnar source (a D3 connector); S3 is the immutable-segment object-store truth (D9) and the Ref<Blob> byte store (D8). DataFusion’s TableProvider is borrowed only as the connector interface shape — it is not Argon’s execution engine (RFD 0035 D4: Argon’s own vectorized engine is forced by the inline-provenance segment and justified by the small vectorized-vs-compiled constant). Federating to such a store is correct precisely for data Argon does not own or has deliberately placed there (D6) — the permanent-orchestrator half of the Decision lead — while Argon’s own engine remains the first-class default for what Argon owns.

D4 — Recursion × federation: the frozen-foreign-EDB rule + the refusal gates

A foreign relation in a fixpoint body is frozen-materialized once (RFD 0035 D3) — sound because a frozen slice is indistinguishable from a native EDB at the level of the mechanized semantics. For free-scannable sources this is the complete v1 mechanism — within a materialization cardinality budget. Freezing a slice into an in-memory BTreeMap CatalogEntry is bounded by demand for a binding-limited (bf) source, but bounded by nothing for a large or non-selective free-scan (ff) — an O(foreign-database) pull into memory, in direct tension with C5. So free-scannable freezing carries an explicit cardinality guard: a slice projected to exceed the budget is refused with a remediation diagnostic (“add a selective filter or declare a binding pattern”), never silently materialized to OOM. (The market-oracle headline is safe — k-means runs in-store and only the small cluster_of returns; a generic free-scan federated join is the case the guard protects.) Push-compute-to-data + streaming columnar execution is load-bearing for the >TB bar, not a follow-on (updated per the performance campaign, 2026-06-15): the BTreeMap freeze fundamentally cannot do >TB, so the trading-grade analytical path is pushdown (D3) + in-store compute where possible + Argon’s own vectorized streaming engine over columnar data otherwise (RFD 0035 D4) — the frozen-into-BTreeMap EDB is reserved for the small result re-entering the fixpoint. (Spilling joins and pushing the join itself down remain genuine follow-ons that further lift the budget.) For binding-limited (bf) sources the demand is itself recursive (Duschka–Genesereth: binding-limited access compiles to a recursive demand program), so “freeze once” is naive; the demand is computed by magic-sets/demand transformation (RFD 0035 D5) and the bounded slice pulled, with the connector required idempotent and monotone under growing demand. The two resolutions — demand-stratify (compute the complete magic_F extent in a lower stratum, then freeze) vs monotone bounded re-consultation (consult per demand-growth round; F* only grows) — are settled in implementation; free-scannable is the floor, binding-limited the careful extension.

Refusal gates (C9 teeth — static, checkable, never silent mis-evaluation):

  • NAF over an OWA foreign relation — absence ≠ false in an open world; refuse (or thread three-valued).
  • A relation both foreign-mapped and rule-derived — intensional/extensional conflict; compile-time refusal.
  • Pushing recursion into a source — Li–Chang: decidable for conjunctive fragments, undecidable with recursion + integrity constraints; only bounded binding-slices push, the fixpoint stays in-engine.
  • Result-bounded incomplete foreign slices (paginated/rate-limited APIs) under CWA-NAF — an incomplete F* silently breaks closed-world negation; refuse until the slice is warranted complete (D5).

D5 — World assumption is a decidability-tier input, not a soundness flag

A foreign relation declares its CWA/OWA (C6); the mark propagates into the tier classifier (the Tier metadata LogicalPlan nodes carry — RFD 0035 D5/D6). Closing a predicate is a complexity cliff — CQ answering jumps from AC0 (DL-Lite) to coNP-hard the instant any predicate is closed, unless the query is quantifier-free. So Argon admits closed foreign predicates only inside the Lutz–Seylan–Wolter Thm-5 FO-rewritable island (quantifier-free UCQs, no open→closed role inclusion — a static syntactic gate, firing the reserved OE0901/OW0902), and refuses the rest rather than silently moving a query past its tier ceiling. The world-assumption mark also gates which fixpoint flavour a foreign relation may enter (a CWA relation admits NAF / a WFS-SCC; an OWA one does not — the C6×D4 hinge). The cross-boundary completeness warrant — what a connector must supply for the CWA→OWA transfer to be sound — is the same composite leaf as D7’s freshness token, specialized with a closed? flag; the in-engine theorem (cwa_owa_transfer, CwaOwa.lean) exists, the connector contract is net-new.

D6 — Mapping & placement: three levels + a compiled content-addressed artifact; placement versioned separately from schema

  • Level 1 — source annotation (vocabulary-free — C7): a declaration marks a relation/concept foreign, with its world assumption and an abstract field-correspondence to logical names. No ontology vocabulary, no store identity.
  • Level 2 — ox.toml [store] / [placement] (versioned package contract, no secrets — C11): the named store, its kind (columnar/blob/relational/kv), binding-pattern hints, and an RML-style mapping shape; credentials referenced only by an indirect @deploy: handle.
  • Level 3 — deployment config (not versioned): endpoints, credentials, the concrete connector instance.

The mapping compiles to a content-addressed artifact hashed against the Schema composition signature (the .oxbin discipline — oxc-oxbin already carries per-section BLAKE3 + a composition signature — C10), so schema↔mapping drift is a load-time refusal, not a runtime surprise. Placement is versioned separately from schema: a relation can move stores without a schema bump; the mapping pins to a schema hash. Schema stays store-agnostic throughout (C1) — it answers type-checking questions (subsumption, refinement, world assumption) for a foreign relation without learning where bytes live; placement is the parallel catalog layer beside it.

D7 — Provenance & freshness: a foreign leaf in the existing PosBool(M) DNF

AxiomEvent already is the native token (Context). Federation adds one generator leaf (source_id, mapping_content_hash, as_of, closed?) into the same derivation DNF — the engine’s ⊗/⊕ provenance composition is unchanged. The mapping_content_hash triple-duties: the OBDA mapping-axiom provenance label (Calvanese 2019) + the freshness coordinate + the C10 content-address. Freshness is a three-rung ladder gated by source capability: (a) TTL/staleness-bound (weakest; a liveness property, source needs no cooperation); (b) CDC/change-feed (push invalidation — the delta path, RFD 0035 D7); (c) per-source as_of barrier (strongest, read-your-writes). Argon’s bitemporal tt is a barrier coordinate, so the engine-side mechanism for rung (c) already exists — but the rung is not “free”: the connector must expose a monotonic source position and a mapping that aligns it with tt. That alignment is a real per-connector obligation, not a given. The mandatory floor and the composite freshness of a multi-source join (v1: meet-of-leaves — the answer is as fresh as its weakest leaf) are the open residuals.

Provenance under incremental maintenance — the two-semiring split; the segment carries provenance inline (updated per the performance campaign, 2026-06-15). The persisted read-model segment (D9) carries the PosBool(M) derivation DNF + the proof_tag + the BitemporalExtent inline — “answer why at segment granularity” — which no surveyed columnar store does (they are all provenance-free), and which is one of the two reasons Argon’s read-model needs Argon’s own engine rather than an off-the-shelf one (RFD 0035 D4). The IVM maintainer keeps the two concerns on separate semiring components so they cannot fight: ℤ weights drive the cardinality IVM (DRedc / two-semiring DBSP; insertion is free under semi-naive, retraction is what forces the real algorithm), while PosBool(M) why-provenance rides as a value-field payload via the Green et al. ℕ[X] → PosBool(M) homomorphism. distinct collapses only the ℤ multiplicity component to set semantics for the oracle-identity obligation (RFD 0035 D7/D8); it does not touch the PosBool(M) payload — so the equivalence collapse and the provenance-carrying obligation are discharged by construction, not in tension.

D8 — P3 external-valued attributes: Ref<Blob>, the one tractable cross-store write

A blob-valued property is a first-class Ref<Blob> handle type (explicit indirection, composing with the reflective-Type/refinement machinery — RFD 0023 — over a magic blob-typed field). The write decomposes into three ops with different guarantees:

  1. idempotent content-addressed puthandle = BLAKE3(bytes), outside any transaction; re-putting identical bytes is a no-op by content hash;
  2. a transactional single-entity reference write — a native AxiomEvent holding the handle (the token already exists);
  3. a background GC sweep for the orphan window (put succeeded, reference never written).

It is admissible precisely because it is not a distributed transaction — it never crosses an entity boundary. The genuinely hard part is GC over a bitemporal, four-axis, fork-branched, content-addressed log: a blob referenced in fork A but retracted in fork B is not orphaned; one referenced only outside the current as_of window is live-but-invisible. Convex’s flat refCount is insufficient. v1: conservative mark-and-sweep with a grace period — and its cost is named, not implied cheap: to prove a blob unreferenced the sweep must scan reachability across all forks × as_of windows (≈ O(log) per sweep over a branched history). It is a background job, so C5 (hot-path) does not bind it, but it is not free, and that cost is precisely why a maintained per-fork refCount CQRS projection (incremental, O(1) per reference event) is the named follow-on rather than the v1 default. Schema sees only the Ref<Blob> type (C1).

D9 — P1 persistence swap: a thin async durable seam below the existing replay seam

The existing sync RuntimeStorageBackend (replay seam, oxc-runtime/src/lib.rs:2890) is preserved. A P1 backend (DynamoDB / FoundationDB-layer / RocksDB / Cassandra) plugs in at a separate, thin, async durable layer — where PgStorage already sits — reached by hydrate-then-replay (the async/sync split is by design, not a defect; RDFox + Datomic confirm it as the normal shape). The contract is Datomic-shaped and tiny: a consistent kv-read + one linearizable CAS on the root/watermark; the bulk store needs only eventual consistency, because the stored data is immutable (Datomic stood up DynamoDB in ~2 weeks on exactly this). First-party backends behind a compile-time enum + an async builder trait for third-party “external storage composers” (SurrealDB’s actual hybrid — not the enum-vs-trait dichotomy a naive reading assumes). The external durability backend (DynamoDB/S3/FoundationDB) is a swappable durability primitive, not Argon’s engine — the engine over it is always Argon’s (Decision lead); ox.toml holds at most a backend-kind selector; URIs/secrets are deployment config (C11).

Read-model persistence is the primary read path, not a coupled afterthought (updated per the performance campaign, 2026-06-15). The live read-model is a columnar, content-addressed, immutable segment (Datomic/Materialize/TerminusDB lineage, made columnar — RFD 0035 D7), maintained incrementally by the IVM maintainer so a mutate is a delta, not a rebuild. It serves C5 (reads hit segments, never the log), discharges C10 (the read model is a content-addressed cache), survives restart, and is the campaign’s #1 single-node win. This is content-addressed immutable segments, not the current scope-versioned mutable cache — the immutable shape is load-bearing (it is also the replication and cache-placement unit, and the fork mechanism). The fork axis (C8) plausibly rides the same content-addressed-segment mechanism as the read-model (fork = a pointer-set over shared immutable segments — the Neon/Snowflake zero-copy-clone shape; the Datomic/TerminusDB C10↔C8 convergence), while tenant/standpoint/module stay scoping coordinates — a two-mechanism split, flagged for investigation, not forced here.

  • The segment manifest — “which segments compose the read-model at watermark W per (tenant, fork, standpoint, module) at as_of” — is runtime state, not config (the Neon IndexPart / Iceberg metadata role), and is 4-axis + bitemporal. It lives with the write-spine (the natural home for the linearizable watermark). It is distinct from D6’s foreign-placement artifact.
  • The write spine stays a single scalar linearizable CAS (Datomic “db root” shape — the Track C verdict). The structured 4-axis + bitemporal manifest does not force a structured CAS: the manifest is itself an immutable, content-addressed object, and advancing the frontier is write the new immutable manifest, then one scalar CAS swings the root pointer to its content_id. Atomicity is automatic (the manifest is written and content-addressed before the CAS makes it live), and the outsourced-single-CAS simplicity is preserved — the linearizable cell’s value stays scalar even though what it points at is arbitrarily structured. This resolves the campaign’s scalar-CAS-vs-structured-manifest tension.

D10 — Sequencing and the scope line (relationship to RFD 0035)

The complete, correct mechanism is frozen-per-query federation on the RFD 0035 pipeline — it lowers end-to-end for every stage or refuses at a checkable gate; nothing half-checked executes (C9). The persisted-read-model + IVM (cross-query reuse, delta-maintained freshness — D7 rung (b)) is the named subsequent optimization whose correct fallback is the frozen-per-query path (RFD 0035 D7) — not a hollow deferral. P1 (D9) and P3 (D8) are independent of the pipeline and can proceed in parallel. P2 splits cleanly: the relation-provider half for free-scannable sources rides existing semantics (frozen EDB ≡ native EDB); binding-limited sources extend it via demand (D4, RFD 0035 D5); the compute-provider half (table operators / analytical tier) is the genuinely novel IR work (RFD 0035 D4/D6). Two hard prerequisites gate P2’s federation-split (both are sequencing facts, not open questions): (i) the logical-layer expression interpreter (RFD 0035 D5) — without an inspectable predicate, apply_filter (D3) cannot return a verdict; and (ii) the connector conformance harness (D3) — without it, Exact cannot be honored, so federation runs Inexact-only (correct, just slower). Until both land, federation-split is a stub, and the RFDs say so plainly rather than implying it works. The implementation owns the ordering and the cut, subject to: no hollow path, every optimization a genuine mechanism with a correct fallback, the differential oracle gating each (RFD 0021 D8 / RFD 0035 D8).

The cut, informed by the performance campaign (2026-06-15). The campaign’s evidence-grounded sequencing (research, not a directive; the cut stays the implementation’s): (1) the single-node IVM + columnar content-addressed segment read-model — the highest-leverage win, no S3/distribution/consensus, the benchmark suite lands here (none exists today, CP8); (2) the financial read paths over those segments; (3) the outsourced-CAS write spine + batching, independent and parallelizable; (4) distribution — later and greenfield (distribute storage, keep compute local: distributing the fixpoint imposes a per-iteration barrier and distributed incremental-recursive Datalog does not exist to adopt — reasoning stays single-node in v1). This reorders the earlier framing, which treated the persisted read-model + IVM as a someday-optimization; the architecture was already right (pure Z-set, RFD 0035 D7), only the priority was set without performance data.

The financial path is three workloads, not one. “Trading query” decomposes into a point lookup (one instrument’s current state — the operational tier, sub-ms–5ms), an analytical slice (aggregate/k-means over a >TB returns slice — the columnar segment + Argon’s vectorized engine, ~100ms–1s), and a ledger write (contended debit-credit — the TigerBeetle pattern). TigerBeetle is an accelerator, not a system of record (“Write Last, Read First”), so Argon’s reasoning + query layer is the general-purpose DB beside the ledger — the ledger federates out (a frozen-foreign EDB of its user_data-linked facts), and the trading-query path is a read/OLAP problem, not an OLTP-ledger one. v1 targets the analytical/columnar path (the market-oracle headline). A dedicated sub-ms operational point-lookup tier over the 5-coordinate (4-axis + bitemporal) key — and whether it forces a second segment kind (a point-lookup index layout vs the analytical scan layout, opposite physical shapes over the same log) — is a named-later residual (no operational store does sub-ms on a 5-coordinate key today).

D11 — The async execution boundary: await once at the EDB-loading edge; the fixpoint stays synchronous

A real foreign connector (Postgres, S3, DynamoDB, DuckDB) does async network I/O; the Argon evaluator (executor/eval.rs) and RuntimeStorageBackend are synchronous (semi-naive over BTreeMap). The two are reconciled by the frozen-foreign-EDB rule (D4) itself: a connector’s scan is async, but it is awaited exactly once, at the EDB-loading edge, before the fixpoint — its bounded demand-slice (D4, RFD 0035 D5) is drained into the frozen CatalogEntry, and the synchronous fixpoint then iterates over a constant, in-memory snapshot. The async↔sync seam sits outside and above the fixpoint, never inside an iteration. This is the same async-durable-seam-below-sync-replay discipline D9 establishes for persistence, now for reads — the read-side counterpart of “hydrate-then-replay.”

This is forced, not chosen — await-inside-iteration is unsound, not merely awkward. A synchronous fixpoint’s monotonicity / WFS guarantees assume a fixed input relation; re-consulting a foreign source mid-fixpoint lets the EDB change under the operator — the case the proofs do not cover (the operator’s parameter, not just its argument, would vary — D4’s freeze rationale). The boundary is the convergent answer across every mature engine, on primary sources: Soufflé .input / Nemo @import load once before evaluation as stratum-0 EDBs; DDlog / Materialize / Differential Dataflow ingest async sources from outside the synchronous dataflow (SyncActivator wake + capability-stamped batches) and keep operators non-blocking; DataFusion’s own recursive-CTE operator iterates over an in-memory WorkTable, not re-issued remote scans; and PostgreSQL recursive-CTE-over-FDW re-scanning per iteration is the bug its Dec-2025 Material-node patch fixes (the negative control — freeze is the soundness fix, not an optimization). The connector-SPI strawman reached the same shape independently: await a bounded binding-pushed slice once, freeze it, iterate the fixpoint over the frozen synchronous snapshot.

The SPI is async, dyn-dispatched, and the connector lives in Store state. ForeignRelation::scan is async; the connector is held Arc<dyn> in Store state, never in CatalogEntry (which must stay Serialize / Eq for the read-model segments and the differential oracle — D9 / RFD 0035 D8). Native async fn in a trait is not dyn-compatible, so the trait carries #[async_trait] (the per-call box is noise against a network round-trip; dynosaur is the static-dispatch-by-default alternative if the box ever matters). A blocking-only source (rare; the targeted stores are natively async) bridges via spawn_blocking + a channel (the DataFusion pattern), never a blocking call on a runtime worker.

Where the await lives, per entry point. Serve handlers are already async; the foreign fetch is hoisted above the existing flavour-aware sync core (run_reasoner’s block_in_place-vs-inline, oxc-serve/src/lib.rs). Concretely, materialize_predicates splits into [sync: seed base + plan demand] → [async: fetch + freeze] → [sync: fixpoint], with the one await in the middle and the CPU-bound fixpoint kept off the executor exactly as today. The CLI (no runtime today) builds one current-thread runtime and block_ons the whole command, doing all fetching inside that single block_on before the sync core. Rejected alternatives, on mechanism: making the query stack async end-to-end (function-colouring contagion — it colours the recursive core async for zero benefit, since the core does no I/O, and forces a runtime into the CLI while dragging the maintainer and the differential oracle along); and block_on inside the sync freeze (it runs the CPU-bound fixpoint on a runtime worker — executor starvation — and panics at the CLI, which has no runtime).

The single-fetch soundness boundary → a new refusal gate (extends D4). A single bounded fetch is complete iff the foreign relation’s extension is independent of the IDB computed in that fixpoint — i.e. there is no recursion through the foreign source (it sits strictly below the IDB it feeds; magic-sets / limited-access-patterns theory — Duschka–Levy 1997, Nash–Ludäscher 2004). This is the dual of D4’s Li–Chang gate (which forbids pushing recursion into a source): here the source is a leaf, but a recursive cycle that derives new foreign demand from already-consumed foreign tuples would make one fetch incomplete. v1 gates it statically: recursion-through-a-foreign-source is a refusal, reusing the analytical tier’s theorem-backed transitive no-cycle / dependency-cone check (AvoidsVocab, F2.4 / Reasoning/Datalog/AnalyticalFreeze.lean) — a foreign scan in an SCC with the IDB deriving its demand is refused with a remediation diagnostic, never silently under-derived. The relaxation — admit it via monotone bounded re-consultation (iterative demand rounds owned by the orchestrator at the boundary, the connector required idempotent + monotone under growing demand — D4’s second resolution) — is a named, gated follow-on, not a v1 shortcut; the full sans-IO yield-demand state machine is explicitly not adopted (overkill for batch compute — the loop, when needed, lives in the orchestrator, not the evaluator). F2.3’s existing base-binder boundary (bf binders must be already-materialized base relations) already enforces a conservative form of the gate.


Rationale

  • OBDA over a federated executor, not Convex-style absorption. You cannot absorb a pre-existing >TB store into one integrated backend (Convex’s model); Argon’s value is the ontology/reasoning layer over data where it lives. Foreign data = virtual EDB mapped into the ontology, queried through it (D2/D3/D6) — the OBDA/Trino/DataFusion shape.
  • The substrate was designed well, and it shows (D5/D7). AxiomEvent already being the provenance/freshness token, and per-concept CWA/OWA already being mechanized and ahead of deployed OBDA, mean federation adds a leaf and a tier input, not new machinery.
  • The freeze rule is the spine, and it carries zero new reasoning semantics (D2/D4). Because the mechanized operators never inspect provenance, a frozen foreign/computed EDB is a native EDB; federation’s hardness is engineering (the pipeline, the SPI), not semantics.
  • Type-identity is non-negotiable, so the SPI is per-operation (D3). The one Argon demand no federation system has — both-sides type-check must agree — forces the per-operation, independently-re-derivable residual over the opaque delegated rewrite.
  • Read-only-foreign is correct, not conservative (D2). The distributed-systems literature treats per-entity ACID + idempotent messaging as the right steady state; P3’s content-put is the one admissible cross-store write because it isn’t a distributed transaction.

Alternatives considered

  • Ingest/mirror foreign data into axiom events. Rejected (D2): O(database) copy of a >TB store, stale by construction; defeats the premise.
  • Delegated-subplan-rewrite connector SPI (datafusion-federation). Rejected (D3): opaque absorption is irreconcilable with the type-identical-residual demand (C2/C3).
  • Mapping as hand-authored interpreted config (R2RML/RML verbatim). Kept as influence, not adopted whole (D6): drift becomes a runtime surprise; the compiled-content-addressed artifact makes it a load-time refusal.
  • A magic blob-typed field (P3). Rejected in favor of explicit Ref<Blob> (D8): honest indirection, composes with reflective-Type/refinement.
  • Generalize the sync RuntimeStorageBackend to durable backends (P1). Rejected (D9): every backend would have to speak ABox kinds / retraction tombstoning / deep-clone synchronously — a heavy contract few stores fit; the thin async kv+CAS seam below it admits the widest backend set.
  • 2PC / cross-store distributed transactions. Rejected (D2): anti-availability; only works inside one store.
  • Treat IVM / the persisted read-model as a someday-optimization. Rejected after the performance campaign (D9/D10): the columnar content-addressed segment + IVM is the primary read-model and the highest-leverage work; frozen-per-query is its correct fallback. The architecture was already right; only the priority was wrong.
  • Delegate the analytical engine to DataFusion/DuckDB. Rejected (D3, RFD 0035 D4): Argon’s own vectorized engine, forced by the inline-provenance segment and unjustified-against by the small vectorized-vs-compiled constant. Off-the-shelf engines are a connector shape / temporary bridge, never Argon’s own engine. (Federating to such a store for data Argon does not own remains correct and permanent — the orchestrator half of the Decision lead.)
  • Make the write spine a structured (4-axis) CAS to carry the manifest. Rejected (D9): the manifest is an immutable content-addressed object the scalar root points at; one scalar CAS advances it. A structured CAS would needlessly reopen the harder-consensus question.
  • Make the query stack async end-to-end, or block_on inside the sync freeze. Rejected (D11): the first colours the recursive core async for zero benefit (it does no I/O) and breaks the sync CLI + maintainer + oracle; the second runs the CPU-bound fixpoint on a runtime worker (executor starvation) and panics at the CLI. The connector await belongs at the EDB-loading edge, above the sync fixpoint — which the frozen-foreign-EDB rule (D4) makes both sound and natural.

Consequences

  • New runtime dependencies + seams. The connector SPI (ForeignRelation, async / #[async_trait], dyn-dispatched, held in Store state — D11), the table-operator/analytical-tier executor (RFD 0035 D4/D6), the [store]/[placement] ox.toml sections + the compiled mapping artifact, the foreign-provenance leaf in derivation, the world-assumption tier-input threading, the Ref<Blob> type + blob-put SPI + GC sweep, and the thin async durable persistence seam. Each lands behind RFD 0035’s pipeline seams. The async↔sync seam is the EDB-loading edge (D11): materialize_predicates gains a plan-demand → await-fetch → freeze → sync-fixpoint split, with the await hoisted above the existing sync evaluator core (serve) or a single per-command block_on (CLI).
  • Spec / reference / Lean. Reference gains a heterogeneous-stores chapter; the only net-new semantic obligation is the frozen-foreign-EDB preservation theorem (RFD 0035 D3) against Fixpoint.lean/Compiled.lean. The CWA→OWA completeness warrant reads against CwaOwa.lean.
  • AGENTS nodes. oxc-reasoning, oxc-runtime, oxc-serve, and a new store-layer node updated once seams land; the “reasoner was not built here” / “rejected by design” framings retired (coordinated with RFD 0033).
  • Performance posture. The RFD 0020 scaling contract holds across the federation boundary: no O(database) on any hot path; the bounded binding-pushed slice + frozen EDB is the realization; the foreign store’s cost is the unknown the v1 cost model treats degenerately (push only when strictly better, RFD 0035 D5).

Open questions / tracked-future

  1. Engine-driven verdict (chosen) vs provider-driven callback — settled to per-operation (D3); the iterate-to-fixpoint negotiation (Trino) is deferred until a connector needs it. The genuinely-open piece is the connector conformance harness that gates the Exact claim (D3): its shape (a generated battery of predicates checked source-vs-oracle? a declared semantic profile — collation/NULL/coercion — the engine validates?) is net-new and unattested in the surveyed systems.
  2. Where mapping compilation lives — engine-side off-line (Ontop T-mappings) vs pushed into the source as views (Ultrawrap); and the exact [store]/[placement] schema (D6).
  3. Demand-stratify vs monotone bounded re-consultation for recursive foreign demand (D4) — settled with RFD 0035 D5’s magic-sets interface.
  4. Freeze vs delta as steady state (D7 rung (b)) — when a foreign CDC/change-feed earns its cost; the IVM follow-on (D10, RFD 0035 D7).
  5. Composite multi-source freshness beyond meet-of-leaves; read-your-writes across a mixed native-exact / foreign-barrier boundary (D7).
  6. The cross-boundary CWA→OWA completeness warrant — the connector contract that discharges cwa_owa_transfer for a closed foreign predicate, and how OE0901 fires when it is absent (D5).
  7. Blob GC over the bitemporal/fork-branched/content-addressed log — conservative mark-and-sweep vs a maintained per-fork refCount projection (D8).
  8. Whether content-addressed immutable layers unify C10 identity with the C8 fork axis (D9) — a deeper storage simplification to investigate.
  9. The per-symbol identity residual (RFD 0033): artifact identity is solid (composition signature + section hashes); threading it to per-event/per-symbol resolution (the module_id collision) is shared with the storage-identity fix and bears on the foreign-leaf source_id.
  10. Read-model segment GC over the 4-axis + bitemporal + fork space (D9): a single segment is referenced from many (tenant, fork) coordinates (cheap branching = pointer-sets over shared segments), so it is collectable only when no axis-coordinate’s manifest references it — a cross-axis reachability computation no surveyed system does (Neon GCs by single-axis LSN-horizon). The cheap-branching win and the GC-reachability cost are in direct tension; the algorithm is unsketched.
  11. The IVM maintainer’s checkpoint cadence (D9, RFD 0035 D7): per-mutation segment minting (churn + GC pressure) vs batched (the in-memory materialization must survive restart some other way). A genuine open the maintainer loop surfaces.
  12. Composed cross-tier freshness (extends D7’s meet-of-leaves): when one fixpoint joins facts from a point-lookup (operational tier), an analytical slice (segment watermark), and a federated ledger (as_of barrier), the derived conclusion is consistent only relative to the weakest of the three barriers — a three-way compose sharper than the two-source (native + foreign) case D7 states.

RFD 0037 — The macro atom: a phase-separated, hygienic, declarative-first expander over surface syntax

  • State: discussion
  • Opened: 2026-06-16
  • Decides: how Argon realizes its macro atom — the last unbuilt of the five substrate atoms (meta-calculus, constructs, rule, trait, macro). Settles: (1) expansion is a real pipeline phase between parse and resolve, not a rewrite smuggled into elaboration; (2) a macro expands to surface syntax, which is re-parsed and re-elaborated through the one existing path — never to events directly; (3) hygiene is scope-sets + Racket-style binding spaces, white-box, over Argon’s existing binder namespaces; (4) rule variables are alpha-canonicalized at AST→event lowering — a content-addressing correctness fix that also dissolves the macro determinism hazard; (5) v1 is purely declarative (pub macro, pattern→template) — verified sufficient for every current client including MLT; the procedural / analytic layer is deferred with its end-state shape committed (total structural recursion over reflected syntax, Lean-mechanizable); (6) migration re-homes the relation-property family to a library declarative macro byte-identically, and the MLT decorators via a library-surface-stub / privileged-native-expander split (RFD 0009 RP-003 GAP-3); (7) collisions resolve by uniqueness-at-registration (loud OE0705-class, not silent priority); (8) the Lean line holds at the typed AST (rung a) with a re-check obligation. Two acceptance tests gate v1 (§8). This RFD leads the arc per the workflow table — language surface: RFD + reference draft → Lean → code. Hard prerequisite for the directive surface (RFD 0028) and the std theory libraries (std::temporal, std::lifecycle, std::mlt).

Prior state (verified at 2c2854959):

  • The surface parses; nothing expands. pub macro Name { … } parses to MACRO_DECL with the body eaten as opaque balanced tokens — no pattern/template structure, no fragment specifiers recognized (oxc-parser/src/grammar.rs:2134, eat_balanced). #[procmacro] is a reserved directive (OE0706, directives.rs:468); there is no TokenStream type. name!(…) bang-invocation does not parse at all — no MACRO_INVOCATION node, the BANG token is unused in expression position (expr.rs:238). Lowering intentionally skips macro decls (oxc-instantiate/src/lower.rs:5).
  • A working proto-macro already exists. synthesize_relation_property_rules (lower.rs:5095) is a string-template macro in all but name: it format!s Argon source — "pub derive {rel}(x, z) :- {rel}(x, y), {rel}(y, z)" (lower.rs:5172) — then parse_files it, extracts the Item, and lowers it with the live ctx so the body resolves to the right qualified paths (lower.rs:5135). It works only because it runs inside instantiate on self-contained synthesis whose introduced names nothing else resolves against.
  • The pipeline is hard-gated, parse-frozen. parse → [error gate] → resolve/check → [error gate] → instantiate → tier-classify → discharge → write (oxc-driver/src/lib.rs:1520– 1647). parse_file(db, SourceFile) is a memoized salsa query (oxc-db/src/lib.rs). Each phase assumes the prior AST is immutable; there is no phase that rewrites the AST before resolution.
  • Rule variables are name-carried and un-canonicalized. Term::Var { name: Ident { text: String } } (core_ir.rs:240,142); content_id = BLAKE3(encode_rule_decl(body)) (lower.rs:3969); no alpha-canonicalization anywhere (grep across compiler/ is empty). So alpha-variant rules get different AxiomKeys — contradicting the stated intent “same proposition asserted twice has the same AxiomKey” (ids.rs:393). #[forall] lowering already mints counter-named $fa_N vars (atom_lower.rs:226) — a latent reproducibility hazard.
  • The §3.4 gate and head resolution are concrete and reusable. resolve_metatype_introducer / resolve_metarel_introducer (lower.rs:1171/1209): three arms (local pub metatype → ambient std::core → workspace-unique, else OE0605/0606). Rule-atom resolution is local-first → unique-workspace → ambiguous-refuse (“never a silent union or an arbitrary pick”, lower.rs:1528). Same-module same-head derives union; cross-module same-name refuses.
  • MLT is declarative templating, not computed expansion. #[categorizes(T)] reads only T’s name and emits a MetaProperty event (lower.rs:4331); it does not inspect T’s structure. The Lean model is a five-kind enumeration with a round-trip theorem (MLTKinds.lean, classify_declOfKind).

Question

Macros are the last unbuilt atom and the extensibility substrate the ontology-neutral doctrine depends on: every hard-coded compiler substitute is a macro waiting to exist — the #[…] directive registry, the elaborator-native MLT decorators (RFD 0009 commits to re-homing them byte-identically), the just-built relation-property directives (#434, a string-template proto-macro), std::temporal’s ten DatalogMTL operators, std::lifecycle, and derive. Without macros, “the core stays small; theories are libraries” cannot hold — every vocabulary a user wants needs a compiler patch.

The campaign that precedes this RFD (vault: Efforts/On/Argon/research/macro-system/) converged on a design and then code verification overturned two of its premises, which this RFD encodes:

  1. The research assumed expansion could sit “between parse and elaborate” on the existing pipeline. It cannot — the pipeline is parse-frozen and hard-gated (prior state). v1 requires a real expansion phase, a driver restructuring. This is the correct solution; hacking expansion into instantiate (as the proto-macro does) only works for self-contained synthesis and breaks the moment a macro introduces a name another declaration resolves against, or rewrites a rule body the resolver would otherwise reject as unknown atoms (since/ever).
  2. The research treated the procedural fork (declarative-first “C” vs bootstrapped-Argon “B”) as the central decision. Verification collapses it: every current client — relation-property, the ten temporal operators, std::lifecycle sugar, navigation, and even MLT — is declarative (pattern→template ± name resolution). The only workload needing analytic computation (inspect a concept’s fields) is genuine derive-class, which has no current client. So v1 is declarative, full stop; the analytic layer is deferred, not staged-around.

A worked motivating case (a real user’s tax-code rule):

pub derive realizes_gain(pcr: PCR) :-
    Performs(pcr, perf), HasContent(perf, content),
    HasObjectConstraint(content, oc), ConstrainsObject(oc, obj),
    compute_1001b(pcr) > obj.basis_for_gain;

The long relational-navigation chain is exactly what a declarative navigation macro should desugar; the transitive-closure sugar x.Role+(y) is already a loud refusal telling users to hand-write the recursion (rule_atom.rs:338, #297). These are the macro engine’s first clients, and they are all pattern→template.


Decision

D1 — Expansion is a phase between parse and resolve

Insert a new compilation phase. The pipeline becomes:

lex → parse → EXPAND (to a fixed point) → resolve → check → instantiate → tier-classify → discharge → write

EXPAND consumes the parsed module set and produces an expanded module set (fresh green trees / synthetic SourceFiles) that resolve/check/instantiate consume as if hand-written. It is a memoized salsa query keyed on the parsed input + the in-scope macro definitions. Expansion runs to a fixed point (macros producing macro invocations re-expand) with a fuel cap that errors on exhaustion (OE-coded). The relation-property and MLT synthesis currently living inside instantiate move to this phase (or, for MLT, to the stub/expander split of D8).

Why a phase, not a rewrite in instantiate (the correctness call): a macro that introduces a concept/relation other code references, or body sugar the resolver would reject pre-expansion, must expand before name resolution runs. The proto-macro’s instantiate-time re-parse only works because its output references already-declared names and introduces nothing referenced elsewhere. Generalizing requires the phase. The error gates re-order accordingly: a parse gate on the original source, then expansion (which may itself emit diagnostics), then the resolve/check gate on the expanded tree.

D2 — A macro expands to surface syntax, re-parsed and re-elaborated

A declarative macro is Syntax → Syntax: it matches a token pattern and produces surface tokens, which are spliced into the module and re-parsed, then flow through the one existing parse→resolve→check→instantiate→lower path. This is the model the proto-macro already validates (emit source text → parse_file → lower). Consequences, all verified to hold:

  • The drift contract (S2) is preserved for free — the only thing that ever produces events is the unchanged lowering, now running over post-expansion AST.
  • The §3.4 gate runs unchanged — a macro-introduced concept flows through resolve_metatype_introducer like any other (lower.rs:1171).
  • Tier classification is correct by construction — the classifier already runs last, after instantiate, on lowered events (oxc-driver/lib.rs:1571); it sees post-expansion reality (S4).
  • Head composition is sound — a macro emits into its invocation module, where its rules union with the user’s (same-module same-head) and structurally cannot pollute another module’s heads (cross-module is ambiguous-refuse, lower.rs:1528).

Direct event emission is rejected for user macros (it forks the drift contract and bypasses the gate and classifier). The one exception is the privileged MLT-style axis-event emitter (D8), which has no surface form and stays a compiler builtin behind a library surface.

D3 — Hygiene: scope-sets + binding spaces, white-box

Adopt Flatt-2016 scope-sets with Racket-style binding spaces — each of Argon’s existing binder namespaces (rule vars, concept/type, rel/metarel, metatype/metaxis + axis values, individuals, trait members) is an interned scope in one scope-set; the maximal-subset resolution rule is unchanged. Hygiene is white-box (Lean-style effectful quotation): the expander does not stamp scopes globally — the quotation applies a fresh macro scope to the identifiers it introduces. (Black-box mark-and-invert is quadratic on Argon’s per-axiom-event structure.)

Integration is an extension of existing resolution: the resolver already does local-first → candidate-set → ambiguous-refuse (lower.rs:1528) and tracks bound_vars: BTreeSet<String>. Hygiene adds a scope dimension to candidate filtering; bound_vars becomes scope-tagged. The §3.4 gate rides candidate-set disambiguation as a commuting pass — it rejects an ill-formed introducer but never re-points a reference (candidate Lean theorem #1, deferred per D7).

Hygiene splits cleanly by what reaches identity (see D4):

  • Rule variables (bound, clause-local): hygiene need only guarantee non-capture — alpha-canonicalization (D4) makes their names irrelevant to identity and the content hash.
  • Introduced vocabulary (concepts/rels/metatypes a macro declares): names are semantic and referenceable, so hygiene must produce a content-derived, stable name (derived from macro-identity ⊕ argument-content ⊕ expansion-path, content not source span.oxbin must survive non-semantic edits). For macro-producing macros this is a content-derived path.

unhygienic! is not shipped in v1: a $crate-style targeted self-reference plus syntax-parameter keywords cover the real needs; any future raw escape must be namespace-indexed (name which binding space).

Implementation (realization Y). The white-box scope-set model above is realized over the expand-to-surface carrier (D2) by rewriting each transcriber-literal identifier as expansion runs, classified against the macro’s definition module: a reference to a global is qualified to its package-anchored canonical path (reference hygiene); a macro-introduced variable is freshened to a content-derived name in a reserved namespace (variable hygiene, marker · / U+00B7, forbidden in user source — OE0725); metavariable substitutions (use-site syntax) are left untouched. The classification reuses the resolver’s own local-first → workspace-candidate → ambiguous-refuse discipline at the definition site, so the embedded §3.4 gate rides along unchanged. The carrier-level rewrite (oxc_parser::hygiene

  • oxc_workspace::hygiene) was validated before implementation: it is proven to induce the same binding as the scope-set specification — resolves_equiv in spec/lean/Scratch/MacroHygiene.lean (branch research/0037-macro-hygiene; design memo spec/rfd/0037-macro-atom-hygiene-design.md), with non-capture resting on the reserved namespace’s disjointness — and that proof depends on no project-specific axioms. v1 covers single-level expansion and the variable/reference partition; reference hygiene of a metaequality metatype target (c :: T) is the one documented carrier-level gap (the variable c is still freshened), pending the nested-scope generalization.

D4 — Alpha-canonicalize rule variables at AST→event lowering

Before computing content_id, rename every bound rule variable to a positional canonical form (_0, _1, … by first-occurrence traversal), consistently across all ~25–30 binder sites: head args, body predicate args, Comparison/Compute/Aggregate/TypeTest operands and outputs, Comprehension binders (respecting shadowing), and the #[forall] $fa_N vars. Surface names are preserved in a side table for diagnostics (errors keep the user’s names; the hash sees the canonical form).

This is a content-addressing correctness fix in its own right, independent of macros: it makes rule identity up-to-alpha (honoring the ids.rs:393 intent), fixes the latent $fa_N counter non-determinism, and lets alpha-equivalent rules dedup and share lineage. For macros it is the lever that dissolves the gensym→hash hazard: hygiene’s fresh variable names never reach the hash, so hygiene reduces to non-capture and reproducibility is automatic. Verified sound — variables are clause-local, string-identity-only, and carry no semantic weight beyond binding (no reflection, no match-by-name, defeat-edge resolution is by-name but maps through the side table).

D5 — Fragment specifiers: a closed v1 set, each a binding-space-targeted parse

v1 specifiers: concept, rel, metatype, rule, plus the syntactic Rust-lineage set (expr, ident, ty, literal, path, tt, and $( … )*/+/? repetition). standpoint is cut from v1 (no forcing client; admit later if needed). Each ontological specifier is “parse this syntax category, bind/reference in the corresponding binding space”: $r:rel binds r in the rel space and re-resolves use-site; $c:concept clears the §3.4 gate at disambiguation. S7 (ontology-neutrality) and S4 (tier-honesty) hold by construction — the gate runs on emitted references, and no specifier carries a tier (the classifier assigns it post-expansion).

$x:rule is the subtle one: it is a quotation of rule-plane syntax carrying its own sub-bindings (the rule variables inside the matched fragment must keep their use-site identity and not be captured by the macro’s introduced vars). It is the specifier the v1 prototype must exercise hardest.

This requires real grammar for the macro body (today an opaque token blob): a (pattern) => { template } form with $name:spec metavariables — net-new parser work.

D6 — Invariants: classifier-last, strong normalization, denied sources

  • Tier-honesty (S4): structural, by D1+D2 — the classifier runs last on lowered post-expansion events. “Nothing produces events after the classifier” is a checked pipeline invariant. No tier monotonicity rule — the classifier measures the true post-expansion tier; there is nothing to police (a tier cap, if a package declares one, is enforced on the measured tier with the breadcrumb pointing back to the invocation).
  • Termination (S5): the declarative layer is strongly normalizing by construction (finite templates; macro-calls-macro bounded by a structural measure), with a fuel cap as a backstop against bugs, not the primary mechanism.
  • Determinism (S5): the declarative layer has no I/O sources to deny; emitted collections are emitted in canonical (sorted-by-content) order; and D4 removes the fresh-variable hazard. Result: byte-identical .oxbin across builds with no author discipline required.

D7 — Lean line: hold at the typed AST (rung a) + re-check obligation

The expander is untrusted by design. Assurance comes from re-checking its output: the tier classifier runs last (D6), the .oxbin content hash is re-derivable, and the drift gate covers the @[language_interface] shape of MacroAtom = declarative MacroDecl | procedural ProcMacroDecl. Mechanize the re-checker, not the producer — the CompCert pole, which §13.7 already follows, and which the headline ITP precedent (Ullrich & de Moura) actually uses (it mechanized nothing; trust = kernel re-check). Do not mechanize expansion in v1. The tier-honesty commutation theorem (classify ∘ expand commutes with the true decidability class) is the one worth pursuing later — it is Argon’s distinctive invariant — but it is statable only once expand is a Lean object, i.e. only under the analytic layer (D9). Hygiene-algebra mechanization (POPLmark-scale) and full expansion-preservation (likely ill-defined per Leroy — a macro’s meaning is its expansion) are out.

D8 — Migration: re-home by emit-target, surface-stable at every step

  • Relation-property family (#[transitive]/#[irreflexive]/#[asymmetric]/#[functional]): re-homes to std::rel, but splits by what the declarative layer can express (design revised during implementation — the family is not uniformly declarative; PR #549/#556/#560):
    • #[transitive] is a genuine declarative pub macro: it emits the closure rule (which has surface) into the invocation module, and because the library template is byte-identical to the proto-macro’s string ({rel}(x, z) :- {rel}(x, y), {rel}(y, z)), the lowered events are byte-identical — the v1 differential test (§8).
    • #[irreflexive]/#[asymmetric]/#[functional] are builtin-backed macros — the same library-stub / privileged-expander split as the MLT decorators below. Their synthesized check has a computed head (__{rel}_{prop}), which the splice-only declarative layer cannot construct (no concat_idents), and #[functional]-on-rel is a cardinality cap, not a surface rule. So a std::rel library stub marked #[builtin] (a new directive on a pub macro decl — Argon’s analogue of Rust’s #[rustc_builtin_macro]) owns the importable surface, the EXPAND phase leaves the attribute in place, and the unchanged privileged synthesis stays the implementation (byte-identical). They become genuine declarative macros once the procedural layer (D9) lands. All four require use std::rel::{…} (nothing is ambient — RFD 0038); a bare one is refused (transitive: OE0705; the checks: OE1362).
  • MLT decorators (#[categorizes(T)] …): re-home the surface to std::mlt via the Rust #[rustc_builtin_macro] pattern — a library stub owns the surface (name, stability, visibility, importability, docs; byte-identical per RFD 0009 RP-003 GAP-3) while the expander stays a privileged compiler builtin that emits the MetaProperty event (which has no user-writable surface). The expander flips to library Argon only when the analytic layer (D9) lands and an axis-assertion surface exists — both phases surface-stable.
  • std::temporal / navigation / std::lifecycle: declarative library macros; a native-or-library scoping choice per operator once the engine exists.

D9 — Collisions, identity, and the deferred analytic layer

  • Collision resolution: uniqueness-at-registration (Lean model) — one implementation per #[name]; a user macro colliding with a builtin is an OE0705-class error, not a silent shadow (matches Argon’s loud-refusal posture and the existing ambiguous-refuse resolver). use mod::foo is the module-qualified escape for distinct cross-package names. Identity is keyed to a stable diagnostic-item-style handle, not the path, giving resolution-invariance under re-homing. Implemented for duplicate macro definitions — two macro name declarations in one module are refused with OE0726, never silently merged into one invocation name; builtin-name collision in the shared #[…] attribute namespace follows with the attribute-macro surface.
  • The procedural / analytic layer is deferred, with its end-state shape committed: a total, structurally-recursive meta-language over reflected syntaxnot general-purpose Argon. This is the correct end-state because totality is what the substrate’s own doctrine demands (deterministic content-addressed builds, decidability tiers, Lean-canonical): it is strongly-normalizing and deterministic by construction, and — being a total function over an inductive Syntax type — it is the natural object for the D7 tier-honesty theorem. A Turing-complete compile-time Argon would be more powerful and less correct. The layer’s trigger is the first genuine derive-class client (inspect a concept’s fields), and its acceptance test is the MLT expander flip.

v1 scope and the two acceptance tests

In v1: the EXPAND phase (D1); the declarative pub macro engine (structured pattern→template grammar, fragment specifiers per D5, expand-to-surface→reparse per D2); scope-set/binding-space hygiene (D3); alpha-canonicalization (D4); invocation surface (name!(…) parser + MACRO_INVOCATION node; #[name(args)] argument plumbing, lifting OE0709 for macro-bearing attributes); the relation-property re-home (D8); the MLT surface stub (D8).

Deferred: the analytic/procedural layer and derive-class (D9); type-directed/elab-class expansion (no forcing client; the real ergonomic need — legal scoping — is context-directed, which binding-space resolution already serves); standpoint specifier; unhygienic!.

v1 ships only when both tests pass:

  1. Migration proof (byte-identical): #[transitive] (and the relation-property family), re-homed from the hard-coded synthesis to a library pub macro, produces byte-identical lowered events vs the current path — a differential test, trivially satisfiable because the template is the same and D4 makes variable naming hash-irrelevant.
  2. Grow-a-language proof: at least one genuinely user-defined pub macro expands end-to-end through the full pipeline (parse → EXPAND → resolve → check → instantiate → classify), clearing the §3.4 gate and landing on its true tier.

What this RFD does not leave implicit

  1. Expansion is a phase, not an instantiate-time rewrite (D1) — the verified architectural correction.
  2. Alpha-canonicalization (D4) is a committed correctness fix, prerequisite to the clean hygiene story and valuable independently.
  3. The analytic layer’s end-state shape (total recursion over reflected syntax) and its trigger (first derive-class client; MLT-expander-flip acceptance test) (D9).
  4. The origin breadcrumb for diagnostics (mapping a lowered event back to its macro invocation) is metadata excluded from the semantic content hash, carried in a separate diagnostic index — decoupling reproducibility (D6), re-homing identity (D9), and diagnostics. No full unexpander in v1.
  5. The $x:rule fragment’s sub-binding hygiene (D5) — the prototype’s hardest case.

Open items for ratification

  • The exact content-derivation function for introduced-vocabulary scopes (D3).
  • The fragment-specifier grammar for the macro body (D5) — the parser work that replaces the opaque-token-blob body.
  • Whether std::temporal/std::lifecycle operators land native or library in the first cut (D8) — a scoping call, not a design blocker.
  • The driver/salsa shape of the EXPAND phase (D1) — incremental re-expansion granularity.

RFD 0038 — The prelude, ambient scope, and symbol-precise stdlib loading

  • Status: discussion
  • Depends on: RFD 0009 (MLT-as-library / nothing-privileged), RFD 0030 (path dependencies)
  • Blocks: RFD 0037 D8 (relation-property re-home needs a real prelude to surface library attribute-macros without ceremony)

Summary

Argon’s prelude is specified but unbuilt: the reference designs a full Rust-like prelude (§3.4 four-tier resolution; §15 std::prelude::v1 with an auto-import set + #![no_implicit_prelude] opt-out), but the compiler implements none of it — three ad-hoc mechanisms stand in for it, it diverges from the reference, and the reference contradicts itself on the most basic question: whether type/rel are ambient. This RFD settles the ambient-scope architecture:

  1. Tier 0 — substrate. A fixed, hardcoded, non-opt-outable set: the names that are the language, not library declarations.
  2. Tier 1 — the prelude. A real, configurable, auto-imported (opt-out) prelude that carries no ontology by defaultno type/rel. The prelude is a package feature (ox), not a compiler feature: there is no compiler-default prelude.
  3. Tier 2 — explicit use, with stdlib and dependencies loaded symbol-precisely (⊥-locality extraction) from the transitive use-graph — only what the program actually uses, never whole packages.

The frame is the existing oxc = rustc, ox = cargo split (the binaries already encode it: oxc is “the language compiler … pure source-to-artifact passes”; ox is “the project CLI”). oxc compiles loose files with substrate + explicit uses and no prelude; ox is the package tool where the manifest supplies the prelude, dependencies, tier, and world. We copy Rust’s split and manifest model but deliberately do not copy rustc’s rich auto-applied std::prelude — Rust can privilege std because it is a universal library; Argon structurally cannot privilege any vocabulary (RFD 0009). So the governing principle is: nothing from the standard library is privileged into ambient scope — not categorizes, and not the no-commitment baseline type/rel. Ontological commitment is always explicit and visible at the top of a module. This resolves the reference-manual contradiction in favor of its own better half (§4 / §15.0.1) and deletes the “auto-injected ambient type/rel” claims (§5.2 / §15).

Background — the validated problem

Ground truth, verified against the compiler (compiler/crates/) and by ox check:

  • The specified prelude is unbuilt. The reference fully specifies a four-tier resolution (local → use → auto-prelude std::prelude::v1::* → primordials, 03-modules.md §3.4) with #![no_implicit_prelude] opt-out, and §15 even enumerates a rich Rust-like auto-prelude (Option/Result/Ordering/traits/ macros). But the compiler builds none of tier 3: no std::prelude::v1, no #![no_implicit_prelude] (listed as a directive in §9 but absent from DIRECTIVE_REGISTRY, so it parses then fails OE0705). The prelude exists on paper, not in code.
  • The substrate/prelude boundary (clarification). Of §15’s auto-prelude list, the items that actually resolve today — Option/Result/Ordering/List/ Set/Map/Range/Truth4/Diagnostic/Severity — are Tier-0 substrate (hardcoded is_builtin_type_form + the check surface), not a library prelude. So “empty default prelude” (D2) never strips these — a package always has them. The empty default applies to the ontology/library layer (type/rel/ vocabulary). The not-yet-shipped traits/macros (Display/format!/…) are deferred: substrate-vs-prelude is decided when they land (lean: language macros and core operator-traits are substrate).
  • All seven stdlib packages load unconditionally. STDLIB_SOURCES (oxc-instantiate/src/lower.rs) is a fixed seven-element list; load_stdlib iterates it with no dependency graph, no opt-in, no opt-out. Every artifact carries std::mlt/std::kripke/std::fin/… axioms whether used or not.
  • Ambient leak in classifier position (demonstrated). Type references require use (pub rel R(World, World) with no import → OE0101, test T1). But metatype/metarel classifiers resolve local → ambient pin → workspace-unique (resolve_metatype_introducer/resolve_metarel_introducer, lower.rs:1171/1209); because every std package is loaded into the workspace, the workspace-unique arm makes every loaded metatype/metarel an ambient classifierpub categorizes AB(A, B) checks clean with no use (test T2). This directly violates RFD 0009 (“MLT is an explicitly-imported library, nothing privileged”) and §5.2.
  • type/rel are hardcoded pins. if classifier == "type" { return std::core::type } (lower.rs:1182; :1220 for rel) — special-cased, not resolved through any inclusion mechanism.
  • Top/Bot have two sources of truth. Defined both as primordials (resolve.rs primordial_kind) and as pub types in std/core/root.ar.
  • The reference contradicts itself. §15.0.1: “No vocabulary ships with the language or the stdlib … external vocabularies may re-export std::core::rel in their own prelude as a convenience”; §4: after use std::core::rel; the modeler can write pub rel … — both explicit-inclusion. Yet §5.2 / §15: std::core is auto-injected … pub type Foo works with no use.” These cannot both hold.

Infrastructure that already exists and we build on: ox.toml package manifests with [package]/[dependencies] (RFD 0030); inner-attribute parsing (#![...]ATTR_INNER CST, validated against DIRECTIVE_REGISTRY); the prelude.ar / pub use pkg::prelude::* convention (§3). Infrastructure that does not exist: selective stdlib loading, a transitive use-graph (only mod-chain reachability and per-file import maps exist), and any prelude-control directive.

Decision

D0 — oxc is the compiler, ox is the package tool (rustc / cargo)

The two binaries (crates/oxc-driver/src/bin/) already encode the split:

  • oxc (rustc): compiles loose source files — “the pure source-to-artifact passes.” A loose file is not a package, so it has no prelude: only the Tier-0 substrate is in scope, and everything else is an explicit use. A bare oxc foo.ar with pub type Person {} and no import is an error (OE0605, unresolved metatype type) with a hint to use std::core::{type, rel}.
  • ox (cargo): the package tool. It requires an ox.toml, and the manifest supplies the prelude ([package].prelude, D4), dependencies, tier ceiling, and world. ox invokes the same oxc passes with that manifest context — exactly as cargo drives rustc.

Consequence: the prelude is a package feature, not a compiler default. The current standalone path single_file_workspace_with_stdlib (oxc-driver), which injects all seven stdlib packages into any loose file, is deleted — it is the auto-load-everything behavior this RFD removes. Loose-file compilation keeps working under oxc with substrate + explicit uses + symbol-precise extraction (D3); there is no separate “standalone mode” with divergent rules.

D1 — Tier 0: the substrate is fixed and is not the library

The always-in-scope, non-opt-outable set is exactly the language primitives — the carriers of the meta-calculus and type system, which have no library declaration form:

  • Primordials: Nat, Int, Real, Decimal, Money, Date, Time, DateTime, Duration, Bool, String, Top, Bot (and /).
  • Builtin type forms: List, Set, Map, Range, Option, Result, Ordering, Truth4, Truth4Of, Path, Metatype, TypeRef, Entity.
  • Reflection intrinsics: meta, iof, specializes, extent, implements, implementors.
  • Aggregate heads: count, count_distinct, sum, min, max, avg, exists.
  • Modal operators, operators, true/false, the check surface (Diagnostic/Severity).

These stay hardcoded (resolved before imports), and the set is closed: adding to it is a language change, not a library change. Top/Bot are primordials (Tier 0); the duplicate pub type Top/Bot in std/core are removed (D5).

D2 — Tier 1: ONE prelude, carrying no ontology by default

There is exactly one auto-import mechanism. Two distinct roles were being conflated under the word “prelude” — separating them is what unsticks this (both exist in Rust too):

  • The auto-import set (role 1 — the prelude): what every module of a package gets without writing use. Configured by ox.toml [package].prelude (D4), default empty, opt-out per-module via #![no_implicit_prelude]. This is the one thing called “the prelude,” and it is a package concept.
  • A package’s exported public module (role 2): a normal module conventionally named prelude that pub uses the package’s public surface; consumers opt in explicitly with use pkg::prelude::*, or feed it into their own role-1 config. No special mechanism — just a module. The §3 prelude.ar convention is this. It is not a competing auto-prelude.

The earlier confusion (std::prelude::v1 and std::core::prelude both acting as global auto-preludes) is gone: there is one role-1 prelude per package, empty by default — the extension point, not a dumping ground.

type/rel are opt-in, with no special prelude for them. A package that wants the baseline auto-available in its own modules lists it in role 1 — [package].prelude = ["std::core::{type, rel}"]. A module (or a loose oxc file) that wants it without a package prelude writes use std::core::{type, rel} directly. A foundational-ontology package sets [package].prelude = ["std::ufo::prelude::*"] and never sees type/rel. The hardcoded pins (lower.rs:1182/1220) are removed; type/rel resolve through the prelude / imports like every other classifier.

A future #[cfg(...)]-style mechanism (Argon has none today) could conditionally configure the prelude — a clean home for “this build wants the core baseline” — but the manifest config covers the need now without a new language feature; cfg is noted as a later generalization, not a v1 dependency.

Rationale. The no-commitment baseline is itself a commitment — to neutrality. Defaulting it privileges that choice and forces every committed package to opt out of a commitment it never made (backwards). Making it opt-in (a) keeps the language neutral (RFD 0009), (b) makes every module’s metatype basis visible at its top, and (c) unifies resolution: classifiers and references both resolve local → imports/prelude → substrate, eliminating the T1/T2 asymmetry.

D3 — Tier 2: explicit use, symbol-precise extraction (not whole-package)

Loading is symbol-precise, not package-granular. use A::b::{c, D} brings in c and D and the transitive closure of declarations they depend on (endpoint types, supertypes, referenced relations, the symbols their bodies mention) — and nothing else from A::b. Pulling in all of A::b::* because one symbol was named is a hollow approximation and is rejected: the artifact must contain only what the program actually uses.

This is exactly ⊥-locality module extraction, which the substrate already mechanizes and proves conservative (spec/lean/Argon/Locality/; AGENTS.md §3.5 — “Σ-scoped CWA-conservativity, domain-conservative extraction for ghost individuals, chained extraction across import chains”). The compiler does not implement it today (all stdlib loads whole); D3 wires the existing, proven theory into the build:

  • Build the transitive use-graph — a new pass over use edges plus the dependency edges between declarations — seeded from the entry module.
  • Mint events for exactly the reachable declaration closure, across stdlib and path dependencies alike. use std::fin::{MonetaryAmount}MonetaryAmount and its dependency closure, not the Currency/RoundingMode surface it never references; no use of std::fin ⇒ zero std::fin axioms.
  • The metatype/metarel introducer’s workspace-unique arm sees only the extracted/in-scope metatypes (imported or in the prelude) — never every loaded package. This is what closes the T2 leak.

Conservativity is a composition of two properties (validated by reading the whole spec/lean/Argon/ tree), both now Lean-proven for their core:

  • Ontology/concept layer — ⊥-locality module extraction → Σ-scoped CWA-conservativity (concept entailment): proven (Locality/ScopedConservativity.lean, DomainConservative, ChainedCwa).
  • Datalog/derived layer — a program slice (the rules in the dependency-closure of the used predicates) preserves the least-fixpoint on the closure predicates, i.e. derived extents + checks: proven for positive Datalog (Scratch/DatalogSliceRelevance.lean, agree_on_closure, axiom-free — Lean obligation L1, discharged). The existing Locality/ mechanization did not cover this (Seminaive.lean proves the module-id column is harmless — a different result). The extension to stratified negation + aggregates is L2 (below), gated meanwhile by the differential test.

The differential and determinism tests below guard the implementation; L1 discharges the positive-Datalog core of the derived-layer proof gap.

D4 — Prelude configuration: manifest default + per-module opt-out

Two controls, both honored:

  • ox.toml [package].prelude — an array of use-tails, each parsed exactly as if written use <entry>; and auto-prepended to every module of the package. Reusing the real use parser gives single / brace-list / glob / alias forms for free, with identical resolution and identical participation in the D3 use-graph (a prelude entry is an implicit use). Default empty. An entry that fails to parse or resolve is a hard error (explicit author intent), not an OW1240 warning (OW1240 stays for unknown keys).

    [package]
    prelude = [
      "std::core::{type, rel}",   # opt into the no-commitment baseline
      "std::ufo::prelude::*",     # or glob a foundational ontology's public prelude
    ]
    

    Large preludes: point at your own prelude module. For a non-trivial prelude, the cleanest shape is a one-line config that globs the package’s own prelude module, with the actual re-exports written as real Argon code:

    [package]
    prelude = ["pkg::prelude::*"]
    
    // prelude.ar
    pub use std::core::{type, rel};
    pub use std::ufo::prelude::*;
    // … grows here, commentable, reviewable
    

    Validated against the resolver: pkg:: is the package self-anchor (resolve.rs:680, and it anchors at that package’s root even as a dependency, :681-684); a glob use pkg::prelude::* pulls in the module’s public surface including its pub use re-exports, which resolve transitively and cycle-guarded (resolve.rs:410-416, :452-453). This is role-1 (auto-import) pointing at role-2 (the exported module) — one mechanism, composed. Caveat (new): the prelude module must be exempt from auto-prelude injection into itself (else use pkg::prelude::* is prepended to pkg::prelude); the resolver’s cycle-guard catches the loop, but exempting the prelude module is cleaner.

  • #![no_implicit_prelude] — a per-module override. Add it to DIRECTIVE_REGISTRY (module position, ATTR_INNER); expose inner attributes on the SourceFile AST (today argon.ungrammar has only items:Item*); thread the flag into prelude injection. A module with the flag gets Tier 0 only — no auto-prelude — and must use everything else explicitly.

D5 — Resolve the spec contradiction; dedupe Top/Bot

The reference is amended to a single consistent rule: nothing from the stdlib is ambient; type/rel require use std::core::{type, rel} (or a prelude that re-exports them). Delete the §5.2 / §15 “auto-injected ambient type/rel” clauses; keep §4 / §15.0.1. Remove the pub type Top/Bot declarations from std/core/root.ar (Top/Bot are Tier 0 primordials).

Migration

This is a breaking change for any code relying on the leak or on the deleted standalone auto-load. Accepted (the corpus is ours).

  • Delete single_file_workspace_with_stdlib (oxc-driver). Loose-file compilation runs under oxc with substrate + explicit uses + extraction.
  • A corpus migration pass: every module that declares pub type/pub rel, or uses a stdlib classifier without importing it, gains the right use (std::core::{type, rel}, std::mlt::*, …) — or, if it is a package, the entry in [package].prelude.
  • Examples and single-file tests either run under oxc with an explicit use std::core::{type, rel}, or gain a one-line ox.toml (prelude = ["std::core::{type, rel}"]); the test harness (write_temp_source) writes whichever it needs.
  • The OE0605/OE0606 unresolved-introducer diagnostics gain a hint suggesting the likely use (e.g. “did you mean use std::core::{type, rel}?”).
  • The stdlib packages themselves declare their own dependencies via use (e.g. std::mlt uses std::core).

Acceptance tests

  1. Leak closed: pub categorizes AB(A, B) with no useOE0606 (unresolved metarel introducer), not ok. (Inverts test T2.)
  2. Baseline opt-in: pub type Foo {} with no use and #![no_implicit_prelude]OE0605; with use std::core::{type}ok.
  3. Symbol-precise extraction: use std::fin::{MonetaryAmount} yields an artifact with MonetaryAmount and its dependency closure but not the std::fin declarations it never references; no use of std::fin ⇒ zero std::fin events. Assert over the event stream.
  4. No regression for committed vocabularies: use std::ufo::prelude::*; pub kind Person {} checks clean with no std::core in scope.
  5. Determinism preserved: byte-identical .oxbin across builds (S5) holds under extraction (the extracted set is a deterministic function of the use-graph).
  6. Extraction is conservative: for any program, the answers (derived extents, check firings, tiers) under symbol-precise extraction equal those under load-everything — spot-checked differentially over the corpus. The DL/concept half is the Lean-proven ⊥-locality property (Locality/); the positive-Datalog half is L1 (Scratch/DatalogSliceRelevance.lean, proven, axiom-free); the stratified-negation/aggregate cases (L2) rest on the differential test until mechanized.

Lean obligations

  • L1 — positive Datalog slice-relevance: DISCHARGED. Proven in spec/lean/Scratch/DatalogSliceRelevance.lean (theorem agree_on_closure): for a ground positive program, the slice (rules whose head predicate is in the dependency-closure of the used predicate set Q) and the full program agree, at every fixpoint iteration — hence at the lfp — on every atom whose predicate is in the closure of Q. So loading only the use-graph closure yields the same derived extents over the used predicates. Self-contained (no Mathlib), no sorry, and #print axioms reports it depends on no axioms at all (fully constructive) — the agreement is proven by induction on the fixpoint chain, not assumed ([[no-axiomatizing-the-conclusion]]). This is D3’s derived-layer conservativity for the monotone core, composed with the ⊥-locality result below.
  • L2 — stratified negation + aggregates (remaining). L1’s T is monotone (positive Datalog). Argon’s reasoning is stratified well-founded with negation and aggregates; the relevance result extends in the standard way, but a faithful proof must model the strata, so L1 does not by itself cover the non-positive cases. Until L2 is mechanized, the differential test (#6) gates those. (Per AGENTS.md the Rust reasoner leads the Lean on the reasoning layer.)
  • Already discharged (ontology layer): ⊥-locality → Σ-scoped CWA-conservativity (Locality/ScopedConservativity.lean), domain-conservative extraction (DomainConservative), chained across imports (ChainedCwa).

Spec reconciliation (reference edits)

  • 05-constructs.md §5.2, 15-stdlib.md §15/§15.0.1: delete “auto-injected ambient type/rel”; state the explicit-inclusion rule and the prelude tiers.
  • 03-modules.md §3.4: make the resolution order match D1–D3 (substrate is the floor, not auto-prelude-then-primordials); document #![no_implicit_prelude].
  • appendix-a-reserved-keywords.md: type/rel are not reserved ambient names.

Resolved (this RFD)

  • oxc = rustc, ox = cargo. The prelude is a package feature; there is no compiler-default prelude. Loose oxc files use substrate + explicit uses. single_file_workspace_with_stdlib is deleted.
  • One auto-import mechanism (role 1), distinct from a package’s exported prelude module (role 2). No std::prelude::v1, no std::core::prelude.
  • [package].prelude = an array of use-tails, default empty; per-module opt-out #![no_implicit_prelude]. Bad entry = hard error.
  • type/rel opt-in via [package].prelude or explicit use — never privileged ambient.
  • Loading is symbol-precise (⊥-locality extraction), never whole-package.

Open questions

  1. Extraction staging. Symbol-precise ⊥-locality extraction is substantial. It may land in stages, but the hollow package-level version must not ship — if an interim step is needed it must still be sound (over-approximate only in ways that never change answers, and log/document any conservatism). Sequence the use-graph pass and the extraction pass against the existing Lean theory.
  2. #[cfg(...)] as the eventual conditional-prelude mechanism — out of scope for this RFD, noted as the natural later home for build-conditional baselines.

RFD 0039 — Composable mutations: nested invocation and derived reads

  • State: accepted — implemented (nested invocation Operation::Invoke #566; derived-extent reads Operation::ForEach #571; closed-nesting soundness mechanized on the never-merged scratch/nested-mutation-soundness branch)
  • Opened: 2026-06-19
  • Decides: how a mutate body (1) invokes another mutation — including a trait mutate member — and (2) reads the deductive plane (a derive/query result) to drive effects, so that the “rules that apply rules” pattern is expressible inside the language rather than only via host-side orchestration. Builds on RFD 0015 (the atomic buffer→prevalidate→commit body), RFD 0025 (the check delta-guard), RFD 0026 (trait members + receiver dispatch), and the pipeline slot RFD 0035 D1 reserved for mutations.

This RFD is Lean-first where it touches soundness: the atomicity of nested composition and the confluence of snapshot reads are the executed meaning, held in spec/lean/Argon/Runtime/ (extending MutationSemantics.lean’s runMutation_error_noop) and gated by the differential oracle. The surface grammar, the operation IR, and the call-graph analysis are engine architecture settled here. It commits a plan, folded into the implementing PRs per the discussion-first practice.


Question

A modeler writes a generic workflow rule as a trait and an impl:

pub trait Rule {
    derive Applicable(Self)
    mutate Apply(self)
}
impl Rule for LateFeeRule {
    derive Applicable(rule: Self) :- /* … conditions over rule.* … */
    mutate Apply(self) { /* create a RightDutyPair; enqueue a WorkflowRequest; update self.applied = true */ }
}

The deductive half works — Applicable(rule) derives, and a query returns the applicable rule ids. The modeler then wants to apply them, in Argon:

pub mutate ApplyApplicableRules() {
    for rule in Applicable {        // read the derived set
        Rule::Apply(rule);          // invoke each rule's mutation
    }
}

Today neither line works. There is no operation for invoking a mutation — a call in statement position is silently dropped (oxc-instantiate/src/mutate_lower.rs, the _ => {} arm of lower_stmt_expr) — and there is no path from a mutate body into the reasoner, so a for cannot range over a derived predicate. What are the correct semantics for a mutation that calls a mutation, and for a mutation that reads a derived set to drive its effects?


Context

The two-plane substrate

Argon already firewalls a deductive plane (derive/query: monotone, fixpoint, no effects) from an effect plane (mutate: insert/update/delete, executed as one atomic all-or-nothing transaction over a discardable overlay — RFD 0015, proven by runMutation_error_noop in Runtime/MutationSemantics.lean). The check delta-guard (RFD 0025) already invokes the reasoner over the committed ⊎ buffered overlay at one defined point, so a controlled deductive read at a transaction boundary is not a new capability — only a newly surfaced one.

Trait-member dispatch already works

RFD 0026’s receiver dispatch is live at runtime: resolve_mutation_invocable recognizes a trait-member callable, member_receiver_individual extracts the self receiver from the args map, and select_member_impl picks the unique covering impl by the receiver’s actual classification (refusing on zero or on <:-incomparable multiple covers). What is missing is (a) self lowering in expression position (a separate bug, #550) and (b) an operation that drives this dispatch from inside a body.

Prior art (the design is not invented here)

Six independent traditions converge on one model:

  • Transaction Logic (Bonner & Kifer, TCS 133, 1994): a transaction is evaluated over a path of states; serial conjunction is “do φ then ψ”; a sub-transaction call splices its body inline into the caller’s path, so the whole transitive call tree is one path that commits-or-aborts wholesale by construction. Tests (reads) interleave with updates and see prior writes.
  • Nested transactions / closed nesting (Moss 1981; Gray & Reuter savepoints): a subtransaction’s effects merge into the parent on subcommit and become durable only at top-level commit; a child abort is a partial rollback the parent may catch. Closed nesting is exactly the model that preserves top-level all-or-nothing.
  • Dedalus / Bloom + CALM (Alvaro, Hellerstein et al.): deductions are instantaneous within a step over a frozen snapshot; state change is deferred to a step boundary. CALM explains why: applying a non-monotone write (delete/aggregate) mid-fixpoint makes derivations order-dependent and destroys confluence.
  • LogicBlox / Rel (Aref et al., SIGMOD 2015; RelationalAI 2025): EDB/IDB firewall; updates are a delta plane recomputed over an immutable snapshot at named stages (@start/@final); one composite transactional fixpoint; integrity checked at the boundary, abort wholesale.
  • Active-rule termination & confluence (Aiken, Widom, Hellerstein, SIGMOD 1992): set-oriented (statement-level) firing over the net-effect delta; termination guaranteed by an acyclic triggering graph (sufficient, conservative); confluence requires commutativity over the transitive-trigger closure.
  • Golog / IndiGolog and Flix: a body as an ordered sequence of tests and primitive actions with read-your-writes; a static type-and-effect wall with effect polymorphism (a caller’s effect subsumes its callee’s, by inference).

Decision

D1 — A mutate body is reads-over-a-snapshot serially composed with deferred writes

The guiding principle, and the one-line semantics:

A mutate body is a serial composition of state-preserving reads (against the deductive-fixpoint snapshot) and deferred writes; a sub-mutation call is closed-nested — its writes merge into the parent’s single transactional buffer and become durable only at the top-level commit, so the whole transitive call tree is one atomic all-or-nothing unit.

D2 — Nested invocation: Operation::Invoke, closed-nested

Add an Operation::Invoke { callable, args } to the core IR. At runtime it does not call execute_mutation (which commits and runs its own check cycle). It resolves the callable (reusing resolve_mutation_invocable + select_member_impl) and runs the callee’s operations into the caller’s BodyExec — the same pending effect buffer and collections overlay — under a child binding scope for the callee’s parameters. That is closed nesting realized directly on the RFD 0015 overlay: effects merge up, the check delta-guard runs once at the outer commit, and atomicity composes for free.

  • Termination: the mutation call graph must be statically acyclic in v1 (no recursive mutation cycles). This is the decidable, safe choice and matches the decidability-tier philosophy. Mutual recursion / fixpoint-to-quiescence is a separately-gated future feature.
  • Failure: a sub-mutation’s failed require aborts the whole top-level transaction. Argon has no try/recover surface; catchable savepoint-style partial rollback is a deliberate deferral (it needs a recovery construct first).
  • Effect discipline: adopt the call-purity ladder fn ⊆ query ⊆ derive ⊆ mutation; a caller may invoke only equal-or-lower-impurity callees, and mutation → mutation composes into one atomic transaction. The effect is inferred (Flix-style polymorphism), not annotated.

D3 — Derived reads: snapshot at a fixpoint boundary, set-oriented, fire-once

A for x in <derived-extent> iterates a snapshot of the derived predicate taken at the deductive fixpoint of the body-entry state — never a partially-applied relation (CALM). This needs a read-goal term that invokes the reasoner from the mutation runtime, which is the mutation slot RFD 0035 D1 reserved.

  • Default = snapshot-once: compute the applicable set at entry, fire each Apply exactly once. Trivially terminating; confluent when the rule mutations have disjoint footprints. This is the set-oriented / statement-level reading.
  • Iterate-to-quiescence (recompute the set after each firing) is a strictly-more-expressive, strictly-more-dangerous later feature, gated on a static acyclicity check of the triggering graph (the stratification analysis in another hat) plus a loud runtime iteration bound. Not in v1.

D4 — Keep the two planes’ read semantics distinct

The apparent tension between Transaction-Logic read-your-writes and CALM snapshot-reads dissolves once the planes are separated:

  • Reading a derived predicate → a stable snapshot at a fixpoint boundary (D3).
  • Reading the body’s own direct writes (a scalar/collection it set) → read-your-writes (already partial via buffered_collection, RFD 0015 RC2).

Rationale

The decision is the intersection of all six traditions, and — crucially — it requires no new execution substrate. Operation::Invoke-into-the-parent-BodyExec is simultaneously Transaction Logic’s serial-conjunction splice, Moss’s closed-nested merge-up, and Bloom’s defer-to-boundary; the snapshot read is LogicBlox’s staged @final and Dedalus’s frozen-snapshot fixpoint; the fire-once default is Aiken–Widom–Hellerstein’s set-oriented semantics with a guaranteed-terminating triggering graph. Each maps onto machinery Argon already has (the overlay, the reasoner-over-overlay in check discharge, the stratification/tier analysis), which is why the soundness obligation is an extension of an existing theorem rather than a new framework.


Alternatives

  • Open nesting (a sub-mutation’s effects escape early, atomicity restored via compensating inverses). Rejected: it breaks top-level all-or-nothing and owes hand-written inverses; closed nesting is strictly safer and is what the overlay already supports.
  • Iterate-to-quiescence as the default. Rejected as default: it is what some workflow modelers expect, but it is exactly where non-termination and order-dependence live. It is offered only behind the static guard above.
  • Nested calls each commit independently (no closed nesting). Rejected: it shatters atomicity — a failure in a later sub-call could not undo an earlier committed one — contradicting the §7.5 “any error emits nothing” guarantee.
  • Host-side-only orchestration (the status quo: query, then loop and dispatch from the SDK). Retained as always-valid, but insufficient — and even it was blocked by the self bug (#550). “Rules that apply rules” is a first-class modeling pattern, not glue.

Consequences

  • A new Operation::Invoke IR variant and a read-goal term; the lowering replaces the silent _ => {} drop. The runtime threads Invoke into the current BodyExec rather than a fresh one.
  • The Lean gains a closed-nesting atomicity lemma (composition preserves runMutation_error_noop) and a snapshot-read confluence statement; the differential oracle gates the executor.
  • The reference manual §7.5 grows the invocation and derived-read forms; §7.7 (emit/sinks) is orthogonal.
  • Supersedes the nested-call scope creep folded into #74. Tracking issue: #551.

Soundness plan (Lean-first, scratch-mechanized before the executor)

Per the discipline that novel soundness frameworks are mechanized in scratch Lean before implementation and held by the differential oracle, here is the obligation, scoped against the existing model in Argon/Runtime/MutationSemantics.lean.

What the existing model already gives us (verified)

  • A mutate body is evalMutate : MutateDecl → Env → ReadView → Except Abort (List Effect × Value). It threads a MutState := { fresh, effects : List Effect } through evalStmts; the read-view rv is a read-only argument — it never changes during a body. Effects accumulate in MutState.effects; commit folds applyEffect over them only on the .ok branch; runMutation no-ops on .error (runMutation_error_noop, require_fail_atomic).
  • Consequence for D3 (snapshot read), already structural: because rv is read-only through evalStmts/evalExpr, any derived read inside a body observes the body-entry committed state by construction. The v1 snapshot semantics needs no new invariant — it is the only thing the model can express. (The overlay-staged re-derivation variant — the iterate-to-quiescence door — is what would require new machinery.)

What to add

  1. A statement constructor Stmt.invoke (callee : MutateDecl) (args : List Expr) (the Lean image of Operation::Invoke).
  2. An evalStmt (.invoke callee argExprs) case that evaluates the argument expressions against the current (env, rv, st), binds the callee’s parameters, and runs the callee’s require guards + body statements into the same MutState — appending the callee’s effects to st.effects and threading st.fresh. Aborts propagate through Except unchanged.

Theorems to prove

  • T1 — Closed-nesting atomicity (the headline). For any body containing invoke statements, runMutation is all-or-nothing: if evalMutate = .error err then runMutation = rv. Strategy: this is runMutation_error_noop unchanged — it depends only on evalMutate returning effects solely on .ok, which the shared-buffer/Except-propagation invoke case preserves. The proof lifts for free; that is the entire point of running the callee into the parent buffer rather than committing it.
  • T2 — Effect-buffer monotonicity. On .ok, an invoke extends st.effects by exactly the callee’s emitted effects (a list append), in callee-emission order spliced at the call site — the Lean form of Transaction Logic’s serial-conjunction splice / Moss’s merge-up. Grounds “the transitive call tree is one effect list committed once.”
  • T3 — Fire-once confluence (D3). For a snapshot set S iterated by for x in S { Apply(x) }, if the per-element effect sets have disjoint footprints (no two write the same (id, field) / (id, concept) / relation tuple), then commit is independent of the iteration order of S. Strategy: a commutativity lemma on applyEffect for footprint-disjoint effects, lifted over foldl.

Mechanization result (scratch branch scratch/nested-mutation-soundness)

A faithful self-contained miniature of MutationSemantics.lean proved all three (zero sorry/axiom; only propext/Classical.choice/Quot.sound), with one consequential refinement on T3:

  • T1 and T2 lift cleanly, no extra hypotheses. runMutation_error_noop survives the shared-buffer Stmt.invoke verbatim — confirming the formal payoff of run-into-parent over commit-nested.
  • T3 holds only up to observational equivalence, not structural equality — and this is mechanized as a counterexample theorem, not a caveat. The literal goal commit (commit rv A) B = commit (commit rv B) A is false even for footprint-disjoint effects, because applyEffect prepends to the classification/relation lists ((id,c) :: …, as the production applyEffect does too), so two disjoint asserts land in opposite list order. Confluence is true against an Obs interface — equal classification/relation membership, equal property lookups, equal nextFresh — i.e. up to what reads can observe. T3 is proved up to Obs, lifted over List.Perm.

Consequence for the executor. Order-independence of the apply-all loop is a read-interface property, not a representation property. The Invoke executor needs no commit-order canonicalization provided reads go through the membership/lookup interface, and the differential oracle must compare observable reads, not raw buffer/store layout.

The one new obligation: termination

Adding invoke breaks the structural termination_by (sizeOf s, 0) measure — a call runs the callee’s body, which is not a sub-term of the call statement. This is precisely why D2 mandates a statically acyclic mutation call graph: it is what makes the composed evaluator terminating and the recursion well-founded. Two encodings, in increasing fidelity:

  • (a) Fuel/gas — a depth parameter that decreases per invoke; models the runtime depth bound. Cheapest; lets T1–T3 be proved immediately, parametrically over fuel.
  • (b) Well-founded recursion on the acyclic call-graph rank — measure = (topological rank of the callee in the dependency DAG, then sizeOf within a body). This is the faithful image of D2’s static check and the honest termination story; it is the harder mechanization.

Recommended cut: prove T1–T3 under (a) fuel first (they are about atomicity/confluence, orthogonal to why evaluation terminates), and discharge termination separately under (b) as the acyclicity decision’s own lemma. Keep all of this on a scratch branch (never merged); the differential oracle gates the Rust executor against the executed meaning, exactly as for the reasoner.


Open questions

  • Lean first: mechanize closed-nesting atomicity and snapshot-read confluence in scratch Lean before the executor lands (the F2.4 bar). What is the minimal lemma shape that composes with the existing overlay proof? Resolved (scratch branch scratch/nested-mutation-soundness): T1/T2 lift unchanged; T3 confluence holds up to observational equivalence (see Mechanization result above). The minimal shape is runMutation_error_noop verbatim for atomicity + an Obs-quotiented commutativity for confluence.
  • Dispatch over buffered classification: should select_member_impl at an Invoke site read the receiver’s buffered (read-your-writes) classification or the committed one? Committed is the v1 answer; buffered is a coherent enhancement once scalar RYW lands.
  • Derived-read staging: is the snapshot taken at body-entry committed state, or re-derived over the committed ⊎ buffered overlay at the point of the for (as check discharge does)? D3 fixes v1 at body-entry; the overlay-staged variant is the iterate-to-quiescence door.
  • Quiescence guard: the exact triggering-graph construction over mutation effects and its relationship to the existing stratification pass.

RFD 0040 — The procedural macro layer

  • Status: P2 built — all builtin stubs retired. Reflection-types-as-real-types (#850) and the Lean line (D7) remain.
  • Depends on: RFD 0037 (the macro atom — declarative layer, D7 Lean line, D9 deferred analytic layer), RFD 0038 (nothing-ambient / import discipline)
  • Blocks: the genuine re-home of the relation-property checks and the MLT decorators (today shipped as builtin-backed P0 stubs, RFD 0037 D8); the classify ∘ expand tier-honesty theorem (RFD 0037 D7)
  • Implements: RFD 0037 D9 — “the procedural / analytic layer is deferred, with its end-state shape committed.”

Amendment (P2 built) — bounded structural iteration

D4 below commits to general structural recursion checked at declaration. Building P2 ruled the meta-language’s traversal to be bounded structural iteration instead, and this amendment records that decision (it supersedes the recursion framing in D4 / D6 stage 3 / Acceptance test 2):

  • The one iteration form is $( for x in item.fields ) { … } (and item.params) inside quote — a bounded loop over a finite reflected child-list, total by construction, nestable for depth. There is no user-writable recursion or call form (only the concat_idents paste builtin). So totality needs no decreasing-argument analysis: the body is a closed fragment {let-paste, quote + $(for) + ${…}/if, match on a reflected scalar, list-lit of quote/artifacts}, and anything outside it is refused at the declaration (OE0729 — Acceptance test 2, reframed from “non-structural recursion refused” to “out-of-fragment construct refused”).
  • Rationale (not effort): strong normalization without a checker that could itself be wrong; alignment with Argon’s determinism + decidability + cheap-Lean doctrine; the warning in this RFD’s own Background that compile-time-Argon must not grow “more powerful and less correct.” Every real client needs ≤2 levels of named children, which nested bounded iteration covers.

Landed: P2 structural field reflection + the derive-class capability (proven by #[reflect_fields], Acceptance test 3); the OE0729 totality guard (Acceptance test 2); #[functional] retired — the last #[builtin] (it is now a std::rel procmacro that dispatches on item.kind: a rel re-emits with a [0..1] target cardinality cap via structural re-emission, a metarel pastes the __{rel}_functional check — Acceptance test 1’s third). The MLT re-home (Acceptance test 4) had already landed under RFD 0043. OE1362 (the #[functional] import gate) is retired — an unimported one is now OE0705, like its siblings.

Remaining: the reflection vocabulary as real resolvable types (#850; the hover symptom is already fixed) and the Lean Syntax carrier + tier-honesty theorem (D7). Reference §13.5/§13.8 + §05 prose lift from “deferred”/“builtin” to “built” — coordinated with the docs work, not in the compiler branch.

Summary

RFD 0037 shipped the macro atom’s declarative layer (pub macro, pattern→template, hygiene, expand-to-surface) and committed — but did not design — the procedural layer: “a total, structurally-recursive meta-language over reflected syntaxnot general-purpose Argon.” This RFD designs that layer.

The motivation is honesty, not novelty. Two macro clients ship today as P0 stubs — a library surface whose implementation is still a privileged compiler builtin, because the declarative layer cannot express them:

  • the relation-property checks (#[irreflexive]/#[asymmetric]) need to construct an identifier (__{rel}_irreflexive) — paste — which a splice-only language has no operator for. (#[functional] is the family’s third member but is not paste-shaped: one of its two arms re-emits a rel with a modified cardinality bracket, which is structural re-emission — P2 — so it stays a builtin stub past P1; see D6 / Acceptance test 1.)
  • the MLT decorators (#[categorizes(T)] …) need to read the decorated declaration’s name and emit a metaproperty — which has no user-writable surface.

A stub is a deferral, not a re-home: the library owns the surface, the compiler still owns the implementation. The procedural layer is what turns these into genuine macros and lets the library migrate its implementations. It is deliberately smaller than “compile-time Argon”: a total fragment, strongly-normalizing by construction, so it preserves Argon’s determinism (content-addressed builds), decidability-tier honesty, and Lean-mechanizability.

This RFD is design-first: it crystallizes the surface, the reflection model, the totality discipline, the staged delivery, and the Lean line. The committed first implementation step — P1, identifier construction (paste) — is now built (#567): it retires the #[irreflexive] + #[asymmetric] stubs (genuine #[procmacro]s, byte-identical events). #[functional] stays a builtin stub until P2 (its cardinality-cap arm is structural re-emission, not paste).

Background — what’s a stub today, and why

The declarative layer is Syntax → Syntax by splice: it matches a token pattern and substitutes captured fragments into a template. It has no operation that (a) builds a new identifier from pieces, or (b) inspects a declaration’s structure and computes output from it. Both current stubs need exactly one of these:

ClientWhat it needsWhy declarative can’tStatus today
#[irreflexive]/#[asymmetric]head __{rel}_{prop} built from the relation nameno concat_idents / pasteP1: genuine #[procmacro]s (#567); OE0705 if unimported
#[functional]a metarel-check head (paste-able) and a rel-cardinality-cap [0..1] re-emit (structural)one attribute can’t be half-builtin/half-procmacro; the cap arm needs structural re-emissionbuiltin stub (P2); OE1362 import-gate
#[categorizes(T)]/#[partitions]/#[subordinate_to]/#[power_type_of]read the decorated concept’s name; emit MetaProperty(subject, axis, T)no reflection; no metaproperty surfacebuiltin-backed stub (RFD 0037 D8)
derive-class (inspect a concept’s fields, synthesize per-field)structural recursion over a declarationno reflectionthe capability P2 builds (this RFD, stage 3)

The research campaign (research/macro-system, D4) recommended C-then-B: declarative now, then a bootstrapped-in-Argon procedural layer on a zero-capability Wasm substrate, fuel-bounded. RFD 0037 D9 narrowed that to a total meta-language — strongly-normalizing by construction rather than fuel-bounded-and-Turing-complete — because totality is what Argon’s own doctrine demands (deterministic builds, the decidability ladder, Lean-canonical substrate) and “a Turing-complete compile-time Argon would be more powerful and less correct.” This RFD designs the committed total meta-language; Wasm is then an implementation option for its sandboxed substrate, never the user-facing surface.

Decision

D1 — Surface: #[procmacro] pub fn, body in a total fragment

A procedural macro is a function marked #[procmacro], in the one macro namespace (RFD 0037 D9; the !/#[…] sigils are invocation markers, not namespace tags). This is the shape already carried by the Lean substrate (Macro.lean: MacroAtom.procedural : ProcMacroDecl → MacroAtom, p.fn.name/p.fn.isPub).

#[procmacro]
pub fn transitive(item: Decl) -> Syntax = …      // a derive-style decorator

The body is not general-purpose Argon. It is a total fragment: structural recursion over reflected Syntax, the construction/quotation builtins (D3), and pure expressions — no I/O, no clock/RNG/filesystem, no unbounded recursion. The body’s totality is checked at declaration (D4), so a #[procmacro] either is strongly-normalizing-by-construction or is refused — never “trusted to terminate.” #[procmacro] is currently reserved (OE0706); this RFD lifts the reservation in stages (D6).

Rationale: reusing pub fn (not a bespoke DSL keyword) keeps the surface familiar and the namespace single; restricting the body to the total fragment is what buys determinism + decidability + mechanizability. This mirrors Argon’s own tier ladder — the meta-language is, in effect, a low-tier total sublanguage applied to syntax.

D2 — Reflection model: an inductive Syntax, read-only, finite

The macro receives its input as a value of an inductive Syntax type — the reflected post-parse AST (a deep embedding mirroring the typed AST that @[language_interface] already governs). Inputs by invocation position:

  • function-like name!(tokens)Syntax of the argument fragment;
  • attribute / derive #[name(args)] <item> → the decorated Decl (a Syntax subtype) plus the attribute args fragment.

Reflection is read-only and finite: a Decl exposes its name, kind, generics, parameters, fields, and attributes as Syntax children; a macro pattern-matches and recurses on those children, which are structurally smaller. There is no reflection of resolved identity (DefIds), types post-elaboration, or tiers — expansion runs before resolve/check (RFD 0037 D1), and reflecting post-resolution facts would break the phase ordering and S4 (tier-honesty). The Syntax shape is itself an @[language_interface] carrier, so the drift gate covers it (D7).

D3 — The construction builtins: paste, quote, splice

The meta-language’s output side is three primitives, each total:

  • quote { … } / splice — build Syntax from a literal template with $-holes (the declarative transcriber, now first-class and nestable). Output is surface text re-parsed by the ordinary path (RFD 0037 D2) — never events directly.
  • concat_idents(a, b, …) (paste) — construct a new identifier from identifier/literal pieces. This is the single capability the three checks need (concat_idents("__", rel_name, "_irreflexive")). It is total (string-level), non-recursive, and deterministic.
  • structural match over Syntax — case-split a reflected node and recurse on its children (D4).

A pasted identifier is raw (definition-site, not freshened): the macro intends it as the actual program name — like the check head __{rel}_irreflexive, which must be a stable, collision-resistant identity the classifier and the .oxbin see. Paste therefore composes with hygiene (RFD 0037 D3) as a definition-site name in the reserved-marker namespace where collision-resistance is needed, and as a plain raw name where the macro author wants a predictable public name. (The exact hygiene interaction of paste is the one sub-decision flagged in Open Questions.)

As shipped in P1: concat_idents emits the pasted head as a raw definition-site name with no freshening — exactly the former synthesis’s __{rel}_{prop}. Splices ($item, $item.name, $item.params[i].ty) are substitution barriers: the reflected text is dropped in verbatim, carrying its own use-site identity, not re-scoped by the macro. So P1 does no hygienic renaming at all — which is correct for these checks (they want the stable public head), and is the conservative floor the Open-Questions paste×hygiene decision will build on.

D4 — Totality: structural recursion, checked at declaration

Termination is by construction, not by fuel. The only recursion the meta-language admits is structural: a recursive #[procmacro] (or meta-helper) may recurse only on a Syntax value that is a proper child of its argument. The compiler checks this at declaration (a decreasing-argument check over the finite Syntax tree — the same well-founded-recursion discipline Lean uses for structural recursion). A body that cannot be shown structurally decreasing is refused (a new OE07xx), not accepted-and-fuel-capped.

A fuel cap (RFD 0037’s OE0727 runaway diagnostic) remains as a backstop against implementation bugs, but it is not the termination argument: the argument is strong normalization of the total fragment. This is what makes expand a total function over an inductive type — the precondition for the D7 theorem — and what distinguishes this layer from the research’s fuel-bounded Option B.

Determinism: no non-determinism sources are in scope (no clock/RNG/FS/hash-seed); emitted collections are emitted in canonical (content-sorted) order; fresh names are content-derived (RFD 0037 D3). The drift contract (S2) and tier-honesty (S4) hold because the classifier still runs last on lowered events (RFD 0037 D6) — expansion is upstream of, and invisible to, the trusted re-check.

D5 — The MLT path: an axis-assertion surface

The MLT decorators are not paste and not field-reflection — they read the decorated concept’s name and emit a MetaProperty(subject = the concept, axis = the metarel, value = T). Today that event has no user-writable surface, which is why the expander stays native. The procedural layer’s MLT deliverable is therefore a surface for asserting a metaproperty, e.g.

#[procmacro]
pub fn categorizes(item: Decl, target: Ident) -> Syntax =
    quote { assert std::mlt::categorizes($item.name, $target) }

where assert <metarel>(a, b) is a surface form that lowers to a MetaProperty event. With (a) light decl-reflection ($item.name) and (b) this axis-assertion surface, the four MLT decorators become genuine procedural macros emitting through the ordinary path — and the native lower_relational_mlt_decorators emitter retires. The metaproperty surface is partly independent of the meta-language (it is an event surface question), so it is its own decision here and could land separately.

D6 — Staging: the full layer, built in sequence

The procedural layer ships in full, in three stages built in order — each stage retires real debt, and stage 3 follows stages 1 and 2; no stage is gated on a hypothetical future client. (“Built when a derive-class client forces it” — the framing RFD 0037 D9 and the research used — is a hollow deferral that lets the capability never get built; this RFD rejects it. The capability is the deliverable.)

  1. P1 — paste (concat_idents) + the #[procmacro] surface for non-recursive bodies. Total trivially (no recursion). SHIPPED: retires the #[irreflexive] + #[asymmetric] P0 stub — they become genuine std::rel procedural macros emitting byte-identical events (their arms in synthesize_relation_property_rules are deleted; OE0706 reservation for #[procmacro] is lifted; the new evaluator is oxc-workspace/src/procmacro.rs). #[functional] does NOT migrate in P1: its rel-cardinality-cap arm needs to re-emit a rel with a modified cardinality bracket = structural re-emission = P2, and a single attribute name cannot be half-builtin/half-procmacro — so functional stays a #[builtin] stub (OE1362-gated) until P2. This was the committed first implementation step; it is now built.
  2. The axis-assertion surface (D5). Retires the MLT P0 stub — the four std::mlt decorators become genuine procedural macros.
  3. P2 — full structural recursion over reflected Syntax (D2+D4) — the committed end-state meta-language. Built once stages 1 and 2 are done. It is exercised + proven by writing a real derive-class macro (one that inspects a concept’s fields and synthesizes per-field output — e.g. a #[derive(Reflect)]-style macro) as part of the deliverable, not by waiting for one to “arrive.” This stage also makes expand a total Lean object, unlocking the D7 theorem.

Each stage is surface-stable: a stub-backed #[name] and its procedural-macro implementation have byte-identical lowered events, so migration never changes a program’s meaning (RFD 0037 D8 / S3). RFD 0037 D9 deferred this layer “until a forcing client”; this RFD supersedes that — the full layer is built in sequence.

D7 — The Lean line: hold the AST boundary; pursue tier-honesty when expand is a Lean object

Per RFD 0037 D7 and the research (track-F: Lean 4, CompCert, translation-validation all mechanize the re-checker, not the producer), the expander is untrusted by design. Assurance is the downstream re-check: the tier classifier runs last, the .oxbin content hash is re-derivable, and the drift gate covers the MacroAtom = declarative MacroDecl | procedural ProcMacroDecl shape plus the Syntax carrier (D2).

What changes with this RFD: the total meta-language makes expand a total function over an inductive Syntax type — the precondition for stating the tier-honesty commutation theorem (classify ∘ expand lands a program on the same decidability class as its expansion). RFD 0037 named this “the one worth pursuing.” This RFD commits: when P2 lands, expand becomes a Lean object and the tier-honesty theorem is the rung-(c) deliverable — not a full MacroExpansion.lean expansion-preservation proof (rung d, CakeML-scale, out), not hygiene-algebra mechanization (rung b, POPLmark-scale, out). The totality discipline (D4) is precisely what keeps that theorem statable and the substrate honest.

D8 — Conservative monotonicity: a macro may only raise the tier

A macro’s expansion may land its program at a higher decidability tier than the source spelled, never silently lower it (the program is classified last, on the truth). Sound tier-lowering desugarings — where a macro provably produces a lower-tier equivalent — are a later, proof-carrying escape hatch, out of this RFD. This keeps S4 a structural guarantee during expansion, not a per-macro audit.

Migration

  • P1 landedstd::rel’s #[irreflexive] and #[asymmetric] are now genuine #[procmacro] pub fn … -> Syntaxs: each re-emits the decorated declaration ($item), projects the relation’s name and endpoint types ($item.name, $item.params[i].ty), and pastes a guarding pub check whose head is concat_idents("__", item.name, "_{prop}"). Their arms in the native synthesize_relation_property_rules are deleted, and their import gate shifted from OE1362 to OE0705 (a bare unimported #[irreflexive] is now an unknown attribute macro, exactly like transitive). The byte-identical differential test (relation_property_rehome.rs) now asserts the procedural macro’s events equal the hand-written expansion. #[functional] is deferred to P2 and stays the #[builtin] stub (OE1362-gated): its rel-cardinality-cap arm needs structural re-emission, and an attribute is builtin or procmacro, not both.
  • Axis-surface lands → rewrite the four std::mlt decorators as #[procmacro]s; delete lower_relational_mlt_decorators. Closes #483 genuinely (not as a surface stub).
  • No source migration for users: #[transitive], #[irreflexive], #[categorizes(T)] are spelled identically before and after; only the import (use std::{rel,mlt}::{…}, already required post-RFD-0038) and the byte-identical events are observable. S3 holds at every step.

Acceptance tests

  1. P1 retires the checks’ stub (byte-identical): #[irreflexive] and #[asymmetric] are re-expressed as #[procmacro]s — each re-emits the decorated declaration ($item) and pastes a guarding pub check whose head is built by concat_idents("__", item.name, "_{prop}") — and produce events byte-identical to the former synthesis (relation_property_rehome.rs); their arms in synthesize_relation_property_rules are deleted. #[functional] stays a #[builtin] stub, not a procmacro: it is one attribute that covers BOTH a metarel-check (paste-able) AND a rel-cardinality-cap [0..1] on the target endpoint (lower.rs, the cap arm) — and re-emitting a rel with a modified cardinality bracket is structural reflection, which is P2. Because an attribute name is either a builtin or a procmacro (not both halves split across the two), functional stays entirely a #[builtin] stub (both the rel-cap and the metarel-check synthesized natively, OE1362 import-gate retained) until P2 builds structural re-emission. So P1 retires two of the three checks byte-identically; the third is deferred because the capability (structural re-emission) is unbuilt — not for want of a client.
  2. Totality is enforced, not trusted: a #[procmacro] whose body recurses non-structurally is refused at declaration (the new totality OE07xx), with no fuel-cap fallback masking it.
  3. A genuine derive-class macro (P2): a real procedural macro that inspects a concept’s fields and synthesizes per-field output — written as part of the P2 deliverable — expands end-to-end (parse → EXPAND → resolve → check → instantiate → classify), clears the §3.4 gate, and lands on its true tier.
  4. MLT genuinely re-homed: the four MLT decorators are #[procmacro]s emitting via the axis-assertion surface; lower_relational_mlt_decorators is deleted; events byte-identical.

Lean obligations

  • Extend the @[language_interface] drift coverage to the reflected Syntax carrier (D2), so the Rust Syntax reflection type and the Lean Syntax inductive stay aligned by the existing gate.
  • No MacroExpansion.lean in P1/P2-surface. When P2’s expand becomes a total Lean function, schedule the tier-honesty commutation theorem (rung c) — the distinctive Argon invariant — as the macro layer’s first mechanized theorem (D7).

Spec reconciliation (reference edits)

  • §13.5 (“Procedural macros (deferred)”) → describe this design: the #[procmacro] pub fn surface, the total meta-language, reflection, paste, the axis-surface, and the full staged build (P1 → axis-surface → P2, in sequence). Lift “deferred” to “built (P1 first)” — drop the “first derive-class client” trigger.
  • §13.8 (Lean correspondence) → add the Syntax carrier + the tier-honesty theorem schedule.

Resolved (this RFD)

  • The procedural model is the total meta-language (RFD 0037 D9), not the research’s fuel-bounded bootstrapped-Argon (D4 R1) — committed and now designed.
  • Totality is by construction (structural recursion, checked at declaration), not fuel-bounded (D4).
  • No client-gating: the full layer is built in sequence (P1 → axis-surface → P2); P2 is built once 1 and 2 are done, not “when a derive-class client forces it.” This supersedes RFD 0037 D9’s forcing-client trigger (a hollow deferral) (D6).
  • P1 (paste) is built — it retires the #[irreflexive] + #[asymmetric] stubs byte-identically; #[functional] stays a #[builtin] stub pending P2 structural re-emission (D6, Acceptance test 1).
  • quote’s ${expr} antiquotation is expansion-time. The in-string ${…} form inside a quote { … } body (e.g. the diagnostic message "relation ${item.name} is #[irreflexive] …") is resolved by the macro renderer at EXPAND, substituting the reflected/let-bound value into the rendered surface text before re-parse. It needs no runtime string concatenation — it is a render-time splice, total and deterministic.

Open questions

  1. Paste × hygiene: the precise hygiene treatment of a concat_idents result — raw definition-site name vs reserved-marker freshening — per use (D3). The three checks want a stable public-ish __{rel}_{prop}; a general paste in user code may want freshening.
  2. The axis-assertion surface shape (D5): assert categorizes(a, b) vs a metaproperty-bearing declaration form — an event-surface question that may warrant its own short RFD.
  3. Reflection accessor API for P2: the precise Decl/Syntax accessor set — settled when building P2 as a complete reflection of a declaration’s structure (name, kind, generics, parameters, fields, attributes), not a minimal subset and not deferred to a client.
  4. ${} spelling-overlap with runtime string interpolation. P1’s quote antiquotation ${expr} is an expansion-time render splice (resolved above, Resolved). It is spelled the same as a hypothetical runtime string-interpolation form "…${x}…" that would lower to string concatenation and const-fold — but that is a separate, unbuilt foundational arc (tracked as issue #575): the runtime today has no Text + Text operator and no const-fold pass, so general runtime interpolation does not exist. Do not assume the two ${…} spellings unify — whether the render-time antiquotation and a future runtime interpolation should share a surface (or must be kept distinct, since one is total-by-construction at EXPAND and the other is value-level) is an open question for the #575 arc, not settled here.
  5. elab-class (type-directed) expansion — firing a macro over the expected metatype/sort — is a distinct mechanism (type-direction), not part of the procedural layer (computing an expansion). It stays out of this RFD’s scope; the real ergonomic need it was floated for (legal scoping) is already served by binding-space resolution. If type-direction is wanted, it is its own design, not a deferred piece of this one.

RFD 0042 — The re-checkable emission boundary: a self-validating .oxbin and sound direct artifact emission

  • State: discussion
  • Depends on: RFD 0037 (the macro atom — this amends D2), RFD 0040 (the procedural macro layer — this extends it, and revises its shipped P1 surface), RFD 0036 (heterogeneous stores — the MappingArtifact build-section gate), RFD 0031 (relation-constraint plane — cardinality), RFD 0027 (meta-property plane), RFD 0009 (MLT)
  • Supersedes: RFD 0041 (pub metafact — a surface-per-metadata-kind fix; this RFD makes it unnecessary). RFD 0040 stage-2’s MLT axis-assertion surface became RFD 0041; with 0041 superseded, MLT re-homes through this RFD’s emission gate, not a metafact surface.
  • Blocks: the genuine, builtin-free re-home of the MLT decorators (#483); a library foreign! (RFD 0036 placement)

Question

The macro atom (RFD 0037 D2) committed that a macro expands to surface syntax only, never to events — so everything a macro produces is re-checked by the one trusted parse→resolve→check→instantiate→classify path. That is sound, but it has a cost the campaign made concrete: any vocabulary whose effect has no surface form must be implemented as a privileged compiler builtin (a doctrine violation — the core ships vocabulary) or be granted a new substrate surface per metadata-kind (accretion; libraries gated on compiler changes). Both are paying with the wrong currency. The MLT decorators (→ MetaProperty, no surface), the relation-property checks (→ a pasted __{rel}_{prop} head), and foreign! (→ a .oxbin MappingArtifact, RFD 0036) are all stuck on this. Can a macro emit a substrate artifact directly — keeping D2’s safety — without a builtin, an unsafe escape, or surface-accretion?

Context

D2’s deepest content (RFD 0037 D7) is the CompCert / Ullrich-&-de-Moura posture: the expander is untrusted; assurance comes from re-checking its output. D2 then made one move too many — it conflated re-checked with re-parsed, and required output to be surface. But the trust comes from the re-check, not from the surface provenance: if the substrate re-checks an emitted artifact as thoroughly as a lowered one, direct emission is exactly as safe.

A two-round adversarial review (2026-06-19), verified against the merged substrate, established the load-bearing facts:

  1. Today’s substrate does NOT re-check emitted events. classify runs only on RuleDecl bodies; resolve validates names; encode validates nothing. The load-bearing event validations live in elaboration pre-passes / lower.rs / the runtime write-path — not over the event stream. A directly-emitted event enters the runtime through ingest_locked/seed_from (an infallible index insert), a separate ingress from the write-path (execute_mutation/apply_operation). So a hand-built or macro-emitted RelationTuple smuggles past endpoint-existence (OE0232), arity/endpoint-type (OE0221/OE0222) and max-cardinality (OE1341); a MetaProperty smuggles past axis domain/literal/refinement (OE0622OE0624); a property/iof assertion past refinement (OE0668), required-field (OE0207), value-type (OE0236) and iof-on-defined (OE0211). The lone exceptions are three hand-added load-time backstops — IofAssertion abstract-target (OE0233), axis same-precedence (OE0629), and property-id injectivity (OE0231) — one of whose comments says “a hand-built artifact must not bypass the elaborator’s gate.” So direct emission is unsound on the substrate as it stands, and the gap is broader than first thought: the authors plugged three holes and the rest remain open. This latent debt exists independently of macros — any corrupt, hand-built, or foreign .oxbin already bypasses these checks today.

  2. There is a third output kind. A MappingArtifact (placement) is a .oxbin section (id 11), deliberately versioned independently of the schema (RFD 0036 D6) and explicitly forbidden from carrying axiom-event semantics (RFD 0036 D1). It is neither quote!-able surface nor an AxiomEvent. So {surface | event} is not exhaustive. Crucially, build-sections have a different and simpler soundness story than events (see D4): the trusted production path is config-driven, and a macro can feed the identical struct into the identical sink — parity, not a new gate.

  3. The event re-check set is bounded but larger than a first pass suggested — ~11–12 event-derivable load-bearing validations, not “5–6”. The census (verified file:line in the review record) is:

    • Relation tuples: endpoint-existence OE0232, arity OE0221, endpoint-type OE0222, max-cardinality OE1341 (incl. the #[functional] [0..1] rel-cap, same mechanism).
    • Meta-properties: axis-value-in-domain OE0622, axis-literal-type/refinement OE0623, axis-tier OE0624.
    • Iof / individuals: abstract-target OE0233 (already at load), iof-on-defined OE0211, fixed-reclassification OE0234 (see Fork A — needs an encoding discriminator), property-id injectivity OE0231 (already at load).
    • Construct/property: refinement invariant OE0668, required-field OE0207, value-type OE0236.
    • Axis precedence: OE0629 (already at load).

    Two clarifications the first pass got wrong:

    • The relation-property checks OE1359 (irreflexive) / OE1360 (asymmetric) are NOT in this set. They are enforced by the check rules the macros emit (surface, re-parsed), which run over the relation’s full extent regardless of how a tuple entered — so they self-enforce and need no load-time pass.
    • The remaining validations (arg-shape, match exhaustiveness, defeasibility well-formedness OE0716OE0721, temporal-qualifier forms, in-body construction completeness, the §3.4 introducer-resolution gate, relation-subsumption OE0150OE0154, metarel endpoint-metatype OE0631) gate surface declarations and stay on the lowering path — macros produce those via quote!. A handful (OE0631, OE0150OE0154, OE0222 endpoint-category) are load-bearing but not event-derivable; they bound what is emittable as an event (such artifacts stay quote!-surface).
  4. The .oxbin validation framework exists but is unwired and mis-shaped for this. Layer2Invariants (oxc-oxbin/src/validation.rs) is six always_ok() stubs (symbol_resolution, lattice_acyclicity, provenance_well_formed, composition_consistency, tier_consistency, doc_links_resolve), drift-paired Rust↔Lean (Argon/BuildArtifact/Validation.lean). None of the six slots maps onto the census checks (the near-misses are false friends: symbol_resolution ≠ “endpoint exists as an individual”; tier_consistency = “rule tier ≤ envelope” ≠ “axis binds at the target’s tier”). And Module::load never calls the framework — its sole caller is oxc-serve, passing the stubs. So this RFD does not “fill stubs”: it adds net-new predicates to the framework, wires the framework into Module::load, and thereby grows the trusted base (and the Rust↔Lean drift surface). That is the honest characterisation, and it is fine — see the Rationale.

So the missing piece is not a new trust model — it is making the substrate re-check what it already should: a self-validating .oxbin.

Decision

D1 — The re-checkable emission boundary

A procedural macro may emit, in one expansion, any mix of:

  • Syntax — built with quote!, re-parsed and lowered through the ordinary path (RFD 0037 D2’s mechanism, retained);
  • typed substrate artifacts for which the substrate has a complete re-check gate over the artifact itselfAxiomEvents (the event-stream gate, D3) and build-sections like MappingArtifact (its existing gate, D4).

An artifact-kind with no complete gate is not directly emittable — it must be produced as Syntax (via quote!). There is no unsafe escape: emission is sound by construction because the gate re-checks every emitted artifact to the same standard a lowered one faces. The restriction is not “flat events only” but “only what the substrate fully re-checks.”

The canonical example of the not-directly-emittable case is a raw IofAssertion: its OE0234 fixed-reclassification check is not event-derivable under the current encoding (Fork A), so iof is emitted as Syntax until the discriminator lands. No current client needs to emit a raw iof event (MLT emits MetaProperty; foreign! emits MappingArtifact; the relation-property family emits check rules), so this costs nothing today — and the boundary stays honest rather than pretending the gate is complete when it is not.

D2 — Amend RFD 0037 D2

Lift D2 from “a macro expands to surface syntax, never to events” to: “a macro expands to surface syntax or to re-checked substrate artifacts.” The invariants D2 protected are preserved — not by surface provenance but by the gate (D3/D4): the drift contract (S2 — events are still typed @[language_interface] values), the §3.4 ontology-neutrality discipline (emitted names resolve through the resolver), tier-honesty (S4 — classification runs on emitted events via the gate), and determinism (canonical encoding). The privileged axis-event-emitter carve-out D2/D8 reserved is retired: MLT and the relation-property family become library macros emitting through the gate.

D3 — The self-validating .oxbin (a second trusted checker)

Implement the event-derivable load-bearing validations of Context §3 as .oxbin load-time predicates over the decoded event stream, and wire them into Module::load (which does not call the validation framework today). This is net-new trusted code, not a stub-fill: the existing Layer2Invariants slots do not cover these checks (Context §4), so the framework grows by ~11–12 predicates, drift-paired into Lean (D7).

Reading: whole-module, not single-event. The re-checker validates each event against the fully decoded module (all RelationDecl/MetaxisDecl/ConceptDecl events + the individual set), not against the event’s own body in isolation. Module::load already materialises the entire event stream before any validation could run (decode_eventsVec<AxiomEvent>), so whole-module visibility is available. This is load-bearing: OE0232 needs the workspace individual set; OE0622OE0624 need a join of the MetaProperty event against its MetaxisDecl on axis_id (the domain/targets/tier live on the decl, not the property) — so OE0624’s tier is event-derivable via that join, resolving the earlier open question.

Architecture — shared cores where they exist, fresh load checks where they don’t. Two shapes, do not pretend they are one:

  • AST-shaped checks with an extractable core (OE0232, OE0622, OE0623, and the value/refinement family OE0668/OE0207/OE0236): extract the predicate core keyed on resolved ids/values, behind two thin adapters — an AST-time adapter (lowering, behavior unchanged) and an event-time adapter (load). The two MUST NOT diverge on the decision (see comparability below).
  • Set-level aggregations with no AST core (OE1341 max-cardinality): lowering only records the [lo..hi] bracket; the actual check is a stateful runtime tuple-count over scan_live, per (tenant, fork). There is no AST predicate to factor — the load form is a fresh whole-event-stream aggregation, partitioned by (tenant, fork). Build it as such; do not force it into the two-adapter mould.

Comparability — decision-agreement, not byte-identical diagnostics. The two checkers cannot emit byte-identical diagnostics: the AST-time error carries a source offset and source-level names; the event-time error has no source span and names individuals by interned #i ids. The honest, enforceable contract is decision-agreement: on the same logical violation, both emit the same diagnostic code (and the closest available message). Golden tests pin agreement at the decision level (code fires / does not fire on a corpus of matched cases). This is weaker than byte-identity — it cannot catch message/hint drift or pin spans — and the RFD says so plainly rather than overclaiming.

Independent value (the clincher): this makes every .oxbin self-validating against corrupt, hand-built, foreign, or macro-emitted artifacts — paying down latent soundness debt that exists today regardless of macros (Context §1). So D3 is a substrate-trust feature macros happen to need, and ships first on its own merits. (Note: min-cardinality OW1342 is recorded-but-unenforced today even in lowering; D3 may close it at load, but it is out of the load-bearing soundness set and tracked separately.)

D4 — Build-sections re-check by their own gate (the third kind)

A MappingArtifact is re-checked by validate_composition_signature (OE1213, which re-derives the composition signature from the event legs and anchors the mapping’s pin to the actual elaborated wiring) + check_against_schema (OE1245), both already invoked at load. A macro emitting a build-section is admitted iff that kind’s gate runs.

The soundness story for build-sections is parity, not a new gate. The trusted production path for a MappingArtifact is config-driven (ox.toml [store]/[placement]MappingSectionsoxbin_for_events). A macro that feeds the same PlacementDecl struct into the same MappingSections the config path feeds produces an artifact indistinguishable downstream from a config one → it gets identical compile, pin, and load treatment. The soundness bar is parity with the trusted config path, which holds by construction. (Placement mapping directives are connector-opaque and validated at scan-time for any placement; a macro-emitted one carries no more risk than a config one.) So build-section emission needs no new event-validation (D3) — it rides the existing gate, and is unblocked independently of the self-validating-.oxbin substrate lift.

Phase-ordering protocol (the one real wrinkle). The composition-signature pin is a fixpoint over the entire elaborated event set, computed at build time (oxbin_for_events), after macros run at expand. So a macro cannot and must not supply the pin. The protocol:

  1. Expand: the macro emits an unpinned placement payload — a PlacementDecl (store name + mapping directives) keyed by the logical relation name. Everything in placement_hash() is knowable at expand from the macro’s input; schema_signature is left unset.
  2. Build: the existing MappingSections::compile(composition_signature) stamps the pin over the merged placements — identically to the config path.
  3. Load: OE1213 + OE1245 run unchanged.

Wiring task (the RFD 0036 seam). MappingSections is populated today exclusively by parse_mapping_sections from ox.toml. The emission boundary opens a second source: macro-emitted PlacementDecls, surfaced from the EXPAND phase (D5), unioned into the MappingSections the driver hands to oxbin_for_events. A placement for the same logical relation declared by both config and a macro is a loud refusal (a new OE code — never the silent BTreeMap::insert overwrite). ox.toml [placement] remains supported as an alternative. The macro emits only the PlacementDecl; the StoreDecl (store kind + @deploy: handle — a deployment concern, RFD 0036 C11) stays in ox.toml.

D5 — The emission surface (extends, and revises, RFD 0040)

A procedural macro returns an Expansion — a sequence of emissions, each either a Syntax value or a typed substrate artifact:

#[procmacro]
pub fn foreign(item: Decl, attr: Args) -> Expansion =
    [ quote! { $item },                                            // Syntax → re-parsed (the pure `pub rel`)
      Placement { rel: item.name, store: attr.store, mapping: attr.mapping } ];  // build-section → MappingArtifact gate (D4), unpinned
#[procmacro]
pub fn categorizes(item: Decl, target: Ident) -> Expansion =
    [ quote! { $item },
      MetaProperty { subject: item.name, axis: std::mlt::categorizes, value: target } ];  // event → re-checked at load (D3)
  • quote! builds Syntax; splices ($item, $item.name) interpolate reflected values. A single Syntax auto-lifts to an Expansion, so a surface-only macro keeps returning -> Syntax (the shipped P1 procmacros are unchanged).
  • A typed-constructor literal (MetaProperty { … }, Placement { … }) builds an artifact; its names are symbolic and re-resolved by the gate.
  • No emit keyword (taken by the emit/sink feature) and no unsafe — the macro returns its emissions; soundness is the gate’s job.

This revises RFD 0040’s shipped P1 surface — own it. P1 (#581) ships quote { … } (a brace form), -> Syntax, $item as a substitution barrier, ${expr} antiquotation. This RFD re-specs quote! (a !-invoked builtin, not a keyword — already contextual from P1, consistent with format!), -> Expansion, and $item as a splice. That is a code migration of live surface, the P1 parser recognition (quote_brace_at), the shipped std::rel irreflexive/asymmetric procmacros, and their tests — not a greenfield addition. The migration may ride this RFD’s implementation or land as a focused surface-migration PR first; either way D5 owns that it changes shipped P1, and silently resolves the ${} antiquotation spelling that #575 left open (string interpolation as a runtime feature remains a separate arc, #575).

foreign! surface (Ivan, 2026-06-19): the attribute form #[foreign(...)] ships first; the item form foreign! { … } is a later addition (both spellings eventually). The attribute form requires threading ATTR_ARGS into the macro invocation as a second attr input (Rust’s #[proc_macro_attribute] fn(attr, item) shape) — today rewrite_attribute_macro parses but drops attribute args (expand.rs:236); this is a bounded, contained change. #[foreign] is consistent with the shipped #[transitive]/#[irreflexive] attribute spelling (Argon has no @-decorators; #[name] is the shipped form).

D6 — The soundness contract

An emitted artifact is admitted iff its kind’s re-check gate subjects it to the same validations a lowered/config one faces: names through the resolver; the event-stream validations (D3) for events, or the section gate (D4) for build-sections; tier classification; canonical encoding. The standing invariant that makes “no unsafe” true: no event-derivable load-bearing validation may live only in lowering or only on the write-path. That invariant is currently violated by the substrate itself across the ~8 write-path/lowering-only checks of Context §1 — D3 pays that debt down. It is then kept honest by the one-core/two-adapter discipline plus decision-agreement golden tests, and (Open Q b) a structural CI gate asserting every relocated core has an event-time adapter, at decision-agreement granularity.

D7 — The Lean line

The @[language_interface] event types are already drift-gated. What this RFD adds to the Lean substrate:

  • State the new load-time validations as total predicates over the event model. These are net-new and grow the drift-paired Layer2 struct in both Rust and Lean — there is no existing slot to reuse (Context §4).
  • Prove the bridge lemma “a lowered event and a directly-emitted event satisfy the same validation predicate.” For the AST-shaped checks this is the formal content of “one core, two adapters.” For OE1341 the lemma is over an event set (a partitioned aggregation with order-insensitive count), not per-event — state it at that granularity; the per-event form does not type-check against the actual check.
  • Correction to the draft’s earlier claim: there is today no Lean model of fact/metaproperty → event lowering (CoreIR/Lowering.lean proves only structural count-preservation; full term lowering is stubbed). So the bridge lemma rests on net-new Lean infra, not an existing lowering model. The event-side predicate scaffolding (AxisBindingValid.lean) exists and is reusable.
  • RFD 0040 D7’s tier-honesty theorem (classify ∘ expand) is unaffected (classification already runs on emitted events through the gate) and remains gated on P2 making expand a Lean object. No expansion-preservation proof is owed (RFD 0037 D7 still holds: mechanize the re-checker, not the producer — D3 is the re-checker, now total and emission-agnostic).

Fork A — OE0234 fixed-reclassification: quote! now, the discriminator later (committed)

OE0234 rejects a re-classification — an iof that is not the construction site — onto a fixed type. The distinguishing information is the operation variant (Construct vs InsertIof), which is erased at emit: both paths call the identical encoder, and IofAssertionBody = {concept_id, individual_id} carries no construction-site marker. So a load pass over the flat event stream cannot re-check OE0234 under the current encoding.

Resolution (Ivan, 2026-06-19): for now, iof stays Syntax-only-emittable (macros quote! it; the elaborator, which has the operation variant, checks OE0234). This is principled per D1 (an artifact whose gate is incomplete is produced as Syntax) and costs nothing today (no client needs raw iof emission).

This is a committed deferral with a real prerequisite, NOT “no client.” We intend to implement the full construction-site discriminator — a wire-format change so construction and reclassification encode (and content-hash) differently, drift-gated — if it is the correct choice, which makes raw iof directly-emittable and OE0234 event-derivable. The capability is genuinely gated on that unbuilt encoding, not on a hypothetical future client. Tracked as real scope.

Rationale

The design pays in the right currency. D2’s safety came from re-checking, not from surface; we keep the re-check and drop the incidental surface requirement — and in doing so we are forced to make the re-check actually run on artifacts (the self-validating .oxbin), which is independently the correct hardening of a content-addressed artifact format whose soundness debt the review made concrete. The alternative currencies are wrong: a builtin spends ontology-neutrality; a surface-per-kind spends substrate minimality and gates libraries on compiler work; an unsafe escape spends soundness (the review showed it would genuinely be unsound, so the escape would be load-bearing, not cosmetic). “Restrict to what the substrate fully re-checks, and make the substrate re-check fully” is the only option that spends nothing — and it grows the trusted base honestly (D3 is a second trusted checker, declared as such), which is the correct place to spend, because a re-checker is exactly what the CompCert posture asks us to trust.

The scope is larger than a first pass claimed (~11–12 event checks, a wire-format discriminator owed for iof, the Layer2/Lean drift surface grows). That is not the design weakening — it is the review revealing pre-existing soundness debt that this work pays down. Build-sections (foreign!) need none of it and ship first.

Alternatives

  • RFD 0041 (pub metafact surface). Sound (it lowers through the validating path) and neutral, but it is the surface-per-metadata-kind accretion: MLT needs metafact, foreign! needs a placement form, the next vocabulary needs the next form — libraries perpetually gated on compiler surface work. Superseded.
  • unsafe direct emission. Cheap, neutral, no accretion — but a genuine trust-waiver: the review proved a directly-emitted event bypasses real checks, so unsafe would be load-bearing, and a content-addressed .oxbin would still be un-self-validating against corrupt input. Rejected in favor of fixing the substrate.
  • Privileged builtins (status quo). The doctrine violation this whole arc exists to kill.

Consequences

  • foreign! becomes a library macro (attribute form #[foreign(...)] first, item form later) emitting a MappingArtifact placement through the build-section gate (D4). Unblocked now — it needs only the Expansion surface (D5), the MappingSections union + collision OE code, and the #[foreign] attr-args plumbing; no Layer2 work. This is the first client and the proof of the mixed-emission boundary.
  • MLT (#[categorizes(T)] …) and #[order(N)] become library #[procmacro]s emitting MetaProperty; lower_relational_mlt_decorators / lower_order_decorator are deleted; #483 closes genuinely. Added scope the review surfaced: this depends on (a) the MetaProperty event checks OE0622OE0624 at load (D3), and (b) building the MLT concept-membership semantics OE190x/OE1904 (partition disjointness, order) which are currently reserved/unbuilt even in lowering — so “MLT emits MetaProperty” is bigger than deleting the builtin. Also resolve #517 (MLT metarel signatures disagree across book/std::mlt/Lean) during the re-home.
  • The relation-property family keeps its current re-home: #[irreflexive]/#[asymmetric] are already genuine procmacros emitting check rules (self-enforcing, no Layer2); #[functional] on a rel stays a #[builtin] stub until P2 (its [0..1] cardinality-cap needs structural re-emission); the metarel-functional check is already re-homed.
  • .oxbin becomes self-validating at load against any malformed artifact — a robustness win beyond macros, paying down the Context §1 debt.
  • No user-source migration anywhere: #[categorizes(T)] / #[foreign(...)] are spelled identically; only the (already-required) imports and the byte-identical/parity artifacts are observable (RFD 0037 S3).
  • #587 / RFD 0041 closes as superseded (done).

Implementation sequence:

  1. The Expansion/emission surface (D5) in the procmacro evaluator (procmacro.rs): the Expansion return type + auto-lift, the EXPAND Vec<EmittedArtifact> buffer, surfaced from expand_package_workspace. Includes the quote{}quote! P1 migration (or a preceding focused PR).
  2. foreign! (build-section track) — in parallel, no Layer2 dependency: the Placement constructor in procmacro.rs, the MappingSections union + collision OE, the #[foreign] attr-args plumbing. (Heterostore team owns the connector + MappingSections seam; macro side owns the emission buffer.)
  3. The self-validating .oxbin (D3, substrate-trust track, ships on its own merit): the ~11–12 event predicates, wired into Module::load; the Layer2 framework grown + drift-paired; decision-agreement golden tests.
  4. MLT re-home (#483) — needs (1)+(3) plus building OE190x/OE1904; delete the builtins.
  5. The Lean predicates + bridge lemmas (D7), including the set-level OE1341 lemma.
  6. (committed, later) the iof construction-site discriminator (Fork A) → raw iof becomes directly-emittable.

Open questions

a. OE0624 (axis tier). Resolved — event-derivable via the MetaPropertyMetaxisDecl join on axis_id (the tier lives in MetaxisDeclBody.targets). Confirm the join is reliable at load. b. Enforcing the D6 invariant structurally. Make “every relocated predicate core has an event-time adapter” a drift/CI gate (at decision-agreement granularity), so a future lowering-only check can’t silently re-open the hole. c. Expansion ergonomics. The exact type for the mixed-emission return (heterogeneous list vs a named Expansion builder) and the single-Syntax auto-lift surface. d. fixed-reclassification (OE0234). Resolved by Fork Aquote!-only for now; the construction-site discriminator is committed scope. e. Which other build-sections become emittable (DocBlocks, IndividualNames) — each only when it carries a re-check gate (D4). Per the census, most .oxbin sections have no load gate today (not even byte-integrity recompute), so they stay non-emittable until one is added. f. OE1341 partitioning. Confirm the load aggregation partitions per (tenant, fork) exactly as the write-path does, to avoid false rejections.

RFD 0043 — Theory packages and the neutrality boundary: where ontologies and higher-order theories live

  • State: discussion
  • Depends on: RFD 0030 (package dependencies — path deps, the on-ramp), RFD 0027 (meta-property plane — the neutral axis machinery a theory rides), RFD 0042 (the re-checkable emission boundary — the event-emission gate a theory’s decorators emit through), RFD 0037 / 0040 (the macro atom + procedural layer — the authoring surface), RFD 0009 (MLT — the first theory re-homed)
  • Amends: RFD 0009 (relocates MLT out of std to a first-party package) · RFD 0042 (realizes its “blocks the genuine, builtin-free re-home of the MLT decorators” clause)
  • Blocks: the genuine re-home of the MLT decorators (#483); ArgUFO authorable end-to-end (milestone #8)

Question

Where do higher-order type theories and foundational ontologies live, and what may std contain?

Today the asymmetry is the tell: UFO is an external package (correct — the substrate ships no ontological category), but MLT is embedded in std and expanded by privileged compiler builtins — ambient theory vocabulary at four layers. MLT and UFO are the same kind of thing: a committed theory the substrate is deliberately neutral about. The doctrine already says “no ambient ontology vocabulary” and “higher-order theories ship as libraries” — but those are two rules without one operational test, and the MLT leak slipped through the gap between them.

So: what is the single test that decides whether a thing belongs in the substrate, in std, or in a package — and where, concretely, does a theory like MLT live?

Context

The leak surface runs four layers. MLT-specific vocabulary is wired into:

  1. the compiler directive registry — categorizes et al. registered as known directives with hard-coded arg-shapes (oxc-instantiate/src/directives.rs);
  2. the lowering path — lower_relational_mlt_decorators / lower_order_decorator natively emit MetaProperty (oxc-instantiate/src/lower.rs);
  3. std itself — std/mlt/ is include_str!-embedded and elaborated in a fixed order after std::core (oxc-instantiate/src/lower.rs);
  4. the diagnostic registry — OE1903–OE1907 reserved for MLT’s specific violations (oxc-diagnostics, oxc-syntax/grammar.toml);
  5. the substrate mechanization — MetaCalculus/MLTKinds.lean names categorizes/partitions/subordinates carriers (its Wellformed.lean predicates are already parametric/neutral — the names are the residue).

The neutral substrate the theories need already exists. RFD 0027 gives axes the compiler never interprets; the meta-calculus exposes a generic metarel carrier, iof/specializes, reflection, and MetaProperty events; RFD 0042 adds (in progress) the re-checked event-emission gate a decorator emits through. None of this names a theory.

The packaging on-ramp already exists. RFD 0030 shipped [dependencies] name = { path = "…" } end-to-end — transitive closure, cycle/collision diagnostics, package-qualified module paths, a two-package acceptance test. A non-std package on disk is buildable and dependable today; the include_str! embed is how std is distributed, not a constraint on path deps. (Versioning, a registry, a lockfile, [workspace], and ox new/add/publish do not yet exist — that is a separate cargo-parity arc, below, and it does not block this RFD.)

Decision

D1 — The neutrality test

A capability belongs in the substrate or std only if it passes the reflect-not-smuggle test:

std (and the substrate) may reflect a commitment the substrate has already made. It may not smuggle a commitment the substrate deliberately withholds.

The competing-theory rule is its corollary: a competing formalization exists exactly where the substrate withheld commitment, so if multiple credible theories occupy the same layer, none of them belongs in std — shipping one privileges a theory.

Applied to the current std set (each verified against its header / the substrate it rides):

PackageVerdictWhy
std::corereflectthe substrate’s own classification floor (Top/Bot, primordials)
std::relreflectconventions over the relation mechanism; transitivity has no competing theory
std::kripkereflectthe substrate committed to Kripke frames (Decidability/Modal.lean, Standpoint/, Locality/SheafEquivalence.lean); box/diamond are substrate operators
std::finreflectconventions (currency identity, rounding-mode vocabulary) over the substrate-owned exact Money/Decimal tower; no competing arithmetic
std::datetime / std::path / std::temporal / std::storereflectutility/convention over substrate primitives
std::mltsmuggle → OUTthe substrate is neutral on multi-level theory; MLT competes with potency (Atkinson–Kühne), ML2, powertype (Cardelli–Odell)

The same verdict puts UFO, BFO, DOLCE, potency, and ML2 in packages — none in std, none built-in.

D2 — Three layers

  • Substrate (intrinsic, inert, neutral): the meta-calculus — a generic metarel carrier, axes the compiler never interprets (including a neutral ordinal axis a level-theory rides), iof/specializes, reflection, MetaProperty events, the partition/disjointness mechanism, and the RFD 0042 re-checked event-emission boundary. The substrate names no theory.
  • std (neutral, non-theory utilities): everything that passes D1. No higher-order type theory, no foundational ontology.
  • Packages (theories and ontologies, none privileged): MLT, UFO, BFO, potency, ML2. Each declares its own metarels, authors its decorators as library #[procmacro]s emitting MetaProperty through the RFD 0042 gate, expresses its semantics as derive/check rules, and emits its own diagnostics via RFD 0025 check-discharge.

There is no “higher-order type utilities” tier in std: anything generic enough to be theory-neutral already is the substrate (generic metarel + ordinal axis); anything more specific smuggles a theory’s commitments.

D3 — The packages/ directory

First-party libraries that are not std live in a new top-level packages/ directory (this RFD authorizes the new top-level directory). A package there is an ordinary ox package with its own ox.toml, depended on as name = { path = "packages/name" } and imported as use name::…. packages/ is visibly distinct from examples/: packages/ holds publishable libraries; examples/ holds demonstrations. The future registry’s naming aligns with packages/ (the three-layer story maps to substrate / std / registry, ≈ language / stdlib / crates.io).

D4 — MLT re-homes to packages/mlt as an unprivileged package

Delete the four-layer privilege (D1 context items 1–5): the directive-registry entries, the lower_* builtins, the std/mlt/ embed + its fixed elaboration order, the OE1903–OE1907 codes, and the MLT names in MLTKinds.lean (leaving the neutral parametric mechanism). packages/mlt declares its metarels, authors #[categorizes(T)] et al. as #[procmacro]s emitting MetaProperty, and emits its own diagnostics via RFD 0025. User source is unchanged — #[categorizes(T)] is spelled identically; only the (already-required) import and the now-unprivileged provenance differ.

The re-home is two-phase, because its two halves have different dependencies:

  • Phase 1 — emission/classification neutrality (independent; do now). The RFD 0042 EmittedArtifact::Event boundary + routing an emitted MetaProperty into the event stream where the shipped D3 load re-checks (OE0622–OE0624) gate it; delete the privilege; the packages/mlt skeleton (metarels + #[procmacro]s). This closes #483’s emission scope by construction and touches none of the reasoning fixpoint — its files are the emission boundary, the lowering-deletion sites, the diagnostic registry, and packages/; not oxc-reasoning’s disjointness/classifier semantics.
  • Phase 2 — reasoning-time function (converges with the substrate keystone). MLT functioning: the declarable-disjointness primitive (the OE1904-Reserved mechanism, downstream of the CWA/OWA write-side ruling), the ordinal axis, and the reflective #[static] checks that express partitions ⇒ disjoint + cover and the order arithmetic. These are not macro work — they are the shared substrate keystone that also makes ArgUFO authorable (milestone #8). They are designed once, in the keystone arc’s own RFD and tracker (see Consequences), and consumed by packages/mlt. The full acceptance test (D6) is met only here.

D5 — Package soundness-proof home

A package’s soundness proofs travel with the package, and must not pollute the substrate’s proof corpus:

  • In-repo first-party packages home their Lean under spec/lean/Packages/<Name>/ — in the same lake build (so they are CI-gated and held to the same no-sorry / no-uncited-axiom discipline as the substrate), but namespaced out of Argon/ so the Argon/↔Rust @[language_interface] drift gate and the substrate theorem corpus stay pure. packages/mlt’s categorizes ⇒ order+1 / partitions ⇒ disjoint+cover soundness lives at spec/lean/Packages/Mlt/, proved against the neutral substrate it imports.
  • External packages carry their proofs in their own repository, importing the substrate Lean as a lake dependency.

This sets the precedent for every theory package (UFO, BFO, potency).

D6 — Acceptance: an unprivileged package authors a full type-theory

The neutrality proof is that a package with zero compiler privilege authors a complete type-theory:

  • Phase 1: packages/mlt’s #[procmacro] decorators expand end-to-end, emit MetaProperty events that are re-checked at load by the substrate’s own gate, classify on their true tier, and round-trip an .oxbin — with no builtin, no std embed, no reserved diagnostic code. The criterion is parity, not byte-identity: the metarel path moves (std::mlt::categorizesmlt::categorizes), so the emitted event’s axis_id interns a different string and cannot be byte-identical to the old builtin output (unlike the relation-property re-home, which stayed at std::rel). Parity = the semantically-equivalent re-checkable event (correct axis metarel, target, value), load re-check clean, .oxbin round-trip.
  • Phase 2: packages/mlt’s #[static] checks enforce its well-formedness (disjointness, order) at reasoning time over the neutral substrate primitives, emitting its own diagnostics.

Rationale

Why reflect-not-smuggle over competing-theory alone. “No competing theory” is true but secondary — it explains why a layer is neutral (a competitor exists precisely because the substrate withheld commitment) but does not, on its own, justify keeping std::kripke (modal logic has competing semantics — neighborhood, algebraic, topological). The primary fact is that the substrate already committed to Kripke frames in its mechanized modal/standpoint semantics; std::kripke reflects that commitment honestly. The test has to be about what the substrate committed to, with competing-theory as the diagnostic for “did it withhold here?”. That ordering is what makes “keep kripke, drop mlt” a consequence of doctrine rather than a judgment call.

Why packages/, not std and not external-only. std-membership is the privilege we are removing. External-only (like UFO today) loses the in-CI neutrality proof. An in-repo first-party package gets both: zero privilege and a CI-gated proof that the substrate is neutral — co-evolving with the substrate work that unblocks it.

Why two-phase. The emission re-home is genuinely independent of the reasoning substrate; gating it on the keystone would stall a clean, provable win behind a multi-session research arc. Splitting also exposes the real shape: MLT is a second forcing function for the keystone (alongside ArgUFO), not the owner of it. One disjointness primitive, designed in the keystone, consumed by every theory — never a parallel mechanism invented inside the macro arc.

Why package proofs under spec/lean/Packages/. The substrate’s quality bar (no sorry, drift-gated, one lake build) is worth extending to first-party theory proofs, but the substrate’s purity (the Argon/↔Rust contract, the neutral theorem corpus) must not absorb theory-specific lemmas. A sibling namespace in the same build is the only option that keeps both.

Consequences

  • std is purged of theory vocabulary. MLT moves to packages/mlt; UFO/BFO/potency/ML2 follow the same path. std::kripke/std::fin/std::rel/std::core stay, justified by D1. (std::kripke’s “elaborate after std::core and std::mlt” wiring simplifies to “after std::core”.)
  • #483 closes its emission scope by construction (Phase 1) — the strongest neutrality statement we can make: an unprivileged package authors decorators that emit re-checked substrate events with no compiler involvement. #517 (the MLT signature disagreement) becomes a package-internal naming choice aligned to the neutral substrate.
  • The reasoning-time keystone (Phase 2) is owned by the substrate keystone arc, not this RFD. The declarable-disjointness primitive (CWA/OWA write-side ruling), the reflective #[static]-check plane, and the set-valued metaxis work are one coordinated substrate cluster currently spread across the macros, tiago-meta, and design-review threads, all editing oxc-reasoning / the classifier / the meta-plane. They share one design and one tracker (a converged keystone epic), with packages/mlt Phase 2 and ArgUFO as its two independent forcing functions. This RFD references that arc; it does not design it.
  • The package registry / ox publish / lockfile / [workspace] / ox new/add is a distinct cargo-parity arc. Path deps (RFD 0030) already carry the re-home. MLT is the forcing function for “the first published package” once the registry lands; it does not block here.
  • D5 sets the package-proof precedent for every future theory package.

Alternatives considered

  • Keep MLT in std, just delete the builtins (library-surface-stub + privileged-native-expander). Rejected: relocates the surface while the privileged expander stays — a hollow re-home, and std-membership is itself the privilege. RFD 0042 already commits to the genuine version.
  • MLT external-only, like UFO. Workable, but forfeits the in-CI neutrality proof and the co-evolution with the substrate work. In-repo first-party (packages/) dominates.
  • A neutral “higher-order type utilities” tier in std. Rejected: any such util generic enough to be neutral already is the substrate; anything more specific smuggles a theory (see D2).

RFD 0044 — Package registry, workspaces, and distribution

  • State: discussion
  • Depends on: RFD 0030 (package dependencies — path deps, the on-ramp this extends), RFD 0022 (package-path addressing + the build gate), RFD 0038 (prelude & ambient scope — the import model), RFD 0043 (theory packages — the first publishable package, packages/mlt), RFD 0013 (toolchain distribution — the CDN infra this reuses; it explicitly deferred the registry to here)
  • Blocks: a publishable/installable package ecosystem (milestone #11 “Package ecosystem”); packages/mlt distribution (the modeling team’s MLT vendor-vs-registry decision)
  • Tracking: epic #688; children #689–#703

Question

Argon has path dependencies (RFD 0030) but no workspace, no lockfile, no version resolution, and no package registry — every layer above local path deps is recognized-and-refused (OE1240) and deferred to “a later RFD.” How should Argon distribute packages? Concretely: what is the registry substrate (it must not be a GitHub repo), the workspace + lockfile + resolution model, and what does Argon’s nominal type system dictate about identity across package versions?

Context

Where we are. ox = the package orchestrator (cargo), oxc = the single-package compiler (rustc) — the committed frame (book §16), realizing Backpack-’17’s two-phase pipeline (ox computes a wiring diagram + composition signature; oxc instantiates per package). Path deps fold a dependency’s modules into the consumer’s workspace and embed into one .oxbin; the compiler front-end already resolves imports through resolved deps. version/edition parse but are inert; [workspace] does not exist; the ~/.argon/packages content-addressed cache and ox.lock are reserved, not built.

The prototype (orca-mvp) is prior art, not ground truth. Its registry was a GitHub repo (registry.json on a branch + GitHub Releases + the GitHub API), which we are replacing. It got real things right — a deterministic tarball, a bivalent hash (a BLAKE3 byte hash and a constructs Merkle root over per-declaration semantic signatures), a content-addressed cache, a lockfile, PubGrub — and real things wrong: two manifest parsers that disagreed, a compiler that never saw resolved deps (the deepest bug), two publish pipelines that produced different hashes, and one hardcoded GitHub repo with a mutable index under concurrent writers.

Argon is unusually well-positioned. It already owns the two most expensive ingredients of a modern registry — a content-addressed byte hash (content_hash) and a semantic Merkle root (constructs, which no surveyed system has) — plus the exact S3 + CloudFront topology cache.nixos.org runs in production (the infra oxup already uses). A five-system prior-art sweep (Unison, Nix, Go modules, Dhall, Sigstore/TUF | Cargo, JSR, PubGrub, pnpm) converges on one architecture, recorded below; the research lives at .local/research/package-system/DESIGN.md.

Decision

D1 — Concept identity is nominal/path; the resolved graph is single-version-per-package-name

Argon’s type identity is nominal, by qualified path — verified in both the substrate and the research. In code: a concept is identified by NameRef (its canonical-symbol-table position for a qualified path) and DefId = (file, start, name, kind, visibility), never by a hash of its structure; subtyping is nominal end-to-end (Lean TypeSystem/Subtyping.lean: “Subtyping of named types is nominal”; Rust oxc-check types_compatible = schema.concept_ancestors(a).contains(b)); the BLAKE3 content-ids that exist (ContentId/AxiomKey/CompositionSignature) are body/build fingerprints kept separate from symbol identity. The vault’s identity research is explicit: “a naive content hash would make every schema edit a new type — the opposite of what a nominal type system wants … Unison’s model fits structural identity; Argon is largely nominal.”

It follows that:

  1. Person@1 and Person@2 are the same type by path. A field addition or refinement edit does not mint a new type — it trips the drift fingerprint, not identity. Identity is the full qualified path: pkg::mod_a::Person and pkg::mod_b::Person are simply two distinct concepts (like two Error types in different Rust modules), no conflict.
  2. The resolved graph is single-version-per-package-name. A package name is one namespace root mapping to exactly one package (already enforced: OE1241 name = published [package].name; OE1243 a name cannot denote two package directories). This is the applicative shared-base model (track-F module research; D-77 shared immutable base appears once). This differs deliberately from Cargo, which permits multiple semver-incompatible versions to coexist via name-mangling: Argon must not, because two concepts at one path cannot both be “the” type. The resolver (D2) therefore resolves each package to exactly one version graph-wide, or fails loudly.

Cross-version compatibility is a drift question, not an identity question (see D4 / Open questions).

D2 — Version resolution is PubGrub over a SemVer VersionSet

Use pubgrub-rs (already proven in the prototype). PubGrub is generic over a VersionSet, so Argon can later define its own range algebra (the 4-axis versioning) without being locked to caret SemVer; its derivation-graph errors route into Argon’s OE-coded diagnostics (a named root cause + fix, fitting the loud-over-silent ethos). Resolution enforces the D1 single-version invariant: one version per package across the graph, or a loud refusal. Features/optional-deps are encoded as virtual packages from the start. (MVS was considered — see Alternatives.)

D3 — The registry is a static, content-addressed store over our own CDN — not a GitHub repo

Three layers over S3 + CloudFront (argon.sharpe-dev.com, the infra oxup uses), with no trusted live service on the read/integrity path:

  • Layer A — immutable content-addressed blob store + sparse index. The S3 object key is the BLAKE3 content_hash (blobs/<blake3>), served immutable/cache-forever; beside each blob a tiny signed metadata sidecar (Nix .narinfo shape: size, constructs root, dependency closure, provenance, signature). A Cargo-style sparse-index protocol (config.json + per-package append-only NDJSON version records, uniform hash-prefix sharding, mandatory ETag/If-None-Match). Yank is an append-only event, never in-place mutation — every object is write-once, which structurally removes the prototype’s concurrent-index contention. Publish source (.ar) as canonical with a per-file content manifest (JSR’s model); compile .oxbin on demand, cached by source-manifest hash. Nothing opaque is ever published.
  • Layer B — a transparency log of both hashes. A Go-sumdb / Certificate-Transparency-style append-only Merkle log of package@version → (content_hash, constructs_root), with signed tree heads and static tiles on the same CDN; ox verifies inclusion + consistency proofs and fails loudly. Logging the semantic constructs root next to the byte hash makes the log tamper-evident over meaning, auditable at per-declaration granularity via subset Merkle proofs — a property no surveyed registry has, costing ~nothing once the log exists.
  • Layer C — a thin TUF metadata cap. timestamp + snapshot for freshness and anti-rollback/freeze/mix-and-match (essential because CloudFront caches stale objects), over a threshold offline root + targets key for key-compromise survival and in-band rotation. This makes S3 + CloudFront fully untrusted transport; trust anchors in offline keys + the public log. Fulcio / keyless OIDC, delegated targets, and SLSA attestations are deferred until many external publishers exist.

Because the registry is just static files, a local directory or file:// is a conformant registry — which yields offline builds, air-gapped mirrors, CI fixtures, and a “local registry” for free, with no special-casing. The ~/.argon/packages content-addressed cache (D-78, fail-closed) is retained; ox vendor covers fully-pinned reproducible builds.

The alternative considered and rejected is an OCI registry (ECR): standard auth/mirroring, but heavier and a dependency we don’t need given we already own a CDN.

D4 — v1 is minimal-correct; trust hardening and the correctness-oracle edge are follow-ons

v1: one unified manifest with [workspace] virtual manifests + inheritance + a shared ox.lock; PubGrub resolution; a static content-addressed sparse-registry client with integrity verification; one authoritative deterministic publish builder + the ox package CLI; packages/mlt as the first published package. Follow-ons: the transparency log (Layer B), the TUF cap (Layer C), tokenless-OIDC + Sigstore/Rekor provenance, and the JSR-inspired publish-time correctness oracle — the registry runs ox check + the tier classifier + the drift gate at publish and publishes correctness metadata (decidability tier, CWA/OWA cleanliness, silent-accept count, provenance) as first-class, hard-weighted data. Argon’s trust-first posture turns the registry into a correctness oracle, not an opaque host.

Rationale

  • Nominal/path identity (D1) is forced, not chosen — it is what the substrate already implements and what a nominal-plus-refinement type system requires. The registry design conforms to the substrate, not the reverse. “No dependency hell” (Unison) does not vanish; it relocates into cross-version compatibility, which Argon answers with constructs drift rather than by silently re-identifying types.
  • Single-version-per-package-name is the only coherent rule when identity is the path: it is already enforced, it matches the applicative shared-base model, and it gives a stronger guarantee than Cargo’s name-mangling — appropriate for a KR language where vocabulary identity must be stable.
  • PubGrub (D2) is greenfield-appropriate (no legacy resolver to preserve bug-for-bug), already in hand, and its error quality + generic VersionSet are direct wins.
  • The static content-addressed CDN (D3) is the convergent state of the art (Nix’s binary cache, Go’s proxy + sumdb, Cargo’s sparse index, JSR’s static API) and reuses infra we own; the transparency log of the semantic root is where Argon’s existing constructs signature lets it exceed every prior art.
  • Source-published + compiled-on-demand keeps packages auditable (the Deno lesson: URL-as-identity was the mistake, the hash was the safety net) and avoids opaque binaries.

Alternatives

  • GitHub-repo-as-registry (the prototype, D-27). Rejected: mutable index under concurrent writers, no namespacing/mirroring, couples distribution to a VCS host. The static CA store subsumes its every use.
  • OCI registry (ECR). Rejected for v1 (see D3): heavier, an unneeded dependency.
  • MVS instead of PubGrub. Considered seriously — Go’s minimal version selection is deterministic, lock-free, and carries a genuine safety argument for a KR language (“a transitive release has no effect until you ask”). Rejected for v1 because PubGrub is already in hand, gives superior errors, and its generic VersionSet future-proofs Argon’s own range algebra; the MVS safety intuition is preserved by the single-version invariant + the publish-time compatibility gate.
  • Unison-style structural / content-addressed type identity. Rejected at the language level (D1): it fits structural identity, but Argon is nominal — a content hash would mint a new type on every edit.

Consequences

  • The OE1240 manifest refusal of registry/version dep-forms is replaced by real resolution; version/ edition become load-bearing; [workspace] lands; ox.lock lands (bivalent: content_hash + constructs root).
  • A new authoritative deterministic publish builder is the only artifact producer (the prototype’s two-pipeline divergence does not recur).
  • The registry infra reuses the oxup CDN/account; the toolchain CDN and the package registry remain distinct surfaces sharing transport.
  • packages/mlt becomes installable, unblocking the MLT vendor-vs-registry decision.
  • Two substrate prerequisites become correctness floors (Open questions): freezing the constructs canonicalization, and authoring the breaking-change taxonomy.

Open questions

  1. The breaking-change / compatibility taxonomy (#697). Cross-version compatibility rides constructs drift: additive (new pub decl, widened bound) = compatible; narrowing a refinement, removing/renaming a pub decl, a CWA→OWA flip = breaking. The vault scoped a Java-binary-compatibility-style ruleset but never authored it. Per the mechanize-soundness-first directive, the compatibility condition is a scratch-Lean candidate before implementation. This is the soundness-bearing piece, and it ties to the keystone disjointness work (#628) and the R1 CWA/OWA write-side ruling.
  2. constructs canonicalization freeze (#696) — landed. The semantic Merkle is specified, frozen, and versioned independently of the hash input (Dhall’s v6.0.0 lesson: the spec version is a constant, never folded into the hash), with cycle hashing pinned (Unison’s #x.n recipe). The canonicalization lives in oxc_protocol::constructs (the per-pub-declaration signature projection → BLAKE3 leaf → D-114-alphabetical Merkle root, with NAF clauses kept distinct from positive boundaries per the #697 oracle), wired to the build via oxc_workspace::constructs and recorded in the ox.lock constructs column. Vocabulary reconciled: the vault’s D-026 calls constructs “semantic identity”; functionally it is the drift fingerprintcontent_hash = byte fingerprint, constructs = semantic drift/compatibility fingerprint, nominal identity = the qualified path (no separate identity hash).
  3. Namespacing/scopes. JSR’s scoped names (@scope/pkg, admins-not-owners) kill squatting and fit internal teams. Whether to adopt scopes from v1 or start flat is open.
  4. Asymmetric publish tokens (Cargo PASETO v3.public) vs the deferred OIDC path for the internal bootstrap window.
  5. An Argon-native non-SemVer VersionSet over the 4-axis versioning (#703) — deferred until a concrete substrate need forces it.

RFD 0045 — The world-assumption write-side: refuse-on-K3-not and the #[world] opt-in

  • State: discussion
  • Depends on: RFD 0025 (check discharge — the delta-guard this rides), RFD 0017 (the where/iff refinement split — the owned-vs-derived escape), RFD 0027 (the meta-property plane and the $axis catalog), RFD 0031 (the relation-constraint plane and the D-013 disjoint/complete/partition block surface)
  • Blocks: #627 (set-valued metaxis — its membership read needs the world-side semantics settled), #697 (the breaking-change taxonomy’s closed→open arm), #249 residue, packages/mlt Phase 2
  • Implements: the §6.9 per-concept world assumption (today specified but inert), the meta-plane keystone ruling R1 (#628)

Question

A modeler declares partition Vehicle { Car, Truck } and then inserts a Vehicle that is neither a Car nor a Truck. What happens? Three answers are on the table: refuse the write, derive one of the missing memberships, or cascade some repair. The same question recurs one plane up, on derived membership: a Person becomes an Adult when iff age >= 18 holds — but under open-world, age may be absent, so the refinement is neither true nor false. Does the derived Adult membership get asserted, refused, or left undecided?

These are not taste calls. The substrate already fixes the answer; this RFD records it and builds the one surface that lets a modeler opt out of the default. Concretely: (1) what is the write-side rule when a covering or derived-membership constraint is unmet, and (2) how does a concept declare that it lives under the open-world assumption instead of the closed-world default?

Context

Where we are. Three pieces are already on main, and they constrain the answer.

The world-assumption substrate exists. WorldAssumption (oxc-protocol/src/world_assumption.rs) mirrors the Lean Argon.Schema.WorldAssumption.WorldAssumptionClosed and Open, with WorldAssumptionMap the total function CN → WorldAssumption over a sparse override map plus a default. The type is wired; nothing reads it to change behavior yet.

The #[world] directive parses but is refused. oxc-instantiate/src/directives.rs:520 registers world and rejects it: “per-concept world assumption (§6.9) is specified but not built; the engine evaluates closed-world unconditionally.” So every concept is closed today, unconditionally.

Covering already ships under the closed default. The D-013 block surface (disjoint/complete/partition, RFD 0031) lowers to RFD-0025 checks: OE0240 (overlap), OE0241 (runtime covering, delta-guard-enforced at write), OE0242 (build-time static covering over the <: graph), OE0243 (non-subtype member). A partition whose cover is unmet at write is refused today. The covering question is therefore not whether covering is enforced — it is — but under which world assumption the enforcement should soften.

Why the answer is forced, not chosen. The closed-world write-side rule composes theorems already proven in spec/lean/Argon/, with no new framework and no bridge lemma. The composition was validated against the source, not assumed:

  • cwaCollapse_is_iff (TypeSystem/Soundness/CwaOwa.lean:110) — collapsing a classification yields is only if the input was already is. The closed-world collapse turns can into not, never into is. It cannot fabricate positive evidence.
  • collapse_conclusion_lacks_positive_evidence (CwaOwa.lean:197) — ¬ cwaTrue .can: a conclusion that exists only because the collapse invented a not carries no positive backing.
  • guard_iff (Reasoning/Checks.lean:288) — the RFD-0025 delta-guard passes iff violations post ⊆ violations pre. A write that creates a violation is refused; pre-existing violations are observed but do not block.
  • violations_mono_of_positive (Checks.lean:298) and the Truth4 joins (Foundation/Truth4.lean, can ∨ can = can, is ∨ _ = is) — the closure under which an unmet covering classifies as not and the check fires.

Put together: an unmet covering under the closed default is not, the covering check fires, and the delta-guard refuses the write. Refuse is the closed-world behavior the substrate already enforces; this RFD names it and makes the open-world alternative declarable.

Decision

D1 — The write-side rule: refuse-on-K3-not

When a covering, partition, or derived-membership constraint is unmet at a write under the prevailing world assumption, refuse the write iff the constraint classifies as K3 not (a definite violation). Under the closed default, an unmet covering collapses can → not and is refused — the behavior on main. Under #[world(open)] the same unmet covering stays can and is tolerated (incomplete is not violated; see D2).

This is one rule, applied at two planes:

  • Covering / partition (complete/partition): an instance of the parent whose membership in no declared variant can be established is not under closed-world → refused (OE0241), can under open-world → tolerated.
  • Derived membership (iff, RFD 0017): a computed membership whose refinement lands K3-undefined is not under closed-world → the membership is absent and any check depending on it fires; under open-world the refinement reads three-valued and a membership that would be asserted only by collapsing can → is is refused, never asserted.

Derive is rejected as unsound, not declined as a preference. Deriving the missing membership asserts is where the model only supports can. cwaCollapse_is_iff forbids exactly this move, and a fabricated is does not survive re-evaluation: cwa_owa_transfer (CwaOwa.lean:133) carries genuine is conclusions from closed to open worlds, but a derive-fabricated membership has no is to carry, so an open-world reader would find can. The two planes would disagree about the same fact. Cascade is rejected on a separate ground: a repairing write breaks the fixed-gate constancy theorems and the append-only event log, and it smuggles the same abductive guess into the store.

D2 — #[world(open)] becomes a real per-scope opt-in

Lift #[world(open)] from reserved-and-refused (directives.rs:520) to a real per-scope annotation that writes the concept’s entry in the WorldAssumptionMap.

  • The default stays closed. An un-annotated concept behaves exactly as it does today. Shipping this RFD is observably a no-op until a concept is opted into open. This is forced by the CWA-to-OWA monotone-transfer theorem: closed is the sound default, and opening a concept is the modeler’s explicit declaration that absence means ignorance, not falsity.
  • #[world(open)] on a concept makes its refinement reads three-valued: an absent fact reads can (unknown), not not (false). A where-asserted membership is still admitted (it is owned — D3); an iff-derived membership that lands can is refused (D1).
  • #[world(open)] on a concept governs the generalization sets declared under it. A partition/complete cover under an open concept softens from refuse to K3-tolerate: an instance not yet placed in a variant is can, not a violation. Only covering softens. Disjointness is world-assumption-invariant — a disjointness violation (OE0240) is two memberships that are both is, a positive overlap, and a positive overlap is a definite violation under any world assumption. The keystone rule softens refuse-on-K3-not (covering); it has nothing to tolerate on refuse-on-positive-overlap (disjointness), which is a separate, invariant rule.

D3 — The owned-vs-derived escape already exists: where / iff

No new assertion surface is needed for “I take responsibility for this membership.” RFD 0017 already split it:

  • where = asserted / primitive. The modeler owns the membership; insert iof(x, C) is permitted; violation is OE0668. Under open-world, an absent field permits the write — information-absence is not violation.
  • iff = defined / derived. Membership is computed; explicit insert iof is rejected (OE0211); under open-world it refuses-on-K3-not (D1).

So the modeler’s lever is the refinement keyword they already choose. Use where where the model asserts a membership it owns; expect iff-derived membership to refuse rather than guess when the evidence is absent.

D4 — Scope, attachment, and the relationship to store placement

#[world(open)] attaches to a concept declaration — the surface §6.9 / 06-types.md:230 already documents — giving the directive the real positions it lacks today (directives.rs:520). It writes a per-concept override into WorldAssumptionMap; the default remains Closed. There is no separate per-generalization-set annotation: a concept’s world assumption governs the covers declared under it (D2), so the surface stays exactly what the book scopes — concepts only.

This is the modeling opt-in — a statement about a concept’s domain (is the set of Persons in this model closed, or partial?). It is distinct from, and composes with, the store-side world tiering in RFD 0036 D6 (feat/store-placement-and-world-assumption), where a federated external store may force open-world reads on the data it owns. Where both apply, the store tier and the concept annotation must agree or the existing mixed-world conflict gate (MixedWorldAssumptionConflict) fires.

Coordination hazard — one WorldAssumptionMap writer, not two. This RFD’s per-concept opt-in and RFD 0036 D6’s store-side tiering both write the same WorldAssumptionMap. They must converge on a single writer reconciled through MixedWorldAssumptionConflict; two independent writers would silently disagree. This is a real blocking coupling between the two arcs, not a prose aside — whichever lands second builds on the first’s writer.

The decidability cost is real and must be honored in the tier ladder: mixing closed predicates into an open-world base can push data complexity from PTIME to coNP-hard (Lutz et al. 2013). A per-concept #[world] is not free; the §10 classifier accounts for it.

D5 — Diagnostics

  • OE0706 stays as the general reserved-directive gate — it still serves #[brave], #[intrinsic], and the other reserved directives. Once #[world] is built it simply stops firing for #[world].
  • OE0241 (covering) softens under #[world(open)] rather than being replaced: the same check, evaluated three-valued, tolerates can. OE0240 (disjointness) does not soften — a positive overlap is a definite violation under any world assumption (D2). OE0242 (build-time static covering) is unaffected — it is a catalog-level well-formedness check over the <: graph, independent of instance data and world assumption.
  • No new error code is required. One discretionary micro-call remains (the only piece of this RFD that is preference, not consequence): when a write is tolerated under open-world that would have been refused under closed-world, emit an informational diagnostic or stay silent? Recommended: silent. Opting a scope into open is itself the declaration that incompleteness is intended; a note on every such write is noise. If a lint is ever wanted, it belongs in the allow/warn/deny lint plane (#707), not as a hard diagnostic.

D6 — Lean obligations

The closed-world write-side rule (D1) is already discharged by compositioncwaCollapse_is_iff + guard_iff + violations_mono_of_positive + the Truth4 joins, as walked in Context. No new theorem, no bridge lemma.

The #[world(open)] softening (D2) rides the existing three-valued CwaOwa semantics: tolerating-on-can is the sound direction (it weakens refusal, never strengthens an assertion), so it needs no new framework.

One Lean-catchup item is owed, and it does not gate this RFD (Rust leads the reasoner here). The covering check is implemented in Rust, but its closed-world covering-completeness soundness is not yet mechanized: CwaOwa.lean proves positive-evidence transfer, not “the covering check fires whenever coverage is unmet.” That theorem — sitting between Storage/AxiomBody.lean’s PartitionAxiomBody and Reasoning/Checks.lean — is tracked as #760. File it; proceed on the softening under existing semantics.

Alternatives considered

  • Derive the missing membership. Rejected as unsound (D1): it asserts is from can, which cwaCollapse_is_iff forbids, and the fabricated fact fails cwa_owa_transfer re-evaluation. This is the option a forward-chaining engine cannot take soundly — a covering axiom is an open-world disjunction (“in at least one variant”), and a CWA-NAF engine cannot represent the disjunction, so deriving a specific variant is an abductive guess.
  • Cascade a repair. Rejected: breaks the fixed-gate constancy theorems and the append-only event log, and carries the same abductive guess.
  • Make open-world the default. Rejected: closed is the sound default by the monotone-transfer theorem, and it is the established §6.9 doctrine (“extents are the authority”). Opening is the exception a modeler declares, not the baseline.
  • A new assert-style membership statement for the owned case. Unnecessary: where/iff (RFD 0017) is already the owned-vs-derived lever.

This rule is the integrity-constraints-as-selective-CWA pattern from the description-logic literature (Motik, Horrocks, Sattler 2007; Tao, Sirin, Bao, McGuinness 2010): treat a covering or partition as a CWA-checked integrity constraint — denial-on-violation — layered over an OWA base. Per-concept world assumption is itself prior art (Reiter 1978; the Open/Closed/ClosedWithDefault tiers of earlier systems). The delta-guard-check model is exactly that pattern.

Sequencing

  1. R1 (this RFD) — record the write-side rule; no code change, the closed behavior already ships.

  2. #[world] surface — lift it from reserved (directives.rs:520) to a real per-concept annotation writing WorldAssumptionMap; soften OE0241 (covering) under open, leaving OE0240 (disjointness) invariant; refuse-on-K3-not on the iff path. Sequenced after the meta-plane classifier seat clears oxc-instantiate — one seat in the checker at a time. The same change satisfies the book↔engine drift gate: rewrite §6.9 / 06-types.md’s “refuses today” language to the built behavior.

    Prerequisite — the reasoner must consult the world assumption for negation (a live gap). The package-wide default_world is already a reachable manifest field (oxc-workspace/src/lib.rs:385, "open"/"closed", RFD 0036 D5), but the reasoner consults it nowhere — NAF is evaluated unconditionally closed-world. Under default_world = "open", NAF over a catalog-closed axis relation ($axis, and $setAxis once #627 lands) wrongly reads not where the open world demands can. Threading WorldAssumptionMap into the oxc-reasoning executor so NAF over these relations is world-gated (v ∉ Snot under closed, can under open) is a prerequisite this RFD’s open softening rests on, and it closes the pre-existing single-valued $axis gap at the same time. The soundness condition is already mechanized (AxisRelation.lean’s “K3-honest only under CWA”; the set analogue in Scratch/SetValuedAxis.lean’s setMembership_k3_honest); the executor is the catch-up.

  3. #760 Lean-catchup — mechanize covering-completeness soundness, ahead of or alongside the softening.

  4. #627 set-valued metaxis — now unblocked on the world-side semantics; its own set-membership K3 soundness is mechanize-first scratch-Lean, separate from this RFD.

Relationship to existing issues

  • #628 / R1 — this RFD is the ruling that issue tracks as gating the meta-plane keystone.
  • #627 — the membership read over a set-valued axis needs D1/D2 settled; this RFD settles them. The set machinery itself is #627’s own work.
  • #697closed → open is now a real per-concept property, so the breaking-change taxonomy’s world-flip arm is definable (a flip is breaking iff a NAF clause depended on the closed collapse).
  • #760 — the owed covering-completeness Lean-catchup (D6).
  • RFD 0036 D6 — the store-side world tiering composes with the per-concept opt-in via the existing mixed-world conflict gate (D4).

RFD 0046 — Derivation serving surfaces: query, delta, explain, trace

  • State: discussion
  • Depends on: RFD 0018 (the reasoner — semi-naive/WFS engine these surfaces read), RFD 0020 (runtime engine — Engine::evaluate, the one evaluation path), RFD 0036 (heterogeneous stores / IVM — the incremental read-model the delta surface rides), RFD 0028 (defeasibility — the proof tags a proof tree’s nodes carry)
  • Prior art: orca-mvp’s two-subsystem design — the OTel-like hierarchical trace (crates/nous/src/reasoning/trace/) and the fact-keyed derivation DAG (crates/datalog/src/provenance.rs); orca-mvp RFD 0007 (first-class queries/mutations + why-provenance); the vault note Provenance Under Tabling and Bilattice.

Question

How should the runtime expose derived state to clients — the derived-fact results a program needs, the per-write “what changed”, explanations of why a fact holds, and execution traces for debugging — and what is the single rule that keeps these from collapsing into the O(N·F) anti-pattern they collapsed into today?

Context

The runtime today exposes a derive/trace/explain HTTP plane in oxc-serve whose core is a loop:

#![allow(unused)]
fn main() {
// collect_derived_facts / derive_trace_value
for rule in module.rule_short_names() {
    let tuples = store.query_derive(module, rule);   // a full fixpoint, per rule
}
}

For a non-monotone program (recursion-through-negation, e.g. a breach/fulfilment calculus Fulfilled :- … not BreachedAt(…)) query_derive takes the uncached path and re-runs the whole stratified fixpoint from scratch on every call. So one whole-fork /derive is N fixpoints (N = number of derive heads), and for a layered calculus the shared lower layers are recomputed once per dependent head. The consumer — the ODE workflow runner — then calls this whole-program derive ~3× per write (/derive before, /derive after, /derive/trace) to render a per-step visualization diff. Net per workflow: ~3·W·N from-scratch fixpoints, each non-monotone-expensive. That is “derives take forever.”

Two facts reframe the fix:

  1. Correctness never touches /derive. Workflow decisions flow exclusively through dispatch/query and dispatch/mutation; an Argon query already evaluates against the materialized (derived) model, so the derived facts a decision needs arrive in the query result. No control path reads a /derive projection. The author-facing derive() op returns nothing, is absent from the generated SDK, and is called by zero workflows. Removing the per-rule derive plane loses zero correctness.

  2. Every /derive + /derive/trace call is display. It serves three real needs — a per-step delta of derived facts for a timeline, a derived snapshot for an instance graph + post-run validation, and an on-demand explanation/trace of a fact a user clicks. The ODE fakes the delta by diffing two full projections; its own code names the gap: “new Argon does not yet expose mutation-scoped proof trees for full causal provenance.”

The substrate the correct surfaces need already exists or is designed: the IVM read-model (RFD 0036; the maintainer wired into the strict path) already computes a per-commit delta; the reasoner runs one Engine::evaluate (RFD 0020); RFD 0028 defines proof tags. And the orca-mvp prototype already built the right shape — two distinct subsystems: an OTel-like hierarchical execution trace (spans: derive → engine → stratum → rule-firing), and a fact-keyed AND/OR derivation DAG (ProvenanceStore/DerivationTree) reconstructible into a proof tree on demand, reused by DRed for incremental deletion. The vault’s verdict is explicit and load-bearing: no production system stores full how-provenance in the answer table; the answer table stores answers, a side-track derivation log stores justifications, and explanation is reconstructed lazily. Why-provenance is PosBool(M) DNF; incremental maintenance uses the counting semiring; the ℕ[X] → PosBool homomorphism bridges them — two semirings, two jobs.

Decision

Four needs, four distinct surfaces. The governing rule:

The runtime materializes the derived model once; clients QUERY it. Derives are never “called” one at a time, and explanation/trace are reconstructed from a single materialization — never by re-evaluating per rule.

D1 — Correctness: query the materialized model (unchanged)

dispatch/{query,mutation,compute} stay the correctness surface. A declared pub query selecting over a derived head returns the derived facts a decision needs, evaluated against the one materialized model. There is no client-facing “evaluate this derive” primitive; deriving is the runtime’s job, querying is the client’s.

D2 — Per-step derived delta from the IVM read-model

A mutation already maintains the derived read-model incrementally (RFD 0036; monotone programs maintain in place, non-monotone rebuild once). Expose the derived delta of a commit{added, removed} derived facts (optionally per head) — as a by-product of dispatch/mutation, computed from the read-model maintenance that already happens. This replaces the “two full /derive projections + diff per write” pattern with a cheap commit-scoped delta. It is the mutation-scoped change feed the ODE timeline actually wants.

D3 — On-demand per-fact explanation (proof tree)

Maintain a provenance store populated during the single derive pass: a forward index fact → [RuleApplication{rule, substitution, input_facts}] and a reverse index input_fact → {derived facts} (the orca-mvp ProvenanceStore shape — and the reverse index is the same one DRed needs, so it pays for itself). Expose explain(fact) that reconstructs an AND/OR proof tree on demand (input_facts within one application = AND; multiple applications = OR; recursion bounded by a visited set + depth cap, the truncation point becoming an expand-on-demand hole). No re-evaluation: explanation is a pure read against the materialized store, O(proof size) not O(rules · facts).

Reconcile with RFD 0028: a proof-tree node carries its proof tag as a verdict. → a strict acyclic tree; +∂ → the supporting argument plus the defeat-check substructure (which attackers were considered and out-prioritized), built from the same DAG on demand; −∂/−Δ → why-not, a failure graph (which rule heads could have produced the fact and which body atom failed), computed by reverse reasoning over the reverse index. One model, four tags.

Store why-provenance as PosBool(M) DNF (the side-track log; not full how-provenance in the answer table). Keep IVM on the counting semiring; the ℕ[X] → PosBool homomorphism is the bridge — do not make one structure do both jobs.

D4 — Execution trace as a separate, off-by-default debug surface

Port orca-mvp’s OTel-like hierarchical span model (root deriveengine.<kind>stratum.<n>, with rule-firings/rounds/clashes as span events) as a distinct debug surface, emitted from the same single derive, behind a depth parameter, off by default (a zero-overhead NoOp sink, devirtualized in release — the orca-mvp contract). This answers “what happened, in order” for a step-debugger. It is never the explain path: explanation is per-fact and logical; the trace is bulk and temporal.

D5 — Remove the per-rule derive plane

Delete the per-rule loop (collect_derived_facts, derive_trace_value, explain_*_value and the HTTP routes built only on them) and the dead derive() author op. If a “all derived facts” debug dump is ever genuinely needed, it is one materialization projected over every head (O(F + N·project)), not O(N·F) — but D1/D2 make it unnecessary for production.

Rationale

The anti-pattern is a category error: it treats a derive as a callable procedure to invoke per head, when the engine computes the entire least/well-founded model in one stratified pass. Once “materialize once, query/project/explain from that” is the rule, all four needs fall out cheaply: decisions are queries (D1), change is a maintenance by-product (D2), explanation is a read of a side-track log written during the one pass (D3), and the temporal trace is an opt-in instrumentation of that same pass (D4). The orca-mvp prototype already separated the temporal trace from the logical proof DAG; the regression was collapsing them into one per-rule loop. The vault’s economics decide the split: explanation queries are rare and per-fact, tracing is bulk and opt-in, maintenance is per-commit — three different cadences, three different mechanisms, paid where each cost is incurred.

Alternatives

  • Fix collect_derived_facts to one-pass-project, keep the dump-all endpoint. Removes the N× multiplier but keeps the wrong shape — clients still pull “all derivations” rather than querying what they need, and it gives no proof trees. Rejected as the end state; acceptable only as a stopgap.
  • Always-on full how-provenance in the answer table. The semiring-complete ℕ[X] answer. Rejected per the vault / the literature (XSB, Souffle, PUG): every cache-hit pays a provenance update, and answer-table dedup discards alternative proofs anyway. Side-track log + lazy reconstruction is the established design.
  • Keep the ODE per-write capture but cache it. Still display-coupled, still no proof trees, still two full projections. D2’s maintenance-delta is strictly cheaper and is the change feed the ODE actually wants.

Consequences

  • oxc-serve: the per-rule derive/trace/explain plane is removed (D5); dispatch/mutation gains a derived-delta in its response (D2); new on-demand explain(fact) (D3) and an opt-in debug trace (D4) are added on the single-materialization substrate.
  • Reasoner: gains a provenance store written during Engine::evaluate (D3), sharing the reverse index with DRed; and an opt-in trace sink (D4). The counting-semiring IVM is unchanged; PosBool why-provenance is the new explain-side artifact.
  • ODE (devbox-workflow-runner, playground): stops the per-write /derive×2 + /derive/trace capture; consumes the mutation delta for the timeline, one derived snapshot per step (not per write), and on-demand explain/trace only on user click; the dead derive() op is dropped. A PR lands against ontology-tooling.
  • Sequencing. Phase 1: remove the per-rule plane + expose the D2 delta (kills the O(N·F) immediately, loses no correctness). Phase 2: the D3 provenance store + on-demand explain. Phase 3: the D4 debug trace; the ODE rewire. Phase 1 is independent of and unblocks the #781 workspace-resolution work.

Open questions

  • The in-memory representation of the PosBool DNF provenance and whether/when it is persisted (the storage-backed read-model #455 is the natural home; in-memory suffices for Phase 2).
  • Scope of why-not (−∂) explanations in Phase 2 — full PUG-style failure graph vs. a first cut that names the failed body atom.
  • Depth of defeasible (+∂) explanation — how much of the ASPIC+ argument/defeat structure to reconstruct vs. summarize.
  • The D4 trace wire format — a custom JSON/SSE now (orca-mvp shape) vs. OTLP later (a separate observability concern).

RFD 0047 — The temporal value library, and the value/ontology boundary

  • State: discussion
  • Depends on: RFD 0043 (theory packages + the neutrality boundary — std reflects, never smuggles, commitment), RFD 0044 (packages — how a vendored backing is distributed)
  • Relates to: std::temporal (the DatalogMTL operators, §17 — a distinct namespace, not this), std::datetime (the existing neutral value layer this enriches)

Question

What is Argon’s standard date/time support, and where is the line between a temporal value (a thing you compute with) and a temporal commitment (a thing the ontology is about)?

Today the temporal layer is ad-hoc: Date/DateTime/Duration are opaque ISO-text literal primordials (Literal::Date("2026-01-01")) with no Rust temporal library behind them — no arithmetic, no time zones, no parsing/formatting, no calendar math, only < comparison. std::datetime (174 lines) builds the Allen interval calculus + a TimeInterval value on top of that thin base. Domain ontologies then re-implement TimeInterval/AllenRelationType/AllenHolds again (the sharpe-ontology common/datetime duplicates std::datetime verbatim). The result: every temporal need re-derives the same primitives over string-shaped dates, and anything beyond < (durations, time zones, “the third Tuesday”, DST) is unavailable.

Context

  • Date/DateTime/Duration are primordials carrying ISO text, lowered in oxc-instantiate (expr_lower.rs); the runtime stores the string and compares it. No chrono/jiff/temporal_rs dependency exists.
  • std::datetime is the ontology-neutral value home: TimeInterval { startsOn: Date, endsOn: Date }, AllenRelationType, the thirteen Allen derives, AllenHolds. Correct as far as it goes; starved of a real value layer beneath it.
  • std::temporal is a different thing — the reserved namespace for the DatalogMTL convenience operators (ever/always/since_event/…, §7.3.2), behind the V1 macro system. The temporal logic plane. Do not conflate it with the value library; do not reuse its name.
  • TC39 Temporal is the modern, settled design for date/time values (it fixed the legacy Date/Joda/java.time pitfalls: no ambiguous “month 0”, explicit time-zone vs wall-clock, immutable, calendar-aware). Two mature Rust implementations track it: temporal_rs (the Boa reference impl, spec-faithful, churns) and jiff (BurntSushi, Temporal-inspired, stability-first API). temporal_rs is, notably, the crate whose version drift broke a local build during this work.
  • Doctrine (RFD 0043): std may reflect a commitment the substrate already made, never smuggle one it withholds. A temporal value makes no ontological commitment — so it belongs in std, free of ufo.

Decision

D1 — Adopt the TC39 Temporal model for Argon’s temporal value layer

Argon’s temporal values are the Temporal type set, ontology-neutral, in std::datetime (the existing neutral home, enriched — not a new namespace):

  • Instant (a fixed point on the timeline, UTC), Duration (calendar-aware span);
  • PlainDate, PlainTime, PlainDateTime (wall-clock, no zone);
  • ZonedDateTime (instant + TimeZone + calendar);
  • TimeZone, and a value-level Calendar system (ISO-8601/Gregorian arithmetic — leap years, month lengths);
  • the operations: construction, parse/format (ISO-8601), comparison, arithmetic (add/subtract/until/since/round), field access.

The existing TimeInterval + Allen calculus stays, now expressed over these richer values rather than bare ISO strings.

D2 — The value/ontology boundary (the rule)

A temporal value — an instant, a date, a duration, a zone, a calendar system — is ontology-neutral and lives in std. Temporal ontology — a calendar as a social artifact, a reified interval that mediates individuals, time-indexed facts — is committed and lives in a ufo-based package.

A PlainDate is a value like an Int; you do not ontologically commit to “2026-06-24”. The Gregorian calendar as ISO arithmetic is a value-level Calendar system (std::datetime); the Gregorian calendar as a NormativeDescription a society adopts is a domain kind (the ontology layer). Both exist, separated by this line. Not everything temporal needs ontological commitment — most of it is values.

D3 — Runtime-back the primitives; do not reimplement temporal math

The Date/DateTime/Duration/… primordials gain a real backing: their arithmetic, parsing, formatting, time-zone resolution, and calendar math are runtime intrinsics that delegate to a vendored Rust Temporal implementation — not hand-rolled, not computed in Argon source. Calendar and time-zone arithmetic is a notorious correctness sink; we consume a spec-tracking library, we do not author one.

Backing choice: temporal_rs is spec-faithful but churns; jiff is stability-first. For a runtime intrinsic that must pin to a stable API, lean jiff — but the Argon surface is TC39 Temporal regardless of which backs it, so this is an isolated, reversible impl decision (kept behind the intrinsic boundary). Pin it deliberately (RFD 0044 distribution).

D4 — Two temporal namespaces, kept distinct

std::datetime = the value library (D1). std::temporal = the DatalogMTL operators (§17). They compose (an MTL operator ranges over events stamped with std::datetime instants) but are different layers — the value algebra vs. the temporal logic. No rename; this RFD only clarifies the boundary.

D5 — Domain temporal layers depend on std::datetime, never re-implement it

A domain/ontology package needing time uses std::datetime for values + the Allen calculus, and adds only commitment on top (e.g. Calendar <: NormativeDescription, a reified TimeInterval-as-individual when it must mediate). The sharpe-ontology common/datetime duplication of TimeInterval/AllenRelationType/AllenHolds collapses to a re-export of / dependency on std::datetime.

Rationale

The current ISO-text primordials are a floor, not a library — anything past < is absent, and every consumer re-derives the same intervals. TC39 Temporal is the one date/time model worth standardizing on (it is the lesson learned from every prior date/time API), and two Rust impls already exist, so the cost is binding, not authoring. The value/ontology split (D2) is forced by the neutrality doctrine and by common sense: a date is a value; a calendar-as-institution is a commitment. Keeping the math in a vendored intrinsic (D3) avoids the single most error-prone thing a language can try to write itself.

Alternatives

  • Keep the ad-hoc ISO-text primordials. Rejected — no arithmetic/zones/calendars; every domain re-derives intervals over strings; correctness hazards (DST, leap) unaddressed.
  • Author temporal math from scratch (Argon or hand-rolled Rust). Rejected — calendar/time-zone arithmetic is a correctness sink with a maintained standard impl available.
  • Model everything temporal ontologically (ufo-committed dates). Rejected — a value is not a commitment; it would force ufo into std and make trivial date math a reasoning problem.

Consequences

  • The Date/DateTime/Duration primordials are enriched with runtime intrinsics backed by the chosen Rust impl; new value types (Instant, ZonedDateTime, TimeZone, …) are added to std::datetime.
  • std::datetime grows from a 174-line Allen layer into the Temporal value library; its Allen calculus re-expresses over the richer values.
  • Domain temporal duplication (sharpe-ontology common/datetime) is removed in favor of depending on std::datetime.
  • A new vendored runtime dependency (jiff/temporal_rs), pinned; the only place temporal math lives.
  • std::temporal (MTL) is unaffected beyond the documented boundary.

Open questions

  • jiff vs temporal_rs as the intrinsic backing (lean jiff for stability; both expose a TC39-Temporal-shaped API).
  • v1 surface scope — the full Temporal type set, or Instant/PlainDate/PlainDateTime/Duration/ZonedDateTime first and TimeZone/non-ISO calendars later.
  • Relation to the bitemporal substrate (tx_from/tx_to, as_of N) — those extents should be std::datetime instants; confirm the wiring.
  • Whether TimeInterval stays a std::datetime value type, with the reified-individual form a separate ontology-layer concept (D2/D5).

RFD 0048 — The test atom: in-language unit tests, and why a test is substrate

  • State: accepted — partially implemented (v1 shipped in #804; deferred assertion forms remain)
  • Depends on: RFD 0015 (mutate body surface — a test body is a mutate body plus assert), RFD 0025 (check discharge — the closest relative, and the contrast that defines a test), RFD 0020 (runtime engine — Engine::evaluate, the one read path a deductive-plane assert uses), RFD 0042 (self-validating .oxbin — the trust argument for carrying tests in the artifact), the scenario harness (#764 — the integration-test sibling).
  • Prior art: the §19 walking-example test "…" { … } sketch (a pre-implementation surface, superseded here); the scenario-harness expect vocabulary (oxc-driver/src/harness.rs); Rust’s #[test] / #[cfg(test)] (the source-discovery model this RFD weighs and rejects for Argon).

Question

Argon needs in-language tests: a way to write, inside a package, a unit test of what its rules, fns, and mutations actually do. Two questions follow. (1) Surface: what does a test look like and what does it assert? (2) Carrier: is a test a substrate concern — an elaborated declaration persisted in the .oxbin axiom-event log and mechanized in the Lean carrier taxonomy, like mutate/query/check — or a tooling concern, discovered from source by ox test and never persisted? The carrier question is the load-bearing one, because it decides whether the Lean mechanizes anything about tests at all.

Context

The test keyword has been reserved since the early grammar, parsing-but-silently-swallowing its body — the last entry on the v0.2.1 loudness burn-down (#342). The scenario harness (#764, ox run-scenario over scenarios/*.toml) shipped as the integration-test surface and deliberately reserved the tests/ directory for the in-language test atom, the unit-test surface. The §19 walking-example sketched a test form (imperative let/insert + assert) that never parsed.

The construct shipped in #804. During review one architectural question was deferred to this RFD: putting a test in the substrate means adding AxiomKind::TestDecl, which — because AxiomKind is @[language_interface]-mechanized — forces a testDecl variant into the Lean. AGENTS.md says “the Lean does not cover … tooling.” So either tests are not tooling, or the carrier choice is wrong. This RFD settles it.

Decision

A test is substrate, at the carrier / data-shape layer. The runner is tooling. Precisely:

  • The test declaration is an elaborated Core IR artifact, carried in the axiom-event log as AxiomKind::TestDecl with body TestDeclBody { name: String, operations: Vec<Operation> } — structurally identical to MutationDeclBody. The body is the same Operation Core IR a mutate lowers to, plus the test-only assertion ops: Operation::Assert { condition, rendered } (value/boolean), Operation::AssertDerivable { predicate, args, negated, rendered } (derivability), and Operation::AssertRejects { ops, expected_code, rendered } (negative enforcement — the block’s own lowered Operation stream, run isolated). The Lean carries testDecl in the AxiomKind taxonomy (Argon/Storage/AxiomKind.lean).
  • The runner is tooling, in Rust and prose only: ox test, the per-test fresh-store orchestration, the PASS/FAIL/ERROR classification and reporting, --filter, the non-zero exit. The Lean mechanizes none of this, and Operation::Assert’s arity is not in the drift gate (Operation is not a @[language_interface] inductive — same as every other mutate op).

The surface (shipped):

test "active leases are returned for tenant" {
    let alice = insert Person { name: "Alice", age: 30 };
    let unit  = insert Property { address: "1 Main", sqft: 700 };
    let l = sign_lease(alice, unit, 2500, 365);
    assert alice.active_leases() == [l];
}

A test "<string-name>" { … } is a named imperative block run top-to-bottom against a fresh store. The body is the mutate-body statement set (RFD 0015) interleaved with assert <bool-expr>; — no separate fixture block. assert mirrors require, with one difference: a failed require aborts the body; a failed assert records a pass/fail outcome and execution continues. An assert whose condition names a derived predicate / pub query / nav-method is evaluated against the reasoner’s materialized extent (via Store::query_derive, the one read path), so a test asserts what the rules derive, not just what was stored. assert is test-only: a stray assert in a mutate/fn body refuses at build time with OE1318.

The assertion vocabulary completes its negative-enforcement half (shipped): alongside the positive assert <bool-expr> and assert [not] derivable F(args), the rejection form assert rejects [( Pkg::Code )] { <mutate-body-stmts> } asserts that a write block is refused by a write-path guard. Argon is a constraint language — checks, where/iff invariants, group axioms — and without this form the test atom could assert what a model accepts but not what it refuses, which is the more important property of a constraint. The block runs against an isolated copy of the test world (committing nothing back) and the result is classified: a genuine write-path guard rejection (a where-invariant OE0668, a check delta-guard, a group axiom, an endpoint refusal) PASSes — matching a pinned Pkg::Code when given, a different code FAILing as the wrong reason; an accepted write FAILs; and a non-guard error (a typo, an unbound reference, a type error) ERRORs loudly. The guard-vs-non-guard line is load-bearing: only a genuine constraint refusal satisfies rejects, so a broken test can never masquerade as a passing rejection test. rejects, like derivable, is a contextual keyword (leading position after assert).

Rationale

The carrier ruling is validatable on four independent grounds; each was checked against the code and the Lean.

  1. AxiomKind is the event-log taxonomy, not the five-atom set — and tests join an existing precedent. The five atoms (meta-calculus, constructs, rule, trait, macro) are the vocabulary-introduction primitives and are fixed. AxiomKind is broader: it already carries ruleDecl, queryDecl, mutationDecl, computeDecl, and bridgeDecl — none of which is one of the five atoms. testDecl sits in exactly that company. So calling it “the test atom” is colloquial naming of a declaration form, not a claim of a sixth substrate atom. (Verifiable: Argon/Storage/AxiomKind.lean, oxc-protocol/src/storage.rs.)

  2. A test body is Core IR, which the Lean scope already covers. TestDeclBody.operations is Vec<Operation> — the identical elaborated IR a mutate lowers to. AGENTS.md’s mechanization scope lists “Core IR: elaborated intermediate representation; surface-to-IR lowering preservation” and the Storage event log. A test, once elaborated, is a member of both. Carrying it is consistent with what is already mechanized; excluding it would be the special case requiring justification.

  3. The carrier/runner split is exactly what “the Lean does not cover tooling” means. The Lean testDecl is a tag in the carrier enum; it carries no test semantics — there is no mechanized pass/fail relation, no ox test model, no isolation theorem. The tooling (the runner and its reporting) lives in Rust and prose, untouched by the Lean. So the scope line holds verbatim: the event-log taxonomy (Storage) is substrate and mechanized; the runner is tooling and is not. Adding testDecl to the taxonomy no more “mechanizes tooling” than mutationDecl does.

  4. Trust and reproducibility (RFD 0042). The .oxbin is the package’s complete elaborated form, and it is self-validating: it re-checks its own invariants at load. A test carried in the artifact runs against the exact elaborated state that ox check/ox build validated — there is no second elaboration that could drift from the checked one. Source-discovery would re-parse and re-elaborate test bodies independently of the artifact, opening precisely that drift. Carrying tests is the choice that preserves “the artifact is the package.”

Alternatives

  • Source-discovery (the Rust #[cfg(test)] model): rejected. ox test would parse tests/*.ar, elaborate test bodies on demand, and run them, persisting nothing. It is appealing on the intuition that “tests aren’t knowledge,” but it (a) reintroduces a second elaboration path that can drift from the checked artifact (against RFD 0042), (b) makes a test the only package-level declaration not carried in the artifact, splitting the declaration model for no semantic gain, and (c) buys nothing the carrier model lacks — tests are already inert at query/serve time, so persisting them costs nothing at runtime.
  • A sixth atom: rejected, and a category error. A test introduces no vocabulary and no new substrate primitive; it is a declaration form whose body is existing Core IR. The five atoms are fixed (see ground 1).
  • Stripping tests from every artifact: not now. Whether a release artifact should omit its tests (as a Rust release binary omits #[cfg(test)]) is a build-profile question, not a carrier question — it does not bear on whether tests are substrate. Deferred (see open questions).

Consequences

  • AxiomKind::TestDecl is in the .oxbin event log and the Lean carrier taxonomy (the variant count is now 26). Tests are inert at query/serve time — only ox test enumerates them.
  • A test is not a check. A check is a standing universal obligation that fires a diagnostic over whatever world is loaded and is observer-only (it populates no IDB); a test is an existential example that constructs a known world and asserts a specific expected outcome. Check-firing is one thing a test can assert — directly, via assert rejects [( Code )] { <write> }, which asserts the write is refused by a guard (and on the exact code when pinned) — but the two are duals, not the same construct.
  • Isolation is fresh-store-per-test (the scenario-harness discipline), so tests are order-independent; within a test, writes are read-your-writes over the committed + deductive state.
  • A published artifact currently carries its tests. This is acceptable (they are inert) and is the trust-preserving default; a future release profile may strip them.

Open questions

  • Shared / parameterized fixtures across tests — the one deferred assertion-surface item (the fixture / expect block forms; refuses loudly until built, never silently accepted). The dedicated derivability forms assert derivable F / assert not derivable F and the three-valued outcomes (an INCONCLUSIVE result distinct from FAIL when a derivability assert is evaluated over an open-world relation, where absence is can, not not — the same fail-closed discipline RFD 0045 forced for the write side) are now built (§17.14): present ⇒ PASS/FAIL, absent under CWA ⇒ FAIL/PASS, absent under OWA ⇒ a loud INCONCLUSIVE that exits ox test non-zero. The negative-enforcement form assert rejects [( Pkg::Code )] { … } is also now built (§17.14): a write block is run isolated and a write-path guard rejection PASSes (on the pinned code when given), an accepted write FAILs, and a non-guard error ERRORs loudly — so the assertion vocabulary covers both what a model accepts and what it refuses.
  • Release-profile stripping of tests from a distributed artifact (a build-profile lever, orthogonal to the carrier ruling).
  • An assertion-soundness theorem in Lean (optional research): that a passing deductive-plane assert implies the asserted derivation holds at the fixpoint. The runner stays tooling regardless; this would only mechanize the meaning of the carried Assert op, adjacent to Reasoning/Checks.lean.

RFD 0049 — Error-tolerant diagnostics: recovery, source-faithful expansion, and the What/Where/Why/Fix model

  • State: discussion
  • Depends on: RFD 0037 (declarative macros + carrier-based hygiene — the expansion pipeline this preserves source through), RFD 0025 (check discharge — the user-check diagnostic channel), RFD 0027 (the #[order]/meta-property decorators whose mis-binding exposed the gap), the editor experience epic (#723 — the LSP surfaces these renderers feed), the teaching-diagnostics arc (#705/#727 — the What/Where/Why/Fix model this completes).
  • Prior art: orca-mvp’s compiler/LSP — granular parser recovery (error_here/error_and_bump/err_and_recover + a vocabulary-agnostic IDENT IDENT sync point, oxc/src/cst/parser.rs), diagnostic attribution maps that never surface synthesized forms (oxc/src/elaborate/diagnostics.rs), a shipped LSP quickfix system (lsp/src/convert.rs, snippet_resolver.rs), and partial-analysis caching that survives parse errors (lsp/src/analysis.rs). rustc/rust-analyzer (rowan ERROR nodes, structured Suggestions feeding both rendered help and code actions), Roslyn (red-green trees, skipped-token/missing-node recovery), forward source maps (TS→JS, Sass→CSS). The vault: Projectional Editing Taxonomy (Hazel — “a program with holes is never a parse-error/type-error wall”) and decision D-012 (the OE/OW/OI severity-prefix scheme).

Question

How should Argon’s compiler, runtime, and language server behave on broken or incomplete models so that diagnostics genuinely guide the modeler — and what invariant prevents the class of failures where an error is rendered against text the user never wrote?

Context

A real bug surfaced the gap. This input (note the missing ; after Top):

use mlt::*;
pub category Top
#[order(2)]
pub category Kind <: Top

fails with OE0001: unexpected token BANG at module level and renders synthesized post-expansion sourcepub category Toporder! { (2) pub category Kind <: Top } — at line numbers that do not exist in the file. The #[order(2)] decorator was re-serialized as a module-level macro-bang. The reported cause (order!/MLT) is wrong; the actual fault is a missing terminator on the preceding declaration, and the rendering shows text no one wrote.

This is not a wording problem (those are addressed by the citation scrub and the source-frame work that already landed). It is three structural defects in the layers beneath the message:

  1. Expansion destroys source fidelity. oxc-workspace/src/expand.rs expands one invocation per round by format!-building a synthetic string ("{name}! {{ {args} {body} }}"), replacing the module text, and re-parsing. After any expansion, every span is an offset into synthetic text; a downstream error renders that synthetic text at phantom line numbers. The renderer already distinguishes the pieces it emits (Piece::Sub — a metavariable substitution carrying the user’s argument tokens — vs Piece::Lit/Tok — macro-body tokens), but that origin information is discarded the moment the pieces are joined into a flat string.

  2. The grammar is ambiguous at declaration boundaries. A bodyless concept declaration (pub category X <: Y, no { … } body and no = union) is accepted with an optional terminator (p.eat(SEMI)). With no ;, the parser cannot tell the declaration ended before the next item, so a following #[…] attribute is silently mis-attached, and the only diagnostic — generic “unexpected token” — never says what was expected.

  3. Broken input is a wall. Recovery is coarse (skip-to-item-start); there are no per-construct “expected X” diagnostics, no machine-applicable fixes, and the LSP’s structural features (completion, document-symbol, references — all recently shipped) collapse on a file that does not fully parse rather than degrading over the recoverable region.

The substrate to do this correctly already exists or is proven. orca-mvp shipped the whole error-tolerant spine — granular recovery, attribution maps that assert every diagnostic resolves to real source, a one-diagnostic→many-renderers model with quickfixes, and partial-analysis caching. The current parser is rowan-based (lossless green trees with ERROR nodes are natural). The expansion pipeline expands one contiguous invocation per round, which means a forward provenance map is exactly composable. The vault’s north star is Hazel’s principle that a program with holes is never a wall; for a textual language the achievable, complete form of that is an error-tolerant spine: lossless parse → recovery → faithful provenance → partial analysis → actionable fixes.

Decision

The load-bearing invariant, from which everything else follows:

Every diagnostic span resolves to a location the user actually wrote, and the user is never shown text they did not write. A diagnostic span that cannot resolve to real source is a loud compiler bug — an internal-error diagnostic — never a phantom rendered at the user.

The architecture is five layers. Each is built as a complete vertical slice; none is optional or stubbed.

D1 — Lossless, error-tolerant parsing with granular recovery

The parser produces a lossless rowan green tree for any input, with ERROR nodes wrapping unparseable spans and explicit MISSING markers where a required token is absent. Recovery is per-construct, not module-level: each construct emits a specific expected-token diagnostic (“expected ; to terminate category Top”, “expected } to close this body”) and resynchronizes at the nearest construct boundary, including the vocabulary-agnostic IDENT IDENT concept-declaration sync point (so recovery does not hard-code domain metatype names — a concept can be introduced by any in-scope pub metatype). This recovers orca-mvp’s error_here / error_and_bump / err_and_recover discipline. Parsing always continues past an error; the rest of the file yields a usable tree.

D2 — The terminator rule (the grammar is made unambiguous)

A declaration is terminated by exactly one of: its body’s closing }, the end of its = union, or — when it has neither — a required ;. This is precisely Rust’s item-termination rule (struct Foo; requires ;; struct Foo { … } self-terminates), and it is the project’s stated Rust-aesthetic default. p.eat(SEMI) becomes p.expect(SEMI) with recovery: a missing terminator on a bodyless declaration emits “expected ; to terminate <decl>” with an add-; fix and continues. This removes the ambiguity that silently mis-attaches a trailing attribute — the defect was the optional terminator, and an ambiguous grammar is a defect, not a stylistic choice.

D3 — Source-faithful expansion via a forward, piece-granular provenance map

Expansion preserves source provenance through a forward-built provenance map, not by reverse-engineering offsets and not by rebuilding the pipeline around per-token SyntaxContext. As each expansion round renders, it records, for every output range, its origin:

  • a Sub piece → the invocation argument’s real source range (the user’s text);
  • a Lit/Tok piece → the macro definition-site range (with the macro identity);
  • a synthesized wrapper (e.g. the order! { … } an attribute rewrite produces) → the attribute/invocation site.

Because each round replaces a single contiguous source range, per-round maps compose: round N’s map composes through round N−1’s, so any final-text offset resolves transitively to either a real user-source location or “synthesized inside the expansion of M, invoked at S”.

Diagnostic resolution walks this map: an error on copied-through user code lands on the user’s real declaration; an error on macro-body output lands at the invocation site with a secondary “in expansion of M, defined here” label. The synthesized macro-bang text becomes structurally unrenderable to the user. The orca-mvp attribution-map assert is adopted: if resolution finds no real-source anchor, that is an internal-error diagnostic.

Hygiene is unaffected. Argon’s carrier/oracle hygiene (RFD 0037) resolves names at render time; the provenance map resolves spans for diagnostics. They are orthogonal — there is no hygiene case that forces per-token provenance — so the provenance map is added alongside the existing hygiene oracle, which is left intact.

D4 — One structured diagnostic, What/Where/Why/Fix

A single Diagnostic value, structured rather than prose:

  • What — the headline (citation-free; guarded).
  • Where — a primary span resolved through D3, plus secondary labels (the prior declaration, a macro definition site, the conflicting decl).
  • Why — the justification chain: the axiom-trace provenance chain for semantic checks, the grammar expectation for syntax errors.
  • FixVec<Suggestion>, each a span + replacement text, machine-applicable.

Every renderer consumes this one value: terminal (miette), LSP, ox explain, the mdBook appendix, the InfoView inspector. This completes the #727 model.

D5 — Quickfixes and code actions, from the same Suggestions

The Suggestions of D4 are the LSP code actions — one source, two surfaces (the rendered “help:” line and the editor quickfix are never two implementations). This recovers orca-mvp’s shipped set (add missing pub, add else, disambiguate glob) plus the cases this RFD creates (add ;, add a use for an unimported vocabulary classifier — the OE0605 cliff). A “did you mean” producer (bounded edit-distance over in-scope names) emits Suggestions for unresolved paths and metatypes.

D6 — LSP graceful degradation over the recoverable region

A salsa-cached partial analysis — symbol table, reference index, resolved names over the non-ERROR parts of the CST — that survives parse errors (orca-mvp’s persisted AnalysisResult). Hover, completion, document-symbol, semantic tokens, and references all operate over the parts that parsed, even when other parts are broken. A missing ; never blanks the whole server; it produces a precise diagnostic and the rest of the file stays live.

D7 — The invariant is enforced, not aspirational

Two guards keep the spine from eroding: (a) a test asserting that a diagnostic produced over expanded input resolves to a real source span (never synthesized text) — the D3 attribution-map assert, exercised on the motivating bug; (b) the LSP degradation is covered by tests that feed a deliberately broken file and assert hover/completion/symbols still answer over the recoverable region.

Rationale

The defects are architectural, so the fixes are too. D1+D2 make a syntax error a precise, recoverable event instead of a silent mis-parse. D3 is the keystone: the worst confusion (“what is order! { } and why is that line number wrong?”) is a fidelity violation, and once provenance is preserved that entire class is gone. D4+D5 make every diagnostic teach (Why) and act (Fix) from one structured source. D6 makes the editor useful precisely when the model is broken — which is most of the time during authoring.

The piece-granular forward map (D3) is chosen over a per-token SyntaxContext/ExpnId rebuild because it is the correct fit for Argon’s actual architecture, not because it is simpler. rustc unifies provenance and hygiene in SyntaxContext because its hygiene is per-identifier scope-sets; Argon’s hygiene is carrier/oracle-based and works at the name level (RFD 0037), so a per-token model would rewrite a working subsystem to buy provenance it can get exactly from the map. The current renderer already computes piece origin (Sub vs Lit/Tok); the map records what is already known. Forward source maps are the standard, exact technique for “spans must survive a text-rewriting transform” (TS→JS, Sass→CSS) — and being built forward, with full structural knowledge of what each expansion replaced, they are exact, not the fragile reverse-offset-guessing they are sometimes confused with.

Alternatives

  • Per-token SyntaxContext/ExpnId, tree-to-tree expansion (the rustc model). Strictly more powerful (per-identifier hygiene + provenance unified) but requires rebuilding the expansion pipeline, lexer token, and parser integration, and supplants Argon’s working carrier hygiene. Rejected: it buys no correctness over D3 for Argon’s hygiene model, at large cost. If a future hygiene requirement ever needs per-identifier scope-sets (macro-defines-macro with capture across expansions the carrier cannot express), D3’s map is forward-compatible — the ExpnId is the same content-derived expansion_id already minted.
  • Reverse offset remapping (recover origin by diffing expanded vs original text post hoc). Rejected: lossy and fragile under nesting and substitution; cannot distinguish user-argument tokens from macro-body tokens. D3 records origin forward at render time instead.
  • Layout / automatic-semicolon-insertion instead of D2’s required ;. Rejected: ASI has well-known footguns (Go, JavaScript) and trades one ambiguity for a subtler one; the required terminator is unambiguous and matches the Rust default.
  • Leave expansion text-based, fix only the message wording. Rejected: it cannot satisfy the invariant — any error over expanded input still renders synthetic text.

Consequences

  • Macro/attribute expansion gains a provenance map threaded through the per-round driver; the per-round text pipeline (a correct nesting design) is retained, hygiene is untouched.
  • The Diagnostic type is restructured once to carry structured What/Where/Why/Fix; all existing renderers (frames, axiom-trace, ox explain, the citation-clean catalog) plug into it. Recently-shipped LSP features (completion, symbols, references) gain graceful degradation.
  • ; becomes required after a bodyless declaration. Existing corpora with bodyless declarations missing terminators will get a precise, auto-fixable diagnostic; a one-time ox fmt/quickfix sweep adds the terminators. This is a surface change and is recorded as such.
  • Two regression guards (D7) make the source-fidelity invariant and the LSP degradation permanent.

Open questions

None blocking. The expansion_id already minted for hygiene is reused as the provenance map’s expansion identity, so no new identity scheme is needed. The build order is dependency-driven (D1/D2 → D3 → D4 → D5 → D6); each lands as a complete slice.

RFD 0050 — Documentation architecture: three books, correctness by construction, and a verified authoring pipeline

  • State: discussion
  • Depends on: the reference manual (Part I of the book) and its crash course; RFD 0049 (error-tolerant diagnostics — the What/Where/Why/Fix model and ox explain corpus the diagnostic docs draw on); RFD 0044 (packages and the registry — the example corpus is a set of real packages); the @[language_interface] drift gate (spec/lean/Argon/Interface.lean), whose discipline the documentation-freshness gate mirrors; the editor-experience work (the LSP shares the diagnostic corpus these renderers feed).
  • Prior art: Rust’s three-artifact split — The Rust Programming Language, The Rust Reference, Rust by Example — plus the rustc error index (rustc --explain); the Diátaxis documentation framework (tutorial / how-to / reference / explanation as four distinct reader-needs); mdbook’s {{#include}} transclusion; the Oxide Computer RFD process this series already follows; literate-specification precedents that cite a mechanization alongside prose.

Question

How should Argon’s documentation be structured, authored, and kept correct as the language moves — given that the reference manual systematically drifts from the implementation today, that the substrate is mechanized in Lean while the reasoner is implemented Rust-first, and that “what runs today” cannot be read reliably from prose or even from code comments?

Context

The drift is structural, not a matter of diligence. The reference manual asks one artifact, in one voice, to do three different jobs — teach a newcomer, specify the language normatively, and record design history — and to track two different timelines at once: the language as designed (which changes by deliberate decision, rarely) and the language as implemented (which changes every merge). An inline status badge or a “refuses today” sentence in normative prose is therefore a time-bomb with a one-merge fuse.

This is measurable, not hypothetical. A fact-check of the prose against the tree at main found shipped capabilities described as unbuilt: the temporal value library (real, jiff-backed) and the in-language test declaration (real, with its own ox test runner) were both still called “future” in committed prose. And reading the implementation’s own code comments and reserved markers *under-*reported the language: well-founded semantics for cyclic negation and the defeasible-rule surviving extent both run today, yet are easy to read as “not built.” The lesson is sharp and load-bearing for everything below: implementation status is unreliable read from prose, and unreliable read from code comments. The only trustworthy signals are an example that compiles and runs in CI, the deliberately-honest “what runs today” sections of the crash course, and commit history.

Three further facts shape the design:

  • The layers move at different speeds. For the surface and substrate semantics, the book runs ahead of the Lean and the Lean ahead of the Rust. The one inversion is the reasoner: well-founded semantics, the join/optimizer engine, incremental maintenance, and defeasible evaluation are implemented in Rust ahead of their Lean mechanization. Documentation must make this legible without misleading a reader about what is proven versus what runs.
  • The crash course already demonstrates the target. It teaches the meta-calculus before any vocabulary, frames a foundational ontology as an ordinary package rather than a language feature, and states plainly what is specified versus what runs. The drift lives in the numbered reference chapters, not here. The crash course is the seed, not a thing to replace.
  • The language is unfamiliar and dual-purpose. A declared (not built-in) classifying vocabulary, four-valued Truth4, per-concept world assumptions, a seven-tier cost ladder, defeasibility, and standpoints are not what a reader arrives expecting from OWL, SQL, or Prolog. Argon is also both a language and a database, with a modeler audience and a data-systems audience. The documentation has to install a correct mental model, not just list features.

Decision

One principle is load-bearing, and the rest follows from it:

The artifact most at risk of being wrong carries the fewest independently-falsifiable claims. Narrative teaches by pointing — at examples that compile in CI, and at a reference whose claims are mechanically anchored — rather than by restating facts it could get wrong. Risk is inverted on purpose: the highest-variance prose is made the lowest-risk by construction.

D1 — Three user-facing books, one information architecture

Not three silos. One system, layered by who owns truth, each book answering one reader-question (the Diátaxis split, adapted):

  • Argon by Example — the verified substrate (“show me it working”). Real .ar packages, compiled and run in CI. This is the only place code lives. Both other books transclude from it, so a broken example is a failed build, not a stale snippet. Indexed two ways: by concept and by task (“how do I model a role / a temporal fact / a defeasible exception”).
  • The Argon Reference — the normative surface (“what is the exact rule”). Terse and complete. Each section carries a Lean-provenance link (existence-checked in CI); grammar sections are generated from grammar.toml; code is transcluded, never pasted; the diagnostic appendix is the error index, single-sourced with ox explain <CODE>. It describes the language as it is — no inline status badges (see D2).
  • The Argon Book — the teaching narrative (“how do I think about this”). It grows from the crash course, teaches substrate-first, and makes minimal original factual claims: it motivates, sequences, and explains, but every code sample is a transclusion and every precise rule is a link to the Reference. It is correct by construction.
  • No published implementation-status surface. The language is done enough that “what is implemented today” is no longer a question the documentation must answer — so the published status surfaces (inline chapter badges, the feature-status page, and a generated coverage grid) are retired. The three books describe the language as it is. The anti-drift value those surfaces carried — catching when the reference falls behind the language — is preserved as an internal CI check (cargo xtask check-coverage) over a feature registry (coverage-features.toml): every feature names its CI-checkable signals (a runnable example, a diagnostic code in the generated catalog, a Lean file) and the check fails the build if any cited signal no longer exists. That is the anti-drift mechanism, not a reader-facing grid. The live, CI-verified evidence of what runs is Argon by Example — every package there compiles and runs in CI.
  • A single glossary: one definition per term (metaxis, metatype, metarel, refinement, standpoint, tier, defeasible, the value/ontology boundary), authored once and transcluded into every book.
  • One cross-link grammar, so the books read as one system: learn it → Book §; exact rule → Reference §; see it run → By Example #; why this design → RFD; is it proven → Lean.

RFDs and the Lean are referenced archives, not part of any reading path. The AGENTS.md intent nodes remain contributor-facing and separate from the three user books.

D3 — The Book spine

The expanded crash-course arc — a modeler’s workflow, not an enumeration of atoms (the atom-by-atom organization belongs to the Reference):

  1. Orientation — what Argon is and why (a typed knowledge graph, a rule engine, and a bitemporal store in one); install; a fast end-to-end taste.
  2. Modeling a domain — the meta-calculus, substrate-first (declare your own metatype/metaxis/metarel, plus the reflective intrinsics); the value/ontology boundary; data versus concepts; <: (specialization) versus : (instance-of); first-class relations; refinement (iff versus where). Examples are neutral; a foundational ontology appears only as one bounded worked example.
  3. Reasoning — the five rule modes; derivation and recursion; the stratified fixpoint and well-founded semantics; the write path (mutate); queries.
  4. Truth under incompleteness — Truth4; world assumptions (closed by default, the per-concept open-world opt-in); defeasibility; standpoints and federation.
  5. Confidence — the decidability ladder (why a model terminates and what it costs) and checks-and-diagnostics as the trust surface (ox explain, the justification “why”).
  6. Building real systems — packages and the registry; traits; macros; tests; the runtime and serving; the bitemporal store; generated SDKs.

D4 — The anti-drift machinery

  • A feature registry (coverage-features.toml) checked by cargo xtask check-coverage: every feature’s cited signals (example, diagnostic, Lean file) must exist, so the reference cannot silently fall behind the language (D2). The check is internal — it does not publish a status surface.
  • Example transclusion (mdbook {{#include}} with named anchors), so no book contains a code snippet that is not a region of a compiling package.
  • A source-commit drift-check: each generated/anchored claim pins the commit it was verified against; a check flags cited sources that moved.
  • A documentation-freshness gate that mirrors the @[language_interface] drift gate: a feature PR that flips an RFD state to shipped, changes a drift-checked carrier, adds a diagnostic code, or adds an example prompts the matching doc touch. Soft warning first, hardenable.
  • Grammar generation of the Reference’s syntax sections from grammar.toml.
  • The diagnostic corpus single-sourced into both ox explain and the Reference error index.

D5 — The verified authoring pipeline

Per section, a pipeline (not a single pass): structure (charter: what it covers, which examples it owns, dependencies) → content ledger (every claim paired with a source — a Lean module/theorem, an oxc location, an RFD, a diagnostic code, or an example id; every code sample a real package or a flagged gap) → adversarial verify (the compiler for code claims, an existence-check for Lean links, an agent told to refute for prose semantics) → cross-section reconcile (resolve contradictions, build the glossary, confirm coverage) → constrained authoring (prose written only from the verified ledger; code only by transclusion) → review (voice, then a technical re-check that prose still matches the ledger and examples still pass).

Three properties make it sound rather than merely orderly:

  • Ground-truth order and a blocklist. Authority runs Lean (substrate semantics) → oxc source (surface, runtime, diagnostics) → reference prose. Explicitly not authoritative: stale editor grammars, superseded surface versions, and any external note. Where reference prose and code disagree, code wins; where the Lean covers the substrate, the Lean wins.
  • Verification is mechanical wherever possible. Code claims are checked by the compiler in CI; grammar is generated; status is a CI signal; Lean links are existence-checked. The agent’s judgment is the fallback for prose semantics only — the smallest surface that must rely on it.
  • The drift/gap ledger is a first-class output. A claim that cannot be verified becomes one of three things: a documentation fix, a spec-or-code drift ticket, or a language gap that needs a decision. The documentation build doubles as a correctness audit of the language.

The pipeline is layered by book (By Example green first, then the Reference, then the Book) and is designed for steady state: the freshness gate, drift-check, and CI examples run for the life of the project; the initial authoring is run number one, not a finished project.

D6 — The plan

  • Wave 0 — the anti-drift tooling (coverage registry, transclusion, drift-check, freshness gate, shared-glossary infrastructure, Lean-link check) plus the small, already-identified prose drift-fixes.
  • Wave 0.5 — a tracer bullet: one concept taken through the entire system end-to-end (a CI-verified example → a Reference section with provenance → a Book passage transcluding it → its feature-registry entry → its freshness hook), to validate every seam before fan-out.
  • Wave 1 — Argon by Example (the verified substrate).
  • Wave 2 — the Reference (normative, status-free, literate).
  • Wave 3 — the Argon Book (narrative, transcluding and linking).
  • Steady state — the gates run continuously; new features ship with their doc touch, example, and feature-registry entry by construction.

Rationale

The correctness-by-construction inversion is the whole design. Documentation rots because prose asserts facts that later change; the fix is to let prose assert as little as possible and point at things that are checked. Examples are checked by compilation; the Reference is anchored to the Lean and to generated grammar; status is a CI signal. The narrative book — the artifact a model is most likely to hallucinate — ends up carrying almost no standalone claims, so it cannot drift in the ways that matter.

Three books rather than one because the three reader-questions are genuinely different and have different cadences: a learner wants a motivated path, a practitioner wants a precise rule, and either wants to see code run. Folding them into one artifact is exactly the conflation that produced today’s drift. Splitting them lets each take the drift-control that fits — transcluded-and-compiled for examples, anchored-and-generated for the reference, point-don’t-restate for the book.

No published status surface, and that is forced. The Context shows status is wrong when read from prose and wrong when read from code comments; any hand-maintained surface is a smaller copy that drifts the same way. With the language done enough that “what is implemented today” is no longer a question the documentation must answer, the right move is to publish no status surface at all and let the books describe the language as it is. The anti-drift value is kept where it belongs — as an internal CI check (check-coverage) over signals that are already checked: a green example, a present diagnostic, an existing Lean symbol. The check fails loudly if a cited signal vanishes, so the reference cannot quietly fall behind, without any reader-facing grid to drift.

Lean-provenance in the Reference earns its place twice: it is a trust asset unique to a language with a mechanized substrate (“this rule is proven sound; here is the theorem”), and it is a drift anchor (a CI check that the cited symbol exists). It must be honest about the reasoner inversion: where Rust leads the Lean, the provenance says so rather than implying a proof that is still owed.

The plan is sequenced by dependency and by risk. Tooling first because everything stands on it. A tracer bullet before fan-out because the integration seams — transclusion anchors, the provenance link format, status derivation — are where a docs system of this size will actually break, and proving them on one concept is far cheaper than discovering them across the whole corpus.

Alternatives considered

  • One mdbook with clearer parts. Lower effort, but the audience-and-timeline conflation that causes the drift survives. Rejected.
  • Keep inline status badges, auto-generated from the registry. Preserves at-a-glance reading, but it re-introduces status into the normative prose surface. Rejected: keeping the spec timeless means publishing no status at all (the decision retired even the generated grid).
  • A hand-maintained coverage ledger. This is a smaller version of the old hand-maintained STATUS.md feature matrix and drifts the same way. Rejected in favor of CI-derived status.
  • A single big-bang reference rewrite. The reference is largely sound; the drift engine is the problem, not the prose. A rewrite would re-create the drift the day it shipped. Rejected in favor of decoupling status, anchoring claims, and fixing the localized offenders.
  • RFDs and Lean inline in the reading path. Rejected: they are referenced archives. Inlining design history and proof into a learner’s or practitioner’s path is the conflation again.

Consequences

  • New build tooling and CI gates (registry, transclusion preprocessor, drift-check, freshness gate, Lean-link check).
  • The Reference loses inline status badges and publishes no status surface; the anti-drift signal lives in the internal check-coverage check instead; RFDs leave the Reference reading path; the diagnostic corpus becomes a shared single source feeding both ox explain and the error index.
  • A standing obligation: a feature PR touches its example, its registry signal, and (when relevant) its doc section — enforced softly at first.
  • A positive side effect: the documentation build surfaces language drift as tickets, making the docs a continuous correctness audit.
  • Migration is evolution, not rewrite — the crash course seeds the Book, the numbered chapters become the Reference once decoupled and anchored, and the existing example packages become the By Example substrate once re-verified against main.

Open questions

  • The tracer-bullet concept for Wave 0.5 — first-class relations (exercises concepts, relations, and Lean-provenance together, and is the framing most in need of being publicly precise) versus refinement (iff/where).
  • Whether the freshness gate ships soft-warning or hard-fail, and on which triggers.
  • The Book’s per-chapter granularity, and whether a task/idiom cookbook is part of Argon by Example or a separate surface.
  • Hosting layout: three books under one site with a shared theme and a portal landing page.
  • How the value/ontology boundary is taught — as its own early chapter, or woven through Modeling.

RFD 0051 — oxfmt: a canonical, idempotent source formatter

  • State: discussion
  • Depends on: the shared lossless rowan CST (oxc-parser, oxc-syntax); the [[notation]] table and the dual-notation surface (§2.4.1); the [fmt] notation-policy cascade (RFD 0013 toolchain, #762); the example corpus (RFD 0044 / RFD 0050), which the formatter is exercised against.
  • Prior art: the pretty-printing lineage — Oppen, Prettyprinting (1980); Hughes, The Design of a Pretty-Printing Library (1995); Wadler, A Prettier Printer (2003); Lindig, Strictly Pretty (2000); Bernardy, A Pretty But Not Greedy Printer (2017). Production formatters built on that algebra — gofmt, Black, Prettier, dprint, Biome, nixfmt, Dhall, and Lean’s printer. The normal-form framing — Eberhart, On the Relationship Between Parsing and Pretty-Printing (2012); Clarke, Liepelt & Orchard, Scrap Your Reprinter (2017), on why layout-preservation and canonicalization conflict; Black’s assert_equivalent AST-safety check.

Question

What should Argon’s ox fmt / oxfmt be? The shipped v1 normalized whitespace only — it stripped trailing space and capped blank lines but could not re-indent, reflow, or impose a canonical layout, so the promise in the toolchain chapter that “spacing and layout are the Argon convention” had no implementation behind it.

Context

A formatter is the normal-form function for layout-equivalence on programs. Define s₁ ∼ s₂ iff parse(s₁) = parse(s₂); then fmt = print ∘ parse sends each text to the canonical representative of its class. Two properties follow from that framing rather than being engineered:

  • Idempotence (fmt(fmt(s)) = fmt(s)) is forced — the normal form of a normal form is itself.
  • Canonical form is defined by the printer, not discovered. There is no platonic “right” layout; the break and spacing rules are the definition.

Three facts about the codebase shape the design. The parser already produces a lossless rowan CST in which trivia (whitespace, the four comment kinds) are ordinary tokens — so losslessness is structural, with no parse-time attachment to get wrong. The grammar is uniform: every composite node is a delimited comma-list, a brace-delimited item/statement body, a bare comma-list, or an inline token sequence — so the lowering is a generic element walk plus a few family handlers, not ~100 bespoke rules. And the surface is newline-insensitive, so layout carries no meaning to preserve beyond the author’s blank-line paragraphing.

Decision

Build the formatter as a normalizer over a Wadler/Oppen combinator document IR, fed from the shared CST, emitting a single canonical layout with near-zero configuration, behind a layered correctness guard. Concretely:

  1. Document IR (the one irreversible choice). Each node lowers to a Doc of text / line / softline / hardline / nest / group / concatenation; a renderer resolves each group to flat-or-broken by whether the flat form fits the target width. A naive transcription of Wadler’s lazy algorithm is exponential in a strict language, so the renderer is the strict (Lindig) reformulation: a single work-stack with bounded look-ahead and pre-propagated break flags (a group containing a forced break never probes flat), keeping it linear.

  2. Greedy selector. Each group is resolved locally (flat if it fits, else broken). Greedy is space-suboptimal on a minority of constructs; an optimal cost-based evaluator can be swapped in behind the same IR per-construct later, against the real corpus — that is not an architectural fork.

  3. Near-zero configuration. The only layout knob is the target line width. Everything else is fiat (the gofmt/Black/Dhall posture, matching Argon’s single-canonical-form stance). Notation direction (preserve / unicode / ascii) is policy, resolved through the existing ox.toml cascade, not layout.

  4. Canonicalize, do not preserve. A single tool cannot both preserve the author’s layout and produce an idempotent canonical form — the two obey conflicting lens laws (Clarke et al. 2017). oxfmt chooses canonicalization: it discards input layout and imposes the canonical one. The one author signal it keeps is blank lines between items, collapsed to at most one (gofmt semantics). A layout-preserving reprinter — for refactoring tools and LSP code actions — is a separate tool with distinct laws and is out of scope here.

  5. Trivia stays in the CST. Comments are read from the tree and placed by line ownership (own-line comments lead, same-line comments trail); the /// / //! / // / /* */ distinctions are preserved and comment interiors are never reflowed. Post-parse attachment-to-AST-nodes — gofmt’s self-described “biggest mistake” — is avoided.

  6. Macro and quote {} bodies are opaque. A macro body is parsed twice — by the host grammar (where layout is dead) and by the macro engine (where token adjacency, repetition spacing like $($x:tt),*, and trailing commas can be load-bearing). Host-grammar token-equivalence therefore does not imply the macro is unchanged, so oxfmt renders macro definitions and quote {} subtrees verbatim and formats only the surrounding code (the rustfmt-conservative stance). This keeps the guard below sound — the formatter never alters bytes it cannot prove inert — with the matching consequence that a macro-bearing file is not layout-invariant (its body layout is preserved), exactly as blank-line paragraphing is.

  7. A meaning-preservation safety guard (the maximal-correctness contract). Every rewrite is verified before it is returned: the candidate must re-parse cleanly, its significant-token sequence (kind + text, modulo trailing commas, which are layout in Argon’s grammar) must equal the input’s, and its comment multiset must match. On any failure the formatter emits the input unchanged — it is, by construction, incapable of changing what a file means. This is stronger than an AST-shape comparison because the lossless tree lets the guard check comment preservation directly.

  8. Location. In oxc-fmt, over the shared oxc lexer/parser/CST — no second grammar (the Nix cautionary tale). ox fmt [--check] <paths> and the oxfmt binary share one path; --check is exactly the “already a normal form?” predicate.

The canonical-forms layering (why this ships now)

Settling a canonical surface form (clause ordering, dual-form spellings, the forall/exists surface, predicate-sublanguage unification) refines by merging classes, giving a chain ∼₀ ⊆ ∼₁ ⊆ ⋯. The formatter is well-defined at every stage, and each later decision lands as one additional confluent pass that only shrinks the already-canonical set — it never invalidates prior output. So the layout floor ships now on settled syntax, and the unsettled canonical-form decisions are neither blocked by nor forced by the formatter; each becomes a small pass with a built-in acceptance test (idempotence + token/comment equivalence on the corpus) when its decision lands.

Correctness

The layered net, each rung catching what the one below misses:

  1. Parse-preservation — the output re-parses cleanly (cheapest gate).
  2. Token-and-comment equivalence — the safety guard above, enforced on every format.
  3. Idempotencefmt(fmt(s)) = fmt(s) byte-exact, over the whole corpus and under fuzzing.
  4. Layout-invariance (modulo blank lines) — re-spacing a file’s token stream (mangling indentation and intra-line spacing, preserving paragraph breaks) yields identical canonical bytes across the corpus: input layout does not leak into output.
  5. Totality / no-panic fuzzing — arbitrary-string and token-salad property tests confirm the lowering and renderer never crash and every produced output is a fixed point.

What is out of scope

The layout-preserving reprinter (refactoring / range edits); incremental and range formatting for the LSP; an optimal cost-based evaluator beyond the constructs that demonstrably need it; and forcing the unsettled canonical-surface decisions, which the layering above defers to their own rulings. A code style guide (spec/reference/code-styleguide.md) describes the canonical layout the implementation produces — derived from the formatter, not the other way round.

RFD 0052 — Deployment topologies, the connection abstraction, and host-language parity

  • State: discussion

Question

Argon is a language backed by a database. A model is rarely a standalone artifact — it is embedded into systems written in general-purpose languages, or deployed as its own service those systems call. How is Argon deployed and consumed across three independent choices — where the database runs (embedded in a host process vs. a standalone process), how a host reaches it (a native call, a wire protocol, or a generated SDK over either), and which host language drives it (Rust and TypeScript, co-equal) — and where exactly does the embedded/standalone line fall for concurrency?

This RFD does not define the serving API (RFD 0014), the in-process runtime contract (RFD 0020), persistence swap and connectors (RFD 0036), or the derivation surfaces (RFD 0046). It sits above them and fixes the consumption model they compose into.

Context

Most databases ship an object-relational mapper because there are two models that do not agree: the store’s data model and query language on one side, the host’s object model on the other. The ORM is the impedance-matching middleman — leaky, drift-prone, two sources of truth kept in sync by hand.

Argon has one model. Schema, type system, constraints, queries, and reasoning are all declared in Argon under a single type discipline. So a host-language binding is not a mapping between two models; it is a faithful typed projection of the one model into the host language, drift-gated the way the Lean↔Rust interface is. The generated client is thin and total: it exposes the model’s declared query / mutate / derive / compute surface (and the ad-hoc surface of RFD 0033) as typed host functions, and the host types are the schema. The schema-migration drift an ORM fights does not arise — the host’s types are regenerated from the model and gated on change.

Two facts follow and frame everything below:

  • The generated SDK is one access mechanism, not the data model. It must be orthogonal to where the database runs.
  • Argon must be deployable as a database, not only embeddable as a library. Embedded (linked into a host process), sidecar (a process beside the host), and standalone (its own networked, durable service) are all first-class.

Consumers include ordinary Rust and TypeScript services, the Tide TypeScript workflow runtime, and — by construction — a future Rust durable-execution library. None is privileged; each reaches Argon through the same surface.

Decision

D1 — Two orthogonal axes: topology × access

TopologyWhere it runsAccessAnalogue
Embeddedruntime linked into the host process; local storagenative call (Rust crate / TS napi) or SDK over an embedded handleSQLite, DuckDB
SidecarArgon process beside the host on one nodewire (/v1) or SDK over a local clientlocal Postgres
StandaloneArgon as its own durable, networked servicewire or SDK over a remote clientmanaged Postgres

The generated SDK sits on top of the access column: it wraps the native handle in the embedded case and the wire client in the sidecar/standalone cases. A host writes to the SDK and chooses a topology at deploy time by which handle it constructs.

D2 — One connection abstraction

There is a single Connection abstraction whose surface is the OxbinRuntime semantics of RFD 0020, with two implementations: Embedded (links the runtime, local storage) and Remote (a /v1 client, capability-gated). ox gen emits code written against Connection, so host code — model::queries::all_staff(conn) — is identical whether conn is in-process or a network client. This one indirection is what makes “use Argon any of these ways” a fact rather than a slogan.

D3 — Host-language parity

ox gen --target rust and ox gen --target ts are co-equal deliverables. Both project the same model from the same source, expose equivalent typed surfaces, and are both in-process-capable and remote-capable. Both must emit the model’s rich result shapesTruth4, standpoint-tagged, set-valued, ordinal results — idiomatically (a Rust enum; a TS discriminated union), never a scalarized flattening. In-process mechanics differ by host: Rust links the runtime crate; TypeScript uses a napi bridge (D7); HTTP is the universal floor for both.

D4 — The concurrency contract is set by topology

This is the line between an embedded library and a production database, and it is deliberate:

  • Embedded ⇒ single-process ownership. Durable embedded storage is owned by exactly one process: in-memory for tests, a single-owner file backend otherwise. There is one writer. Embedding does not promise multi-process shared-file access; a host that needs concurrent multi-writer access has, by that need, chosen the sidecar or standalone topology.
  • Standalone ⇒ the serve layer is the single logical writer, and must handle concurrent multi-caller load gracefully and performantly. A standalone Argon is a world-class production database server: connection handling and admission control, snapshot-isolated concurrent reads over the bitemporal log, serialized promotion of writes (one logical writer; no silent merge), backpressure, and fair scheduling. The detailed design of this layer is the significant engineering effort this RFD opens (see Consequences); this RFD fixes the contract, not the mechanism.

D5 — Access is provided; durability layers on top

Argon provides access: the connection, the generated SDK, and the serve layer. A workflow runtime provides durability: journaling, deterministic replay, and fork-scoped writes, layered over a Connection. Determinism is the workflow wrapper’s concern — pin a transaction-time read point, journal the reads, scope writes to a fork — and works over any connection regardless of topology. Argon stays workflow-agnostic; Tide is the TypeScript implementation of this wrapper; a Rust durable-execution library would be another. Workflow semantics are never baked into the runtime.

D6 — Semantic transparency across transports

Embedded and remote differ only in latency and in the capability boundary — never in expressivity or in the shape of what comes back. as_of(vt, tt) means the same; rich results survive the wire (CBOR) identically to in-process. The /v1 protocol and the in-process trait are two encodings of one semantics. Capabilities are enforced at the network edge — generic writes denied, only the declared (and ad-hoc-permitted) surface dispatches — and are not imposed on the in-process, in-trust embedded caller. The SDK surface is identical across topologies; the remote path additionally enforces capabilities.

D7 — Tide extraction and the JS bridge

Tide is extracted to its own repository, structured like Argon, with its orca-mvp couplings (world-state, kernel-storage) severed to traits so its core depends on no orca-mvp component and runs non-ontology workflows. The dependency direction is tide → argon; the Argon binding lives on the Tide side; Argon remains ignorant of workflows. The JavaScript↔runtime in-process bridge (napi, later optionally WASM) is an Argon component — it is about embedding Argon in JavaScript, not about workflows — and Tide reuses it for its ops, so standalone Bun/Node services get the same in-process embedding a Tide workflow gets.

Rationale

The orthogonality in D1/D2 is the whole point of removing the ORM. Because there is one model, the typed projection is faithful and thin, so it can be a facade over any transport without re-introducing a second model. Collapsing topology and access — making the SDK mean “remote” or making “embedded” mean “no SDK” — would rebuild the middleman it eliminated.

D3’s parity is a requirement, not a courtesy: Argon is consumed at least as much from Rust as from TypeScript, and today only the TypeScript projection exists (because Tide needed it). Rust consumption is currently “link the runtime and hand-wire it,” which is below parity. The rich-shape clause is where the no-silent-scalarization discipline lives — a contested or set-valued result that the embedded path returns intact and the remote path or the codegen flattens would make D2/D6 a lie.

D4 is the production hinge. Embedded single-ownership keeps the SQLite/DuckDB contract honest and cheap. Standalone multi-caller concurrency is the price of being a real database, and it is where the hard engineering is; naming it as a contract now prevents an embedded-shaped design from being quietly assumed to scale to a server.

D5 keeps the runtime clean and reusable. Journaling and replay are properties of orchestration, not of data access; pushing them into Argon would couple the database to one workflow system and bar the plain (non-durable) Rust or TS consumer. The determinism design composes with D6: a tx-pinned as_of read is reproducible whether the connection is embedded or remote.

D7’s direction is the standard foundation rule: a foundation does not depend on its consumers. Locating the napi bridge in Argon, not Tide, follows from what it is — Argon-in-JavaScript — and lets one bridge serve both standalone JS services and Tide ops.

Alternatives

  • A meta-build-system wrapping Cargo and npm/Bun. Rejected. Integration follows the protobuf/protoc precedent: the model is the neutral source (like a .proto), ox gen is the generator, and the host’s native build drives codegen (build.rs for Rust; a prepare step for TS). The moment the toolchain owns the host build it becomes a framework and stops being portable — the ODE failure mode.
  • SDK-only; no native embedding. Rejected. Argon is a database; embedded in-process operation (SQLite/DuckDB-shaped) is a primary topology, not a remote-only convenience.
  • Multi-writer embedded shared-file access. Rejected for v1. Concurrency control across processes sharing one on-disk store is the standalone topology’s problem; embedded promises single-owner.
  • A dedicated binary wire protocol now. Deferred. /v1 HTTP is the one wire protocol initially; a binary/streaming protocol can later sit behind the same Remote handle without changing host code.
  • A third storage time-axis for law enactment/effective dates. Rejected as a storage concern. The store stays two-axis (valid-time, transaction-time); rule version and effective date are ordinary bitemporal facts interpreted by the reasoning layer (consistent with RFD 0036’s “richer temporal structure as payload”).

Consequences

  • ox gen --target rust is a new first-class deliverable, co-equal with the TypeScript target: typed concept types, typed query/mutate/derive wrappers over Connection, the CBOR codec, and a generated /v1 client.
  • A JavaScript↔runtime napi bridge becomes an Argon component, shared with Tide’s ops.
  • The standalone serve concurrency layer requires real engineering — admission control, snapshot-isolated concurrent reads, serialized promotion, backpressure, scheduling — and is the next deep design effort this RFD opens, deferred behind the portable-substrate phases and tracked in issue #978. It extends RFD 0014’s surface with a concurrency-and-load contract.
  • Tide is extracted to its own repository with orca-mvp couplings reduced to traits; the Argon binding and ox-tide plugin shim live Tide-side.
  • ox grows cargo-style plugin discovery (ox <name>ox-<name> on PATH), so ox tide run is convenience and tide run standalone always works.
  • Host integration follows the native build of each language; ox.toml remains the consumer-agnostic model manifest and gains no host-codegen configuration (that lives host-side).

Open questions

  • TypeScript in-process: napi-first or WASM-first? Leaning napi-first (Bun and Node both support it; shares code with Tide ops), WASM later for edge/browser. HTTP is the floor regardless.
  • ox gen --target rust: build.rs/OUT_DIR or a published crate? Leaning build.rs (the prost/tonic model, no per-version crate churn), with “emit a crate” as a flag when several hosts share one model.
  • One wire protocol or two? /v1 HTTP now; a binary protocol only if the hot path demands it, behind the same Remote handle.
  • Embedded durable concurrency: what does single-owner mean concretely for the file backend — advisory lock, lockfile, exclusive open?
  • Standalone concurrency model specifics: how reads achieve snapshot isolation over the bitemporal log; how promotion serializes against concurrent readers; the admission/backpressure policy under load.
  • Does oxup distribute Tide as a component (matching-version pinning, the rustup-toolchain model), or does Tide ship a standalone installer first?