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.
| Flag | Effect |
|---|---|
--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-shapes | omit 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-queries | carry 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 |
--turtle | additionally write a Turtle view for humans |
--destination internal | skip 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:
- Skips the outward scrub entirely. Not a per-pattern exception, not an allowlist — the check does not run.
- Stamps
destination: "internal"intomanifest.json, andquipu:destination "internal"intomanifest.ttl. The exemption travels with the bytes instead of living in the shell history of whoever produced them. - Binds that stamp into
share_id. Unlikeattestation, 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=…
| Flag | Effect |
|---|---|
--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-queries | replace 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:
| path | committed | what |
|---|---|---|
.quipu/project | yes | one line, the project id; the graph is urn:quipu:project:<id> |
.quipu/graph/ | yes | the share bundle for that graph (manifest.json, export.nt, …) |
.quipu/.gitignore | yes | written by quipu: an allow-list for the two above |
.quipu/local.db*, .quipu/verifier.pk8 | never | the 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.
| Exit | Meaning |
|---|---|
0 | merged |
2 | conflicts — nothing was guessed; the decision records name what needs a human |
1 | error |
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.
- Emit.
--emit-decisionswrites one row per conflict to a JSON file and changes nothing. Each row has the slot (subject,predicate,max_count), thebase/ours/theirsvalues, and each side’s provenance: ROOT’s current facts with theirvalid_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 akind(max_count_exceededordelete_replace) and arulein words (“sh:maxCount 1 on status: ours and theirs together hold 2 values”), and the file carrieslabels: onerdfs:labelper 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) throughlocal_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. - Propose (agent).
--proposealso fills each row’sproposal(chooseplusevidence) from mechanical evidence: a side unchanged from base, the newer side, an attested share. It never setsdecision. - Decide (operator, or any tool that edits the file). A tool may also set
decided_byanddecided_atper 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’sdecisionto{"choose": "ours"},{"choose": "theirs"},{"choose": "base"}, or{"values": ["\"an edited value\""]}(N-Triples terms). - Apply.
--decisionscommits 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:
| Refusal | Why |
|---|---|
a row has no decision | nothing is guessed |
| a reviewed slot’s values or provenance changed, or its hash is missing | the decisions were made against other evidence; emit again |
| the incoming share, its graph hash, or base identity changed | the decisions target another merge; emit again |
| ROOT changed for a legacy v1 file | v1 retains its whole-ROOT binding; emit again |
| the conflicts differ from the file’s rows | same reason |
a decision has more values than the slot’s sh:maxCount | it would re-create the conflict |
| a value is not an RDF term | it cannot be stored |
| the file has a field its merge-decisions schema does not define | apply 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>]
| Flag | Effect |
|---|---|
--out <file> | destination |
--verify <file> | check an existing pack instead of writing one |
--name / --version | identify 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-vectors | include embeddings |
--format turtle | carry 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>]
| Flag | Effect |
|---|---|
--full | pack the whole store losslessly, rather than one graph |
--format text | render that whole-store pack as a git-friendly DIRECTORY of text instead of a SQLite file |
--force | allow 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.
| tier | what it means |
|---|---|
transport | No envelope. The payload hashes verify, so the bytes are intact, but nothing says who produced them. |
claimed | A 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. |
attested | The 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:labelwith a compact name beside it; an unlabelled IRI is shown compactly (prefix:localfor 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:nameandschema:nameboth 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 -> newis 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 withxN. 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 markdownsuits a PR comment;--format jsonis 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:
- Facts. The
quipu share diffoutput above: per-entity changed, added and removed facts, by label. - 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
shaclfeature, the section says NOT CHECKED. That is not a zero, and--fail-on-introducedrefuses (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. - Merge decisions. When the change carries a
decisions.jsonsidecar fromquipu 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. - 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.
- 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.