Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

CLI: sharing, import and legacy packs

Reference for the commands behind Sharing & Federation. Every flag here is checked against quipu --help by tests/cli_doc_drift.rs, so this page cannot quietly fall behind the binary.

A note on vocabulary: the share is the portable artifact. quipu share writes its standard text files directly; releases may carry the same files in a deterministic .pendant.tar.gz archive. The older pack and unpack commands remain for local SQLite compatibility, but a .pendant.db is not the published interchange format.


quipu share — produce a share

Prerequisite: load the identifier-policy catalogue and the shapes governing your data. The default destination is outward. An empty block-tier catalogue exits 2 (cannot verify); a matching identifier exits 1; a checked, clean share exits 0. --no-shapes does not bypass this check.

quipu share --output <dir> [--graph IRI|--group-id ID|--construct QUERY]
            [--shapes NAME]... [--no-shapes] [--queries NAME]... [--no-queries]
            [--parent-share ID] [--since <parent-reference>] [--turtle]
            [--destination internal]

Writes a deterministic, git-native share into <dir>: RDFC-1.0 canonical export.nt (the facts), shapes.ttl (the constraints they were validated against), queries.ttl (the stored queries that answer questions about them, when there are any), and JSON plus PROV-O/DCAT/SPDX Turtle manifests.

FlagEffect
--output <dir>where to write. Required.
--graph <IRI>share one named graph
--group-id <ID>share by group
--construct <QUERY>share exactly what a CONSTRUCT query yields
--shapes <NAME>include a named shape set; repeatable
--no-shapesomit shapes.ttl — the receiver then has no constraints to validate against, so prefer not to
--queries <NAME>carry exactly these stored queries in queries.ttl; repeatable. Default: every query registered against the scope — see Stored queries
--no-queriescarry no stored queries; no queries.ttl is written
--parent-share <ID>record lineage: the share this one descends from
--since <reference>emit a parent-bound SPARQL Update delta instead of a full share; the parent may be a directory, archive or URL, not a share_id
--turtleadditionally write a Turtle view for humans
--destination internalskip the outward scrub and stamp the manifest destination: internal. LAN-internal destinations only — see below

The outward scrub, and --destination internal

Every share is checked against the store’s own aegis:InternalIdentifierPattern catalogue — the rules tiered block — and refused if the payload matches one. Nothing is rewritten: an internal hostname or an RFC1918 address is entity identity, and silently editing it would produce a share that says something the store never said.

That is the right default for a share bound for a public remote, and the wrong one for a share bound for an internal forge, where those identifiers are the point. --destination internal is the single explicit way to say so:

quipu share --output pendant/today --destination internal

It does three things, and the third is what makes the first two safe:

  1. Skips the outward scrub entirely. Not a per-pattern exception, not an allowlist — the check does not run.
  2. Stamps destination: "internal" into manifest.json, and quipu:destination "internal" into manifest.ttl. The exemption travels with the bytes instead of living in the shell history of whoever produced them.
  3. Binds that stamp into share_id. Unlike attestation, the field is not stripped before the manifest is hashed. Deleting it to launder the payload onward leaves a manifest that no longer hashes to the id it carries, and every consumer’s verification refuses it.

There is no environment variable and no config setting. A share that says nothing is scrubbed, including every share produced over HTTP: POST /share cannot select a destination, because a caller who could would be turning the guard off on a server they do not own.

What the marker buys you downstream. quipu import of a stamped share runs the scrub the producer skipped. If the payload passes it imports normally — a share marked internal out of caution is not quarantined for it. If the payload fails, the import is refused unless the operator repeats the declaration with quipu import <dir> --destination internal. So internal facts cannot enter a store silently and then leave it in someone else’s outward share.

Deltas are scrubbed separately, and they have to be. delta.ru is not built from the store: its DELETE clause is lifted verbatim from the parent’s export.nt. An identifier retracted from the graph yesterday is still quoted in today’s delta — and the parent is usually the very internal share that was allowed to carry it. A full outward share of the same store passes cleanly while that delta does not, which is exactly why the delta document gets its own check rather than riding on the result share’s.

--since compares the current share with the referenced parent. Its default 8 MiB limit applies to the serialized delta file map (update, shapes and manifests), not to the full result graph. A large unchanged graph can therefore produce a small delta; a large insertion or deletion can still exceed the limit. The producer still materializes the full result internally, so this transport limit is not a memory bound. Use --parent-share to record an identity without computing a delta.

--parent-share is what makes quipu merge possible later. A share without a parent cannot be three-way merged — merge refuses with “incoming share has no parent_share; three-way merge has no base” — so record it at production time, when you know it, rather than trying to reconstruct it at reconnect time.

Stored queries (queries.ttl)

A share carries the stored queries quipu_ask answers by name, so a pendant ships its competency questions beside its data. Each query is described as RDF in queries.ttl; the manifest seals that file’s SHA-256 as queries_hash and it takes part in the share_id, so changing one query’s text changes the share’s identity. A share with no queries has no queries.ttl and a manifest byte-identical to one produced before the member existed.

@prefix dct: <http://purl.org/dc/terms/> .
@prefix quipu: <https://quipu.dev/ontology/> .

<urn:quipu:query:members-of> a quipu:StoredQuery ;
  quipu:queryName "members-of" ;
  quipu:queryForm "SELECT" ;
  dct:description "Who belongs to a team" ;
  quipu:parameter [ a quipu:QueryParameter ; quipu:parameterName "team" ;
    quipu:parameterIndex 0 ; quipu:parameterKind "iri" ; quipu:required true ] ;
  quipu:sparqlTemplate """SELECT ?p WHERE { ?p <http://example.org/memberOf> <{team}> }""" .

quipu:targetsClass lists every constant rdf:type object in the template’s patterns. The vocabulary is governed by shapes/stored-queries.ttl, which the producer and the importer both apply.

Which queries a share carries by default. A query is registered against the shared graph when quipu_ask would answer it from that graph: an unscoped query (no dataset) belongs to a ROOT share and to --group-id and --construct slices of ROOT; a dataset-scoped query belongs to a --graph <IRI> share whose IRI is a member of its dataset, and to nothing else. The dataset IRI itself is producer-local and is not carried.

A carried query is data, not code. Only SELECT, CONSTRUCT, ASK and DESCRIBE travel. The form is decided by parsing the template with Quipu’s SPARQL parser — at share time and again at import — and a SPARQL Update is refused at both ends. Import evaluates nothing: a query runs only when somebody asks for it, under the receiver’s normal read policy and timeouts.

Import namespaces and never overwrites. Carried queries install in the receiver’s registry as <namespace>/<name>. The namespace defaults to pack-<12 hex> derived from the producer’s store identity, so newer shares of the same pack land on the same names; --query-namespace chooses one. A local query with the same name is never touched. A different definition already at a pack-scoped name is reported under queries.collisions and left in place, and a pack-scoped query the share no longer carries is reported as stale. --replace-queries replaces the differing ones and closes the stale ones (closed, never deleted — the prior version stays queryable). A query that targets a class the receiver’s loaded shapes do not sanction adds the blocker query_off_vocabulary: the pack is quarantined and none of its queries is installed. A full share installs its queries when it stages, before promotion, so run quipu import promote before asking questions of the staged data in ROOT.

A delta share carries the resulting share’s complete queries.ttl, like its shapes, so queries added, replaced or removed since the parent arrive as what the member now says, sealed by the result manifest’s queries_hash.

quipu import — receive a share, into quarantine

quipu import <share-dir|archive|URL> [--source <uri>] [--actor <id>]
            [--destination internal] [--query-namespace NS]
            [--replace-queries] [--db <path>]
quipu import delta <parent-share> <delta-share> [--actor <id>]
            [--query-namespace NS]

Verifies the manifest and payload hashes, then stages a local directory in its selected store. Archives and URLs are fetched under fixed size limits and materialized in a fresh in-memory store by default, so no downloaded artifact or database is left behind. With an explicit --db <path>, archives and URLs stage in that database using its loaded shapes and registered identities, just like a directory. Carried shapes are not automatically adopted: a receiver without a matching local vocabulary still quarantines the typed data. Import never touches ROOT without promotion. A hash mismatch is refused outright:

share graph hash mismatch: manifest=… actual=…
FlagEffect
--source <uri>record where the share came from; defaults to the directory, archive path, or URL
--actor <id>attribute the import
--db <path>stage in this store, including archive and URL imports
--query-namespace <NS>install carried queries as NS/<name> ([A-Za-z0-9._-]+); default pack-<12 hex> from the producer’s store identity
--replace-queriesreplace differing NS/* queries and close ones the share no longer carries; otherwise both are only reported

import delta verifies the full parent and the delta’s lineage, hashes and restricted DELETE DATA / INSERT DATA operations, materializes the declared result, then sends that result through the same verified in-memory import path.

Loaded local shapes determine admission. Their explicit quipu:onViolation "emit" diagnostics are advisory; reject-policy Violations quarantine the share. Missing policies mean reject, and unknown or conflicting policy values refuse. Carried shapes cannot downgrade a local reject policy. The report keeps strict conforms separate from blocking, with complete diagnostics and advisory_results. An emit-only Violation can therefore produce conforms: false with blocking: false. Vocabulary, integrity, trust and explicit promotion requirements still apply.

quipu import promote — admit a staged share into ROOT

quipu import promote <share-id> [--actor <id>] [--db <path>]

The second, separate verb. Nothing reaches ROOT because a file arrived; it reaches ROOT because someone ran this. Keeping admission in its own command is the point rather than an inconvenience — see the primitive.

The project graph: quipu share --project and quipu load

quipu share --project [<id>] [--no-shapes] [--destination internal] [--db <path>]
quipu load <bundle-dir> [--destination internal] [--actor <id>] [--db <path>]

A repository commits its project’s graph under .quipu/, tool-neutral:

pathcommittedwhat
.quipu/projectyesone line, the project id; the graph is urn:quipu:project:<id>
.quipu/graph/yesthe share bundle for that graph (manifest.json, export.nt, …)
.quipu/.gitignoreyeswritten by quipu: an allow-list for the two above
.quipu/local.db*, .quipu/verifier.pk8neverthe local store, and the host’s PRIVATE signing key

share --project <id> names the project once (it is then committed) and re-shares the graph into .quipu/graph/. Re-running it replaces the bundle; it never re-points a repository at a different id. The outward scrub applies exactly as for any share, because the repository may be public; use --destination internal only for a private one.

load <dir>, on a directory holding a share manifest, is the one command a fresh clone needs:

git clone <repo> && cd <repo>
quipu load .quipu/graph --db .quipu/local.db

It runs the ordinary import, with every gate import has, and then promotes into the bundle’s own graph instead of ROOT, as a diff. A re-load after git pull changes only what changed; an unchanged bundle opens no transaction. A quarantined import loads nothing. load <file.ttl> still means knot.

The server equivalent is POST /import of the same bundle, which stages it.

quipu status — has this share diverged?

quipu status <share-dir> [--db <path>]

Reports divergence between the local store and the share’s parent, as JSON. Read it before merge to see what a reconnect would have to decide.

quipu merge — three-way reconnect

quipu merge <share-dir> [--actor <id>] [--db <path>]

Locates the common base through parent_share, merges shape-aware (SHACL cardinalities decide what is a conflict), and on conflict keeps the base value and records a decision rather than guessing.

ExitMeaning
0merged
2conflicts — nothing was guessed; the decision records name what needs a human
1error

Exit 2 is a distinct code precisely so a script can tell “needs a decision” from “went wrong”.

Resolving conflicts: emit, propose, decide, apply

quipu merge <share-dir> --emit-decisions <file.json> [--slot-scoped] [--propose] [--db <path>]
quipu merge <share-dir> --decisions <file.json> --reviewer <who> [--dry-run] [--actor <id>] [--db <path>]

The same split as quipu align: a merge that cannot auto-merge is finished by a person, with the evidence in front of them.

  1. Emit. --emit-decisions writes one row per conflict to a JSON file and changes nothing. Each row has the slot (subject, predicate, max_count), the base / ours / theirs values, and each side’s provenance: ROOT’s current facts with their valid_from, transaction, actor and source; the incoming and base shares’ id, created_at, producer store and whether they are attested. For a person reading it, each row also has a kind (max_count_exceeded or delete_replace) and a rule in words (“sh:maxCount 1 on status: ours and theirs together hold 2 values”), and the file carries labels: one rdfs:label per IRI it mentions. Default emission stays v1, strictly bound to all ROOT changes. Explicit --slot-scoped (REST/MCP: slot_scoped: true) emits v2. These files bind each reviewed slot’s current values, local provenance and last-modifying ROOT transaction (including retractions) through local_slot_hash, plus the incoming share and base identities. Unrelated ROOT writes can survive an apply; the clean merge is recomputed against current ROOT. A slot changed and restored is stale, including empty -> value -> empty. Legacy v1 files and files with blank-node conflict subjects retain the conservative whole-ROOT binding. Older clients refuse v2 files; re-emit with the client that will apply the decisions.
  2. Propose (agent). --propose also fills each row’s proposal (choose plus evidence) from mechanical evidence: a side unchanged from base, the newer side, an attested share. It never sets decision.
  3. Decide (operator, or any tool that edits the file). A tool may also set decided_by and decided_at per row; apply echoes them back. They are a claim made by that tool. The attested record is the transaction’s reviewer and the decisions file’s hash. Set each row’s decision to {"choose": "ours"}, {"choose": "theirs"}, {"choose": "base"}, or {"values": ["\"an edited value\""]} (N-Triples terms).
  4. Apply. --decisions commits the clean part of the merge plus every decided slot in one transaction. Its source records both parents, the reviewer and the SHA-256 of the decisions file, and the output lists each applied row.

Apply refuses, and writes nothing, when:

RefusalWhy
a row has no decisionnothing is guessed
a reviewed slot’s values or provenance changed, or its hash is missingthe decisions were made against other evidence; emit again
the incoming share, its graph hash, or base identity changedthe decisions target another merge; emit again
ROOT changed for a legacy v1 filev1 retains its whole-ROOT binding; emit again
the conflicts differ from the file’s rowssame reason
a decision has more values than the slot’s sh:maxCountit would re-create the conflict
a value is not an RDF termit cannot be stored
the file has a field its merge-decisions schema does not defineapply would drop it while the file’s hash still covered it

--dry-run runs every check above and reports the counts that apply would assert and retract, without writing anything. A CI check uses it to validate a decided file before merge.

Plain quipu merge without these flags behaves as before.

quipu pack / quipu unpack — legacy SQLite compatibility

quipu pack <graph-iri> --out <file.pendant.db> [--name N] [--version V] [--space N]
           [--shapes S]... [--queries Q]... [--with-vectors] [--format turtle]
quipu pack --verify <file.pendant.db>
quipu unpack <file.pendant.db> [--into <graph-iri>] [--db <path>]
FlagEffect
--out <file>destination
--verify <file>check an existing pack instead of writing one
--name / --versionidentify the pack in its manifest
--space <N>term space to write into
--shapes <S> / --queries <Q>carry shape sets and named queries alongside the facts; repeatable
--with-vectorsinclude embeddings
--format turtlecarry the payload as Turtle
--into <graph-iri>unpack into a named graph

--verify answers whether a legacy SQLite pack is intact before loading it. New repository and release workflows use share and import; they do not publish .pendant.db files.

quipu pack --full / quipu restore — whole-store packs

A --full pack is a different artifact from everything above: not a graph, but the whole store, carried losslessly for internal backup. share carries current facts; --full carries facts as whole rows — g, tx, valid_from, valid_to, op, retracted_tx — so history, including what was retracted, travels with it.

quipu pack --full [--format text] --destination internal --out <path> [--db <path>]
quipu restore <file.pendant | text-pack-dir> [--force] [--db <path>]
FlagEffect
--fullpack the whole store losslessly, rather than one graph
--format textrender that whole-store pack as a git-friendly DIRECTORY of text instead of a SQLite file
--forceallow restore to replace a destination that still holds live facts

Both forms refuse an outward destination, and that refusal is atomic — no output is left behind. A full pack carries the event log and every operational table that is not explicitly excluded, so publishing one is the operator’s decision rather than something this command may acquire by convenience.

--format text does not transport the declared regenerated set. Today that is vectors: embeddings are derived data, roughly 2.2 GB of floats at homelab scale, and quote() renders a BLOB as X'<hex>' — so inlining them would produce a 4–5 GB “git-friendly” artifact, which is not one. The manifest instead records what was left out, how many rows it was, and the recipe to rebuild it (embedding model name, its SHA-256, and the dimension), and restore prints a REGENERATE: line naming them. A restore from a text pack is therefore complete in facts, history and provenance, and not complete in derived data until those are rebuilt — which is why it says so rather than reporting plain success.

The binary --full pack still transports vectors: a backup that forces a re-embed on restore is a poor backup. The two whole-store packs therefore carry different content by design, and their content hashes are not comparable to each other.

--format text writes manifest.json, schema.sql, and data/<table>.sql. Rows are emitted one INSERT per line, ordered by the row text itself, so a row moving on disk produces no diff and a committed pack changes only when its contents do. restore rebuilds the store, checks referential integrity, and refuses unless the reconstruction hashes identically to what the manifest claims — nothing reaches the destination until that holds, so a dump missing a file, a table or a single row is rejected rather than installed as a quietly smaller store.

restore REPLACES; to merge a published pack into an existing store use unpack. Each verb refuses the other’s format by name rather than reporting an intact artifact as corrupt.

quipu knot — assert facts, including identity across stores

quipu knot <file.ttl> [--graph <iri>] [--shapes <shapes.ttl>]
           [--timestamp <ISO-8601>] [--db <path>]

Asserts Turtle into the store, validated against shapes. In the sharing context this is how owl:sameAs between two stores’ IRIs gets written — identity is a fact in the graph, visible and retractable, not a string-matching heuristic.

quipu load is an alias for knot.

Archives

quipu graph freeze <iri> [--out <dir>] [--actor <who>] [--db <path>]
quipu graph thaw <iri> [--actor <who>] [--db <path>]
quipu graph list [--kind <token>] [--frozen] [--db <path>]

Deep freeze produces read-only, full-history graphs. See Graph Kinds & Deep Freeze.


Producer attestation and the three trust tiers

A share can carry a signed statement of who produced it. Every import reports the tier it reached, and the three are genuinely different claims — not degrees of the same one.

tierwhat it means
transportNo envelope. The payload hashes verify, so the bytes are intact, but nothing says who produced them.
claimedA signature verifies against the key the share itself supplied. The bundle is unaltered since signing and its identity fields are bound together — but nobody here vouched for that key. Integrity without provenance. Replay is not defended at this tier.
attestedThe signature verifies against a session binding registered out of band on the importing store.

Minting a share with an attestation

quipu share --output <dir> ... --attest \
  --attest-agent <agent> --attest-session <session> --attest-introducer <who> \
  --attest-issued-at <epoch> --attest-nonce <32 hex chars> \
  [--attest-key <path>] [--attest-ttl <secs>]

--attest-issued-at is required rather than defaulted to the wall clock: two runs over one pinned dataset must produce the same signed bytes, or the share is not re-derivable. --attest-nonce must be 32 lowercase hex characters and is checked at mint time — a share minted with any other nonce is refused by every importer.

The key comes from --attest-key, else $QUIPU_SIGNING_KEY, else .quipu/verifier.pk8, created 0600 on first use. That is v1 host-file custody, the same the governance plane uses; it is not an HSM.

Registering a producer, out of band

quipu attest register --agent <a> --session <s> --public-key <hex> \
  --introducer <who> --issued-at <epoch> --expires-at <epoch> [--allow-write] [--db <path>]
quipu attest allow-write|deny-write <session> [--db <path>]
quipu attest revoke <session> [--db <path>]
quipu attest list [--db <path>]

A binding is share-only unless an operator grants it write (--allow-write at registration, or allow-write later). Only then may its key sign HTTP writes. list prints each binding’s allow_write, expires_at and revoked, so the scope a key holds is visible without reading the store. revoke keeps the row: a revoked binding refuses as revoked, which is a different finding from an unbound key. Rotation is a new binding plus a revoke of the old one.

To set up an agent end to end (its own user, sd key init, enrollment, verification and revocation), follow CABOODLE’s agent identity and write access guide.

Importing a share never registers its producer. This is the point, not an omission: a key that vouches for the bundle it arrived in vouches for nothing, and an attacker substituting the whole bundle would substitute the key with it. Registration is a separate act by the consumer, using a key obtained some other way — the same rule the governance plane states as quipu never self-registers.

So a first import from an unknown producer reports claimed, and reports it honestly. Reaching attested requires someone to decide that this key is that producer.

Automated callers should require attested. Accepting claimed is reasonable, but it should be a deliberate choice by a caller who says so, not the effect of a tier that merely does not read as failure.


Keeping this page honest

tests/cli_doc_drift.rs reconciles three surfaces: the dispatch arms in src/main.rs, the --help text, and this page. Checking any two is not enough — when that test was written, --help documented share, status, merge and unpack but not import, so a page-versus-help check would have passed while the verb that receives a share stayed undiscoverable.

quipu share diff — what changed between two packs

quipu share diff <old> <new> [--format text|markdown|json]

Each side is a pack directory (the standard artifact’s payload.nq, else a legacy share’s export.nt) or a single N-Triples/N-Quads file. Like the Git transport commands below, it reads files only and opens no store.

The diff compares facts, not lines, and groups them by subject entity:

~ Alice (people/alice)
  ~ age: "30"^^xsd:integer -> "31"^^xsd:integer
  - nickname: "Al"
+ Carol (people/carol)
  + rdfs:label: "Carol"
  + role: "designer"
2 entities: 1 changed, 2 added, 1 removed facts

That is the whole output for the fixture pair in tests/fixtures/share-diff/, whose raw line diff is 11 lines of full IRIs and blank-node labels: Alice’s address is a blank node that RDFC relabelled (_:b0 to _:c14n7) without any change to its content, and it contributes nothing.

  • An entity is shown by its rdfs:label with a compact name beside it; an unlabelled IRI is shown compactly (prefix:local for well-known vocabularies, otherwise its last two path segments). Predicates show their label or local name. When two distinct predicates under one entity would show the same name (ex:name and schema:name both labelled “name”, or two IRIs ending /name), each carries its compact IRI — name (ex/name), name (schema:name) — or its full IRI if even those collide. Compaction depends only on the IRI, never on the data, so both sides of a diff name things the same way.
  • ~ predicate: old -> new is reported only when the slot (subject, predicate, graph) holds exactly one value on both sides. A multi-valued slot shows its removed and added values separately.
  • Blank nodes are matched by structure, not label. RDFC-1.0 can relabel every blank node between two versions of a payload; a pure relabel is zero lines. A blank node referenced from another node is shown inline ([ city "Paris" ; zip "75001" ]) as part of the referencing fact, so an edit inside it is a change of that fact. Structurally identical blank nodes on one slot are one fact with a count: cardinality matters to shapes (sh:maxCount), so adding a second copy is shown as ~ p: [ r "v" ] x1 -> [ r "v" ] x2, and the textconv marks a fact asserted more than once with xN. Limits: identical values nested inside an inlined blank node still collapse; a blank node referenced only from inside a blank-node cycle has no named root and is not shown; a blank graph name is keyed by its label.
  • --format markdown suits a PR comment; --format json is the same structure (entities[].changed/added/removed, plus totals) for tools.

quipu share diff --report — the pull-request review

quipu share diff <old> <new> --report [--format markdown|json]
    [--old-shapes <ttl>] [--new-shapes <ttl>] [--decisions <json>]
    [--fail-on-introduced]

The report a reviewer reads when a pull request changes a pack. When <old> and <new> are pack directories, each side’s shapes.ttl and the new side’s decisions.json are picked up from the directory; the flags name them explicitly otherwise. The repository’s pendant review workflow (.github/workflows/pendant-review.yml) runs it for every pack a pull request touches and publishes the result as the check’s job summary.

Reading the PR report

Sections always appear in this order:

  1. Facts. The quipu share diff output above: per-entity changed, added and removed facts, by label.
  2. SHACL violations introduced. Old data is validated against the old pack’s shapes, new data against the new pack’s shapes, and the report lists the violations that occur more often in new than in old, keyed by focus node, path, constraint component and value. A violation already present before the change is counted as pre-existing, never as introduced; the count of violations the change resolves is shown beside it. When the shapes change, a row whose old data already fails the NEW shapes is marked shapes change: the data did not move, the rules did, and the pack still stops conforming. If the new pack ships no shapes, or the binary was built without the shacl feature, the section says NOT CHECKED. That is not a zero, and --fail-on-introduced refuses (exit 1) on a build without SHACL rather than pass. A surviving pack with missing new shapes also refuses this gate. Whole pack deletion is distinguished by the workflow only when every artifact file is absent at the head; incomplete surviving packs refuse. No new-head validation is claimed for a proven deletion. Blank-node labels are collapsed when matching, because RDFC may relabel every blank node between versions.
  3. Merge decisions. When the change carries a decisions.json sidecar from quipu git-merge (see below), each conflict is listed with its subject, predicate, the constraint that made it a conflict (sh:maxCount 1), the base/ours/theirs values and the recorded resolution, or UNRESOLVED. Alias proposals recorded by the driver are listed the same way. The CI script renders a sidecar only when the pull request adds or changes it.
  4. Alias caveat. Always present. A triple-level diff, merge or validation cannot see two different IRIs minted for one real entity; such a pair looks like two healthy entities. Below the caveat are advisory candidates: an entity ADDED by the change whose normalized label is at least 0.90 Jaro-Winkler-similar to a same-type entity in the old pack, or to another added entity. This is the merge driver’s proposer, reused. No candidates is not evidence of no aliases.
  5. Summary. The report’s last line, used as the check annotation title:
pendant review: 1 entity changed (1 changed, 0 added, 0 removed facts); SHACL 0 introduced; no merge decisions; 0 alias candidates

--format json carries the same structure (diff, shacl, decisions, alias_candidates, summary). With --fail-on-introduced the exit status is 3 when at least one violation is introduced; the report is printed in full first.

The check is red only when a pack introduces a SHACL violation or its report cannot be computed. Everything else is information for the reviewer. A pack, for the workflow, is a directory holding export.nt or payload.nq together with manifest.json or manifest.ttl; the two versions compared are the merge-base version and the pull request head. The workflow runs with a read-only token. It can also keep one sticky PR comment up to date, but only when the repository variable QPACK_REVIEW_COMMENT is true: that job needs pull-requests: write, and granting it is a security-posture decision, so it is off by default.

High-stakes (class-B) DecisionRecords are not settled by approving the pull request. They are resolved through the hardware-signed human verdict tracked as aegis-kzt0ql.9; the report only shows them.

quipu diff-textconv — readable git diff for pack files

quipu diff-textconv <file>

Prints one payload file as stable, labelled, entity-grouped text: one header per entity (sorted by IRI, so a relabel never reorders the file), one predicate: value line per fact, blank nodes inline. Git’s textconv runs it on both sides of a diff, so an ordinary git diff, git log -p or git show reads like this instead of two lines of full IRIs:

@@ -1,5 +1,5 @@
 AAA Tracking (ability/aaa-tracking)
-  abbrev: "AAA"
+  abbrev: "AAA CHANGED"
   effectText: "x2 vs. air attacks"

Setup, once per clone (the attribute is already in this repository’s .gitattributes; add it to your own):

printf '*.nt diff=quipu\n*.nq diff=quipu\n' >> .gitattributes
git config diff.quipu.textconv "quipu diff-textconv"

Without the git config line the attribute is inert and Git diffs raw lines. git diff --no-textconv shows the raw form on demand. A file that does not parse (a working copy with merge conflict markers, say) is printed unchanged, so git diff never fails on it. Textconv affects display only: merges, hashes and pendant-check still operate on the canonical bytes.

Git transport: driver, decisions, and CI

These commands operate on repository files and immutable Git snapshots. They do not open a Quipu store. Use a build with the shacl feature (the default).

They shell out to the git executable found on PATH (the wrapper, the driver’s snapshot reads, the shapes three-way merge, and pendant-check all do). If git cannot be found, the command exits 1 with `git` executable not found on PATH and writes nothing.

quipu git-merge <ref>
quipu merge-driver <base-file> <ours-file> <theirs-file> <path>
quipu pendant-resolve <base-ref> <ours-ref> <theirs-ref> <dir> <key> <choice>
quipu pendant-check <base-ref> <ours-ref> <theirs-ref> <result-ref>

Version attributes for each share directory (adjust pendant to your layout):

pendant/export.nt merge=quipu
pendant/shapes.ttl merge=quipu
pendant/manifest.json merge=quipu
pendant/manifest.ttl merge=quipu

Start at the repository root with a clean index and worktree. Save the branch tips for later checks:

ours=$(git rev-parse HEAD)
theirs=$(git rev-parse topic)
base=$(git merge-base "$ours" "$theirs")
quipu git-merge "$theirs"

git-merge supplies a command-local merge.quipu.driver definition and the three immutable commit IDs to its child Git process. Nothing is installed in global Git configuration. This context matters: Git does not promise to merge shapes.ttl before export.nt. The driver reads and merges shapes from the three commits first, rather than trusting whichever working file happens to exist. Directly invoking the low-level driver without context refuses. Overlapping shape edits refuse before the wrapper changes the worktree; resolve those edits on the branches first. Criss-cross bases and incompatible store/scope/destination/layout metadata also refuse.

The graph operator is the same one used by store-level quipu merge. Functional conflicts retain the base value and create entries in decisions.json. No Git conflict markers are written to N-Triples. New entities from opposite branches with a common type are compared locally: whitespace-normalized, lowercase labels, Jaro-Winkler similarity at least 0.90, top five candidates per new entity. These are proposals, never automatic identity assertions. No paid model or network call is used. This baseline can miss aliases and can propose false matches; the decision belongs to the reviewer.

Exit 2 means decisions or Git conflicts remain. Inspect the sidecar, then choose:

quipu pendant-resolve "$base" "$ours" "$theirs" pendant conflict:0 ours
quipu pendant-resolve "$base" "$ours" "$theirs" pendant alias:0 reject

Conflict choices are base, ours, or theirs; alias choices are accept (add an explicit owl:sameAs) or reject (keep identities distinct). Resolution reconstructs the operator result from Git and refuses stale or altered proposal records. The last resolution must pass SHACL. It rewrites canonical export.nt, regenerates both manifests, drops old attestations and the optional derived export.ttl, and records parent_share=ours plus both IDs in merge_parents. Input tx_anchor and created_at remain the ours snapshot’s metadata; a Git merge does not fabricate a store transaction. Each file is replaced atomically; the directory is not a multi-file transaction, so the hash gate must pass before use. Review and stage the complete directory, then commit:

git add pendant
git commit
quipu pendant-check "$base" "$ours" "$theirs" HEAD

The wrapper always stops before commit, including clean merges. It reconciles whole pendants after Git because Git may skip individual drivers when one file is unchanged. Blank-node graphs currently refuse divergent merges: canonical blank labels are not stable identities across snapshots. Skolemize those nodes first.

CI is required even when local driver use is documented. A clone with no driver definition, a forge merge button, or a rebase can fall back to text merge. The repository’s SHACL test job runs scripts/ci/pendant-merge-check.py with full history and the PR’s synthetic merge commit. It also replays two-parent merges introduced on the topic branch, then reconstructs base/ours/theirs, requires recorded resolutions, compares the committed graph, shapes and manifest with the operator result, and validates SHACL. A hand-rehashed unsafe result still fails. Push/nightly runs validate the committed packs too; a squash commit alone cannot reconstruct the original branch pair, so the PR check remains load-bearing. Keep the SHACL test check required in branch protection.

Known gap: an administrator bypasses the gate. The replay that can actually fail is the PR check. On a push event the final replay is trivially (base, ours, theirs) = (before, before, result): a fast-forward of the previous tip, so it re-validates the committed packs but cannot reconstruct a merge that happened elsewhere. This repository’s main protection uses strict status checks, so a squash merge’s tree equals the synthetic merge commit the PR check tested. It does not enforce protection for administrators, so an admin’s direct push, or an admin merge that skips pending or failing checks, lands a pendant that no merge replay has verified. Treat an admin bypass as unverified and run quipu pendant-check on the original base/ours/theirs yourself, or enable “include administrators” if that gap is unacceptable.

Measured on the repository’s real Datalinks pendant, replacing the same functional value on both branches produces a text conflict, not a clean double. Git writes three marker lines into the RDF. Separate new IRIs with identical labels merge cleanly without an identity decision; disjoint additions merge cleanly and correctly. tests/git_qpack_merge.rs exercises those cases, decision resolution, and the driver-absent CI refusals using actual Git repositories.

Carry alignment evidence

quipu share --alignments FILE --output DIR carries an optional, hash-sealed alignments.ttl. Input is bounded UTF-8 Turtle; an explicit empty carrier is valid. The manifest seals its exact bytes and fixed filename in the share ID. Missing, altered, misnamed or undeclared evidence is refused before import stages data. Legacy shares without this member retain their existing identity.

This is evidence transport, not identity adoption. Imported bytes are retained separately; neither import nor ROOT promotion applies the mappings or loads their shapes. Use the existing alignment propose/decide/apply workflow for curation. The browser reader uses the same verification, and Explorer export preserves the imported carrier. A release needs a compatible engine and actual asset readback; source configuration alone does not establish availability.