release: implement site registry v0.5
All checks were successful
Standalone registry checks / check (push) Successful in 5m58s

This commit is contained in:
Nic Weyand 2026-09-13 14:42:39 -04:00
commit 557ba7cd69
Signed by: nicweyand
SSH key fingerprint: SHA256:2te+ycJIQON/Wo/dH6+ZkFSQ4HnHWpetV2azx9E65dQ
40 changed files with 3331 additions and 158 deletions

View file

@ -17,6 +17,7 @@ clap.workspace = true
csv = "1.4.0"
flate2.workspace = true
libc.workspace = true
md-5.workspace = true
publicsuffix = "=2.3.0"
reqwest.workspace = true
rusqlite = { version = "=0.40.2", features = ["bundled"] }
@ -30,6 +31,7 @@ tokio.workspace = true
toml.workspace = true
unicode-normalization.workspace = true
url.workspace = true
zip.workspace = true
[dev-dependencies]
http.workspace = true

View file

@ -9,6 +9,7 @@ listing is evidence of an assertion, not a guarantee of ownership or safety.
| Source | Exact data license and evidence | Distribution and consumed fields |
| --- | --- | --- |
| Wikidata | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/), [`CC0-1.0`](https://www.wikidata.org/wiki/Wikidata:Licensing) | [JSON dumps](https://www.wikidata.org/wiki/Wikidata:Database_download), [entity JSON](https://www.wikidata.org/wiki/Special:EntityData/Q355.json). Entity ID, revision, all labels/aliases, full P856 statements including ranks, qualifiers and references; P17, P159, P407, P1001 and country/language code mappings P297/P218/P219/P220. Relevant raw entity records remain available for audit. |
| Research Organization Registry (ROR) | [CC0 1.0 Universal](https://creativecommons.org/publicdomain/zero/1.0/), [`CC0-1.0`](https://ror.readme.io/docs/data-dump) | [Official versioned data dump](https://ror.readme.io/docs/data-dump) distributed from ROR's Zenodo community. Schema 2.1 organization ID, names and aliases, website links, declared domains, status, organization types, locations/countries, relationships and external IDs. The importer requires the official JSON-and-CSV ZIP shape and retains the JSON record for audit. ROR documents GeoNames as upstream for location metadata; GeoNames attribution and CC BY 3.0 terms are preserved. |
| Majestic Million | [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/), [`CC-BY-3.0`](https://majestic.com/reports/majestic-million) | [Official CSV](https://downloads.majestic.com/majestic_million.csv). Domain/IDN/TLD, global/TLD ranks, referring subnet/IP counts and their previous values. Ranks remain source-specific signals; no entity ownership is inferred. |
| Chrome UX Report (CrUX), Google | [CC BY 4.0 International](https://creativecommons.org/licenses/by/4.0/), [`CC-BY-4.0`](https://developer.chrome.com/docs/crux/methodology) | [Monthly BigQuery dataset](https://developer.chrome.com/docs/crux/bigquery/): `origin`, `experimental.popularity.rank`, observation month, optional audience-country dataset code. The adapter produces `origin,rank,yyyymm,country_code` CSV. Rank is a coarse bucket, not a precise visit count. Audience country is not website jurisdiction. No API key or OAuth token is retained. |
| Curlie | [CC BY 3.0 Unported](https://creativecommons.org/licenses/by/3.0/), [`CC-BY-3.0`](https://curlie.org/docs/en/license.html), including the attribution placement prescribed on that page | [Format documentation](https://curlie.org/docs/en/rdf.html), [official download redirect](https://curlie.org/directory-dl), currently [Passau-hosted archive](https://share.innkube.fim.uni-passau.de/curlie-rdf/curlie-rdf-all.tar.gz). Despite its RDF name, the current archive contains **literal TSV**. Content: URL, title, description, category ID. Structure: category ID, full category path, entry count, description, latitude, longitude. Archive notices are retained. |
@ -20,6 +21,13 @@ listing is evidence of an assertion, not a guarantee of ownership or safety.
* **Wikidata:** CC0 imposes no attribution condition. Keep Wikidata IDs, source
links and revision evidence for traceability. CC0 structured data does not
extend to unrelated Wikipedia prose, images or linked websites.
* **ROR:** CC0 imposes no attribution condition on the ROR dump. Keep ROR IDs,
release/Zenodo record identity, provider checksum evidence and schema version
for traceability. ROR location fields declare GeoNames as upstream; credit
[GeoNames](https://www.geonames.org/), link its
[CC BY 3.0 license](https://creativecommons.org/licenses/by/3.0/), retain notices
and identify Argand's projection when distributing those fields. A ROR website
or declared domain is an assertion and never grants route approval.
* **Majestic:** credit “Majestic Million, Majestic”, link the source and CC BY 3.0,
retain supplied notices, and identify Argand's changes. The current distribution
page governs this import; older blog posts describe different historic terms.

View file

@ -28,7 +28,7 @@ argand-site-registry --help
cargo test -p argand-site-registry --all-targets --locked --offline
```
The native CLI test imports small source-shaped fixtures for **all five sources**,
The native CLI tests import small source-shaped fixtures for **all six sources**,
repeats the imports, resolves aliases, signs and activates an approved generation,
revokes the destination, and rejects rollback past the revocation. Synthetic
fixtures are authored in Rust test code; no provider datasets or signing keys
@ -105,11 +105,52 @@ ambiguous or overlapping typed coverage.
For a full Wikidata dump, select a real dump URL from the official
[download index](https://dumps.wikimedia.org/wikidatawiki/entities/), then use
`--format wikidata-dump --compression gzip` (or `bzip2`) and `--scope full`.
The parser handles the documented one-entity-per-line JSON array and concatenated
compressed streams. Do not use truthy RDF: it loses statement evidence. Full
The parser streams the documented top-level JSON array without assuming that an
entity is physically one line, and handles concatenated compressed streams. It
rejects a record above the authenticated 16 MiB default; after inspecting the
exact object, a publisher can bind a larger limit with `--maximum-record-bytes`
on `download` or `manifest`, up to the 256 MiB audit-safe ceiling. Do not use
truthy RDF: it loses statement evidence. Full
dumps need substantial disk space and a long sequential scan even though memory
is bounded. A small entity selection is useful on limited hardware.
Wikidata's current Add/Change artifacts are XML history/stub streams. Wikidata's
own download guidance warns that JSON embedded in XML dumps is unstable, so this
release refuses that format. For bounded incremental refresh, acquire official
Wikibase API `wbgetentities` JSON, declare typed `delta` coverage against the
exact selected-entity base and apply consecutive sequences. A changed entity with
no P856 statements emits an empty record, which becomes a website-evidence
tombstone in the audit export. Reconcile API deltas against a later full JSON dump
before replacing broad production coverage.
### ROR
ROR publishes versioned CC0 dumps through Zenodo. The following dated example is
the exact asset inspected for version 0.5; check the [current ROR release page](https://ror.readme.io/docs/data-dump)
before acquiring a newer snapshot and use the checksum published by that Zenodo
record.
```bash
argand-site-registry download --cache "$ARGAND_SITE_DATA/cache" \
--source ror --format ror-zip \
--url https://zenodo.org/api/records/22099990/files/v2.12-2026-08-25-ror-data.zip/content \
--snapshot v2.12-2026-08-25 --scope full --maximum-bytes 100000000 \
--coverage "$ARGAND_SITE_DATA/full-coverage.json" \
--provider-checksum md5:ce8807691455d4ada3216c31408e9e1a \
--provider-checksum-url https://zenodo.org/api/records/22099990 \
> "$ARGAND_SITE_DATA/ror-download.json"
argand-site-registry import --database "$ARGAND_SITE_DATA/import.sqlite" \
--input "$(jq -r .input "$ARGAND_SITE_DATA/ror-download.json")" \
--manifest "$(jq -r .manifest "$ARGAND_SITE_DATA/ror-download.json")"
```
The adapter accepts the official schema 2.1 JSON plus CSV ZIP, consumes only the
documented fields, and fails on extra members or schema drift. ROR entities remain
separate from Wikidata unless reviewed equivalence evidence joins exact stable
IDs. Inactive and withdrawn organizations remain audit evidence but their website
edges are ineligible for resolution. GeoNames is declared as upstream lineage for
location metadata and is included in attribution output.
To reuse an already acquired file, retain its **original** retrieval time,
source URL and snapshot/revision. First verify its acquisition receipt, then:
@ -198,6 +239,7 @@ candidate and inspect its deterministic work queue:
```bash
argand-site-registry build --database "$ARGAND_SITE_DATA/import.sqlite" \
--output "$ARGAND_SITE_DATA/candidate" \
--audit-store "$ARGAND_SITE_DATA/audit-objects" \
--reviewer-trust /secure/reviewer-allowed-signers \
> "$ARGAND_SITE_DATA/candidate.json"
export ARGAND_SITE_PIN="$(jq -r .pin "$ARGAND_SITE_DATA/candidate.json")"
@ -309,7 +351,24 @@ rather than admitted-route lists.
argand-site-registry export --generation "$ARGAND_SITE_DATA/reviewed" \
--pin "$REVIEWED_PIN" --output "$ARGAND_SITE_DATA/active.jsonl"
argand-site-registry export-audit --generation "$ARGAND_SITE_DATA/reviewed" \
--pin "$REVIEWED_PIN" --output "$ARGAND_SITE_DATA/audit.jsonl"
--pin "$REVIEWED_PIN" --audit-store "$ARGAND_SITE_DATA/audit-objects" \
--output "$ARGAND_SITE_DATA/audit.jsonl"
argand-site-registry verify-audit --generation "$ARGAND_SITE_DATA/reviewed" \
--pin "$REVIEWED_PIN" --audit-store "$ARGAND_SITE_DATA/audit-objects"
argand-site-registry audit-checkpoint \
--generation "$ARGAND_SITE_DATA/reviewed" --pin "$REVIEWED_PIN" \
--audit-store "$ARGAND_SITE_DATA/audit-objects" \
--recorded-at 2026-09-13T00:00:00Z \
--output "$ARGAND_SITE_DATA/audit-retention.json"
argand-site-registry sign-audit-checkpoint \
--input "$ARGAND_SITE_DATA/audit-retention.json" \
--signature "$ARGAND_SITE_DATA/audit-retention.json.sig" \
--key /secure/audit-retention-key
argand-site-registry verify-audit-checkpoint \
--input "$ARGAND_SITE_DATA/audit-retention.json" \
--signature "$ARGAND_SITE_DATA/audit-retention.json.sig" \
--allowed-signers /secure/audit-retention-allowed-signers \
--identity registry-retention
argand-site-registry sign --generation "$ARGAND_SITE_DATA/reviewed" \
--pin "$REVIEWED_PIN" --key /secure/publisher-key \
--allowed-reviewers /secure/reviewer-allowed-signers \
@ -349,7 +408,8 @@ time bounds. No scheduled command approves, renews, signs or activates.
## Storage and operating limits
Migrations 001 through 005 own writer schema 5. Source manifests v2 declare typed
Migrations 001 through 005 own writer schema 5. Source manifests v3 authenticate
integrity checks, per-record bounds and declared lineage in addition to typed
full, partition, or delta coverage. Deltas name an exact base, consecutive sequence
and superseded object. Ambiguous coverage, overlap, cycles, gaps, cross-provider
supersession and duplicate native records fail the build. V1 manifests remain
@ -358,7 +418,12 @@ isolated by provider and scope for compatibility.
Each generation contains `registry.sqlite`, `LICENSE_SOURCES.md`,
`ATTRIBUTION.json`, and `COMPLETE.json`, plus an optional publisher signature. The
receipt binds database bytes, source selection, policy, reviewer trust, licenses,
attribution, and decision-time contract. Keep source objects, writer state,
attribution, and decision-time contract. A compact v3 receipt also binds every
cold audit object by digest, length, counts, source, coverage and attribution.
The runtime database omits superseded raw history after the bundle is durable;
`verify-audit` must pass wherever that history is retained. The retention
checkpoint authorizes no deletion and uses the separate
`argand-site-registry-audit-retention` SSH namespace. Keep source objects, writer state,
generations, pins, trust files and signatures for recovery.
Imports are streaming, transactional, resumable and idempotent. Defaults bound

View file

@ -7,10 +7,13 @@ use serde_json::json;
use std::io::{BufRead, Read};
pub(crate) mod csv_sources;
mod curlie;
mod ror;
mod wikidata;
/// Maximum decompressed size of one entity, row, or metadata document.
pub const MAX_RECORD_BYTES: usize = 16 * 1024 * 1024;
/// Largest explicitly authenticated record limit supported by audit bundles.
pub const MAX_CUSTOM_RECORD_BYTES: usize = 256 * 1024 * 1024;
/// Transactional sink; an emitted record and all its facts share one checkpoint.
pub trait RecordSink {
@ -32,14 +35,29 @@ pub trait SourceAdapter {
/// Chooses the explicit source format adapter.
#[must_use]
pub fn adapter(format: Format) -> Box<dyn SourceAdapter> {
pub fn adapter(
format: Format,
maximum_record_bytes: usize,
coverage_delta: bool,
) -> Box<dyn SourceAdapter> {
match format {
Format::WikidataDump => Box::new(wikidata::Wikidata { dump: true }),
Format::WikidataEntities => Box::new(wikidata::Wikidata { dump: false }),
Format::WikidataDump => Box::new(wikidata::Wikidata {
dump: true,
maximum_record_bytes,
retire_empty: coverage_delta,
}),
Format::WikidataEntities => Box::new(wikidata::Wikidata {
dump: false,
maximum_record_bytes,
retire_empty: true,
}),
Format::MajesticCsv => Box::new(csv_sources::CsvSource { crux: false }),
Format::CruxCsv => Box::new(csv_sources::CsvSource { crux: true }),
Format::CurlieTarGz => Box::new(curlie::Curlie),
Format::PslText => Box::new(PslAdapter),
Format::RorZip => Box::new(ror::Ror {
maximum_record_bytes,
}),
}
}

View file

@ -0,0 +1,290 @@
// By Nic Weyand!
//! Official ROR schema 2.1 JSON from a versioned release ZIP.
use super::{RecordSink, SourceAdapter};
use crate::model::{Fact, Record, Source, entity_id};
use anyhow::{Context, ensure};
use serde_json::{Value, json};
use std::{
collections::BTreeSet,
io::{BufRead, BufReader, Read},
};
pub(super) struct Ror {
pub maximum_record_bytes: usize,
}
impl SourceAdapter for Ror {
#[allow(clippy::case_sensitive_file_extension_comparisons)] // Provider member names are exact.
fn ingest(&self, input: &mut dyn BufRead, sink: &mut dyn RecordSink) -> anyhow::Result<()> {
let mut stream = input;
let mut json_name = None;
let mut csv_name = None;
let mut entries = 0_u8;
loop {
let Some(mut entry) = zip::read::read_zipfile_from_stream(&mut stream)? else {
break;
};
entries = entries.checked_add(1).context("too many ROR ZIP members")?;
ensure!(
entries <= 2,
"ROR ZIP must contain exactly JSON and CSV files"
);
ensure!(
entry.is_file() && !entry.encrypted(),
"ROR ZIP members must be unencrypted regular files"
);
ensure!(
matches!(
entry.compression(),
zip::CompressionMethod::Deflated | zip::CompressionMethod::Stored
),
"unsupported ROR ZIP compression"
);
let name = entry.name().to_owned();
ensure!(
!name.contains(['/', '\\'])
&& name.len() <= 128
&& name.starts_with('v')
&& name.contains("-ror-data."),
"unexpected ROR ZIP member name"
);
if name.ends_with(".json") {
ensure!(
json_name.replace(name).is_none(),
"duplicate ROR JSON member"
);
ensure!(
entry.size() <= 2 * 1024 * 1024 * 1024,
"ROR JSON member exceeds 2 GiB"
);
let mut limited = (&mut entry).take(2 * 1024 * 1024 * 1024 + 1);
let mut reader = BufReader::new(&mut limited);
crate::json::for_each_array(&mut reader, self.maximum_record_bytes, |record| {
sink.emit(project(record)?)
})?;
drop(reader);
ensure!(limited.limit() > 0, "ROR JSON member exceeds 2 GiB");
} else if name.ends_with(".csv") {
ensure!(csv_name.replace(name).is_none(), "duplicate ROR CSV member");
ensure!(
entry.size() <= 1024 * 1024 * 1024,
"ROR CSV member exceeds 1 GiB"
);
let copied = std::io::copy(
&mut (&mut entry).take(1024 * 1024 * 1024 + 1),
&mut std::io::sink(),
)?;
ensure!(copied <= 1024 * 1024 * 1024, "ROR CSV member exceeds 1 GiB");
} else {
anyhow::bail!("unrecognized ROR ZIP member");
}
}
let json_name = json_name.context("ROR ZIP has no JSON member")?;
let csv_name = csv_name.context("ROR ZIP has no CSV member")?;
ensure!(entries == 2, "ROR ZIP must contain exactly two members");
ensure!(
json_name.strip_suffix(".json") == csv_name.strip_suffix(".csv"),
"ROR JSON and CSV release names differ"
);
Ok(())
}
}
#[allow(clippy::too_many_lines)] // Keep the exact reviewed schema mapping together.
fn project(raw: Value) -> anyhow::Result<Record> {
let object = raw.as_object().context("ROR record must be an object")?;
let expected = BTreeSet::from([
"admin",
"domains",
"established",
"external_ids",
"id",
"links",
"locations",
"names",
"relationships",
"status",
"types",
]);
ensure!(
object.keys().map(String::as_str).collect::<BTreeSet<_>>() == expected,
"ROR schema differs from inspected schema 2.1"
);
let id = raw["id"].as_str().context("ROR ID must be text")?;
let parsed = url::Url::parse(id)?;
ensure!(
parsed.scheme() == "https"
&& parsed.host_str() == Some("ror.org")
&& parsed.query().is_none()
&& parsed.fragment().is_none()
&& parsed.path().len() == 10
&& parsed.path().as_bytes()[0] == b'/'
&& parsed.path()[1..]
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()),
"invalid ROR ID"
);
validate_admin(&raw["admin"])?;
let status = raw["status"].as_str().context("ROR status must be text")?;
ensure!(
matches!(status, "active" | "inactive" | "withdrawn"),
"unknown ROR status"
);
let subject = entity_id(Source::Ror, id);
let mut facts = Vec::new();
let names = raw["names"]
.as_array()
.context("ROR names must be an array")?;
ensure!(!names.is_empty(), "ROR record has no names");
for (index, name) in names.iter().enumerate() {
let text = name["value"]
.as_str()
.context("ROR name value must be text")?;
let language = match &name["lang"] {
Value::Null => "und",
Value::String(value) => value,
_ => anyhow::bail!("ROR name language must be text or null"),
};
let types = strings(&name["types"], "ROR name types")?;
ensure!(
!types.is_empty()
&& types.iter().all(|value| matches!(
value.as_str(),
"acronym" | "alias" | "label" | "ror_display"
)),
"unknown ROR name type"
);
let kind = if types
.iter()
.any(|value| matches!(value.as_str(), "label" | "ror_display"))
{
"label"
} else {
"alias"
};
facts.push(Fact {
subject: subject.clone(),
predicate: "name".into(),
value: json!({"text":text,"language":language,"kind":kind,"native_id":id,"ror_types":types}),
selector: format!("/names/{index}"),
confidence: 7500,
});
}
for (index, link) in raw["links"]
.as_array()
.context("ROR links must be an array")?
.iter()
.enumerate()
{
let kind = link["type"]
.as_str()
.context("ROR link type must be text")?;
let value = link["value"]
.as_str()
.context("ROR link value must be text")?;
ensure!(
matches!(kind, "website" | "wikipedia"),
"unknown ROR link type"
);
facts.push(Fact {
subject: subject.clone(),
predicate: if kind == "website" { "website" } else { "external_id" }.into(),
value: if kind == "website" {
json!({"url":value,"native_id":id,"active":status=="active","status":status,"statement":{"source":"ror_schema_2.1","link_type":"website"}})
} else {
json!({"type":"wikipedia","value":value,"native_id":id})
},
selector: format!("/links/{index}"),
confidence: 7500,
});
}
for (index, domain) in strings(&raw["domains"], "ROR domains")?.iter().enumerate() {
facts.push(Fact {
subject: subject.clone(),
predicate: "declared_domain".into(),
value: json!({"domain":domain,"native_id":id,"status":status}),
selector: format!("/domains/{index}"),
confidence: 7000,
});
}
for (index, external) in raw["external_ids"]
.as_array()
.context("ROR external IDs must be an array")?
.iter()
.enumerate()
{
let kind = external["type"]
.as_str()
.context("ROR external ID type must be text")?;
ensure!(
matches!(kind, "fundref" | "grid" | "isni" | "wikidata"),
"unknown ROR external ID type"
);
let _ = strings(&external["all"], "ROR external ID values")?;
ensure!(
external["preferred"].is_null() || external["preferred"].is_string(),
"invalid ROR preferred external ID"
);
facts.push(Fact {
subject: subject.clone(),
predicate: "external_id".into(),
value: external.clone(),
selector: format!("/external_ids/{index}"),
confidence: 8000,
});
}
let mut countries = BTreeSet::new();
for location in raw["locations"]
.as_array()
.context("ROR locations must be an array")?
{
let code = location
.pointer("/geonames_details/country_code")
.and_then(Value::as_str)
.context("ROR location country code missing")?;
ensure!(
code.len() == 2 && code.bytes().all(|byte| byte.is_ascii_uppercase()),
"invalid ROR country code"
);
countries.insert(code);
}
facts.push(Fact {
subject: subject.clone(), predicate: "entity_metadata".into(),
value: json!({"status":status,"types":strings(&raw["types"], "ROR organization types")?,"countries":countries,"established":raw["established"],"admin":raw["admin"]}),
selector: String::new(), confidence: 7500,
});
Ok(Record {
native_id: id.into(),
raw,
facts,
})
}
fn validate_admin(admin: &Value) -> anyhow::Result<()> {
for key in ["created", "last_modified"] {
ensure!(admin[key]["date"].is_string(), "ROR admin date missing");
let version = admin[key]["schema_version"]
.as_str()
.context("ROR admin schema version missing")?;
ensure!(
matches!(version, "1.0" | "2.0" | "2.1"),
"unsupported ROR schema version"
);
}
Ok(())
}
fn strings(value: &Value, field: &str) -> anyhow::Result<Vec<String>> {
value
.as_array()
.with_context(|| format!("{field} must be an array"))?
.iter()
.map(|value| {
value
.as_str()
.map(str::to_owned)
.with_context(|| format!("{field} must contain text"))
})
.collect()
}

View file

@ -1,7 +1,7 @@
// By Nic Weyand!
//! Wikibase JSON, retaining complete statement qualifiers, references, and rank.
use super::{MAX_RECORD_BYTES, RecordSink, SourceAdapter, bounded_line};
use super::{RecordSink, SourceAdapter};
use crate::model::{Fact, Record, Source, entity_id};
use anyhow::{Context, ensure};
use serde_json::{Value, json};
@ -9,6 +9,8 @@ use std::io::{BufRead, Read};
pub(super) struct Wikidata {
pub dump: bool,
pub maximum_record_bytes: usize,
pub retire_empty: bool,
}
impl SourceAdapter for Wikidata {
@ -16,11 +18,11 @@ impl SourceAdapter for Wikidata {
if !self.dump {
let mut raw = Vec::new();
input
.take((MAX_RECORD_BYTES + 1) as u64)
.take((self.maximum_record_bytes + 1) as u64)
.read_to_end(&mut raw)?;
ensure!(
raw.len() <= MAX_RECORD_BYTES,
"entity response exceeds 16 MiB; use dump format"
raw.len() <= self.maximum_record_bytes,
"entity response exceeds authenticated record bound"
);
let value = crate::json::parse(&raw)?;
for (key, entity) in value["entities"]
@ -35,33 +37,14 @@ impl SourceAdapter for Wikidata {
}
return Ok(());
}
let mut line = String::new();
bounded_line(input, &mut line)?;
ensure!(line.trim() == "[", "dump must begin with [");
let mut seen = false;
let mut comma = false;
loop {
ensure!(
bounded_line(input, &mut line)? > 0,
"truncated Wikidata dump"
);
let row = line.trim();
if row.is_empty() {
continue;
}
if row == "]" {
ensure!(!comma && seen, "empty dump or trailing comma");
while bounded_line(input, &mut line)? > 0 {
ensure!(line.trim().is_empty(), "data after dump");
}
return Ok(());
}
ensure!(!seen || comma, "missing entity separator");
comma = row.ends_with(',');
let entity = crate::json::parse(row.strip_suffix(',').unwrap_or(row).as_bytes())?;
project(&entity, sink, false)?;
crate::json::for_each_array(input, self.maximum_record_bytes, |entity| {
project(&entity, sink, self.retire_empty)?;
seen = true;
}
Ok(())
})?;
ensure!(seen, "empty Wikidata dump");
Ok(())
}
}

View file

@ -0,0 +1,734 @@
// By Nic Weyand!
//! Content-addressed cold audit bundles for source records and facts.
use anyhow::{Context, ensure};
use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use sha2::{Digest, Sha256};
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Read, Seek, SeekFrom, Write},
path::{Path, PathBuf},
};
const BUNDLE_SCHEMA: &str = "argand.site-audit-bundle/v2";
const MAXIMUM_LINE_BYTES: usize = 512 * 1024 * 1024;
const MAXIMUM_HEADER_BYTES: usize = 1024 * 1024;
const MAXIMUM_CHECKPOINT_BYTES: usize = 16 * 1024 * 1024;
/// OpenSSH namespace for operator-authenticated retention checkpoints.
pub const RETENTION_SIGNATURE_NAMESPACE: &str = "argand-site-registry-audit-retention";
/// Hash-bound external audit object referenced by a compact generation.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BundleReference {
/// Audit bundle format.
pub schema: String,
/// Exact source-manifest identity represented by this bundle.
pub source_id: String,
/// SHA-256 of the complete bundle bytes and its mirror-independent locator.
pub object_sha256: String,
/// Exact bundle byte length.
pub bytes: u64,
/// Indexed source records in the bundle.
pub records: u64,
/// Indexed facts in the bundle.
pub facts: u64,
/// Whether this source participates in the selected coverage graph.
pub selected: bool,
/// Attribution document digest copied into the bundle header.
pub attribution_sha256: String,
/// Coverage projection digest under which selection states were calculated.
pub coverage_sha256: String,
}
impl BundleReference {
/// Stable filename derived only from authenticated content.
#[must_use]
pub fn filename(&self) -> String {
format!("{}.audit.jsonl", self.object_sha256)
}
/// Checks reference syntax before touching an external store.
///
/// # Errors
/// Rejects unsupported schemas, invalid hashes, empty bundles, or impossible counts.
pub fn validate(&self) -> anyhow::Result<()> {
ensure!(
self.schema == BUNDLE_SCHEMA,
"unsupported audit bundle schema"
);
ensure!(
crate::model::valid_digest(&self.source_id)
&& crate::model::valid_digest(&self.object_sha256)
&& crate::model::valid_digest(&self.attribution_sha256)
&& crate::model::valid_digest(&self.coverage_sha256),
"invalid audit bundle digest"
);
ensure!(self.bytes > 0 && self.records > 0, "empty audit bundle");
Ok(())
}
}
/// Aggregate verification result for one generation's complete cold history.
#[derive(Debug, Serialize)]
pub struct VerificationReport {
/// Report format.
pub schema: String,
/// Generation receipt pin.
pub registry: String,
/// Bundles successfully verified.
pub bundles: u64,
/// Complete verified bytes.
pub bytes: u64,
/// Complete verified record count.
pub records: u64,
/// Complete verified fact count.
pub facts: u64,
}
/// Signed-report payload proving which cold objects must remain available.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct RetentionCheckpoint {
/// Report format.
pub schema: String,
/// Exact generation receipt pin whose history is retained.
pub registry: String,
/// Operator-selected report time.
pub recorded_at: chrono::DateTime<chrono::Utc>,
/// All authenticated objects required by this generation.
pub retain: Vec<BundleReference>,
/// Explicitly false: a checkpoint never authorizes deletion.
pub deletion_authorized: bool,
}
impl RetentionCheckpoint {
fn validate(&self) -> anyhow::Result<()> {
ensure!(
self.schema == "argand.site-audit-retention/v1",
"unsupported audit retention checkpoint"
);
ensure!(
crate::model::valid_digest(&self.registry)
&& !self.retain.is_empty()
&& !self.deletion_authorized,
"invalid audit retention checkpoint"
);
let mut sources = std::collections::BTreeSet::new();
for reference in &self.retain {
reference.validate()?;
ensure!(
sources.insert(&reference.source_id),
"duplicate source in retention checkpoint"
);
}
Ok(())
}
}
/// Writes or reuses one deterministic bundle for every complete source snapshot.
pub(crate) fn package_all(
db: &Connection,
store: &Path,
attribution_sha256: &str,
coverage_sha256: &str,
) -> anyhow::Result<Vec<BundleReference>> {
std::fs::create_dir_all(store)?;
ensure!(
std::fs::symlink_metadata(store)?.is_dir(),
"audit store must be a real directory"
);
let mut statement = db.prepare(
"SELECT s.id,s.manifest,EXISTS(SELECT 1 FROM selected_sources a WHERE a.id=s.id) \
FROM sources s WHERE s.complete=1 ORDER BY s.id",
)?;
let sources = statement
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, bool>(2)?,
))
})?
.collect::<Result<Vec<_>, _>>()?;
let mut references = Vec::with_capacity(sources.len());
for (source_id, manifest_json, selected) in sources {
let manifest: crate::model::SourceManifest = serde_json::from_str(&manifest_json)?;
ensure!(
manifest.id()? == source_id,
"stored source identity mismatch"
);
let (records, facts) = source_counts(db, &source_id)?;
if let Some(reference) = find_existing(
store,
&source_id,
selected,
attribution_sha256,
coverage_sha256,
records,
facts,
)? {
references.push(reference);
continue;
}
let temporary = tempfile::NamedTempFile::new_in(store)?;
let mut file = temporary.reopen()?;
let written = write_bundle(
db,
&mut file,
&source_id,
&manifest,
selected,
attribution_sha256,
coverage_sha256,
records,
facts,
)?;
ensure!(written == (records, facts), "audit source counts changed");
file.sync_all()?;
let bytes = file.metadata()?.len();
drop(file);
let object_sha256 = crate::file_digest(temporary.path())?;
let reference = BundleReference {
schema: BUNDLE_SCHEMA.into(),
source_id,
object_sha256,
bytes,
records,
facts,
selected,
attribution_sha256: attribution_sha256.into(),
coverage_sha256: coverage_sha256.into(),
};
reference.validate()?;
let destination = store.join(reference.filename());
match temporary.persist_noclobber(&destination) {
Ok(_) => File::open(store)?.sync_all()?,
Err(error) if error.error.kind() == std::io::ErrorKind::AlreadyExists => {
verify_one(&reference, store)?;
}
Err(error) => return Err(error.error.into()),
}
verify_one(&reference, store)?;
references.push(reference);
}
ensure!(!references.is_empty(), "no complete sources to package");
Ok(references)
}
#[allow(clippy::too_many_arguments)] // Every authenticated header coordinate is explicit.
fn write_bundle(
db: &Connection,
file: &mut File,
source_id: &str,
manifest: &crate::model::SourceManifest,
selected: bool,
attribution_sha256: &str,
coverage_sha256: &str,
expected_records: u64,
expected_facts: u64,
) -> anyhow::Result<(u64, u64)> {
let mut writer = BufWriter::new(file);
line(
&mut writer,
&json!({"type":"manifest","schema":BUNDLE_SCHEMA,"source_id":source_id,"source":manifest,"selected":selected,"rules":crate::store::RULE_VERSION,"attribution_sha256":attribution_sha256,"coverage_sha256":coverage_sha256,"records":expected_records,"facts":expected_facts}),
)?;
let mut records = 0_u64;
let mut record_query = db.prepare(
"SELECT r.ordinal,r.native_id,r.raw_json,(SELECT count(*) FROM facts f WHERE f.source_id=r.source_id AND f.ordinal=r.ordinal) FROM records r WHERE r.source_id=?1 ORDER BY r.ordinal",
)?;
let mut rows = record_query.query([source_id])?;
while let Some(row) = rows.next()? {
records += 1;
line(
&mut writer,
&json!({"type":"record","source_id":source_id,"ordinal":crate::store::unsigned(row,0)?,"native_id":row.get::<_,String>(1)?,"raw":serde_json::from_str::<Value>(&row.get::<_,String>(2)?)?,"facts":crate::store::unsigned(row,3)?}),
)?;
}
drop(rows);
drop(record_query);
let mut facts = 0_u64;
let mut fact_query = db.prepare(
"SELECT f.id,f.ordinal,f.subject,f.predicate,f.value,f.selector,f.confidence,\
CASE WHEN j.fact IS NOT NULL THEN 'rejected' WHEN a.source_id IS NULL THEN 'superseded' ELSE 'active' END,j.reason \
,r.native_id FROM facts f JOIN records r ON r.source_id=f.source_id AND r.ordinal=f.ordinal \
LEFT JOIN active_records a ON a.source_id=f.source_id AND a.ordinal=f.ordinal \
LEFT JOIN rejected j ON j.fact=f.id WHERE f.source_id=?1 ORDER BY f.id",
)?;
let mut rows = fact_query.query([source_id])?;
while let Some(row) = rows.next()? {
facts += 1;
line(
&mut writer,
&json!({"type":"fact","source_id":source_id,"id":row.get::<_,String>(0)?,"ordinal":crate::store::unsigned(row,1)?,"subject":row.get::<_,String>(2)?,"predicate":row.get::<_,String>(3)?,"value":serde_json::from_str::<Value>(&row.get::<_,String>(4)?)?,"selector":row.get::<_,String>(5)?,"confidence":row.get::<_,u16>(6)?,"selection_state":row.get::<_,String>(7)?,"rejection_reason":row.get::<_,Option<String>>(8)?,"native_id":row.get::<_,String>(9)?}),
)?;
}
writer.flush()?;
Ok((records, facts))
}
fn source_counts(db: &Connection, source_id: &str) -> anyhow::Result<(u64, u64)> {
Ok((
db.query_row(
"SELECT count(*) FROM records WHERE source_id=?1",
[source_id],
|row| crate::store::unsigned(row, 0),
)?,
db.query_row(
"SELECT count(*) FROM facts WHERE source_id=?1",
[source_id],
|row| crate::store::unsigned(row, 0),
)?,
))
}
#[allow(clippy::too_many_arguments)] // All bundle identity coordinates are checked explicitly.
fn find_existing(
store: &Path,
source_id: &str,
selected: bool,
attribution_sha256: &str,
coverage_sha256: &str,
records: u64,
facts: u64,
) -> anyhow::Result<Option<BundleReference>> {
let mut entries = 0_u64;
for entry in std::fs::read_dir(store)? {
let entry = entry?;
entries += 1;
ensure!(entries <= 100_000, "audit store entry limit exceeded");
let name = entry
.file_name()
.into_string()
.map_err(|_| anyhow::anyhow!("non-UTF8 audit store entry"))?;
let Some(object_sha256) = name.strip_suffix(".audit.jsonl") else {
continue;
};
if !crate::model::valid_digest(object_sha256) {
continue;
}
ensure!(
entry.file_type()?.is_file(),
"audit object must be a regular file"
);
let file = crate::generation::open_no_follow(&entry.path())?;
let bytes = file.metadata()?.len();
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let header = read_line_with_limit(&mut reader, &mut buffer, MAXIMUM_HEADER_BYTES)?
.context("audit bundle is empty")?;
if header["type"] != "manifest"
|| header["schema"] != BUNDLE_SCHEMA
|| header["source_id"] != source_id
|| header["selected"] != selected
|| header["attribution_sha256"] != attribution_sha256
|| header["coverage_sha256"] != coverage_sha256
|| header["records"] != records
|| header["facts"] != facts
{
continue;
}
let reference = BundleReference {
schema: BUNDLE_SCHEMA.into(),
source_id: source_id.into(),
object_sha256: object_sha256.into(),
bytes,
records,
facts,
selected,
attribution_sha256: attribution_sha256.into(),
coverage_sha256: coverage_sha256.into(),
};
verify_one(&reference, store)?;
return Ok(Some(reference));
}
Ok(None)
}
fn line(output: &mut impl Write, value: &Value) -> anyhow::Result<()> {
serde_json::to_writer(&mut *output, value)?; // atomic-writes: allow caller supplies only unpublished or atomic output
output.write_all(b"\n")?;
Ok(())
}
/// Verifies every external bundle referenced by an already pinned generation.
///
/// # Errors
/// Rejects missing/substituted/truncated objects, malformed records, and attribution,
/// coverage, source, count, or length mismatches.
pub fn verify(
registry: &crate::query::Registry,
store: &Path,
) -> anyhow::Result<VerificationReport> {
ensure!(
registry.receipt.runtime_layout == "compact-v1",
"generation does not use external audit bundles"
);
let mut report = VerificationReport {
schema: "argand.site-audit-verification/v1".into(),
registry: registry.identity.clone(),
bundles: 0,
bytes: 0,
records: 0,
facts: 0,
};
for reference in &registry.receipt.audit_bundles {
verify_one(reference, store)?;
report.bundles += 1;
report.bytes = report
.bytes
.checked_add(reference.bytes)
.context("audit byte count overflow")?;
report.records = report
.records
.checked_add(reference.records)
.context("audit record count overflow")?;
report.facts = report
.facts
.checked_add(reference.facts)
.context("audit fact count overflow")?;
}
Ok(report)
}
/// Verifies the live store and writes a no-delete retention checkpoint.
///
/// The exact output can then be signed with [`sign_retention`]. No deletion
/// operation is provided, so this tool cannot remove the only authenticated copy.
///
/// # Errors
/// Rejects invalid bundles, an existing output, or malformed report time.
pub fn checkpoint(
registry: &crate::query::Registry,
store: &Path,
recorded_at: chrono::DateTime<chrono::Utc>,
output: &Path,
) -> anyhow::Result<RetentionCheckpoint> {
verify(registry, store)?;
let checkpoint = RetentionCheckpoint {
schema: "argand.site-audit-retention/v1".into(),
registry: registry.identity.clone(),
recorded_at,
retain: registry.receipt.audit_bundles.clone(),
deletion_authorized: false,
};
checkpoint.validate()?;
argand_atomic::create_durable(output, &serde_json::to_vec_pretty(&checkpoint)?)?;
Ok(checkpoint)
}
/// Signs an exact, validated retention checkpoint with a distinct SSH namespace.
///
/// # Errors
/// Rejects malformed or oversized input, an invalid key, or an existing signature.
pub fn sign_retention(input: &Path, signature: &Path, key: &Path) -> anyhow::Result<()> {
let input = crate::ssh::sealed_input(input, MAXIMUM_CHECKPOINT_BYTES)?;
let checkpoint: RetentionCheckpoint =
serde_json::from_value(crate::json::parse(&input.bytes)?)?;
checkpoint.validate()?;
crate::ssh::sign(RETENTION_SIGNATURE_NAMESPACE, &input.bytes, key, signature)
}
/// Verifies an exact retention checkpoint against an independent SSH trust root.
///
/// # Errors
/// Rejects malformed input, invalid report data, or an untrusted signature.
pub fn verify_retention(
input: &Path,
signature: &Path,
allowed_signers: &Path,
identity: &str,
) -> anyhow::Result<RetentionCheckpoint> {
ensure!(
!identity.trim().is_empty() && identity.len() <= 256,
"retention signer identity is required and bounded"
);
let input = crate::ssh::sealed_input(input, MAXIMUM_CHECKPOINT_BYTES)?;
let signature = crate::ssh::sealed_input(signature, 64 * 1024)?;
let allowed = crate::ssh::sealed_input(allowed_signers, 1024 * 1024)?;
let checkpoint: RetentionCheckpoint =
serde_json::from_value(crate::json::parse(&input.bytes)?)?;
checkpoint.validate()?;
crate::ssh::verify(
RETENTION_SIGNATURE_NAMESPACE,
&input.bytes,
&signature.bytes,
&allowed.bytes,
identity,
)?;
Ok(checkpoint)
}
fn verify_one(reference: &BundleReference, store: &Path) -> anyhow::Result<()> {
reference.validate()?;
let path = store.join(reference.filename());
let mut file = crate::generation::open_no_follow(&path)
.with_context(|| format!("open audit bundle {}", path.display()))?;
ensure!(
file.metadata()?.is_file(),
"audit bundle is not a regular file"
);
ensure!(
file.metadata()?.len() == reference.bytes,
"audit bundle length mismatch"
);
ensure!(
reader_digest(&mut file)? == reference.object_sha256,
"audit bundle digest mismatch"
);
file.seek(SeekFrom::Start(0))?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let header = read_line_with_limit(&mut reader, &mut buffer, MAXIMUM_HEADER_BYTES)?
.context("audit bundle is empty")?;
ensure!(
header["type"] == "manifest"
&& header["schema"] == BUNDLE_SCHEMA
&& header["source_id"] == reference.source_id
&& header["selected"] == reference.selected
&& header["attribution_sha256"] == reference.attribution_sha256
&& header["coverage_sha256"] == reference.coverage_sha256
&& header["records"] == reference.records
&& header["facts"] == reference.facts,
"audit bundle header mismatch"
);
let manifest: crate::model::SourceManifest = serde_json::from_value(header["source"].clone())?;
manifest.validate()?;
ensure!(
manifest.id()? == reference.source_id,
"audit manifest identity mismatch"
);
let mut records = 0_u64;
let mut facts = 0_u64;
let mut in_facts = false;
while let Some(value) = read_line(&mut reader, &mut buffer)? {
ensure!(
value["source_id"] == reference.source_id,
"cross-source audit entry"
);
match value["type"].as_str() {
Some("record") if !in_facts => {
records += 1;
ensure!(
value["ordinal"].as_u64().is_some()
&& value["native_id"].is_string()
&& value["facts"].as_u64().is_some(),
"invalid audit record index"
);
}
Some("fact") => {
in_facts = true;
facts += 1;
ensure!(
value["ordinal"].as_u64().is_some()
&& value["id"].as_str().is_some_and(crate::model::valid_digest)
&& value["native_id"].is_string()
&& matches!(
value["selection_state"].as_str(),
Some("active" | "superseded" | "rejected")
),
"invalid audit fact index"
);
}
_ => anyhow::bail!("invalid or out-of-order audit entry"),
}
}
ensure!(
records == reference.records && facts == reference.facts,
"audit bundle count mismatch"
);
Ok(())
}
fn read_line(reader: &mut impl BufRead, buffer: &mut Vec<u8>) -> anyhow::Result<Option<Value>> {
read_line_with_limit(reader, buffer, MAXIMUM_LINE_BYTES)
}
fn read_line_with_limit(
reader: &mut impl BufRead,
buffer: &mut Vec<u8>,
maximum_bytes: usize,
) -> anyhow::Result<Option<Value>> {
buffer.clear();
loop {
let (consumed, complete) = {
let available = reader.fill_buf()?;
if available.is_empty() {
ensure!(buffer.is_empty(), "truncated audit bundle line");
return Ok(None);
}
let consumed = available
.iter()
.position(|byte| *byte == b'\n')
.map_or(available.len(), |position| position + 1);
let length = buffer
.len()
.checked_add(consumed)
.context("audit line length overflow")?;
ensure!(length <= maximum_bytes, "audit line exceeds bound");
buffer.extend_from_slice(&available[..consumed]);
(consumed, available[consumed - 1] == b'\n')
};
reader.consume(consumed);
if complete {
break;
}
}
buffer.pop();
Ok(Some(crate::json::parse(buffer)?))
}
fn reader_digest(reader: &mut impl Read) -> anyhow::Result<String> {
let mut sha256 = Sha256::new();
let mut buffer = vec![0_u8; 1024 * 1024];
loop {
let count = reader.read(&mut buffer)?;
if count == 0 {
break;
}
sha256.update(&buffer[..count]);
}
Ok(format!("{:x}", sha256.finalize()))
}
pub(crate) fn compact_runtime(db: &Connection) -> anyhow::Result<()> {
// SQLite otherwise performs an unindexed reverse-FK scan for every bulk
// deletion. The exact keep-set is constructed first and the caller requires
// a complete foreign_key_check before publishing the receipt.
db.execute_batch("PRAGMA foreign_keys=OFF")?;
let result = (|| {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute(
"DELETE FROM facts WHERE NOT EXISTS(SELECT 1 FROM active_records a WHERE a.source_id=facts.source_id AND a.ordinal=facts.ordinal)",
[],
)?;
db.execute(
"DELETE FROM records WHERE NOT EXISTS(SELECT 1 FROM active_records a WHERE a.source_id=records.source_id AND a.ordinal=records.ordinal)",
[],
)?;
// Runtime lookup needs only projection-bearing facts. The complete source
// rows, unprojected metadata and original raw JSON are already sealed in
// the bundle references written before this transaction.
db.execute_batch(
"CREATE TEMP TABLE runtime_facts(id TEXT PRIMARY KEY) WITHOUT ROWID;
INSERT OR IGNORE INTO runtime_facts SELECT fact FROM names;
INSERT OR IGNORE INTO runtime_facts SELECT fact FROM popularity;
INSERT OR IGNORE INTO runtime_facts SELECT fact FROM rejected;
INSERT OR IGNORE INTO runtime_facts SELECT item.value FROM edges,json_each(edges.facts) item;",
)?;
db.execute(
"DELETE FROM facts WHERE NOT EXISTS(SELECT 1 FROM runtime_facts r WHERE r.id=facts.id)",
[],
)?;
db.execute(
"DELETE FROM active_records WHERE NOT EXISTS(SELECT 1 FROM facts f WHERE f.source_id=active_records.source_id AND f.ordinal=active_records.ordinal)",
[],
)?;
db.execute(
"DELETE FROM records WHERE NOT EXISTS(SELECT 1 FROM facts f WHERE f.source_id=records.source_id AND f.ordinal=records.ordinal)",
[],
)?;
db.execute("UPDATE records SET raw_json='null'", [])?;
db.execute_batch("DROP TABLE runtime_facts;")?;
db.execute(
"DELETE FROM sources WHERE NOT EXISTS(SELECT 1 FROM selected_sources a WHERE a.id=sources.id)",
[],
)?;
db.execute_batch("COMMIT")?;
Ok::<(), anyhow::Error>(())
})();
let rollback = if result.is_err() && !db.is_autocommit() {
db.execute_batch("ROLLBACK")
} else {
Ok(())
};
let restore = db.execute_batch("PRAGMA foreign_keys=ON");
rollback.context("roll back failed runtime compaction")?;
restore.context("restore foreign keys after runtime compaction")?;
result?;
db.execute_batch("VACUUM; ANALYZE;")?;
Ok(())
}
/// Returns the external path for a reference without trusting a mirror name.
#[must_use]
pub fn path(store: &Path, reference: &BundleReference) -> PathBuf {
store.join(reference.filename())
}
/// Streams a verified compact history as an attribution-bearing audit export.
///
/// # Errors
/// Rejects an existing output, any invalid external bundle, or malformed evidence.
pub fn export(
registry: &crate::query::Registry,
store: &Path,
output: &Path,
include_descriptions: bool,
) -> anyhow::Result<()> {
verify(registry, store)?;
argand_atomic::create_durable_with(output, |file| {
export_inner(registry, store, file, include_descriptions).map_err(std::io::Error::other)
})?;
Ok(())
}
fn export_inner(
registry: &crate::query::Registry,
store: &Path,
file: &mut File,
include_descriptions: bool,
) -> anyhow::Result<()> {
let mut writer = BufWriter::new(file);
line(
&mut writer,
&json!({"schema":"argand.site-export/v3","registry":registry.identity,"mode":"audit","rules":registry.receipt.rules,"coverage":{"schema":"argand.site-coverage-selection/v1","sha256":registry.receipt.coverage_sha256},"audit_bundles":registry.receipt.audit_bundles,"attribution_sha256":registry.receipt.attribution_sha256,"attribution":crate::release::attribution(),"descriptions_included":include_descriptions}),
)?;
for reference in &registry.receipt.audit_bundles {
let file = crate::generation::open_no_follow(&path(store, reference))?;
let mut reader = BufReader::new(file);
let mut buffer = Vec::new();
let header = read_line_with_limit(&mut reader, &mut buffer, MAXIMUM_HEADER_BYTES)?
.context("audit bundle is empty")?;
let manifest = header["source"].clone();
while let Some(mut value) = read_line(&mut reader, &mut buffer)? {
match value["type"].as_str() {
Some("record") if value["facts"] == 0 => {
let replaces = manifest
.pointer("/coverage/supersedes")
.cloned()
.unwrap_or_else(|| json!([]));
line(
&mut writer,
&json!({"type":"tombstone","selection_state":"tombstoned","source":manifest,"source_identifier":value["native_id"],"source_snapshot_id":reference.source_id,"replaces":replaces}),
)?;
}
Some("record") => {}
Some("fact") => {
let predicate = value["predicate"]
.as_str()
.context("audit predicate missing")?
.to_owned();
if !include_descriptions && predicate == "description" {
continue;
}
if !include_descriptions
&& predicate == "category"
&& let Some(object) = value["value"].as_object_mut()
{
object.remove("description");
}
line(
&mut writer,
&json!({"type":"assertion","selection_state":value["selection_state"],"rejection_reason":value["rejection_reason"],"replacement_source_ids":[],"id":value["id"],"subject":value["subject"],"predicate":predicate,"value":value["value"],"selector":value["selector"],"confidence":value["confidence"],"source":manifest,"source_identifier":value["native_id"],"source_snapshot_id":reference.source_id,"description_redacted":!include_descriptions && predicate=="category"}),
)?;
}
_ => anyhow::bail!("invalid audit bundle entry during export"),
}
}
}
writer.flush()?;
Ok(())
}

View file

@ -35,7 +35,7 @@ CREATE TABLE review_policy(singleton INTEGER PRIMARY KEY CHECK(singleton=1),id T
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct Receipt {
/// `argand.site-registry/v2`.
/// `argand.site-registry/v2` with embedded history or compact v3.
pub schema: String,
/// Parser/derivation contract.
pub rules: String,
@ -64,6 +64,12 @@ pub struct Receipt {
/// Trusted writer-acceptance and bounded-expiry contract.
#[serde(default)]
pub decision_time_policy: String,
/// Empty for v2 embedded history or `compact-v1` for external audit bundles.
#[serde(default, skip_serializing_if = "String::is_empty")]
pub runtime_layout: String,
/// Complete content-addressed cold history for compact generations.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub audit_bundles: Vec<crate::audit::BundleReference>,
/// Distinct entity count.
pub entities: u64,
/// Strict URL identity count.
@ -104,6 +110,31 @@ pub fn build_with_policy_and_trust(
output: &Path,
policy: &crate::policy::ReviewPolicy,
reviewer_trust: Option<&Path>,
) -> anyhow::Result<Receipt> {
build_inner(db, output, policy, reviewer_trust, None)
}
/// Builds a compact runtime generation backed by a content-addressed cold audit store.
///
/// # Errors
/// Rejects invalid policy/trust, unsafe audit storage, corrupt evidence, existing
/// destinations, and any failure to verify the complete cold history.
pub fn build_compact_with_policy_and_trust(
db: &Connection,
output: &Path,
policy: &crate::policy::ReviewPolicy,
reviewer_trust: Option<&Path>,
audit_store: &Path,
) -> anyhow::Result<Receipt> {
build_inner(db, output, policy, reviewer_trust, Some(audit_store))
}
fn build_inner(
db: &Connection,
output: &Path,
policy: &crate::policy::ReviewPolicy,
reviewer_trust: Option<&Path>,
audit_store: Option<&Path>,
) -> anyhow::Result<Receipt> {
policy.validate()?;
let reviewer_trust_sha256 = reviewer_trust
@ -135,6 +166,17 @@ pub fn build_with_policy_and_trust(
project_names(&snapshot)?;
project_facts(&snapshot, &normalizer)?;
snapshot.execute_batch("COMMIT; ANALYZE;")?;
let coverage_sha256 = crate::coverage::projection_digest(&snapshot)?;
let attribution_sha256 =
crate::digest(&serde_json::to_vec_pretty(&crate::release::attribution())?);
let audit_bundles = if let Some(store) = audit_store {
let bundles =
crate::audit::package_all(&snapshot, store, &attribution_sha256, &coverage_sha256)?;
crate::audit::compact_runtime(&snapshot)?;
bundles
} else {
Vec::new()
};
let check: String = snapshot.query_row("PRAGMA integrity_check", [], |r| r.get(0))?;
ensure!(check == "ok", "registry integrity failed");
let foreign_count: u64 =
@ -149,15 +191,17 @@ pub fn build_with_policy_and_trust(
.map(|s| Ok(serde_json::from_str(&s?)?))
.collect::<anyhow::Result<Vec<_>>>()?;
drop(statement);
let coverage_sha256 = crate::coverage::projection_digest(&snapshot)?;
let mut receipt = Receipt {
schema: "argand.site-registry/v2".into(),
schema: if audit_store.is_some() {
"argand.site-registry/v3"
} else {
"argand.site-registry/v2"
}
.into(),
rules: store::RULE_VERSION.into(),
database_sha256: String::new(),
licenses_sha256: crate::digest(crate::release::LICENSES.as_bytes()),
attribution_sha256: crate::digest(&serde_json::to_vec_pretty(
&crate::release::attribution(),
)?),
attribution_sha256,
psl_source: psl_id,
sources,
review_policy: policy.clone(),
@ -165,6 +209,12 @@ pub fn build_with_policy_and_trust(
reviewer_trust_sha256,
coverage_sha256,
decision_time_policy: "argand.site-decision-time/v1".into(),
runtime_layout: if audit_store.is_some() {
"compact-v1".into()
} else {
String::new()
},
audit_bundles,
entities: count(&snapshot, "entities")?,
properties: count(&snapshot, "properties")?,
edges: count(&snapshot, "edges")?,
@ -445,7 +495,7 @@ fn project_edge(
serde_json::to_string(&property)?
],
)?;
let relation = if source == "wikidata" {
let relation = if matches!(source, "wikidata" | "ror") {
"asserted_official"
} else {
"directory_listing"
@ -469,7 +519,8 @@ fn project_edge(
// End-dated assertions stay as historical evidence, never current destinations.
// Future/partial starts require the operator to inspect the retained qualifiers.
let eligible = value.pointer("/statement/rank").and_then(Value::as_str) != Some("deprecated")
&& value.pointer("/statement/qualifiers/P582").is_none();
&& value.pointer("/statement/qualifiers/P582").is_none()
&& value.get("active").and_then(Value::as_bool) != Some(false);
db.execute("INSERT INTO edges VALUES(?1,?2,?3,?4,?5,?6,?7) ON CONFLICT(fingerprint) DO UPDATE SET facts=json_insert(edges.facts,'$[#]',?8)",params![fingerprint,entity,property.id,relation,serde_json::to_string(&vec![id])?,serde_json::to_string(&evidence)?,eligible,id])?;
Ok(())
}

View file

@ -141,9 +141,18 @@ enum Command {
scope: String,
#[arg(long)]
maximum_bytes: u64,
/// Bind a non-default per-record parsing limit into the source manifest.
#[arg(long)]
maximum_record_bytes: Option<u64>,
/// JSON file containing a typed coverage declaration.
#[arg(long)]
coverage: Option<PathBuf>,
/// Optional provider checksum formatted as `sha256:...` or `md5:...`.
#[arg(long, requires = "provider_checksum_url")]
provider_checksum: Option<String>,
/// Authoritative provider record or checksum-file URL.
#[arg(long, requires = "provider_checksum")]
provider_checksum_url: Option<String>,
},
/// Download paginated `CrUX` data using an explicit billing configuration JSON.
CruxDownload {
@ -175,6 +184,9 @@ enum Command {
/// JSON file containing a typed coverage declaration.
#[arg(long)]
coverage: Option<PathBuf>,
/// Bind a non-default per-record parsing limit into the source manifest.
#[arg(long)]
maximum_record_bytes: Option<u64>,
},
/// Import a complete pinned source, resuming committed record batches.
Import {
@ -203,6 +215,9 @@ enum Command {
/// Exact SSH allowed-signers trust root; required by strict policies.
#[arg(long)]
reviewer_trust: Option<PathBuf>,
/// External content-addressed audit store; enables compact v3 generations.
#[arg(long)]
audit_store: Option<PathBuf>,
},
/// Audit an exact name or alias; includes ambiguity counts and attribution.
Lookup {
@ -537,6 +552,51 @@ enum Command {
output: PathBuf,
#[arg(long)]
include_descriptions: bool,
/// External audit store required by compact v3 generations.
#[arg(long)]
audit_store: Option<PathBuf>,
},
/// Stream-verify all cold evidence referenced by a compact generation.
VerifyAudit {
#[arg(long)]
generation: PathBuf,
#[arg(long)]
pin: String,
#[arg(long)]
audit_store: PathBuf,
},
/// Verify cold objects and write a no-delete retention checkpoint.
AuditCheckpoint {
#[arg(long)]
generation: PathBuf,
#[arg(long)]
pin: String,
#[arg(long)]
audit_store: PathBuf,
#[arg(long)]
recorded_at: chrono::DateTime<chrono::Utc>,
#[arg(long)]
output: PathBuf,
},
/// Sign a validated audit retention checkpoint.
SignAuditCheckpoint {
#[arg(long)]
input: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
key: PathBuf,
},
/// Verify a signed audit retention checkpoint against an independent trust root.
VerifyAuditCheckpoint {
#[arg(long)]
input: PathBuf,
#[arg(long)]
signature: PathBuf,
#[arg(long)]
allowed_signers: PathBuf,
#[arg(long)]
identity: String,
},
/// Export cumulative emergency revocation state at an explicit time.
ExportRevocations {
@ -662,6 +722,7 @@ pub(super) async fn run() -> anyhow::Result<()> {
| Command::Observations { .. }
| Command::ObservationLookup { .. }
| Command::Evaluate { .. }
| Command::VerifyAudit { .. }
| Command::Resolve { .. }) => inspect(command)?,
Command::Import {
database,
@ -827,18 +888,30 @@ pub(super) async fn run() -> anyhow::Result<()> {
output,
policy,
reviewer_trust,
audit_store,
} => {
ensure!(database.is_file(), "import database does not exist");
let policy = policy
.map(|path| registry::read_json(&path))
.transpose()?
.unwrap_or_else(registry::policy::ReviewPolicy::reference);
let receipt = registry::build::build_with_policy_and_trust(
&registry::store::open(&database)?,
&output,
&policy,
reviewer_trust.as_deref(),
)?;
let database = registry::store::open(&database)?;
let receipt = if let Some(audit_store) = audit_store {
registry::build::build_compact_with_policy_and_trust(
&database,
&output,
&policy,
reviewer_trust.as_deref(),
&audit_store,
)?
} else {
registry::build::build_with_policy_and_trust(
&database,
&output,
&policy,
reviewer_trust.as_deref(),
)?
};
serde_json::json!({"receipt":receipt,"pin":registry::file_digest(&output.join("COMPLETE.json"))?,"generation":output})
}
Command::Review {
@ -893,14 +966,47 @@ pub(super) async fn run() -> anyhow::Result<()> {
pin,
output,
include_descriptions,
audit_store,
} => {
registry::release::export_audit(
&Registry::open(&generation, &pin)?,
&output,
include_descriptions,
)?;
let registry = Registry::open(&generation, &pin)?;
if let Some(audit_store) = audit_store {
registry::audit::export(&registry, &audit_store, &output, include_descriptions)?;
} else {
registry::release::export_audit(&registry, &output, include_descriptions)?;
}
serde_json::json!({"audit_export":output})
}
Command::AuditCheckpoint {
generation,
pin,
audit_store,
recorded_at,
output,
} => serde_json::to_value(registry::audit::checkpoint(
&Registry::open(&generation, &pin)?,
&audit_store,
recorded_at,
&output,
)?)?,
Command::SignAuditCheckpoint {
input,
signature,
key,
} => {
registry::audit::sign_retention(&input, &signature, &key)?;
serde_json::json!({"checkpoint":input,"signature":signature,"signature_namespace":registry::audit::RETENTION_SIGNATURE_NAMESPACE})
}
Command::VerifyAuditCheckpoint {
input,
signature,
allowed_signers,
identity,
} => serde_json::to_value(registry::audit::verify_retention(
&input,
&signature,
&allowed_signers,
&identity,
)?)?,
Command::ExportRevocations {
generation,
pin,
@ -1226,6 +1332,14 @@ fn inspect(command: Command) -> anyhow::Result<serde_json::Value> {
maximum_cases,
at.unwrap_or_else(chrono::Utc::now),
)?)?,
Command::VerifyAudit {
generation,
pin,
audit_store,
} => serde_json::to_value(registry::audit::verify(
&Registry::open(&generation, &pin)?,
&audit_store,
)?)?,
Command::Resolve {
generation,
pin,
@ -1389,7 +1503,10 @@ async fn acquire(command: Command) -> anyhow::Result<serde_json::Value> {
snapshot,
scope,
maximum_bytes,
maximum_record_bytes,
coverage,
provider_checksum,
provider_checksum_url,
} => serde_json::to_value(
registry::download::download(
&cache,
@ -1401,9 +1518,12 @@ async fn acquire(command: Command) -> anyhow::Result<serde_json::Value> {
snapshot,
scope,
maximum_bytes,
maximum_record_bytes,
coverage: coverage
.map(|path| registry::read_json(&path))
.transpose()?,
provider_checksum,
provider_checksum_url,
},
)
.await?,
@ -1422,17 +1542,14 @@ async fn acquire(command: Command) -> anyhow::Result<serde_json::Value> {
scope,
retrieved_at,
coverage,
maximum_record_bytes,
} => {
let coverage = coverage
.map(|path| registry::read_json(&path))
.transpose()?;
let sha256 = registry::file_digest(&input)?;
let manifest = SourceManifest {
schema: if coverage.is_some() {
"argand.site-source/v2"
} else {
"argand.site-source/v1"
}
.into(),
schema: "argand.site-source/v3".into(),
source,
format,
compression,
@ -1443,8 +1560,16 @@ async fn acquire(command: Command) -> anyhow::Result<serde_json::Value> {
retrieved_at,
license: source.license().into(),
license_url: source.license_url().into(),
sha256: registry::file_digest(&input)?,
sha256: sha256.clone(),
bytes: input.metadata()?.len(),
maximum_record_bytes,
integrity: vec![registry::model::IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: sha256,
evidence_url: None,
}],
lineage: Some(registry::model::SourceLineage::direct(source)),
};
manifest.validate()?;
argand_atomic::create_durable(&output, &serde_json::to_vec_pretty(&manifest)?)?;

View file

@ -416,6 +416,9 @@ mod tests {
.context("test timestamp")?,
sha256: crate::digest(b"input"),
bytes: 5,
maximum_record_bytes: None,
integrity: Vec::new(),
lineage: None,
};
Ok(Snapshot {
id,

View file

@ -82,12 +82,7 @@ pub async fn download(cache: &Path, request: &CruxDownload) -> anyhow::Result<Ca
let sha256 = crate::file_digest(&part)?;
let input = root.join(&sha256);
let manifest = SourceManifest {
schema: if request.coverage.is_some() {
"argand.site-source/v2"
} else {
"argand.site-source/v1"
}
.into(),
schema: "argand.site-source/v3".into(),
source: Source::Crux,
format: Format::CruxCsv,
compression: Compression::None,
@ -106,8 +101,16 @@ pub async fn download(cache: &Path, request: &CruxDownload) -> anyhow::Result<Ca
license: Source::Crux.license().into(),
license_url: Source::Crux.license_url().into(),
retrieved_at: Utc::now(),
sha256,
sha256: sha256.clone(),
bytes: part.metadata()?.len(),
maximum_record_bytes: None,
integrity: vec![crate::model::IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: sha256,
evidence_url: None,
}],
lineage: Some(crate::model::SourceLineage::direct(Source::Crux)),
};
fs::rename(part, &input)?;
File::open(&root)?.sync_all()?;

View file

@ -1,7 +1,7 @@
// By Nic Weyand!
//! Allowlisted source acquisition with validator-bound range resume.
use crate::model::{Compression, Format, Source, SourceManifest};
use crate::model::{Compression, Format, IntegrityProof, Source, SourceLineage, SourceManifest};
use anyhow::{Context, ensure};
use chrono::Utc;
use reqwest::{Client, StatusCode, header};
@ -32,9 +32,18 @@ pub struct Download {
pub scope: String,
/// Maximum downloaded object bytes.
pub maximum_bytes: u64,
/// Authenticated maximum bytes materialized for one source record.
#[serde(default)]
pub maximum_record_bytes: Option<u64>,
/// Explicit full/partition/delta coverage; absent preserves legacy scope semantics.
#[serde(default)]
pub coverage: Option<crate::model::SourceCoverage>,
/// Optional provider-published checksum, formatted as `algorithm:hex`.
#[serde(default)]
pub provider_checksum: Option<String>,
/// Exact provider record or checksum file supplying the expected checksum.
#[serde(default)]
pub provider_checksum_url: Option<String>,
}
/// Result points to immutable cached bytes and their manifest.
@ -92,6 +101,7 @@ pub fn validate_source_url(source: Source, input: &str) -> anyhow::Result<()> {
&& path == "/curlie-rdf/curlie-rdf-all.tar.gz")
}
Source::Psl => host == "publicsuffix.org" && path == "/list/public_suffix_list.dat",
Source::Ror => ror_url(host, path),
};
ensure!(allowed, "unreviewed source endpoint: {host}{path}");
Ok(())
@ -120,6 +130,7 @@ pub async fn download(cache: &Path, request: &Download) -> anyhow::Result<Cached
);
validate_source_url(request.source, &request.url)?;
ensure!(request.maximum_bytes > 0, "maximum bytes must be positive");
let requested_proof = requested_checksum_proof(request)?;
let key = crate::digest(&serde_json::to_vec(request)?);
let dir = cache.join(request.source.key()).join(key);
fs::create_dir_all(&dir)?;
@ -144,6 +155,12 @@ pub async fn download(cache: &Path, request: &Download) -> anyhow::Result<Cached
&& manifest.bytes <= request.maximum_bytes,
"cached source declaration differs from request"
);
ensure!(
requested_proof
.as_ref()
.is_none_or(|proof| manifest.integrity.contains(proof)),
"cached provider checksum differs from request"
);
let input = dir.join(&manifest.sha256);
ensure!(
crate::file_digest(&input)? == manifest.sha256
@ -180,15 +197,35 @@ pub async fn download(cache: &Path, request: &Download) -> anyhow::Result<Cached
if let Some(error) = last_error {
return Err(error);
}
let sha256 = crate::file_digest(&part)?;
seal_download(&dir, &part, &complete, request, requested_proof)
}
fn seal_download(
dir: &Path,
part: &Path,
complete: &Path,
request: &Download,
requested_proof: Option<IntegrityProof>,
) -> anyhow::Result<CachedSource> {
let sha256 = crate::file_digest(part)?;
let bytes = part.metadata()?.len();
let mut integrity = vec![IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: sha256.clone(),
evidence_url: None,
}];
if let Some(proof) = requested_proof {
let actual = match proof.algorithm.as_str() {
"sha256" => sha256.clone(),
"md5" => md5_digest(part)?,
_ => anyhow::bail!("unsupported provider checksum algorithm"),
};
ensure!(actual == proof.value, "provider checksum mismatch");
integrity.push(proof);
}
let manifest = SourceManifest {
schema: if request.coverage.is_some() {
"argand.site-source/v2"
} else {
"argand.site-source/v1"
}
.into(),
schema: "argand.site-source/v3".into(),
source: request.source,
format: request.format,
compression: request.compression,
@ -201,18 +238,117 @@ pub async fn download(cache: &Path, request: &Download) -> anyhow::Result<Cached
retrieved_at: Utc::now(),
sha256: sha256.clone(),
bytes,
maximum_record_bytes: request.maximum_record_bytes,
integrity,
lineage: Some(SourceLineage::direct(request.source)),
};
manifest.validate()?;
let input = dir.join(sha256);
fs::rename(&part, &input)?;
File::open(&dir)?.sync_all()?;
argand_atomic::create_durable(&complete, &serde_json::to_vec_pretty(&manifest)?)?;
fs::rename(part, &input)?;
File::open(dir)?.sync_all()?;
argand_atomic::create_durable(complete, &serde_json::to_vec_pretty(&manifest)?)?;
Ok(CachedSource {
input,
manifest: complete,
manifest: complete.into(),
})
}
fn ror_url(host: &str, path: &str) -> bool {
let components = path.trim_start_matches('/').split('/').collect::<Vec<_>>();
host == "zenodo.org"
&& components.len() == 6
&& components[0] == "api"
&& components[1] == "records"
&& !components[2].is_empty()
&& components[2].bytes().all(|byte| byte.is_ascii_digit())
&& components[3] == "files"
&& components[4].starts_with('v')
&& components[4].ends_with("-ror-data.zip")
&& components[5] == "content"
}
fn requested_checksum_proof(request: &Download) -> anyhow::Result<Option<IntegrityProof>> {
match (&request.provider_checksum, &request.provider_checksum_url) {
(None, None) => Ok(None),
(Some(expected), Some(evidence_url)) => {
let (algorithm, value) = expected
.split_once(':')
.context("provider checksum must be algorithm:hex")?;
let proof = IntegrityProof {
method: "provider_checksum".into(),
algorithm: algorithm.into(),
value: value.into(),
evidence_url: Some(evidence_url.clone()),
};
proof.validate()?;
validate_integrity_evidence(request.source, &request.url, &proof)?;
Ok(Some(proof))
}
(Some(_), None) => anyhow::bail!("provider checksum needs its evidence URL"),
(None, Some(_)) => anyhow::bail!("provider checksum URL needs a checksum"),
}
}
pub(crate) fn validate_integrity_evidence(
source: Source,
source_url: &str,
proof: &IntegrityProof,
) -> anyhow::Result<()> {
if proof.method == "content_digest" {
return Ok(());
}
let source_url = url::Url::parse(source_url)?;
let evidence = url::Url::parse(
proof
.evidence_url
.as_deref()
.context("provider checksum needs its evidence URL")?,
)?;
let allowed = match source {
Source::Ror => {
let source_parts = source_url
.path()
.trim_start_matches('/')
.split('/')
.collect::<Vec<_>>();
let evidence_parts = evidence
.path()
.trim_start_matches('/')
.split('/')
.collect::<Vec<_>>();
evidence.host_str() == Some("zenodo.org")
&& source_parts.len() == 6
&& evidence_parts.as_slice() == ["api", "records", source_parts[2]].as_slice()
}
Source::Wikidata => {
let source_parent = source_url.path().rsplit_once('/').map(|item| item.0);
evidence.host_str() == Some("dumps.wikimedia.org")
&& source_url.host_str() == evidence.host_str()
&& evidence.path().rsplit_once('/').map(|item| item.0) == source_parent
&& evidence.path().ends_with("/sha256sums.txt")
}
Source::Majestic | Source::Crux | Source::Curlie | Source::Psl => false,
};
ensure!(allowed, "unreviewed provider checksum evidence endpoint");
Ok(())
}
fn md5_digest(path: &Path) -> anyhow::Result<String> {
use md5::{Digest, Md5};
use std::io::Read as _;
let mut input = crate::generation::open_no_follow(path)?;
let mut hash = Md5::new();
let mut buffer = [0_u8; 8192];
loop {
let count = input.read(&mut buffer)?;
if count == 0 {
break;
}
hash.update(&buffer[..count]);
}
Ok(format!("{:x}", hash.finalize()))
}
async fn transfer(
client: &Client,
request: &Download,
@ -437,4 +573,33 @@ mod tests {
}
Ok(())
}
#[test]
fn provider_checksums_need_source_bound_evidence() -> anyhow::Result<()> {
let source =
"https://zenodo.org/api/records/22099990/files/v2.12-2026-08-25-ror-data.zip/content";
let proof = IntegrityProof {
method: "provider_checksum".into(),
algorithm: "md5".into(),
value: "ce8807691455d4ada3216c31408e9e1a".into(),
evidence_url: Some("https://zenodo.org/api/records/22099990".into()),
};
validate_integrity_evidence(Source::Ror, source, &proof)?;
for url in [
"https://example.org/api/records/22099990",
"https://zenodo.org/api/records/999",
"https://zenodo.org/api/records/22099990?changed=true",
] {
let mut bad = proof.clone();
bad.evidence_url = Some(url.into());
assert!(
bad.validate().is_err()
|| validate_integrity_evidence(Source::Ror, source, &bad).is_err()
);
}
let mut unsupported = proof;
unsupported.method = "provider_signature".into();
assert!(unsupported.validate().is_err());
Ok(())
}
}

View file

@ -1,7 +1,11 @@
// By Nic Weyand!
//! Bounded, replayable destination-resolution evaluation.
use crate::{ResolutionStatus, query::Registry};
use crate::{
ResolutionStatus,
model::{Source, SourceLineage},
query::Registry,
};
use anyhow::{Context, ensure};
use serde::{Deserialize, Serialize};
use std::{
@ -65,6 +69,8 @@ pub struct Report {
pub registry: String,
/// Explicit resolver clock used for every case.
pub evaluated_at: chrono::DateTime<chrono::Utc>,
/// Active source snapshots and their declared dependency groups.
pub source_lineage: Vec<EvaluationLineage>,
/// Parsed cases.
pub total: u64,
/// Cases matching every asserted field.
@ -79,6 +85,19 @@ pub struct Report {
pub failures: Vec<Failure>,
}
/// Source dependency coordinates bound into an evaluation report.
#[derive(Debug, Serialize)]
pub struct EvaluationLineage {
/// Content identity of the exact source declaration.
pub source_snapshot_id: String,
/// Direct source adapter/provider class.
pub source: Source,
/// Provider-native snapshot coordinate.
pub snapshot: String,
/// Declared dependencies, or null for a legacy source with unknown lineage.
pub lineage: Option<SourceLineage>,
}
/// Evaluates newline-delimited cases against one already verified registry.
///
/// # Errors
@ -151,10 +170,24 @@ pub fn run(
ensure!(total > 0, "evaluation contains no cases");
durations.sort_unstable();
let failed = u64::try_from(failures.len())?;
let source_lineage = registry
.receipt
.sources
.iter()
.map(|source| {
Ok(EvaluationLineage {
source_snapshot_id: source.id()?,
source: source.source,
snapshot: source.snapshot.clone(),
lineage: source.lineage.clone(),
})
})
.collect::<anyhow::Result<Vec<_>>>()?;
Ok(Report {
schema: "argand.site-evaluation/v1".into(),
schema: "argand.site-evaluation/v2".into(),
registry: registry.identity.clone(),
evaluated_at,
source_lineage,
total,
passed: total - failed,
failed,

View file

@ -51,14 +51,17 @@ impl Registry {
&& crate::digest(&attribution) == receipt.attribution_sha256,
"registry license or attribution digest mismatch"
);
let current = receipt.schema == "argand.site-registry/v2"
&& crate::store::supported_rule_version(&receipt.rules)
let current = matches!(
receipt.schema.as_str(),
"argand.site-registry/v2" | "argand.site-registry/v3"
) && crate::store::supported_rule_version(&receipt.rules)
&& crate::model::valid_digest(&receipt.review_policy_sha256)
&& receipt.review_policy.id()? == receipt.review_policy_sha256
&& crate::model::valid_digest(&receipt.coverage_sha256)
&& receipt.decision_time_policy == "argand.site-decision-time/v1"
&& (receipt.review_policy.allow_legacy_reviews
|| crate::model::valid_digest(&receipt.reviewer_trust_sha256));
|| crate::model::valid_digest(&receipt.reviewer_trust_sha256))
&& validate_audit_contract(&receipt).is_ok();
let legacy = allow_legacy_rules
&& receipt.schema == "argand.site-registry/v1"
&& crate::store::legacy_rule_version(&receipt.rules);
@ -71,6 +74,7 @@ impl Registry {
crate::coverage::projection_digest(&db)? == receipt.coverage_sha256,
"selected coverage graph differs from receipt"
);
validate_source_selection(&db, &receipt)?;
}
Ok(Self {
db,
@ -81,6 +85,74 @@ impl Registry {
}
}
fn validate_audit_contract(receipt: &Receipt) -> anyhow::Result<()> {
let mut declared_sources = BTreeSet::new();
for source in &receipt.sources {
source.validate()?;
ensure!(
declared_sources.insert(source.id()?),
"duplicate receipt source declaration"
);
}
ensure!(
receipt.sources.iter().any(|source| {
source.source == crate::model::Source::Psl
&& source.id().ok().as_ref() == Some(&receipt.psl_source)
}),
"receipt PSL is not the active PSL source"
);
if receipt.schema == "argand.site-registry/v2" {
ensure!(
receipt.runtime_layout.is_empty() && receipt.audit_bundles.is_empty(),
"v2 registry cannot reference external audit history"
);
return Ok(());
}
ensure!(
receipt.runtime_layout == "compact-v1" && !receipt.audit_bundles.is_empty(),
"v3 registry needs compact audit references"
);
let mut sources = BTreeSet::new();
let mut selected_sources = BTreeSet::new();
for reference in &receipt.audit_bundles {
reference.validate()?;
ensure!(
reference.attribution_sha256 == receipt.attribution_sha256
&& reference.coverage_sha256 == receipt.coverage_sha256,
"audit reference is bound to another generation contract"
);
ensure!(
sources.insert(&reference.source_id),
"duplicate audit source reference"
);
if reference.selected {
selected_sources.insert(reference.source_id.clone());
}
}
ensure!(
selected_sources == declared_sources,
"selected audit references differ from active receipt sources"
);
Ok(())
}
fn validate_source_selection(db: &Connection, receipt: &Receipt) -> anyhow::Result<()> {
let mut statement = db.prepare("SELECT id FROM selected_sources ORDER BY id")?;
let stored = statement
.query_map([], |row| row.get::<_, String>(0))?
.collect::<Result<BTreeSet<_>, _>>()?;
let declared = receipt
.sources
.iter()
.map(crate::model::SourceManifest::id)
.collect::<anyhow::Result<BTreeSet<_>>>()?;
ensure!(
stored == declared,
"receipt sources differ from selected database sources"
);
Ok(())
}
fn verify_layout(path: &Path) -> anyhow::Result<()> {
ensure!(
std::fs::symlink_metadata(path)?.is_dir(),

View file

@ -4,6 +4,7 @@
use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
use serde_json::{Map, Number, Value};
use std::fmt;
use std::{cell::Cell, io::Read, rc::Rc};
struct Unique(Value);
@ -61,6 +62,71 @@ pub(crate) fn parse(bytes: &[u8]) -> anyhow::Result<Value> {
Ok(serde_json::from_slice::<Unique>(bytes)?.0)
}
/// Streams a top-level JSON array while bounding each materialized record.
pub(crate) fn for_each_array(
reader: &mut dyn Read,
maximum_record_bytes: usize,
mut callback: impl FnMut(Value) -> anyhow::Result<()>,
) -> anyhow::Result<()> {
use serde::de::Error as _;
struct Bound<'a> {
inner: &'a mut dyn Read,
used: Rc<Cell<usize>>,
maximum: usize,
}
impl Read for Bound<'_> {
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
let remaining = self.maximum.saturating_sub(self.used.get());
if remaining == 0 {
return Err(std::io::Error::other(
"JSON record exceeds configured bound",
));
}
let length = buffer.len().min(remaining);
let count = self.inner.read(&mut buffer[..length])?;
self.used.set(self.used.get().saturating_add(count));
Ok(count)
}
}
struct Array<'a, F> {
callback: &'a mut F,
used: Rc<Cell<usize>>,
}
impl<'de, F> Visitor<'de> for Array<'_, F>
where
F: FnMut(Value) -> anyhow::Result<()>,
{
type Value = ();
fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("a JSON array of uniquely keyed records")
}
fn visit_seq<A: SeqAccess<'de>>(self, mut sequence: A) -> Result<(), A::Error> {
while let Some(Unique(value)) = sequence.next_element()? {
(self.callback)(value).map_err(A::Error::custom)?;
self.used.set(0);
}
Ok(())
}
}
anyhow::ensure!(
maximum_record_bytes > 0,
"JSON record bound must be positive"
);
let used = Rc::new(Cell::new(0));
let bound = Bound {
inner: reader,
used: Rc::clone(&used),
maximum: maximum_record_bytes,
};
let mut deserializer = serde_json::Deserializer::from_reader(bound);
deserializer.deserialize_seq(Array {
callback: &mut callback,
used,
})?;
deserializer.end()?;
Ok(())
}
#[cfg(test)]
mod tests {
#[test]
@ -73,4 +139,19 @@ mod tests {
);
Ok(())
}
#[test]
fn streamed_arrays_reject_duplicates_and_oversized_records() -> anyhow::Result<()> {
let mut values = Vec::new();
super::for_each_array(&mut &br#"[{"a":1},{"b":[2,3]}]"#[..], 1024, |value| {
values.push(value);
Ok(())
})?;
assert_eq!(values.len(), 2);
assert!(super::for_each_array(&mut &br#"[{"a":1,"a":2}]"#[..], 1024, |_| Ok(())).is_err());
assert!(
super::for_each_array(&mut &br#"[{"long":"0123456789"}]"#[..], 8, |_| Ok(())).is_err()
);
Ok(())
}
}

View file

@ -2,6 +2,7 @@
//! Source-separated website assertions and reviewed, immutable registry releases.
pub mod adapters;
pub mod audit;
pub mod build;
pub mod bundle;
pub mod catalog;

View file

@ -1,7 +1,7 @@
// By Nic Weyand!
//! Source identities and source-native assertion envelopes.
use anyhow::ensure;
use anyhow::{Context, ensure};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use serde_json::Value;
@ -20,6 +20,8 @@ pub enum Source {
Curlie,
/// Public Suffix List, including PRIVATE rules.
Psl,
/// Research Organization Registry organization records.
Ror,
}
impl Source {
@ -32,13 +34,14 @@ impl Source {
Self::Crux => "crux",
Self::Curlie => "curlie",
Self::Psl => "psl",
Self::Ror => "ror",
}
}
/// Exact SPDX data license.
#[must_use]
pub const fn license(self) -> &'static str {
match self {
Self::Wikidata => "CC0-1.0",
Self::Wikidata | Self::Ror => "CC0-1.0",
Self::Majestic | Self::Curlie => "CC-BY-3.0",
Self::Crux => "CC-BY-4.0",
Self::Psl => "MPL-2.0",
@ -53,6 +56,7 @@ impl Source {
Self::Crux => "https://developer.chrome.com/docs/crux/methodology",
Self::Curlie => "https://curlie.org/docs/en/license.html",
Self::Psl => "https://publicsuffix.org/list/public_suffix_list.dat",
Self::Ror => "https://ror.readme.io/docs/data-dump",
}
}
}
@ -73,6 +77,132 @@ pub enum Format {
CurlieTarGz,
/// UTF-8 PSL text.
PslText,
/// Official ROR release ZIP containing schema 2.1 JSON and CSV.
RorZip,
}
/// How an immutable source object was checked before import.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct IntegrityProof {
/// `content_digest` or `provider_checksum`.
pub method: String,
/// Lowercase digest algorithm, currently `sha256` or `md5`.
pub algorithm: String,
/// Lowercase hexadecimal digest.
pub value: String,
/// Provider page or checksum object that supplied the expected value.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evidence_url: Option<String>,
}
impl IntegrityProof {
/// Validates bounded, explicit integrity evidence.
///
/// # Errors
/// Rejects unknown methods/algorithms, malformed digests, and unsafe evidence URLs.
pub fn validate(&self) -> anyhow::Result<()> {
ensure!(
matches!(self.method.as_str(), "content_digest" | "provider_checksum"),
"unsupported integrity method"
);
let length = match self.algorithm.as_str() {
"sha256" => 64,
"md5" => 32,
_ => anyhow::bail!("unsupported integrity algorithm"),
};
ensure!(
self.value.len() == length
&& self
.value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)),
"invalid integrity digest"
);
if self.method == "content_digest" {
ensure!(
self.algorithm == "sha256" && self.evidence_url.is_none(),
"content digest is the local SHA-256"
);
} else {
let url = url::Url::parse(
self.evidence_url
.as_deref()
.context("provider integrity proof needs an evidence URL")?,
)?;
ensure!(
url.scheme() == "https"
&& url.username().is_empty()
&& url.password().is_none()
&& url.port().is_none()
&& url.fragment().is_none()
&& url.query().is_none(),
"integrity evidence URL must be plain HTTPS"
);
}
Ok(())
}
}
/// Declared provenance path used to detect copied or correlated assertions.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct SourceLineage {
/// Provider directly distributing this object.
pub direct_provider: String,
/// Stable upstream dataset identifiers known to contribute consumed fields.
#[serde(default)]
pub upstream_datasets: Vec<String>,
/// Ordered, declared transformations between upstream and this object.
#[serde(default)]
pub transformations: Vec<String>,
/// Stable corroboration group, or `unknown` when independence is not established.
pub independence_group: String,
}
impl SourceLineage {
/// Conservative direct-provider lineage for an official snapshot.
#[must_use]
pub fn direct(source: Source) -> Self {
Self {
direct_provider: source.key().into(),
upstream_datasets: if source == Source::Ror {
vec!["geonames".into()]
} else {
Vec::new()
},
transformations: vec!["provider_snapshot".into()],
independence_group: source.key().into(),
}
}
/// Validates bounded identifiers without claiming unknown independence.
///
/// # Errors
/// Rejects empty, oversized, duplicate, or unsafe lineage coordinates.
pub fn validate(&self) -> anyhow::Result<()> {
fn item(value: &str) -> bool {
!value.trim().is_empty() && value.len() <= 256 && !value.chars().any(char::is_control)
}
ensure!(
item(&self.direct_provider)
&& item(&self.independence_group)
&& self.upstream_datasets.len() <= 64
&& self.upstream_datasets.iter().all(|value| item(value))
&& self.transformations.len() <= 64
&& self.transformations.iter().all(|value| item(value)),
"invalid source lineage"
);
let upstream = self
.upstream_datasets
.iter()
.collect::<std::collections::BTreeSet<_>>();
ensure!(
upstream.len() == self.upstream_datasets.len(),
"duplicate upstream lineage"
);
Ok(())
}
}
/// Compression of the downloaded object (Curlie tar.gz uses `None` here).
@ -189,7 +319,7 @@ impl SourceCoverage {
#[derive(Clone, Debug, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SourceManifest {
/// `argand.site-source/v1` for legacy scope semantics or v2 for typed coverage.
/// V1 legacy scope, v2 typed coverage, or v3 integrity/lineage coverage.
pub schema: String,
/// Approved provider.
pub source: Source,
@ -217,6 +347,15 @@ pub struct SourceManifest {
pub sha256: String,
/// Original compressed object length.
pub bytes: u64,
/// Authenticated per-record materialization ceiling; defaults to 16 MiB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub maximum_record_bytes: Option<u64>,
/// Checks applied to the exact source bytes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub integrity: Vec<IntegrityProof>,
/// Provider and upstream dependency declaration.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lineage: Option<SourceLineage>,
}
impl SourceManifest {
@ -228,7 +367,9 @@ impl SourceManifest {
ensure!(
matches!(
(self.schema.as_str(), self.coverage.as_ref()),
("argand.site-source/v1", None) | ("argand.site-source/v2", Some(_))
("argand.site-source/v1", None)
| ("argand.site-source/v2", Some(_))
| ("argand.site-source/v3", _)
),
"unsupported source schema or coverage declaration"
);
@ -240,6 +381,16 @@ impl SourceManifest {
self.bytes > 0 && valid_digest(&self.sha256),
"invalid source length or digest"
);
ensure!(
self.maximum_record_bytes.is_none_or(|bytes| {
(1..=crate::adapters::MAX_CUSTOM_RECORD_BYTES as u64).contains(&bytes)
}),
"source record bound must be 1 byte..256 MiB"
);
ensure!(
self.schema == "argand.site-source/v3" || self.maximum_record_bytes.is_none(),
"custom record bounds require source schema v3"
);
ensure!(
!self.snapshot.is_empty()
&& self.snapshot.len() <= 512
@ -256,14 +407,47 @@ impl SourceManifest {
| (Source::Crux, Format::CruxCsv)
| (Source::Curlie, Format::CurlieTarGz)
| (Source::Psl, Format::PslText)
| (Source::Ror, Format::RorZip)
);
ensure!(valid, "source/format mismatch");
if let Some(coverage) = &self.coverage {
coverage.validate()?;
}
for proof in &self.integrity {
proof.validate()?;
crate::download::validate_integrity_evidence(self.source, &self.source_url, proof)?;
}
if self.schema == "argand.site-source/v3" {
ensure!(
self.integrity.iter().any(|proof| {
proof.method == "content_digest"
&& proof.algorithm == "sha256"
&& proof.value == self.sha256
}),
"source v3 needs its exact content SHA-256 proof"
);
self.lineage
.as_ref()
.context("source v3 needs explicit lineage")?
.validate()?;
} else {
ensure!(
self.integrity.is_empty() && self.lineage.is_none(),
"lineage and integrity metadata require source schema v3"
);
}
crate::download::validate_source_url(self.source, &self.source_url)?;
Ok(())
}
/// Exact per-record limit bound into this source declaration.
#[must_use]
pub fn record_bytes_limit(&self) -> usize {
usize::try_from(
self.maximum_record_bytes
.unwrap_or(crate::adapters::MAX_RECORD_BYTES as u64),
)
.unwrap_or(crate::adapters::MAX_RECORD_BYTES)
}
/// Content identity of the declaration, including original retrieval time.
///
/// # Errors

View file

@ -21,6 +21,7 @@ pub fn attribution() -> Value {
"crux":{"license":"CC-BY-4.0","credit":"Chrome UX Report, Google","url":"https://developer.chrome.com/docs/crux/","license_url":"https://creativecommons.org/licenses/by/4.0/"},
"curlie":{"license":"CC-BY-3.0","credit":"With content from Curlie.org - the largest human-edited directory of the web. Contribute by submitting a website or becoming an editor.","url":"https://curlie.org/","license_url":"https://creativecommons.org/licenses/by/3.0/","public_display":"Use the prescribed HTML attribution on every page using Curlie content: https://curlie.org/docs/en/license.html"},
"psl":{"license":"MPL-2.0","url":"https://publicsuffix.org/list/","license_url":"https://mozilla.org/MPL/2.0/"},
"ror":{"license":"CC0-1.0","url":"https://ror.org/","license_url":"https://ror.readme.io/docs/data-dump","lineage_note":"ROR location metadata identifies GeoNames as an upstream CC BY 3.0 source","upstream_attribution":{"credit":"GeoNames","url":"https://www.geonames.org/","license_url":"https://creativecommons.org/licenses/by/3.0/"}},
"argand_candidate_observer":{"license":"CC0-1.0","url":"https://git.argand.org/nicweyand/argand-site-registry","license_url":"https://creativecommons.org/publicdomain/zero/1.0/","scope":"locally authored observation metadata; captured page content is not redistributed"},
"changes":"Argand normalizes and combines assertions; provider endorsement is not implied."})
}
@ -51,6 +52,10 @@ pub fn export_audit(
output: &Path,
include_descriptions: bool,
) -> anyhow::Result<()> {
ensure!(
registry.receipt.runtime_layout.is_empty(),
"compact generation audit export requires its external audit store"
);
argand_atomic::create_durable_with(output, |file| {
export_inner(registry, file, include_descriptions, ExportMode::Audit)
.map_err(std::io::Error::other)

View file

@ -146,6 +146,7 @@ pub fn import(
///
/// # Errors
/// Returns source integrity, resource, parser, filesystem, or database errors.
#[allow(clippy::too_many_lines)] // Keep verification, checkpoints, and cleanup in one transaction lifecycle.
pub fn import_with_limits(
db: &mut Connection,
manifest: &SourceManifest,
@ -217,7 +218,15 @@ pub fn import_with_limits(
limits,
database_before,
};
let parsed = adapters::adapter(manifest.format).ingest(&mut reader, &mut sink);
let parsed = adapters::adapter(
manifest.format,
manifest.record_bytes_limit(),
manifest
.coverage
.as_ref()
.is_some_and(|coverage| coverage.kind == crate::model::CoverageKind::Delta),
)
.ingest(&mut reader, &mut sink);
parsed.and_then(|()| {
std::io::copy(&mut reader, &mut std::io::sink())?;
ensure!(
@ -241,9 +250,7 @@ pub fn import_with_limits(
match result {
Ok(()) => db.execute_batch("COMMIT")?,
Err(error) => {
db.execute_batch("ROLLBACK")?;
discard_incomplete_source(db, &id)?;
db.pragma_update(None, "max_page_count", i64::try_from(original_max_pages)?)?;
recover_failed_import(db, &id, original_max_pages)?;
return Err(error);
}
}
@ -251,6 +258,22 @@ pub fn import_with_limits(
Ok(id)
}
fn recover_failed_import(db: &Connection, id: &str, original_max_pages: u64) -> anyhow::Result<()> {
// SQLITE_FULL and some I/O errors can roll back the transaction themselves.
// Cleanup and ceiling restoration must still run.
let rollback = if db.is_autocommit() {
Ok(())
} else {
db.execute_batch("ROLLBACK")
};
let cleanup = discard_incomplete_source(db, id);
let restore = db.pragma_update(None, "max_page_count", i64::try_from(original_max_pages)?);
rollback.context("roll back failed source import")?;
cleanup.context("discard incomplete source after import failure")?;
restore.context("restore database growth ceiling after import failure")?;
Ok(())
}
fn discard_incomplete_source(db: &Connection, id: &str) -> anyhow::Result<()> {
db.execute_batch("BEGIN IMMEDIATE")?;
db.execute("DELETE FROM facts WHERE source_id=?1", [id])?;

View file

@ -211,6 +211,7 @@ mod tests {
snapshot: "{date}".into(),
scope: "full".into(),
maximum_bytes: 1,
maximum_record_bytes: None,
coverage: Some(SourceCoverage {
collection: "default".into(),
kind: CoverageKind::Partition,
@ -219,6 +220,8 @@ mod tests {
sequence: None,
supersedes: Vec::new(),
}),
provider_checksum: None,
provider_checksum_url: None,
}
}

View file

@ -68,6 +68,9 @@ pub fn manifest(source: Source, format: Format, bytes: &[u8]) -> anyhow::Result<
Source::Majestic => "https://downloads.majestic.com/majestic_million.csv",
Source::Crux => "https://developer.chrome.com/docs/crux/bigquery/",
Source::Curlie => "https://curlie.org/directory-dl",
Source::Ror => {
"https://zenodo.org/api/records/22099990/files/v2.12-2026-08-25-ror-data.zip/content"
}
};
Ok(SourceManifest {
schema: "argand.site-source/v1".into(),
@ -83,6 +86,9 @@ pub fn manifest(source: Source, format: Format, bytes: &[u8]) -> anyhow::Result<
retrieved_at: timestamp()?,
sha256: argand_site_registry::digest(bytes),
bytes: u64::try_from(bytes.len())?,
maximum_record_bytes: None,
integrity: Vec::new(),
lineage: None,
})
}

View file

@ -58,6 +58,14 @@ fn multilingual_regional_and_deceptive_queries_are_replayed() -> anyhow::Result<
assert_eq!(report.passed, 8);
assert_eq!(report.failed, 0);
assert_eq!(report.evaluated_at, common::timestamp()?);
assert_eq!(report.schema, "argand.site-evaluation/v2");
assert_eq!(report.source_lineage.len(), 5);
assert!(
report
.source_lineage
.iter()
.all(|source| source.lineage.is_none())
);
assert!(report.failures.is_empty());
let incorrect = serde_json::to_string(&json!({

View file

@ -205,6 +205,39 @@ fn import_limits_rollback_all_partial_source_rows() -> anyhow::Result<()> {
Ok(())
}
#[test]
fn database_growth_ceiling_simulates_disk_full_and_rolls_back() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("database-full.sqlite"))?;
let mut data = String::from("origin,rank,yyyymm,country_code\n");
for index in 0..20_000 {
use std::fmt::Write as _;
writeln!(data, "https://{index}.example.com/,1000,202608,")?;
}
let input = root.path().join("many-origins.csv");
fs::write(&input, &data)?;
let manifest = common::manifest(Source::Crux, Format::CruxCsv, data.as_bytes())?;
assert!(
store::import_with_limits(
&mut db,
&manifest,
&input,
ImportLimits {
maximum_expanded_bytes: u64::try_from(data.len())? + 1,
maximum_records: 25_000,
maximum_database_growth_bytes: 64 * 1024,
},
)
.is_err()
);
assert_eq!(
db.query_row("SELECT count(*) FROM sources", [], |row| row
.get::<_, i64>(0))?,
0
);
Ok(())
}
#[test]
fn retired_reviewer_keys_verify_history_only_with_a_validity_epoch() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;

View file

@ -0,0 +1,580 @@
// By Nic Weyand!
//! v0.5 source-lineage and ROR schema acceptance proof.
#[allow(dead_code)]
mod common;
use anyhow::{Context, ensure};
use argand_site_registry::{
build, evaluation,
model::{CoverageKind, Format, IntegrityProof, Source, SourceCoverage, SourceLineage},
policy::ReviewPolicy,
query::Registry,
store,
};
use serde_json::{Value, json};
use std::{
io::{Cursor, Write},
process::{Command, Stdio},
thread,
time::{Duration, Instant},
};
fn record(id: &str, name: &str, status: &str, website: &str) -> Value {
json!({
"admin":{"created":{"date":"2020-01-01","schema_version":"1.0"},"last_modified":{"date":"2026-08-01","schema_version":"2.1"}},
"domains":[url::Url::parse(website).ok().and_then(|url| url.host_str().map(str::to_owned)).unwrap_or_default()],
"established":2020,
"external_ids":[{"all":["Q42"],"preferred":"Q42","type":"wikidata"}],
"id":format!("https://ror.org/{id}"),
"links":[{"type":"website","value":website},{"type":"wikipedia","value":"https://en.wikipedia.org/wiki/Example"}],
"locations":[{"geonames_id":2_643_743,"geonames_details":{"continent_code":"EU","continent_name":"Europe","country_code":"GB","country_name":"United Kingdom","country_subdivision_code":"ENG","country_subdivision_name":"England","lat":51.5,"lng":-0.1,"name":"London"}}],
"names":[{"lang":"en","types":["ror_display","label"],"value":name},{"lang":null,"types":["acronym"],"value":"EU"}],
"relationships":[],
"status":status,
"types":["education"]
})
}
fn ror_zip(records: &[Value], extra: Option<(&str, &[u8])>) -> anyhow::Result<Vec<u8>> {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
archive.start_file("v2.12-2026-08-25-ror-data.json", options)?;
archive.write_all(&serde_json::to_vec_pretty(records)?)?;
archive.start_file("v2.12-2026-08-25-ror-data.csv", options)?;
archive.write_all(b"id,name\n")?;
if let Some((name, bytes)) = extra {
archive.start_file(name, options)?;
archive.write_all(bytes)?;
}
Ok(archive.finish()?.into_inner())
}
#[test]
#[allow(clippy::too_many_lines)] // One source scenario covers every reviewed ROR mapping and hold.
fn ror_schema_21_is_separate_ineligible_when_inactive_and_lineage_is_exported() -> anyhow::Result<()>
{
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("writer.sqlite"))?;
common::import(
&mut db,
root.path(),
Source::Psl,
Format::PslText,
common::PSL.as_bytes(),
)?;
let wikidata = json!({"entities":{"Q42":common::entity(
"Q42",
"Example University Wikidata",
&[],
&["https://wikidata-example.org/"]
)}});
common::import(
&mut db,
root.path(),
Source::Wikidata,
Format::WikidataEntities,
&serde_json::to_vec(&wikidata)?,
)?;
let mut sparse = record(
"fedcba210",
"Conflicting Organization",
"active",
"https://example.org/",
);
sparse["domains"] = json!([]);
sparse["established"] = Value::Null;
sparse["external_ids"] = json!([]);
sparse["locations"] = json!([]);
sparse["names"] =
json!([{"lang":"en","types":["ror_display"],"value":"Conflicting Organization"}]);
sparse["links"] = json!([
{"type":"website","value":"https://example.org/"},
{"type":"website","value":"https://regional.example.org/"}
]);
let bytes = ror_zip(
&[
record(
"012345678",
"Example University",
"active",
"https://example.org/",
),
record(
"abcdef012",
"Former Institute",
"inactive",
"https://former.example.org/",
),
sparse,
],
None,
)?;
let mut manifest = common::manifest(Source::Ror, Format::RorZip, &bytes)?;
manifest.schema = "argand.site-source/v3".into();
manifest.integrity = vec![IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: manifest.sha256.clone(),
evidence_url: None,
}];
manifest.lineage = Some(SourceLineage::direct(Source::Ror));
let input = root.path().join("ror.zip");
std::fs::write(&input, &bytes)?;
let first = store::import(&mut db, &manifest, &input)?;
assert_eq!(first, store::import(&mut db, &manifest, &input)?);
let registry = common::build(&db, root.path(), "generation")?;
let active = registry.lookup("Example University", 20)?;
assert_eq!(active.total_entities, 1);
assert_eq!(active.candidates[0].relation, "asserted_official");
assert!(active.candidates[0].eligible);
assert_eq!(
active.candidates[0].provenance[0]["source"]["lineage"]["upstream_datasets"][0],
"geonames"
);
assert!(!registry.lookup("Former Institute", 20)?.candidates[0].eligible);
assert_eq!(registry.lookup("EU", 20)?.total_entities, 2);
assert_eq!(
registry
.lookup("Conflicting Organization", 20)?
.candidates
.len(),
2
);
let ror_id = argand_site_registry::model::entity_id(Source::Ror, "https://ror.org/012345678");
let wikidata_id = argand_site_registry::model::entity_id(Source::Wikidata, "Q42");
let equivalence = argand_site_registry::identity::propose(&registry, &ror_id, &wikidata_id)?;
assert_eq!(equivalence.entities.len(), 2);
let audit = root.path().join("audit.jsonl");
argand_site_registry::release::export_audit(&registry, &audit, false)?;
let audit = std::fs::read_to_string(audit)?;
assert!(audit.contains("declared_domain"));
assert!(audit.contains("geonames"));
assert!(audit.contains("wikidata"));
let report = evaluation::run(
&registry,
Cursor::new(
r#"{"id":"ror","query":"Example University","expected_status":"no_active_name_review"}"#,
),
1,
common::timestamp()?,
)?;
let lineage = report
.source_lineage
.iter()
.find(|source| source.source == Source::Ror)
.context("ROR evaluation lineage missing")?;
assert_eq!(
lineage
.lineage
.as_ref()
.context("ROR lineage missing")?
.upstream_datasets,
vec!["geonames".to_owned()]
);
Ok(())
}
#[test]
fn ror_zip_and_schema_drift_fail_before_completion() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("writer.sqlite"))?;
let mut changed = record(
"012345678",
"Changed Schema",
"active",
"https://example.org/",
);
changed
.as_object_mut()
.context("record object")?
.insert("unreviewed_field".into(), json!(true));
for bytes in [
ror_zip(&[changed], None)?,
ror_zip(
&[record(
"012345678",
"Extra Member",
"active",
"https://example.org/",
)],
Some(("unexpected.txt", b"bad")),
)?,
] {
let manifest = common::manifest(Source::Ror, Format::RorZip, &bytes)?;
let input = root.path().join(format!("{}.zip", manifest.sha256));
std::fs::write(&input, bytes)?;
assert!(store::import(&mut db, &manifest, &input).is_err());
let incomplete: bool = db.query_row(
"SELECT EXISTS(SELECT 1 FROM sources WHERE id=?1)",
[manifest.id()?],
|row| row.get(0),
)?;
ensure!(!incomplete, "failed ROR source remained in writer store");
}
Ok(())
}
#[test]
#[allow(clippy::too_many_lines)] // One lifecycle proves package, compact, verify, export, sign, and tamper behavior.
fn compact_generation_keeps_runtime_results_and_authenticates_cold_history() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = common::fixture(root.path())?;
let before = common::build(&db, root.path(), "before-revision")?;
// A later legacy snapshot in the same source scope leaves the earlier source
// available for audit while replacing it in the active projection.
let mut replacement = json!({"entities":{"Q355":common::entity(
"Q355",
"Facebook",
&["FB"],
&["https://facebook.com/"]
)}});
replacement["entities"]["Q355"]["lastrevid"] = json!(2);
let bytes = serde_json::to_vec(&replacement)?;
let mut manifest = common::manifest(Source::Wikidata, Format::WikidataEntities, &bytes)?;
manifest.retrieved_at += chrono::Duration::days(1);
let input = root.path().join("wikidata-replacement.json");
std::fs::write(&input, &bytes)?;
store::import(&mut db, &manifest, &input)?;
let full = common::build(&db, root.path(), "full")?;
assert_eq!(
before.lookup("Facebook", 20)?.candidates[0].fingerprint,
full.lookup("Facebook", 20)?.candidates[0].fingerprint
);
let mut revision_diff = Vec::new();
argand_site_registry::release::diff(&before, &full, &mut revision_diff)?;
let revision_diff = String::from_utf8(revision_diff)?;
assert!(revision_diff.contains("revision"));
assert!(revision_diff.contains("\"subject\":\"fact\""));
let audit_store = root.path().join("audit-store");
let compact_path = root.path().join("compact");
let compact_receipt = build::build_compact_with_policy_and_trust(
&db,
&compact_path,
&ReviewPolicy::legacy_compatible(),
None,
&audit_store,
)?;
let compact = Registry::open(
&compact_path,
&argand_site_registry::file_digest(&compact_path.join("COMPLETE.json"))?,
)?;
assert_eq!(compact_receipt.runtime_layout, "compact-v1");
assert_eq!(
serde_json::to_value(full.lookup("Facebook", 20)?.candidates)?,
serde_json::to_value(compact.lookup("Facebook", 20)?.candidates)?
);
assert!(
std::fs::metadata(compact_path.join("registry.sqlite"))?.len()
< std::fs::metadata(root.path().join("full/registry.sqlite"))?.len()
);
let verified = argand_site_registry::audit::verify(&compact, &audit_store)?;
assert_eq!(
usize::try_from(verified.bundles)?,
compact_receipt.audit_bundles.len()
);
assert!(
compact_receipt
.audit_bundles
.iter()
.any(|item| !item.selected)
);
let checkpoint_path = root.path().join("retention.json");
let checkpoint = argand_site_registry::audit::checkpoint(
&compact,
&audit_store,
common::timestamp()?,
&checkpoint_path,
)?;
assert!(!checkpoint.deletion_authorized);
assert_eq!(checkpoint.retain, compact_receipt.audit_bundles);
let key = root.path().join("retention-key");
ensure!(
Command::new("ssh-keygen")
.args(["-q", "-t", "ed25519", "-N", "", "-f"])
.arg(&key)
.status()?
.success(),
"generate retention test key"
);
let allowed = root.path().join("retention-allowed-signers");
std::fs::write(
&allowed,
format!(
"retention {}",
std::fs::read_to_string(key.with_extension("pub"))?
),
)?;
let signature = root.path().join("retention.json.sig");
argand_site_registry::audit::sign_retention(&checkpoint_path, &signature, &key)?;
assert_eq!(
argand_site_registry::audit::verify_retention(
&checkpoint_path,
&signature,
&allowed,
"retention"
)?,
checkpoint
);
let export = root.path().join("cold-history.jsonl");
argand_site_registry::audit::export(&compact, &audit_store, &export, false)?;
let export = std::fs::read_to_string(export)?;
assert!(export.contains("superseded"));
assert!(export.contains("\"revision\":2"));
assert!(
argand_site_registry::release::export_audit(
&compact,
&root.path().join("must-not-exist.jsonl"),
false
)
.is_err()
);
// A second build reuses the exact immutable bundle objects.
let second_path = root.path().join("compact-second");
let second = build::build_compact_with_policy_and_trust(
&db,
&second_path,
&ReviewPolicy::legacy_compatible(),
None,
&audit_store,
)?;
assert_eq!(compact_receipt.audit_bundles, second.audit_bundles);
let receipt_path = compact_path.join("COMPLETE.json");
let original_receipt = std::fs::read(&receipt_path)?;
let mut omitted: build::Receipt = serde_json::from_slice(&original_receipt)?;
omitted
.audit_bundles
.retain(|reference| !reference.selected);
std::fs::write(&receipt_path, serde_json::to_vec_pretty(&omitted)?)?;
let omitted_pin = argand_site_registry::file_digest(&receipt_path)?;
assert!(Registry::open(&compact_path, &omitted_pin).is_err());
std::fs::write(&receipt_path, original_receipt)?;
let reference = &compact_receipt.audit_bundles[0];
let object = argand_site_registry::audit::path(&audit_store, reference);
let original = std::fs::read(&object)?;
std::fs::write(&object, &original[..original.len() - 1])?;
assert!(argand_site_registry::audit::verify(&compact, &audit_store).is_err());
std::fs::write(&object, &original)?;
assert!(argand_site_registry::audit::verify(&compact, &audit_store).is_ok());
std::fs::remove_file(&object)?;
assert!(argand_site_registry::audit::verify(&compact, &audit_store).is_err());
Ok(())
}
#[test]
fn wikidata_dump_streams_multiline_entities_with_an_authenticated_large_record_bound()
-> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("writer.sqlite"))?;
let mut entity = common::entity("Q355", "Facebook", &["FB"], &["https://facebook.com/"]);
entity["unconsumed_large_field"] = Value::String("x".repeat(17 * 1024 * 1024));
let bytes = serde_json::to_vec_pretty(&vec![entity])?;
let input = root.path().join("wikidata-large.json");
std::fs::write(&input, &bytes)?;
let mut legacy = common::manifest(Source::Wikidata, Format::WikidataDump, &bytes)?;
assert!(store::import(&mut db, &legacy, &input).is_err());
legacy.schema = "argand.site-source/v3".into();
legacy.maximum_record_bytes = Some(32 * 1024 * 1024);
legacy.integrity = vec![IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: legacy.sha256.clone(),
evidence_url: None,
}];
legacy.lineage = Some(SourceLineage::direct(Source::Wikidata));
let source_id = store::import(&mut db, &legacy, &input)?;
let records: i64 = db.query_row(
"SELECT count(*) FROM records WHERE source_id=?1",
[source_id],
|row| row.get(0),
)?;
assert_eq!(records, 1);
Ok(())
}
#[test]
fn wikidata_json_delta_turns_removed_websites_into_auditable_tombstones() -> anyhow::Result<()> {
let root = tempfile::tempdir()?;
let mut db = store::open(&root.path().join("writer.sqlite"))?;
common::import(
&mut db,
root.path(),
Source::Psl,
Format::PslText,
common::PSL.as_bytes(),
)?;
let base_bytes = serde_json::to_vec(&vec![common::entity(
"Q355",
"Facebook",
&[],
&["https://facebook.com/"],
)])?;
let mut base = common::manifest(Source::Wikidata, Format::WikidataDump, &base_bytes)?;
base.schema = "argand.site-source/v3".into();
base.coverage = Some(SourceCoverage {
collection: "wikidata-entities".into(),
kind: CoverageKind::Full,
partition: None,
base: None,
sequence: None,
supersedes: Vec::new(),
});
base.integrity = vec![IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: base.sha256.clone(),
evidence_url: None,
}];
base.lineage = Some(SourceLineage::direct(Source::Wikidata));
let base_input = root.path().join("wikidata-base.json");
std::fs::write(&base_input, &base_bytes)?;
let base_id = store::import(&mut db, &base, &base_input)?;
let removed =
json!({"id":"Q355","type":"item","lastrevid":2,"labels":{},"aliases":{},"claims":{}});
let delta_bytes = serde_json::to_vec(&vec![removed])?;
let mut delta = common::manifest(Source::Wikidata, Format::WikidataDump, &delta_bytes)?;
delta.schema = "argand.site-source/v3".into();
delta.retrieved_at += chrono::Duration::days(1);
delta.coverage = Some(SourceCoverage {
collection: "wikidata-entities".into(),
kind: CoverageKind::Delta,
partition: None,
base: Some(base_id.clone()),
sequence: Some(1),
supersedes: vec![base_id],
});
delta.integrity = vec![IntegrityProof {
method: "content_digest".into(),
algorithm: "sha256".into(),
value: delta.sha256.clone(),
evidence_url: None,
}];
delta.lineage = Some(SourceLineage::direct(Source::Wikidata));
let delta_input = root.path().join("wikidata-delta.json");
std::fs::write(&delta_input, &delta_bytes)?;
store::import(&mut db, &delta, &delta_input)?;
let generation = root.path().join("compact-delta");
let audit_store = root.path().join("delta-audit");
build::build_compact_with_policy_and_trust(
&db,
&generation,
&ReviewPolicy::legacy_compatible(),
None,
&audit_store,
)?;
let registry = Registry::open(
&generation,
&argand_site_registry::file_digest(&generation.join("COMPLETE.json"))?,
)?;
assert_eq!(registry.lookup("Facebook", 20)?.total_entities, 0);
let export = root.path().join("delta-audit.jsonl");
argand_site_registry::audit::export(&registry, &audit_store, &export, false)?;
let export = std::fs::read_to_string(export)?;
assert!(export.contains("tombstone"));
let replaced = &delta
.coverage
.as_ref()
.context("delta coverage")?
.supersedes[0];
assert!(export.contains(replaced));
Ok(())
}
#[test]
fn killed_import_resumes_from_a_committed_checkpoint() -> anyhow::Result<()> {
const RECORDS: usize = 30_000;
let root = tempfile::tempdir()?;
let database = root.path().join("writer.sqlite");
drop(store::open(&database)?);
let mut dump = String::from("[");
for index in 0..RECORDS {
if index > 0 {
dump.push(',');
}
dump.push_str(&serde_json::to_string(&common::entity(
&format!("Q{}", 10_000_000 + index),
&format!("Checkpoint {index}"),
&[],
&["https://example.org/"],
))?);
}
dump.push(']');
let bytes = common::gzip(dump.as_bytes())?;
let mut manifest = common::manifest(Source::Wikidata, Format::WikidataDump, &bytes)?;
manifest.compression = argand_site_registry::model::Compression::Gzip;
let source_id = manifest.id()?;
let input = root.path().join("large.json.gz");
let declaration = root.path().join("source.json");
std::fs::write(&input, bytes)?;
std::fs::write(&declaration, serde_json::to_vec_pretty(&manifest)?)?;
for target in [256_i64, 2_048] {
let mut child = Command::new(env!("CARGO_BIN_EXE_argand-site-registry"))
.args(["import", "--database"])
.arg(&database)
.arg("--input")
.arg(&input)
.arg("--manifest")
.arg(&declaration)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()?;
let deadline = Instant::now() + Duration::from_secs(30);
let checkpoint = loop {
if child.try_wait()?.is_some() {
anyhow::bail!("fixture import completed before checkpoint interruption");
}
let connection = rusqlite::Connection::open(&database)?;
let observed: i64 = connection.query_row(
"SELECT COALESCE(max(checkpoint),0) FROM sources WHERE id=?1",
[&source_id],
|row| row.get(0),
)?;
if observed >= target {
child.kill()?;
child.wait()?;
break observed;
}
ensure!(Instant::now() < deadline, "import checkpoint timed out");
thread::sleep(Duration::from_millis(2));
};
assert!(checkpoint >= target);
}
let connection = rusqlite::Connection::open(&database)?;
let partial: (i64, bool) = connection.query_row(
"SELECT checkpoint,complete FROM sources WHERE id=?1",
[&source_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert!(partial.0 >= 256 && !partial.1);
drop(connection);
let status = Command::new(env!("CARGO_BIN_EXE_argand-site-registry"))
.args(["import", "--database"])
.arg(&database)
.arg("--input")
.arg(&input)
.arg("--manifest")
.arg(&declaration)
.stdout(Stdio::null())
.status()?;
ensure!(status.success(), "checkpoint replay failed");
let connection = store::open(&database)?;
let completed: (i64, bool) = connection.query_row(
"SELECT checkpoint,complete FROM sources WHERE id=?1",
[&source_id],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(completed, (i64::try_from(RECORDS)?, true));
Ok(())
}