Every public function returns the same envelope. Diagnostics carry stable machine-readable codes.
{
"status": "ok" | "error",
"data": <value | null>,
"diagnostics": [
{ "code": "STABLE_CODE", "severity": "info|warning|error",
"message": "...", "entity_ref": "collection/uuid" }
]
}Envelope::data is a serde_json::Value, not an Option<Value>. On error
it holds Value::Null, which serializes to JSON null. Access it directly -
.expect() and .unwrap() do not exist on Value and will not compile:
// WRONG - Value has no expect() / unwrap()
let flat = env.data.expect("failed").to_string();
// RIGHT
let flat = env.data.to_string();
// Detecting failure
if env.data.is_null() {
for d in &env.diagnostics {
eprintln!("{:?}: {}", d.code, d.message);
}
}Indexing a Value with [] is infallible and yields Value::Null for missing
keys, so a chain like env.data["bundle"] never panics. Converting to a
concrete type is where fallibility appears, and there Option methods are
correct:
let d = env.data;
let id = d["id"].as_str().expect("id missing"); // as_str() -> Option<&str>
let n = d["stats"]["persons"].as_u64().unwrap_or(0);Validation is non-blocking: an envelope with status: "ok" may still
carry warning - or even error-severity - diagnostics. The status reflects
whether the operation was refused, not whether the bundle is problem-free.
Bundles cross the boundary as flat JSON:
{
"manifest": { "axgf": "1.0", "created_at": "...", "stats": {...}, ... },
"persons": { "<uuid>": { ... }, ... },
"families": { ... },
"events": { ... },
"links": { ... },
"occupations": { ... },
"sources": { ... },
"places": { ... },
"documents": { ... },
"attachments": { "documents/files/{uuid}.pdf": "<base64>", ... } // optional
}The eight collections are always present in serialized output (empty {}
when nothing has landed yet). Unknown top-level fields survive a round-trip
untouched - forward-compatibility is a hard requirement.
- Lifecycle -
create_bundle,import_bundle,export_bundle,inspect - Validation -
validate - CRUD -
add_entity,update_entity,delete_entity - Cleanup -
deduplicate - Conversion -
convert_gedcom - Worked scenarios - multi-call recipes
- Diagnostic codes
Return a fresh empty bundle whose manifest declares axgf: "1.0". If
family_name is provided, populates manifest.family.name.
data: the flat bundle JSON.
Reproduces: a user clicks "New family tree" in a client application. The library hands back an empty but structurally complete bundle, ready to receive entities.
use axgf_rs::create_bundle;
let env = create_bundle(Some("Pierre-Leonard Family"));
println!("{}", env.to_json());{
"status": "ok",
"data": {
"manifest": {
"axgf": "1.0",
"created_at": "2026-08-01T21:02:20Z",
"updated_at": "2026-08-01T21:02:20Z",
"stats": { "persons": 0, "families": 0, "events": 0, "links": 0,
"occupations": 0, "sources": 0, "places": 0, "documents": 0 },
"family": { "name": "Pierre-Leonard Family" }
},
"persons": {}, "families": {}, "events": {}, "links": {},
"occupations": {}, "sources": {}, "places": {}, "documents": {}
},
"diagnostics": []
}Reproduces: a conversion pipeline that builds a bundle in memory before it
knows what the family should be called. manifest.family is simply absent.
let flat = create_bundle(None).data.to_string();
// manifest.family is omitted entirely - not null, not empty stringUnpack a .axgf ZIP archive into flat JSON. The manifest's axgf field is
checked against SUPPORTED_SPEC_VERSIONS; a mismatch yields
UNSUPPORTED_SPEC_VERSION. Files under documents/files/** and any other
non-entity paths (e.g. vault/**) land in attachments as base64.
Diagnostics: ZIP_READ_ERROR, INVALID_JSON, INVALID_BUNDLE_STRUCTURE,
UNSUPPORTED_SPEC_VERSION.
data on success: the flat bundle JSON.
Reproduces: a desktop client opening a .axgf a user double-clicked. Note
the library never touches the filesystem - the caller reads the bytes.
use axgf_rs::import_bundle;
let bytes = std::fs::read("family.axgf")?; // caller's responsibility
let env = import_bundle(&bytes);
if env.data.is_null() {
return Err("import failed - see diagnostics");
}
let flat = env.data.to_string();Reproduces: importing an archive that embeds binary documents. The entity
metadata lands in documents, the actual bytes in attachments, keyed by
their original in-archive path.
{
"status": "ok",
"data": {
"manifest": { "axgf": "1.0", "stats": { "documents": 2, ... } },
"documents": {
"d1f0...": {
"type": "document",
"filename": "birth-cert-1923.pdf",
"mime_type": "application/pdf",
"document_type": "birth_certificate",
"status": "present",
"file": { "path": "documents/files/d1f0....pdf",
"size_bytes": 184320, "sha256": "a3f8..." }
}
},
"attachments": {
"documents/files/d1f0....pdf": "JVBERi0xLjQKJc...",
"vault/wiki/persons/550e....md": "IyBKZWFuIFBpZXJyZS1MZW9uYXJk..."
}
},
"diagnostics": []
}Reproduces: a V1 build receiving an AXGF 2.0 archive. The library refuses rather than silently mangling data it does not understand. This gating is why a stale client can never corrupt a newer file.
let env = import_bundle(&bytes_from_axgf_2_0);
assert!(env.data.is_null());{
"status": "error",
"data": null,
"diagnostics": [
{ "code": "UNSUPPORTED_SPEC_VERSION", "severity": "error",
"message": "bundle declares axgf 2.0; this build supports 1.0",
"entity_ref": "manifest" }
]
}Reproduces: a user renaming a JPEG to .axgf, or a truncated download.
let env = import_bundle(b"not a zip at all");
// status: error, code: ZIP_READ_ERROR, data: nullRebuild a .axgf ZIP archive from a flat bundle. Stats are recomputed and
updated_at is refreshed. The canonical schema is embedded at
schema/axgf-1.0.schema.json. Attachments are written back at their original
paths.
data on success: { "zip_base64": "...", "size_bytes": u }.
Reproduces: the "Save" button. The library produces bytes; the caller decides where they go - a file, an S3 bucket, a database blob, a download response.
use axgf_rs::export_bundle;
use base64::Engine as _;
let d = export_bundle(&flat_json).data;
let b64 = d["zip_base64"].as_str().ok_or("export failed")?;
let zip = base64::engine::general_purpose::STANDARD.decode(b64)?;
std::fs::write("family.axgf", zip)?; // caller's responsibility{
"status": "ok",
"data": { "zip_base64": "UEsDBBQAAAAIA...", "size_bytes": 48216 },
"diagnostics": []
}Reproduces: a client that hand-edited the flat JSON and left stats stale.
Export recomputes them from the actual entities, so the written archive is
always internally consistent.
// flat_json.manifest.stats.persons says 3, but persons has 5 keys
let env = export_bundle(&flat_json);
// the produced ZIP's manifest.json reports persons: 5Reproduces: a SaaS endpoint returning the archive to a browser. The base64 crosses the library boundary; the HTTP layer decodes once.
let d = export_bundle(&flat_json).data;
// HTTP 200, Content-Type: application/vnd.axgf+zip
// Content-Length: d["size_bytes"]
// body: base64-decoded d["zip_base64"]Read-only. Returns the manifest as-was plus a freshly computed stats block - useful for reconciling a bundle whose manifest has drifted out of sync with its entities.
data on success: { "manifest": {...}, "stats": {...} }.
Reproduces: a client rendering "767 persons, 295 families" without deserializing every entity. Cheap enough to call on every page load.
use axgf_rs::inspect;
let d = inspect(&flat_json).data;
println!("{} persons in {}",
d["stats"]["persons"],
d["manifest"]["family"]["name"]);Reproduces: an integrity check. If the manifest's own stats disagree with the recomputed ones, something wrote entities without updating the header - worth surfacing to the operator.
let d = inspect(&flat_json).data;
if d["manifest"]["stats"] != d["stats"] {
eprintln!("manifest drift: declared {}, actual {}",
d["manifest"]["stats"], d["stats"]);
}Runs structural (JSON Schema) and semantic checks. Always returns
Status::Ok on parseable input - the report is in diagnostics.
Semantic layers:
| Code | Severity | Meaning |
|---|---|---|
SCHEMA_VALIDATION_FAILED |
Warning | Entity or manifest violates the embedded JSON Schema. |
DANGLING_REFERENCE |
Warning | An _id field points to an entity not present in the bundle. |
CYCLE_DETECTED |
Error | A parent/child cycle exists in the derived DAG. |
CHRONOLOGY_CONFLICT |
Warning | Child's birth year precedes parent's. |
DUPLICATE_UNIQUE_REF |
Warning | Two families share the same union.persons set. |
data on success: { "errors": u, "warnings": u, "infos": u, "total": u }.
Reproduces: the happy path. Zero diagnostics means the bundle is structurally sound and semantically coherent.
{
"status": "ok",
"data": { "errors": 0, "warnings": 0, "infos": 0, "total": 0 },
"diagnostics": []
}Reproduces: real genealogical work in progress. A source was referenced before it was entered, and a transcription error put a child before its parent. Neither blocks the operation - the bundle is usable, the operator sees a punch list.
{
"status": "ok",
"data": { "errors": 0, "warnings": 3, "infos": 0, "total": 3 },
"diagnostics": [
{ "code": "DANGLING_REFERENCE", "severity": "warning",
"message": "persons/550e... birth.source_id references missing sources/src-9f2a...",
"entity_ref": "persons/550e8400-e29b-41d4-a716-446655440001" },
{ "code": "CHRONOLOGY_CONFLICT", "severity": "warning",
"message": "child born 1918 precedes parent born 1923",
"entity_ref": "persons/7c1d..." },
{ "code": "DUPLICATE_UNIQUE_REF", "severity": "warning",
"message": "families/f1a2... and families/f9b3... share the same spouse set",
"entity_ref": "families/f9b3..." }
]
}Reproduces: a bad import or a buggy client that made someone their own
ancestor. CYCLE_DETECTED is error-severity because no downstream consumer
can safely walk this graph.
{
"status": "ok",
"data": { "errors": 1, "warnings": 0, "infos": 0, "total": 1 },
"diagnostics": [
{ "code": "CYCLE_DETECTED", "severity": "error",
"message": "parent/child cycle: 550e... -> 7c1d... -> 550e...",
"entity_ref": "persons/550e8400-e29b-41d4-a716-446655440001" }
]
}Reproduces: a client policy that refuses to write a file containing error-severity findings, while tolerating warnings.
use axgf_rs::{validate, export_bundle};
let errors = validate(&flat_json).data["errors"].as_u64().unwrap_or(0);
if errors > 0 {
return Err("refusing to save a bundle with structural errors");
}
let zip = export_bundle(&flat_json).data;- Generates a UUID v4 when the caller omits
id. - Fills in
typeandaxgf_versionif missing. - Structurally validates the entity - schema failures surface as warnings but the add still succeeds.
- Refuses duplicates with
ENTITY_ALREADY_EXISTS.
kind is EntityKind: one of Person, Family,
Event, Link, Occupation, Source, Place, Document.
data on success: { "id": "<uuid>", "bundle": <flat> }.
Reproduces: a user filling a "New person" form. The client sends no id;
the library returns the one it minted so the client can select the new row.
use axgf_rs::{add_entity, EntityKind};
use serde_json::json;
let person = json!({
"identity": {
"name": { "display": "Jean Pierre-Leonard", "components": [
{ "type": "given_name", "value": "Jean", "order": 1 },
{ "type": "family_name", "value": "Pierre-Leonard", "order": 2 }
]},
"gender": { "value": "M" },
"is_living": false,
"visibility": "members"
},
"birth": { "date": { "value": "1923-04-12", "calendar": "gregorian",
"precision": "exact", "circa": false, "confidence": 0.98 } }
});
let d = add_entity(&flat, EntityKind::Person, &person.to_string()).data;
let new_id = d["id"].as_str().expect("id missing").to_string();
let flat = d["bundle"].to_string(); // carry forwardReproduces: an importer that must preserve stable identifiers from a source system so re-running the import is idempotent.
let person = json!({
"id": "550e8400-e29b-41d4-a716-446655440001",
"identity": { "name": { "display": "Elise Bernard", "components": [] },
"gender": { "value": "F" },
"is_living": false, "visibility": "members" }
});
let env = add_entity(&flat, EntityKind::Person, &person.to_string());
// re-running the same call yields ENTITY_ALREADY_EXISTS and data: nullReproduces: recording a marriage. The family references persons by id; it
does not embed them. The two spouses must already exist or validation will
later report DANGLING_REFERENCE.
let family = json!({
"name": "Family Jean x Elise",
"union": {
"type": "marriage",
"status": "ended_by_death",
"persons": [
{ "person_id": jean_id, "role": "spouse" },
{ "person_id": elise_id, "role": "spouse" }
],
"start": { "date": { "value": "1948-06-15", "precision": "exact" } },
"confidence": 0.99
},
"children": []
});
let d = add_entity(&flat, EntityKind::Family, &family.to_string()).data;
let flat = d["bundle"].to_string();Reproduces: the same marriage as a first-class historical fact. The event references both spouses, a witness, and the family it created - the case that motivates Events being independent rather than owned by a person.
let event = json!({
"category": "marriage",
"subcategory": "civil",
"date": { "value": "1948-06-15", "precision": "exact", "confidence": 0.99 },
"place_id": paris_14_id,
"participants": [
{ "entity_type": "person", "entity_id": jean_id, "role": "spouse_1", "confidence": 0.99 },
{ "entity_type": "person", "entity_id": elise_id, "role": "spouse_2", "confidence": 0.99 },
{ "entity_type": "person", "entity_id": andre_id, "role": "witness", "confidence": 0.90 },
{ "entity_type": "family", "entity_id": family_id, "role": "created", "confidence": 0.99 }
],
"description": "Civil marriage, Paris 14th"
});
let flat = add_entity(&flat, EntityKind::Event, &event.to_string())
.data["bundle"].to_string();Reproduces: recording that Jean was Jules' godfather - a bond GEDCOM cannot express. The link is dated, sourced and carries its own confidence, independent of both persons.
let link = json!({
"from": { "entity_type": "person", "entity_id": jean_id },
"to": { "entity_type": "person", "entity_id": jules_id },
"label": "godfather",
"label_reverse": "godchild",
"category": "spiritual",
"bidirectional": false,
"valid_from": { "date": { "value": "1950-03-15", "precision": "exact" } },
"confidence": 0.85,
"source_id": letter_source_id
});
let flat = add_entity(&flat, EntityKind::Link, &link.to_string())
.data["bundle"].to_string();Reproduces: "schoolteacher from 1948 to 1978". Modelled as an Occupation because it is a span, not a point in time - which is why statistics like "most common trade per generation" are computable.
let occ = json!({
"person_id": jean_id,
"title": "Schoolteacher",
"title_latin": "Primary school teacher",
"employer": { "name": "Public school of Saint-Denis", "place_id": st_denis_id },
"valid_from": { "date": { "value": "1948", "precision": "year" } },
"valid_until": { "date": { "value": "1978", "precision": "year" } },
"confidence": 0.90,
"source_id": municipal_archive_id
});
let flat = add_entity(&flat, EntityKind::Occupation, &occ.to_string())
.data["bundle"].to_string();Reproduces: entering Saint-Denis once and referencing it from births,
deaths, marriages and occupations. Note country_history - the same
coordinates can belong to different states over time.
let place = json!({
"names": [ { "lang": "fr", "value": "Saint-Denis", "is_primary": true },
{ "lang": "en", "value": "Saint-Denis, Reunion" } ],
"place_type": "city",
"country_current": "FR",
"coordinates": { "lat": -20.8823, "lon": 55.4504, "precision": "city_center" },
"country_history": [ { "country": "FR", "from": null, "until": null } ],
"identifiers": { "wikidata": "Q47045" }
});
let d = add_entity(&flat, EntityKind::Place, &place.to_string()).data;
let place_id = d["id"].as_str().expect("id missing").to_string();
let flat = d["bundle"].to_string();Reproduces: the two-step every serious researcher performs - register the evidence, then attach the scan. Source carries the claim about reliability; Document carries the bytes.
let source = json!({
"title": "Birth certificate no.47 - Jean Pierre-Leonard 1923",
"source_type": "birth_certificate",
"reliability": "primary",
"confidence": 0.98,
"status": "verified",
"repository": { "name": "Departmental Archives of Reunion",
"reference": "5MI/47/1923/0047" }
});
let d = add_entity(&flat, EntityKind::Source, &source.to_string()).data;
let src_id = d["id"].as_str().expect("id missing").to_string();
let flat = d["bundle"].to_string();
let document = json!({
"filename": "birth-cert-1923.pdf",
"mime_type": "application/pdf",
"document_type": "birth_certificate",
"status": "present",
"linked_to": [
{ "entity_type": "person", "entity_id": jean_id, "role": "subject" },
{ "entity_type": "source", "entity_id": src_id, "role": "evidence" }
]
});
let flat = add_entity(&flat, EntityKind::Document, &document.to_string())
.data["bundle"].to_string();Reproduces: a partial record captured in the field. The add succeeds so the user does not lose their input; the warning tells them what to complete later. This is the non-blocking philosophy applied to writes.
{
"status": "ok",
"data": { "id": "9a2f...", "bundle": { "...": "..." } },
"diagnostics": [
{ "code": "SCHEMA_VALIDATION_FAILED", "severity": "warning",
"message": "identity.gender is required",
"entity_ref": "persons/9a2f..." }
]
}Replaces an existing entity in full. entity.id is required; a missing entity
yields ENTITY_NOT_FOUND. Callers send the whole entity, not a patch.
data on success: { "id": "<uuid>", "bundle": <flat> }.
Reproduces: editing one field in a UI. Because update is a full replace, the client must fetch the current entity, mutate it, and send it back whole - which also means an update can never silently drop fields the client did not know about.
use axgf_rs::{update_entity, EntityKind};
let bundle: serde_json::Value = serde_json::from_str(&flat)?;
let mut jean = bundle["persons"][jean_id].clone();
jean["id"] = json!(jean_id); // id is mandatory on update
jean["bio"] = json!("Schoolteacher. Order of Merit, 1972.");
let flat = update_entity(&flat, EntityKind::Person, &jean.to_string())
.data["bundle"].to_string();Reproduces: research progress. The birth was recorded as "circa 1923" from family memory; the civil register now gives an exact date, so precision and confidence both rise.
jean["birth"]["date"] = json!({
"value": "1923-04-12", "calendar": "gregorian",
"precision": "exact", "circa": false, "confidence": 0.98
});
jean["birth"]["source_id"] = json!(birth_cert_source_id);
let flat = update_entity(&flat, EntityKind::Person, &jean.to_string())
.data["bundle"].to_string();Reproduces: a birth. The family entity owns the children list, so the update targets the family, not the child.
let mut family = bundle["families"][family_id].clone();
family["id"] = json!(family_id);
family["children"].as_array_mut().expect("children must be an array").push(json!({
"person_id": robert_id, "birth_order": 1, "confidence": 0.99
}));
let flat = update_entity(&flat, EntityKind::Family, &family.to_string())
.data["bundle"].to_string();Reproduces: a stale client sending an edit for a row someone else already deleted.
{
"status": "error",
"data": null,
"diagnostics": [
{ "code": "ENTITY_NOT_FOUND", "severity": "error",
"message": "no person with id 00000000-0000-4000-8000-000000000000",
"entity_ref": "persons/00000000-0000-4000-8000-000000000000" }
]
}Reproduces: an imported branch cut off at the top, four siblings whose
parents are unknown. Spec §4.2.3 permits a family with children and no
union. A researcher later discovers the parents and needs to add a union
block without losing the children or their ids.
Why this demo exists: update_entity is a full replace, not a patch.
The caller MUST read the existing family, add the new union block, and
send the merged object back. Constructing a fresh family with only the
newly-discovered union would silently drop the four children — a failure
mode that would corrupt the archive quietly, with no error. This is why
the read-modify-write pattern from Demo A is non-negotiable when growing
an entity: fetch first, mutate, then write.*
use axgf_rs::{update_entity, EntityKind};
use serde_json::json;
// The sibling group was recorded as a union-less family earlier.
let bundle: serde_json::Value = serde_json::from_str(&flat)?;
// STEP 1 — read the existing family. Do NOT rebuild it from scratch.
let mut family = bundle["families"][family_id].clone();
// STEP 2 — add the newly discovered union. Children stay exactly as
// they were: same ids, same birth_order, same array length.
family["union"] = json!({
"type": "marriage",
"persons": [
{ "person_id": father_id, "role": "spouse" },
{ "person_id": mother_id, "role": "spouse" },
]
});
// STEP 3 — write the merged object back.
let flat = update_entity(&flat, EntityKind::Family, &family.to_string())
.data["bundle"].to_string();Anti-pattern — do not do this. The children silently vanish because update is a full replace and the fresh object never carried them:
// WRONG: constructs a fresh family object with only the union.
// The four children are gone from the archive after this call.
let fresh = json!({
"id": family_id, "type": "family", "axgf_version": "1.0",
"union": { "type": "marriage", "persons": [ /* ... */ ] }
});
update_entity(&flat, EntityKind::Family, &fresh.to_string());Covered by tests/crud.rs::family_gains_union_later_without_losing_children.
policy is DeletePolicy:
Reject- scan every other entity for a reference toid; if any exists, refuse withDELETE_BLOCKED_BY_REFERENCE(bundle unchanged).Cascade- remove the entity; physically remove references (array items dropped, scalar_idfields removed from their object).Orphan- remove the entity; preserve the shape of referring containers (scalar_idfields set tonull, array items kept with_idinside them nulled).
V1 does not recursively delete referring entities - callers can chain deletes if they want a deeper cascade.
data on success: { "id": "<uuid>", "bundle": <flat> }.
Reproduces: the default "Delete" button in a careful client. The person is a spouse in a family, so the deletion is refused and the user is told why before anything is lost.
use axgf_rs::{delete_entity, EntityKind, DeletePolicy};
let env = delete_entity(&flat, EntityKind::Person, jean_id, DeletePolicy::Reject);
if env.data.is_null() {
// bundle untouched; show env.diagnostics to the user
}{
"status": "error",
"data": null,
"diagnostics": [
{ "code": "DELETE_BLOCKED_BY_REFERENCE", "severity": "error",
"message": "persons/f293... is referenced by families/a71c....union.persons",
"entity_ref": "families/a71c..." }
]
}Reproduces: removing a person entered by mistake before any relationship was built. Nothing points at them, so Reject has nothing to object to.
let flat = delete_entity(&flat, EntityKind::Person, orphan_id, DeletePolicy::Reject)
.data["bundle"].to_string();Reproduces: a GDPR erasure request, or undoing a bad merge. The person
disappears and every reference to them is physically removed - the family's
union.persons array shrinks, an event's participant entry is dropped.
let flat = delete_entity(&flat, EntityKind::Person, jean_id, DeletePolicy::Cascade)
.data["bundle"].to_string();Before and after, on the referring family:
// before
"union": { "persons": [ { "person_id": "f293...", "role": "spouse" },
{ "person_id": "8b11...", "role": "spouse" } ] }
// after Cascade
"union": { "persons": [ { "person_id": "8b11...", "role": "spouse" } ] }Reproduces: "we know this child had a father, but the person record we created for him was wrong". Orphan preserves the structure - the slot stays, its occupant becomes unknown - which is often historically truer than pretending the relationship never existed.
let flat = delete_entity(&flat, EntityKind::Person, father_id, DeletePolicy::Orphan)
.data["bundle"].to_string();// before
"union": { "persons": [ { "person_id": "f293...", "role": "spouse" },
{ "person_id": "8b11...", "role": "spouse" } ] }
// after Orphan - array length preserved, identity nulled
"union": { "persons": [ { "person_id": null, "role": "spouse" },
{ "person_id": "8b11...", "role": "spouse" } ] }And on a scalar reference:
// before // after Orphan
"birth": { "place_id": "p4d2..." } "birth": { "place_id": null }
// (under Cascade the place_id key would be removed entirely)Reproduces: merging two duplicate place records. Cascade strips the dead place from every birth, death and event that referenced it; the caller then points them at the surviving place with a series of updates.
let flat = delete_entity(&flat, EntityKind::Place, dup_place_id, DeletePolicy::Cascade)
.data["bundle"].to_string();
// every "place_id": "<dup>" key is now gone from persons, events, occupationsTwo passes, in order:
- Person merge. Groups persons by
(normalized_display_name, birth_year, death_year). Never merges father/son homonyms or same-name siblings/cousins - flags them withMANUAL_REVIEW_REQUIREDinstead. - Family merge. Groups families by their sorted spouse set. Never
merges families disagreeing on
union.typeor start-date year (>1 year apart).
When merged, the lowest-UUID member wins; references across the bundle are rewritten from victims to keeper.
data on success: { "bundle": <flat>, "merged_persons": u, "merged_families": u, "manual_review": u }.
Reproduces: the classic import artifact - two branches of a tree were merged and the shared ancestors got duplicated. Both persons and the resulting duplicate family collapse in one call.
use axgf_rs::deduplicate;
let d = deduplicate(&flat).data;
let flat = d["bundle"].to_string();
println!("merged {} persons, {} families", d["merged_persons"], d["merged_families"]);{
"status": "ok",
"data": {
"bundle": { "...": "..." },
"merged_persons": 2,
"merged_families": 1,
"manual_review": 0
},
"diagnostics": []
}Reproduces: the trap that makes naive deduplication destructive. Two men named Henryk Frick exist - one is the spouse in family F, the other is a child of that same family. They are father and son. The library detects the generational relationship and refuses.
{
"status": "ok",
"data": { "bundle": { "...": "..." },
"merged_persons": 0, "merged_families": 0, "manual_review": 2 },
"diagnostics": [
{ "code": "MANUAL_REVIEW_REQUIRED", "severity": "info",
"message": "persons/a11c... and persons/b22d... share a name but one is a child of a family the other is spouse in (father/son homonym)",
"entity_ref": "persons/b22d..." },
{ "code": "MANUAL_REVIEW_REQUIRED", "severity": "info",
"message": "persons/c33e... and persons/d44f... share a name but have different parents (cousins)",
"entity_ref": "persons/d44f..." }
]
}Reproduces: two records for the same couple, one saying "marriage 1948", the other "cohabitation 1955". The library will not decide which is right - it flags them and leaves both intact.
{
"status": "ok",
"data": { "bundle": { "...": "..." },
"merged_persons": 0, "merged_families": 0, "manual_review": 1 },
"diagnostics": [
{ "code": "MANUAL_REVIEW_REQUIRED", "severity": "info",
"message": "families/f1a2... and families/f9b3... share a spouse set but union.type differs (marriage vs cohabitation)",
"entity_ref": "families/f9b3..." }
]
}Reproduces: the recommended pipeline after converting a legacy file - convert, deduplicate, validate, then export.
let bundle = convert_gedcom(&bytes, 0.8, "pl").data["bundle"].to_string();
let clean = deduplicate(&bundle).data;
println!("merged {} persons, {} families, {} need review",
clean["merged_persons"], clean["merged_families"], clean["manual_review"]);
let flat = clean["bundle"].to_string();
let report = validate(&flat).data;Feature-gated behind gedcom (default-on). Converts a GEDCOM 5.5.1 byte
stream to a flat AXGF bundle. Never fails hard on unknown tags - emits
GEDCOM_UNRECOGNIZED_TAG warnings and drops the offender.
Handles: encoding auto-detect (UTF-8 BOM / UTF-16 / UTF-8 / latin-1),
localised date qualifiers in EN/PL/FR/DE, both webtrees OBJE layouts,
partial dates, NOTE @xref@ resolution, PEDI adopted, and per-file xref
namespace isolation.
data on success: { "bundle": <flat> }.
Reproduces: the entry point for most new users. default_confidence is the
trust level assigned to every imported fact, since GEDCOM has no way to
express uncertainty; 0.8 says "probably right, but not verified by me".
use axgf_rs::convert_gedcom;
let bytes = std::fs::read("export-from-webtrees.ged")?;
let flat = convert_gedcom(&bytes, 0.8, "en").data["bundle"].to_string();Reproduces: the reason the converter is multilingual. Real webtrees exports
localise date qualifiers, and a parser that only knows ABT/BEF/AFT
silently loses them.
Input fragment:
1 BIRT
2 DATE OK 1500
1 DEAT
2 DATE PRZED 1430 R
Output:
"birth": { "date": { "value": "1500", "calendar": "gregorian",
"precision": "year", "circa": true, "confidence": 0.8 } },
"death": { "date": { "calendar": "gregorian", "precision": "unknown",
"circa": false,
"range": { "latest": { "value": "1430", "precision": "year" } } } }OK (approximately) becomes circa: true; PRZED (before) becomes a
range with only an upper bound. place_lang: "pl" tags every imported place
name as Polish.
Reproduces: free text typed into a date field - extremely common in real files. The value is preserved as a note rather than discarded, so no information is lost even though it cannot be parsed.
Input: 2 DATE PO II WOJNIE (after the Second World War)
"death": { "date": { "calendar": "gregorian", "precision": "unknown",
"circa": false, "note": "PO II WOJNIE" } }Reproduces: GEDCOM's OCCU and NOTE tags being promoted. The occupation
leaves the person record and becomes its own dated entity; the note text
follows @xref@ indirection to its target record.
Input:
0 @N1@ NOTE Village school founder.
1 CONT Order of Merit, 1972.
0 @I1@ INDI
1 OCCU Schoolteacher
2 DATE FROM 1948 TO 1978
1 NOTE @N1@
Output: one occupations/{uuid} entity linked to the person, and the
person's notes field carrying both lines of the resolved note.
Reproduces: the OBJE nesting quirk. GEDCOM 5.5.1 puts FORM and TITL
underneath FILE, while older 5.5 files put them at OBJE level. Reading
only one layout produces application/octet-stream for every media file.
0 @X1285@ OBJE
1 FILE photo-1955.jpg
2 FORM jpg
2 TITL Family photo, 1955
"documents": { "e8a1...": {
"filename": "photo-1955.jpg",
"mime_type": "image/jpeg",
"document_type": "photo",
"status": "referenced",
"caption": "Family photo, 1955"
} }status is referenced rather than present because the byte payload is
not alongside the .ged - the metadata is kept so the link can be repaired
later.
Reproduces: end-to-end onboarding of a legacy file into a clean, validated, saved archive.
use base64::Engine as _;
let bytes = std::fs::read("tree.ged")?;
let flat = convert_gedcom(&bytes, 0.8, "pl").data["bundle"].to_string();
let clean = deduplicate(&flat).data["bundle"].to_string();
let report = validate(&clean).data;
println!("validation: {} errors, {} warnings",
report["errors"], report["warnings"]);
let zip = export_bundle(&clean).data;
let b64 = zip["zip_base64"].as_str().ok_or("export failed")?;
std::fs::write("family.axgf",
base64::engine::general_purpose::STANDARD.decode(b64)?)?;Reproduces: a user starting from a blank slate and entering what they know. Note the pattern: every call returns a new bundle that must be carried forward, because the library is stateless.
use axgf_rs::*;
use serde_json::json;
// helper: add an entity and carry the new bundle forward
fn push(flat: &str, kind: EntityKind, e: serde_json::Value) -> (String, String) {
let d = add_entity(flat, kind, &e.to_string()).data;
(d["id"].as_str().expect("id missing").to_string(),
d["bundle"].to_string())
}
// 1. empty archive
let mut flat = create_bundle(Some("Pierre-Leonard Family")).data.to_string();
// 2. a place both generations will reference
let (paris, f) = push(&flat, EntityKind::Place, json!({
"names": [{ "lang": "fr", "value": "Paris", "is_primary": true }],
"place_type": "city", "country_current": "FR",
"coordinates": { "lat": 48.8566, "lon": 2.3522 }
}));
flat = f;
// 3. generation 1
let (jean, f) = push(&flat, EntityKind::Person, json!({
"identity": { "name": { "display": "Jean Pierre-Leonard", "components": [] },
"gender": { "value": "M" }, "is_living": false,
"visibility": "members" },
"birth": { "date": { "value": "1923-04-12", "precision": "exact" },
"place_id": paris }
}));
flat = f;
let (elise, f) = push(&flat, EntityKind::Person, json!({
"identity": { "name": { "display": "Elise Bernard", "components": [] },
"gender": { "value": "F" }, "is_living": false,
"visibility": "members" }
}));
flat = f;
// 4. generation 2
let (robert, f) = push(&flat, EntityKind::Person, json!({
"identity": { "name": { "display": "Robert Pierre-Leonard", "components": [] },
"gender": { "value": "M" }, "is_living": true,
"visibility": "members" },
"birth": { "date": { "value": "1949-06", "precision": "month" },
"place_id": paris }
}));
flat = f;
// 5. the family binding them
let (_family, f) = push(&flat, EntityKind::Family, json!({
"union": { "type": "marriage",
"persons": [ { "person_id": jean, "role": "spouse" },
{ "person_id": elise, "role": "spouse" } ],
"start": { "date": { "value": "1948-06-15", "precision": "exact" } },
"confidence": 0.99 },
"children": [ { "person_id": robert, "birth_order": 1, "confidence": 0.99 } ]
}));
flat = f;
// 6. check coherence, then save
let errors = validate(&flat).data["errors"].as_u64().unwrap_or(0);
assert_eq!(errors, 0);
let zip = export_bundle(&flat).data;Reproduces: two cousins each maintaining a branch, now combining them. The overlap is the shared ancestors, which deduplicate resolves.
let a = import_bundle(&std::fs::read("cousin-a.axgf")?).data;
let b = import_bundle(&std::fs::read("cousin-b.axgf")?).data;
// merge collections client-side: the library does not join bundles in V1
let mut merged = a.clone();
for coll in ["persons","families","events","links",
"occupations","sources","places","documents"] {
let target = merged[coll].as_object_mut().expect("collection must be an object");
if let Some(src) = b[coll].as_object() {
for (id, entity) in src {
target.insert(id.clone(), entity.clone());
}
}
}
// let the library resolve the overlap
let clean = deduplicate(&merged.to_string()).data;
println!("merged {} duplicate persons, {} families; {} need a human",
clean["merged_persons"], clean["merged_families"], clean["manual_review"]);Reproduces: a client surfacing research quality. Diagnostics group naturally into a to-do list for the genealogist. Working from the serialized envelope keeps this identical across every language binding.
use std::collections::BTreeMap;
let env: serde_json::Value =
serde_json::from_str(&validate(&flat).to_json())?;
let mut by_code: BTreeMap<String, Vec<String>> = BTreeMap::new();
if let Some(diags) = env["diagnostics"].as_array() {
for d in diags {
let code = d["code"].as_str().unwrap_or("UNKNOWN").to_string();
let who = d["entity_ref"].as_str().unwrap_or("-").to_string();
by_code.entry(code).or_default().push(who);
}
}
for (code, refs) in &by_code {
println!("{code}: {} affected", refs.len());
}CHRONOLOGY_CONFLICT: 2 affected
DANGLING_REFERENCE: 14 affected
DUPLICATE_UNIQUE_REF: 3 affected
Reproduces: exposing the library as a web API. Because every function is pure, no session state is needed - the bundle travels with each request, which is exactly what makes horizontal scaling trivial.
POST /api/bundle -> create_bundle
POST /api/bundle/import (ZIP body) -> import_bundle
POST /api/bundle/export (flat) -> export_bundle
POST /api/bundle/validate (flat) -> validate
POST /api/entity/:kind (flat+e) -> add_entity
PUT /api/entity/:kind (flat+e) -> update_entity
DELETE /api/entity/:kind/:id?policy -> delete_entity
POST /api/bundle/dedup (flat) -> deduplicate
POST /api/convert/gedcom (bytes) -> convert_gedcom
Every handler is a one-liner: parse the request body, call the function,
return envelope.to_json() verbatim. The envelope already carries status and
diagnostics, so no error-mapping layer is needed.
| Code | Emitted by |
|---|---|
UNSUPPORTED_SPEC_VERSION |
Every operation that reads a manifest |
INVALID_JSON |
Every operation |
INVALID_BUNDLE_STRUCTURE |
Import, CRUD |
SCHEMA_VALIDATION_FAILED |
Validate, CRUD (add/update) |
DANGLING_REFERENCE |
Validate |
DUPLICATE_ENTITY_ID |
CRUD |
DUPLICATE_UNIQUE_REF |
Validate |
CYCLE_DETECTED |
Validate |
CHRONOLOGY_CONFLICT |
Validate |
ENTITY_NOT_FOUND |
CRUD (update/delete) |
ENTITY_ALREADY_EXISTS |
CRUD (add) |
UNKNOWN_ENTITY_KIND |
Adapters |
DELETE_BLOCKED_BY_REFERENCE |
CRUD (delete under Reject) |
MANUAL_REVIEW_REQUIRED |
Deduplicate |
ZIP_READ_ERROR |
Import |
ZIP_WRITE_ERROR |
Export |
GEDCOM_PARSE_ERROR |
Convert (reserved) |
GEDCOM_UNRECOGNIZED_TAG |
Convert |
INTERNAL |
Fallback |
New codes may be added in any minor version. Existing codes never change spelling or meaning.