Repository navigation
Accept TriG on insert (API, server, CLI) - #1971
Conversation
The streaming Turtle parser behind insert has no graph-block production, so a TriG document failed with a Turtle syntax error on every insert lane while upsert read it. A document that parser rejects is now re-read as TriG; when it has graph blocks or a `<#txn-meta>` block it is staged through the named-graph path TriG upsert uses, with insert semantics. Otherwise the Turtle error stands. Plain Turtle never takes the second parse, which the new `turtle_insert` routing stamp pins. The owned builder's `execute()`/`stage()` and the graph builder's `stage()` now stage through the dispatch the locked cached-handle commit uses (`stage_under_lock`, renamed `stage_core`) instead of their own copies. Two of those copies dropped a TriG upsert's graph blocks: the owned builder's policy lane, and the graph builder's `stage()`.
`/insert` no longer answers `application/trig` with a 400. TriG travels as the existing `TransactionBody::TurtleInsert`, since the API recognizes graph blocks itself, so consensus gains no wire variant.
`.trig` and `--format trig` detect as `DataFormat::Trig`. `insert`, `upsert` and `create` hand it to their Turtle paths; `sync` and `validate`, which read one graph, refuse it and name the commands that place graph blocks. N-Quads still points at `fluree create --from`.
Endpoint, header, CLI, Rust API and cookbook pages no longer say insert refuses TriG. The ledger-config recipe stays on SPARQL UPDATE: its anonymous blank nodes inside `GRAPH` blocks are still refused (#1930). Also fixes the txn-meta TriG example, which used an undeclared prefix.
aaj3f
left a comment
There was a problem hiding this comment.
This is really nice work, @bplatz — approving. The asymmetry it closes was the bad kind: fluree export --format trig produces the file, upsert reads it, and insert refused it three different ways depending on which door you came through. And the two upsert bugs you found on the way — the owned builder's policy lane and graph(..).transact().stage() both silently dropping graph blocks and txn-meta — are worse than the feature gap, since neither errored.
The design decision I'd most want to endorse is parse-then-fall-back rather than sniff-up-front. It keeps plain Turtle on the direct-to-flakes path with literally no added work, and the turtle_insert routing stamp with a MustFire/MustNotFire test is the right way to keep it there — that's the kind of thing that silently rots otherwise. phase1.named_graphs.is_empty() && phase1.raw_meta.is_none() → return the original Turtle error is the part I expected to find wrong and it isn't: a Turtle typo still reports as a Turtle error rather than being re-described in TriG terms.
The consolidation is under-sold in the description. Renaming stage_under_lock → stage_core and pointing three builders at it doesn't just remove ~275 lines from tx_builder.rs — the two silent-drop bugs existed because those were hand-copied dispatches, so collapsing them is the fix and the regression-proofing in one move. That's the shared-mechanism direction rather than a fourth copy with TriG bolted on.
I went in worried about the question this feature usually gets wrong — when a request names a target graph and the body also names graphs, who wins? — and the answer here is that it can't arise, which I verified rather than assumed. TurtleOp::Insert carries no graph selector at all (only TurtleOp::Graph, i.e. sync, does, and that refuses TriG), and on the API side Graph<'a> is { fluree, ledger_id, time_spec } — a ledger handle with no graph IRI. The one surface where both can name a graph, /graph/{iri}/sync with an RDF body, already refuses the mismatch with a clear 400 at tx.rs:2290 and is untouched here. Also checked the escaping axis given db#1940's lang-tag injection: this PR adds no serializer and touches no output path, and #1940's check_lang_tags guard did land on main at both entry points, so there's nothing to inherit.
Two things I'd like folded in, neither blocking:
🟡 The not-TriG path copies the document twice to conclude it isn't TriG (tx.rs:3974). parse_trig_phase1's short-circuit still does input.to_string(), and might_contain_graph_block does input.to_ascii_uppercase() first whenever there's no { — which is the plain-Turtle case. Neither allocation is yours (upsert has paid them all along), but this PR is what routes the insert error path through them, and that's where a large body is most likely. A big Turtle file with a trailing syntax error goes from "parse, fail, return" to two full copies first. Gate on might_contain_graph_block before calling, or make it allocation-free and return a Cow — the second also fixes upsert.
🟡 The sync/validate/--shacl TriG refusal can be walked around by piping (detect.rs:80). trig_refused() only fires on .trig or --format trig; sniff_data_format still answers Turtle for everything non-JSON, so cat data.trig | fluree sync … lands in the Turtle branch and dies with the opaque parse error. Harmless for insert/upsert (same arm, and your comment says so correctly), but it's the exact dead end the nquads_help comment argues against a few lines up. A might_contain_graph_block branch in the sniffer costs nothing and gets all three commands the good message.
Plus two smaller notes inline: the consolidation quietly fixes tracking and store_raw_txn on the insert_turtle lane (worth a line in the description — I had to diff to confirm it was deliberate), and ACCEPTED_FORMATS doesn't list nt even though the extension branch accepts .nt.
Adherence to repo commitments
- Patterns / abstractions — ✔.
stage_corereplaces three hand-copied dispatches rather than adding a fourth; TriG reusesstage_transaction_with_named_graphs_tracked(so graph registration, reserved-graph refusal, txn-meta, TriG-star and SHACL come for free) instead of a parallel path; no new consensus wire variant. No JSON-LD parity obligation — this is a transaction-input format, not a query-language feature. - Performance (speed first, memory second) —
⚠️ , non-blocking. No query-engine, index or per-flake change; the plain-Turtle success path is untouched andstamp_fast_pathfires once per document. Worth saying explicitly that this is not a streaming-to-buffered regression:stage_turtle_insert_with_optstakes&str, so the body was already resident and the fallback cannot make memory unbounded. The one real cost is the double clone in OPT-1, on the error path. The TriG-goes-through-IR gap is acknowledged in the description and tracked in #1849, so it isn't a new finding. - Testing — ✔.
it_trig_insert.rs(729 lines) is wired intogrp_transactin this diff, which matters givenfluree-db-api'sautotests = false; every insert lane is covered by name, plus the routing stamp, the parse-error split, and the two upsert paths that dropped blocks.it_reserved_graph_writes.rsextends the reserved-graph refusal to the new lane rather than assuming it carries. Server and CLI each get their own integration test. Four mutation checks named in the description. Full CI green on this exact head, includingtestsuite-sparql. - Conventions — ✔. Four self-describing subjects with substantial bodies that explain the why (the "graph blocks were dropped by two copies of the dispatch" rationale is in the commit, not just the PR). Fifteen docs pages updated, including two genuine corrections — the
<#txn-meta>example that declaredf:and usedfluree:, and a false claim that insert fails on existing subjects.#[allow(clippy::too_many_arguments)]onstage_trig_insertis the honest annotation for a 7-argument private helper; fmt and clippy green.
Happy to talk through either of the two — especially the sniffer one, if you'd already decided piping TriG into sync isn't a case worth the branch.
| tracker: Option<&Tracker>, | ||
| policy: Option<&crate::PolicyContext>, | ||
| ) -> Result<StageResult> { | ||
| let phase1 = fluree_db_transact::parse_trig_phase1(trig)?; |
There was a problem hiding this comment.
🟡 OPT-1 — the not-TriG path copies the whole document twice to conclude "not TriG"
(the parse_trig_phase1 call in stage_trig_insert)
The bail-out logic here is right — a plain-Turtle typo should come back as a Turtle error, and the named_graphs.is_empty() && raw_meta.is_none() guard makes sure it does. What I'd like to tighten is what it costs to reach that conclusion.
parse_trig_phase1 short-circuits on might_contain_graph_block, but the short-circuit still allocates (trig_meta.rs:200-206):
if !might_contain_graph_block(input) {
return Ok(TrigPhase1Result {
turtle: input.to_string(), // full copy of the document
raw_meta: None,
named_graphs: Vec::new(),
});
}…and might_contain_graph_block itself allocates a second copy whenever the document has no {, which is exactly the plain-Turtle case (trig_meta.rs:441-448):
if input.contains('{') { return true; }
input.to_ascii_uppercase().contains("GRAPH") // full uppercase copySo every Turtle insert that fails to parse now clones the body twice and throws both away. Your code didn't introduce either allocation — they're pre-existing in trig_meta.rs and upsert has been paying them all along — but this PR is what routes the insert error path through them, and insert is the lane where a large body is most likely. A 500 MB Turtle file with a syntax error near the end goes from "parse, fail, return" to "parse, fail, uppercase-copy 500 MB, copy 500 MB, return".
Cheapest fix is to not enter the helper at all when it can't help. might_contain_graph_block is private (no callers outside trig_meta.rs, I checked), so either expose it and gate the call:
if !fluree_db_transact::might_contain_graph_block(trig) {
return Err(turtle_err);
}
let phase1 = fluree_db_transact::parse_trig_phase1(trig)?;…or, probably better since it fixes upsert too, make the helper allocation-free and let TrigPhase1Result::turtle be a Cow<'_, str>:
fn might_contain_graph_block(input: &str) -> bool {
if input.contains('{') {
return true;
}
input
.as_bytes()
.windows(5)
.any(|w| w.eq_ignore_ascii_case(b"GRAPH"))
}I recognize this is non-blocking — but if you agree it's right, I'd rather see it folded in now than lost in the backlog.
The two allocations live in fluree-db-transact/src/parse/trig_meta.rs, which isn't in this diff; commenting on the call site that newly reaches them.
There was a problem hiding this comment.
Folded in (c255cfe), with both halves of what you suggested.
might_contain_graph_block now scans bytes in place: {, or a case-insensitive GRAPH over a five-byte window. It is exported, and stage_trig_insert returns the Turtle error before calling parse_trig_phase1 when it says no. A plain-Turtle typo now comes back without either copy. The in-place scan also drops the uppercase copy from every plain-Turtle upsert.
I left the Cow in TrigPhase1Result alone. It would remove upsert's remaining pass-through copy, but it touches upsert's plumbing, which felt outside this PR. A unit test pins the scan matching the keyword in any case and ignoring plain Turtle.
There was a problem hiding this comment.
Correcting myself above: the Cow turned out to be small, so it's in too (6694f81). TrigPhase1Result::turtle is now a Cow<'_, str> that borrows the input when the document cannot contain a graph block. It is owned only when phase 1 rebuilt the Turtle. None of the three callers changed, since the insert fallback, upsert_turtle and TriG import all read it as &str. With that, a plain-Turtle upsert no longer copies its body at all, and phase1_borrows_a_document_with_no_graph_blocks pins the borrow.
| // Content sniffing | ||
| // Content sniffing. A TriG body sniffs as Turtle, which is fine: the | ||
| // Turtle entry points read graph blocks too. | ||
| sniff_data_format(content) |
There was a problem hiding this comment.
🟡 OPT-2 — the sync / validate TriG refusal only fires on an extension or a flag
(the sniff_data_format(content) tail of detect_data_format)
trig_refused() is a genuinely nice error and I like that it names the commands that can place blocks. The gap is that it's only reachable through the two explicit routes: --format trig, or a .trig extension. Content sniffing still returns Turtle for anything non-JSON, and the new comment above it says so deliberately:
Content sniffing. A TriG body sniffs as Turtle, which is fine: the Turtle entry points read graph blocks too.
That reasoning is exactly right for insert and upsert — both now match Turtle | Trig to the same arm, so misdetection is harmless there. It doesn't hold for the three commands that refuse TriG. cat data.trig | fluree sync <ledger> --graph <g> (or any path without a .trig suffix) sniffs as Turtle, goes to fluree_graph_turtle::parse_to_json in SyncSource, and dies with failed to parse Turtle: … at the first GRAPH — which is the same opaque dead end your own detect.rs comment calls out for N-Quads:
An N-Quads file would otherwise sniff as Turtle and die in the parser on its fourth term, which tells the reader nothing.
The reasoning applies here verbatim, so I think the sniffer should distinguish the case it's now being asked to distinguish:
fn sniff_data_format(content: &str) -> CliResult<DataFormat> {
if serde_json::from_str::<serde_json::Value>(content).is_ok() {
Ok(DataFormat::JsonLd)
} else if might_contain_graph_block(content) {
Ok(DataFormat::Trig)
} else {
Ok(DataFormat::Turtle)
}
}…which costs nothing for insert/upsert (same arm) and gets sync, validate and --shacl the good message. If reaching for the transact-crate helper is more coupling than you want in detect.rs, a local content.contains('{') is probably enough for a sniffer — it only has to be better than "always Turtle".
This is more of a question than a suggestion if you'd decided piping TriG into sync isn't worth the branch. But given the effort in the rest of this diff to replace opaque parser errors with commands-you-should-use-instead errors, it felt like the one place it doesn't land.
There was a problem hiding this comment.
Agreed, and it was worse than an opaque error for validate. Its Turtle arm calls insert_turtle, which reads TriG as of this PR, so a TriG body under a .ttl name was silently loaded into named graphs rather than refused.
Fixed in c255cfe, but not by sniffing. might_contain_graph_block is a "might": a { inside a string literal, such as JSON stored as a value, would make valid Turtle sniff as TriG, and then sync would refuse it. Instead the three one-graph commands check the body with detect::refuse_trig_body, which runs parse_trig_phase1 and refuses only when it finds graph blocks or txn-meta. insert and upsert are untouched, since TriG and Turtle share their arm.
validateand--shaclcheck before loading.syncchecks only once the Turtle parse has failed, which TriG always does, so its success path is unchanged.
a_trig_body_under_another_name_is_refused_by_one_graph_commands covers:
- TriG piped to
sync; - a TriG
.ttlfile given tovalidate, and to--shacl; - a Turtle file with
{v}in a literal, which still validates.
With the validate check removed, the test fails on validate exiting 0.
| @@ -1685,9 +1450,10 @@ impl Fluree { | |||
| // Direct flake path for InsertTurtle (bypass JSON-LD / IR). | |||
| if let TransactOperation::InsertTurtle(turtle) = op { | |||
| let stage_result = self | |||
There was a problem hiding this comment.
🔵 INFO-1 — two behavior improvements the consolidation quietly buys
Not a finding — a note for the PR body, because I had to diff the old and new bodies to convince myself these were intentional rather than accidental, and the next reader will too.
Pointing OwnedTransactBuilder::execute() at stage_core changes the InsertTurtle lane in two ways beyond removing the duplicate dispatch. The old code returned early into insert_turtle_with_opts before a tracker was constructed and before the store_raw_txn handling, so:
- tracking options set on an
insert_turtlebuilder were silently ignored; they now take effect (tracker_refis threaded through), store_raw_txnwas a no-op on that lane; it now spawns the upload viamaybe_spawn_txn_upload.
Both look like fixes to me rather than regressions, and the staging semantics are otherwise identical — I compared the new stage_core arm against the old insert_turtle_with_opts body and the stage-then-commit_stage_result sequence matches. Worth a line in the description since "about 190 fewer lines" undersells it.
There was a problem hiding this comment.
Deliberate, and you're right that it deserved a line. The body now says so under the stage_core section: tracking options on an insert_turtle builder, and store_raw_txn on that lane, both now take effect where they were silently ignored.
| fn is_dataset_format(s: &str) -> bool { | ||
| matches!(s, "trig" | "nq" | "nquads" | "n-quads") | ||
| /// Every spelling `--format` accepts, for the errors that list them. | ||
| const ACCEPTED_FORMATS: &str = "turtle (ttl), trig, jsonld (json-ld, json)"; |
There was a problem hiding this comment.
⚪ NIT-1 — ACCEPTED_FORMATS is the right move, and one spelling is missing from it
Hoisting the accepted-format list to one const so the two usage errors can't drift is good, and the_usage_error_names_every_format_the_flag_accepts is a nice guard for exactly the failure mode the comment above nquads_help describes.
Small thing: the extension branch accepts .nt (routed to Turtle) but ACCEPTED_FORMATS — which is what both usage errors print — doesn't mention nt anywhere. Someone with a .nt file who reaches for --format nt gets "unknown data format 'nt'" listing formats that don't include the one their file is. Either add nt to the --format match beside turtle | ttl, or name it in the const. Genuinely minor.
There was a problem hiding this comment.
Fixed in c255cfe. --format nt is accepted next to turtle | ttl, and ACCEPTED_FORMATS lists it. the_usage_error_names_every_format_the_flag_accepts includes nt, and the --format rows in the insert, upsert and sync docs mention it.
…mmands
A Turtle insert the streaming parser rejects is checked for graph blocks
before it is re-read as TriG. That check copied the whole document to
uppercase it, and the TriG parser's early return copied it again, so a
large file with a typo near the end allocated twice its size before the
error came back. The check now scans bytes in place, and the insert
fallback runs it before calling the parser.
`sync`, `validate` and `--shacl` read one graph and refuse TriG, but only
when it was named by `--format trig` or a `.trig` file. A TriG body piped
in, or saved as `.ttl`, sniffed as Turtle: `sync` failed with a Turtle
parse error, and `validate` loaded it into named graphs through
`insert_turtle`, which now reads TriG. They now check the body with the
TriG parser. It parses rather than looking for braces, since a `{` inside
a Turtle string literal is not a graph block.
`--format nt` is accepted and listed alongside `turtle` and `ttl`, as a
`.nt` file already was.
`parse_trig_phase1` returned a document that cannot contain a graph block as a new `String`, so every plain-Turtle upsert copied its whole body just to learn it was plain Turtle. The phase-1 Turtle is now a `Cow` that borrows the input in that case. Callers read it as `&str`, unchanged.
- `cli/insert.md`: format detection as the code does it: `--format`, then the extension, then JSON versus everything else. `--format nt` listed. - `cli/upsert.md`: what a TriG document's graph blocks and txn-meta do, and the same detection rules. - `cli/sync.md`: a `.nt` file reads as Turtle, and a TriG body is refused however it arrives, including piped in or under another extension. - `cli/validate.md`: file mode and `--shacl` read Turtle, N-Triples and JSON-LD, and refuse TriG, with how to validate TriG data instead. - `transactions/turtle.md`: N-Triples is accepted wherever Turtle is, so the `rapper` conversion step for it is gone. - `api/headers.md`: Turtle and TriG list every endpoint that takes them, and the `application/x-turtle` / `application/x-trig` aliases.
`fluree sync` refused TriG, although `/sync` and `Fluree::sync_graph_with`
both accept it for one graph. The CLI converts Turtle to JSON-LD before
sending it, so a Turtle export works against a server from before `/sync`
read RDF bodies, and that conversion has no graph blocks.
TriG is now sent as TriG: locally as `GraphPayload::Rdf`, and to a remote
through a new `sync_trig` client call as `application/trig`. JSON-LD and
Turtle still go as JSON-LD. A body is TriG when it is named `.trig`, passed
with `--format trig`, or read as Turtle, fails that parse, and holds graph
blocks, as a piped TriG body does. Turtle with a `{` in a literal stays
Turtle. The API and server keep their rules: every block names the target
graph, and blocks do not sit beside default-graph triples.
`validate` and `--shacl` still refuse TriG; they read one graph.
Summary
TriG was accepted by
upsertbut refused by everyinsertentry point. v4.2.1'sfluree insert -f data.trigfailed with "dataset format … insert cannot place". ServerPOST /insertwithapplication/trigreturned 400, and a TriG body sent astext/turtlefailed with a Turtle syntax error. With this PR, all insert entry points accept TriG with insert semantics. Two upsert paths that silently dropped a TriG document's graph blocks are also fixed.Partially addresses #1849. The entry-point behavior, docs and per-lane tests that #1849's "Done when" lists are covered here. What remains is the flake-level TriG path proposed in its decision comment. That is a performance change, and this PR does not take it on: TriG inserts go through the same IR path TriG upsert uses.
How insert reads TriG
The streaming Turtle parser, which is the insert fast path, has no graph-block production, so it stops at a TriG document's first block. Only a document it rejects is checked for graph blocks, with a byte scan for
{orGRAPHthat does not copy the document. If it might have any, it is re-read withparse_trig_phase1. A large Turtle file with a typo therefore returns its error without being copied first.parse_trig_phase1also now borrows a document that has no graph blocks rather than copying it, so a plain-Turtle upsert, which calls it on every document, no longer copies its body either. If that finds graph blocks or<#txn-meta>, the document is staged throughstage_transaction_with_named_graphs_trackedwithTxnType::Insert, the function TriG upsert uses. That function already handles graph registration, reserved-graph refusal, txn-meta, TriG-star and SHACL. Otherwise the original Turtle error is returned.Plain Turtle never takes the second parse, including Turtle containing
{|annotations or the word "GRAPH". The route is stamped at a newturtle_insertsite (proceed/fallback:gate_declined), and aMustFire/MustNotFiretest pins it. Named-graph flakes make the write scopeUnbounded, so a TriG insert is always re-staged rather than rebased.One staging dispatch for the builders
stage_under_lockis renamedstage_core. The owned builder'sexecute()andstage(), and the graph builder'sstage(), now stage through it instead of keeping their own copies of the dispatch. That fixes two places where a TriG upsert's graph blocks were dropped:stage_owned(..).upsert_turtle(trig).policy(ctx)): the policy path parsed the default graph and discarded the graph blocks and txn-meta. It was markedTODO: Add named_graphs support.graph(..).transact().stage(): this path never passed graph blocks on, with or without a policy, whilecommit()did.About 190 fewer lines in
fluree-db-api/src. Moving the owned builder'sinsert_turtlelane ontostage_corealso fixes two silent no-ops on that lane: tracking options set on the builder were ignored, andstore_raw_txndid nothing. Both now take effect.Server and CLI
/insertno longer returns 400 forapplication/trig. TriG travels as the existingTransactionBody::TurtleInsert, so there is no new consensus wire variant and mixed-version Raft clusters are unaffected.DataFormat::Trigis detected by.trigextension or--format trig.insert,upsertandcreatehandle it.syncreads TriG for one graph, as/syncand the API already did. It converts Turtle to JSON-LD client-side so a Turtle export works against older servers, but that conversion has no graph blocks, so TriG now goes as TriG: locally asGraphPayload::Rdf, and remotely asapplication/trigthrough a newsync_trigclient call. Every block must name--graph, as the API and server require.validateand--shacl, which read one graph, refuse TriG with a message that namesinsert/upsert..ttl. It sniffs as Turtle, so these commands check it with the TriG parser rather than looking for braces, since a{inside a Turtle literal is not a graph block.syncthen sends it as TriG, andvalidateand--shaclrefuse it. Without the check,syncfailed with a Turtle parse error, andvalidateloaded the body into named graphs throughinsert_turtle.--format ntis accepted as Turtle and listed with the other formats, as a.ntfile already was.fluree create --from.Docs
These pages now describe TriG on insert:
transactions/turtle.md,transactions/insert.md(new "Turtle and TriG" section),transactions/overview.md,transactions/README.mdapi/endpoints.md,api/headers.md,api/overview.mdcli/insert.md,cli/upsert.mdgetting-started/rust-api.mdconcepts/edge-annotations.mdguides/cookbook-edge-annotations.md,guides/cookbook-owl-imports.mdledger-config/README.md,ledger-config/writing-config.mdOther doc changes:
GRAPHblocks hit TriG GRAPH blocks reject anonymous blank nodes [ … ] on /upsert and import #1930.<#txn-meta>TriG example inturtle.mddeclaredf:but usedfluree:commit:this, so it failed withundefined prefix: fluree. It is fixed.turtle.mdclaiming insert fails when subjects already exist is dropped: insert adds, and existing values are kept.Tests
it_trig_insert.rs(new):<#txn-meta>:Fluree::insert_turtle, ownedexecute/stage, graphcommitwith and without a policy, and graphstage.it_reserved_graph_writes.rs: TriG insert into#txn-metais refused, like upsert.trig_insert_lands_named_graphs:/insertwithapplication/trigand withtext/turtle..trigread back byinsert(by extension) andupsert(by--format); N-Quads refusal; detection unit tests.sync_of_a_trig_document_replaces_its_named_graph: a.trigfile syncs its graph; piped TriG is detected and commits only the delta; a block for another graph is refused by the API. With detection of a piped body turned off, it fails with a Turtle parse error.a_trig_body_under_another_name_is_refused_by_one_graph_commands: TriG in a.ttlfile given tovalidateand to--shacl, and a Turtle file with a{in a literal that still validates.syncsends for each input. The--remoteTriG call is not exercised end to end; no CLI test starts a server forsync.GRAPHkeyword scan in any case.trig_meta.rs: phase 1 borrows a document with no graph blocks (phase1_borrows_a_document_with_no_graph_blocks).Mutation checks, each run and then restored:
stagefor insert, graphstagefor upsert, owned upsert with a policy)..trigdetection reverted: the CLI round-trip test fails.validate's body check removed: the end-to-end test fails, becausevalidateexits 0 on a TriG.ttlfile.Full
fluree-db-api,fluree-db-consensus,fluree-db-serverandfluree-db-clisuites pass locally.Related
GRAPHblocks are still refused, now on insert as on upsert.convert_named_graphs_to_templates, the copy that issue proposes to unify.