diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01c53222..c0a62012 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,25 @@ Entries that change an on-disk format or a response shape say so.
## [Unreleased]
### Added
+- `POST /contexts/{name}/promote` and the `promote` MCP tool (#466
+ S2, ADR 0018): graph-path memory promotion — the named scratch
+ sources move into an established destination context as the
+ export/import round trip in one call, no LLM anywhere in the path.
+ Each source moves whole (passage, `date`, tags, only its own share
+ of every edge's weight; aliases exactly when their canonical is
+ live in the promoted slice, the rest counted in `aliases_dropped`),
+ source ids survive so promoted citations still name the session,
+ and applying is per-source retract-then-apply — re-promotion is
+ idempotent. The destination is never created and its own schema
+ judges the incoming batches; a missing source id refuses the whole
+ request path-addressed. After a real apply the destination's
+ consolidation audit (all three checks, default ceilings) rides back
+ under `audit` — candidates to judge, never applied — and
+ `?dry_run=true` previews the same `batches` shape with nothing
+ written. Write role, `retract_source`'s classification; the
+ destination named in the body is scope-checked like `/import`'s
+ body contexts.
+
- `taguru extract --source-id ID`, `--date WHEN`, `--tag TAG` (#466
S1, ADR 0017): bake the promotion runbook's source conventions into
the written batch — the `session:{agent}:{id}` header source (with
@@ -69,6 +88,16 @@ Entries that change an on-disk format or a response shape say so.
not gated, mirroring `sdk/python-langchain`.
### Fixed
+- `POST /import?dry_run=true` now seeds each batch's checks with what
+ the batches before it would intern and create, so two spurious
+ mid-stream refusals the real import never raises are gone: an
+ `UnknownCanonical` alias rejection when a stream's aliases trail
+ their canonicals (every export — aliases ride the last batch), and
+ a `no_context` refusal on every post-first batch of a restore into
+ a fresh context name (the create block rides only the first batch).
+ Cross-batch alias conflicts remain un-predicted — that direction
+ only lets a preview pass what the real run would refuse, like the
+ capacity caps the preview contract already documents as advisory.
- TypeScript SDK: ported the Python SDK hardening that had not reached
the TypeScript twin — `contexts.delete`/`groups.delete` no longer
auto-retry after an ambiguous transport failure (the retry turned an
diff --git a/adr/0018-graph-path-promotion.md b/adr/0018-graph-path-promotion.md
new file mode 100644
index 00000000..831085a2
--- /dev/null
+++ b/adr/0018-graph-path-promotion.md
@@ -0,0 +1,126 @@
+# 0018. Graph-path promotion as a server verb (MCP `promote`)
+
+- **Status**: Accepted
+- **Date**: 2026-08-09
+- **Issue**: #466 (S2)
+- **Related**: #465 (the runbook this bundles), ADR 0012 (the audit it
+ runs and the explicit-forgetting posture it keeps), ADR 0017 (S1,
+ the extract flags that feed this), ADR 0009 §13 (the credential
+ boundary that decides CLI vs server), ADR 0005 (the batch contract
+ the transfer rides)
+- **Supersedes**: — / **Superseded by**: —
+
+Once Accepted, this document's Decision is immutable: a changed decision gets a
+new `adr/000N-*.md` that names this one in *Supersedes*, never an edit here.
+
+## 1. Scope
+
+One server verb — `POST /contexts/{name}/promote`, advertised as the
+MCP tool `promote` — that moves named scratch sources into a permanent
+context over the **graph path**: the scratch's already-structured
+associations, passages, dates, and tags, promoted without
+re-extraction and therefore without an LLM or any model credential.
+Out of scope: the text path (session notes → `taguru extract` → import
+— needs model credentials the server never holds, so it stays a CLI
+composition; a preset verb is #466 S3 if ever needed), applying audit
+judgments, and retiring the promoted scratch.
+
+## 2. Context
+
+The #466 rehearsal measured one promotion at 8–10 manual operations;
+S1 (ADR 0017) removed the hand-editing from the extract half. What
+remains mechanical is the transfer itself: export the scratch, keep
+only the keeper sources, re-head every batch at the permanent context,
+import, audit the landing zone. Every step is an existing server
+operation — which is exactly why the rehearsal's re-evaluation
+concluded the graph path can be a server verb while the text path
+cannot: no step needs a model credential, so bundling them behind
+`/mcp` respects ADR 0009 §13 as-is. The judgment points the rehearsal
+proved irreducible — WHICH sources to promote, and what to do with
+audit candidates — stay with the calling agent on both paths.
+
+## 3. Decision
+
+**`promote` IS export → filter → re-head → import → audit, in one
+request, built from the same machinery the manual procedure uses.**
+Body: `{into, sources[], audit?}`; `?dry_run=true` previews.
+
+1. **The transfer is the export/import round trip, not a third write
+ path.** The scratch's [`ExportSnapshot`] is filtered to the named
+ sources — each association keeps only their attributions, its
+ count/weight recomputed from what is kept, edges left with nothing
+ drop — then rendered as an ordinary import stream headed at `into`
+ and applied batch by batch with `POST /import`'s own
+ retract-then-apply. Re-promoting the same sources is therefore
+ idempotent, aliases are carried exactly when their canonical is
+ live in the promoted slice (the render's standing rule — the count
+ of dropped ones rides the response), the unsourced residual cannot
+ travel (filtering makes attributed count equal total by
+ construction), and the scratch's schema never installs into the
+ destination (the snapshot's schema is cleared; the destination's
+ own schema judges the incoming batches instead, refusing in
+ `strict` exactly as an import would).
+2. **Provenance travels whole.** Source ids, `stored_at`, `date`, and
+ tags ride the stream verbatim, so a promoted fact's citation still
+ names the session that produced it and every windowed read keeps
+ working — the runbook's provenance promise, now enforced by
+ construction rather than by operator care.
+3. **Promote never creates and never retires.** The destination must
+ already exist (checked up front, and the stream's create block is
+ stripped so a context deleted mid-request refuses instead of
+ resurrecting under the scratch's meta — promotion lands in an
+ established context, never silently mints one). The promoted
+ scratch stays until the agent explicitly retracts it —
+ `retract_source`/context deletion remain the runbook's step 5;
+ forgetting stays an explicit operation (ADR 0012's posture).
+4. **Missing sources refuse whole, before anything applies.** A
+ mistyped session id under retract-then-apply would otherwise
+ no-op silently; instead every requested id must exist in the
+ scratch (as a passage or a live attribution) or the request refuses
+ naming the absentees, `nothing_written`.
+5. **The audit is bundled, its judgments are not.** After a real
+ apply, the destination gets the same merge/contradiction/staleness
+ computation `audit_consolidation` runs (all three checks, default
+ ceilings), riding back as `audit` — candidates with fingerprints,
+ never applications; `audit: false` opts out for a large destination
+ and `dry_run` skips it (nothing landed to audit). Tuned re-runs
+ stay one `audit_consolidation` call away, fingerprint reuse intact.
+6. **Write role, both contexts checked.** Promote is an ingest-loop
+ verb — `retract_source`'s classification, not `/import`'s Admin
+ (it cannot create contexts and carries no group or schema records).
+ The route check covers the scratch; the handler checks the
+ key's grant on `into` before anything applies, `/import`'s
+ body-context discipline. Through `taguru router` the request
+ proxies whole to the shard owning the scratch, so a destination
+ living on another shard refuses there (`no_context`) — promotion
+ through the router requires the pair on one shard, a documented
+ divergence in route.rs's own list.
+
+## 4. Consequences
+
+- The runbook's steps 3–4 become one call on the graph path, and step
+ 2 disappears from it entirely (no extract when the scratch's
+ structure is already right); with S1, a session already written in
+ structured form promotes with: review → `promote` → judge audit
+ candidates → `retract_source`. The irreducible judgment points are
+ now the ONLY manual steps.
+- No new credential surface and no new write semantics: everything the
+ verb does was already expressible with existing operations, so the
+ security review surface is composition, not new capability.
+- The response reuses `/import`'s per-batch outcome shape and the
+ audit's section shapes — clients that parse either parse this.
+- Building the dry run exposed two standing `/import?dry_run` defects
+ the same change fixes at the root: a preview held no cross-batch
+ state, so a restore whose aliases trail their canonicals (every
+ export — aliases ride the last batch) and every post-first batch of
+ a fresh-name restore (the create block rides only the first)
+ refused spuriously where the real import applies cleanly. Previews
+ now seed each batch's checks with what the batches before it would
+ intern and create. Cross-batch alias CONFLICTS remain un-predicted —
+ that gap only lets a preview pass what a real run would refuse, the
+ advisory direction the capacity caps already occupy.
+- Not in this split, deliberately: promoting BETWEEN servers (export's
+ file form already covers migration), a `since`/`until` or tag filter
+ choosing sources server-side (the agent already holds
+ `list_sources`), and the text-path preset (#466 S3, if S1 plus this
+ proves insufficient).
diff --git a/docs/promotion.html b/docs/promotion.html
index 52c4b490..ed5a2a14 100644
--- a/docs/promotion.html
+++ b/docs/promotion.html
@@ -113,6 +113,22 @@
The promotion procedure
Review the scratch context: recall/query, or
taguru communities --context scratch-… for a themed overview once the
scratch has grown.
+ Graph path — one call when the structure is already right (ADR 0018):
+ when the keepers are the scratch's own structured associations (written by the
+ session itself, nothing left to extract), the promote MCP tool
+ (POST /contexts/{scratch}/promote, body
+ {into, sources, audit?}, ?dry_run=true to preview)
+ bundles steps 4–5: each named source moves whole — passage, date,
+ tags, and only its own share of every edge's weight — with no LLM
+ anywhere in the path. Source ids survive, so promoted citations still name the
+ session; per-source retract-then-apply makes re-promotion idempotent; the
+ destination must already exist and its own schema judges the incoming batches;
+ the landing-zone audit rides back in the response (audit: false
+ opts out), and a dry run previews the transfer batches only, with nothing
+ written and no audit. What stays yours is exactly the judgment:
+ which sources (step 1's review), the audit's candidates (step 5's rubric),
+ and retirement (step 6). Steps 3–4 below are the text path, needed only
+ when the keepers exist as prose that still wants extraction.
Extract the keepers with taguru extract over the session
passages (or hand-write the batch), targeting the permanent context — keeping
the session:{agent}:{id} source ids and the dates:
@@ -143,8 +159,9 @@ The promotion procedure
What not to do
- - Don't promote into a fresh spelling universe — the resolve-first rule in step 2 is
- what keeps one referent one spelling.
+ - Don't promote into a fresh spelling universe — the resolve-first rule in step 3
+ (and, on the graph path, the landing-zone audit's merge candidates) is what keeps
+ one referent one spelling.
- Don't invent an end date for a superseded fact: assert the new fact with its own
date; as-of queries (
until) and the audit's dated rows sort the regimes
out (ADR 0011 §6).
diff --git a/examples/skills/memory-promotion/SKILL.md b/examples/skills/memory-promotion/SKILL.md
index 8e2f3518..5411b0a0 100644
--- a/examples/skills/memory-promotion/SKILL.md
+++ b/examples/skills/memory-promotion/SKILL.md
@@ -1,6 +1,6 @@
---
name: memory-promotion
-description: "Promote session notes (episodic memory) into a permanent Taguru context (semantic memory): scratch-context conventions, the extract→import→audit→retract procedure, and the rules that keep provenance and time intact. Use when ending a working session whose notes are worth keeping, or when asked to consolidate scratch knowledge."
+description: "Promote session notes (episodic memory) into a permanent Taguru context (semantic memory): scratch-context conventions, the one-call promote tool for already-structured scratch, the extract→import→audit→retract procedure for prose, and the rules that keep provenance and time intact. Use when ending a working session whose notes are worth keeping, or when asked to consolidate scratch knowledge."
---
# memory-promotion
@@ -45,21 +45,37 @@ re-asserting within one note inflates weight — don't.
1. **Review what the scratch holds**: `recall`/`query` the scratch
context, or `taguru communities --context scratch-...` for a themed
overview when the scratch has grown.
-2. **Extract the keepers**: `taguru extract` over the session passages
- (or hand-write the batch) into import batches targeting the
- PERMANENT context — keep the `session:{agent}:{id}` source ids and the
- `date`s. Check spellings against the permanent context first
- (`resolve` / `resolve_label`); reuse its vocabulary, never fork it.
-3. **Import**: `POST /import` / `taguru import` — retract-then-apply
+2. **Graph path — one call when the structure is already right**
+ (ADR 0018): when the keepers are the scratch's own structured
+ associations (you wrote them during the session; there is no prose
+ left to extract), call the `promote` MCP tool with
+ `{context: scratch-{agent}, into: PERMANENT, sources: [the session
+ ids]}`. Each source moves whole — passage, `date`, tags, only its
+ own share of every edge — source ids survive (citations still name
+ the session), re-promotion is idempotent, and the landing-zone
+ audit comes back in the same response: jump straight to step 5's
+ judgments, then step 6. `dry_run: true` previews the same
+ `batches` shape with nothing written and NO audit (step 5's
+ judgments need a real apply). Steps 3–4 are the TEXT path, for
+ keepers that exist as prose.
+3. **Extract the keepers**: `taguru extract` over the session passages
+ into import batches targeting the PERMANENT context —
+ `--source-id session:{agent}:{id}`, `--date`, and `--tag` bake the
+ conventions into the batch (ADR 0017); `--vocabulary` (over a
+ `taguru export` of the permanent context) steers spellings to the
+ ones the graph already uses, and `--coverage` reports what the
+ extraction left behind, sentence by sentence (ADR 0015/0016).
+4. **Import**: `POST /import` / `taguru import` — retract-then-apply
per source, so re-promoting the same session is idempotent, not
duplicated.
-4. **Audit the landing zone**: run the consolidation audit on the
- permanent context (`taguru consolidation --context NAME`, or the
+5. **Audit the landing zone**: judge the consolidation audit on the
+ permanent context (bundled in `promote`'s response on the graph
+ path; standalone via `taguru consolidation --context NAME` or the
`audit_consolidation` MCP tool) — promotion is exactly when merge
twins and contradictions appear. Judgments are proposals; apply the
accepted ones through ordinary writes (alias / retract / negative
weight / re-import).
-5. **Retire the promoted scratch**: `retract_source` the promoted
+6. **Retire the promoted scratch**: `retract_source` the promoted
session sources from the scratch context (or delete the whole
scratch context when everything promoted). Unpromoted scratch stays
until someone decides otherwise — that is the posture, not a gap.
@@ -67,7 +83,8 @@ re-asserting within one note inflates weight — don't.
## What NOT to do
- Don't promote by copying text into a new spelling universe — step
- 2's resolve-first rule is what keeps one referent one spelling.
+ 3's vocabulary steering (and, on the graph path, the audit's merge
+ candidates) is what keeps one referent one spelling.
- Don't invent an end date for a superseded fact: assert the new fact
with its own date and let as-of queries and the audit sort the
regimes out (ADR 0011 §6).
diff --git a/src/api.rs b/src/api.rs
index 06c2f7fc..ed6a09b2 100644
--- a/src/api.rs
+++ b/src/api.rs
@@ -53,6 +53,8 @@ mod explore;
mod groups;
#[path = "api/import.rs"]
mod import;
+#[path = "api/promote.rs"]
+mod promote;
#[path = "api/recall.rs"]
mod recall;
#[path = "api/schema.rs"]
@@ -92,6 +94,7 @@ pub use import::{
// handler's helper — everything else in this module is `pub` for the
// handler wiring above).
pub(crate) use import::{import_outcome, schema_import_outcome};
+pub use promote::promote_sources;
pub use recall::{cross_query, cross_recall, query, recall};
pub use resolve::{explain_resolve, explain_resolve_label, resolve, resolve_label};
pub use schema::{audit_schema, get_schema, put_schema, validate_schema};
diff --git a/src/api/consolidation.rs b/src/api/consolidation.rs
index cb57a927..b04c7a84 100644
--- a/src/api/consolidation.rs
+++ b/src/api/consolidation.rs
@@ -36,7 +36,7 @@ pub(crate) const CONSOLIDATION_DETECTOR: &str = "consolidation/1";
/// Per-list ceiling on merge evidence facts when the caller does not
/// choose one — totals stay exact either way (no silent caps).
-const DEFAULT_EVIDENCE_CAP: usize = 20;
+pub(super) const DEFAULT_EVIDENCE_CAP: usize = 20;
#[derive(Debug, Deserialize)]
pub struct ConsolidationAuditRequest {
@@ -302,7 +302,7 @@ pub async fn audit_consolidation(
/// lexical tier (deterministic, and the lexical score is the
/// explainable one).
#[allow(clippy::too_many_arguments)]
-fn merge_section(
+pub(super) fn merge_section(
state: &AppState,
name: &str,
dice_floor: f64,
@@ -387,7 +387,7 @@ fn merge_section(
/// groups ranked by measured functional tendency with contested edges
/// after them (a dispute is already ordered evidence; the grouped kind
/// is where ranking earns its keep).
-fn contradiction_section(
+pub(super) fn contradiction_section(
context: &Context,
effective: &HashMap,
limit: usize,
@@ -456,7 +456,7 @@ fn contradiction_section(
/// The staleness section: edges left behind by their own subject's
/// neighborhood, the gap measured in assertion time (ADR 0012 §4).
/// Undated edges are counted, never guessed at.
-fn staleness_section(
+pub(super) fn staleness_section(
context: &Context,
effective: &HashMap,
floor_secs: u64,
diff --git a/src/api/import.rs b/src/api/import.rs
index ec6d17f9..cb0ea3ba 100644
--- a/src/api/import.rs
+++ b/src/api/import.rs
@@ -192,7 +192,7 @@ fn alias_rejection_issue(batch_index: usize, rejection: &AliasRejection) -> Issu
/// both the strict-refusal arm below and the warn-mode success path, so
/// the two present identical `Issue` values for the same violation (ADR
/// 0009 §8.3).
-fn schema_issues_in_batch(batch_index: usize, issues: Vec) -> Vec {
+pub(super) fn schema_issues_in_batch(batch_index: usize, issues: Vec) -> Vec {
issues
.into_iter()
.map(|issue| Issue {
@@ -209,7 +209,10 @@ fn schema_issues_in_batch(batch_index: usize, issues: Vec) -> Vec
/// `durable_prefix` naming exactly how many did — never implying a
/// subset of THIS batch's own writes was accepted, since every batch
/// is all-or-nothing.
-fn stream_integrity(previewed_or_landed: usize, dry_run: bool) -> (&'static str, Option) {
+pub(super) fn stream_integrity(
+ previewed_or_landed: usize,
+ dry_run: bool,
+) -> (&'static str, Option) {
if dry_run || previewed_or_landed == 0 {
("nothing_written", None)
} else {
@@ -267,7 +270,7 @@ pub(super) fn import_budget_refusal(
/// `Partial`/`Io`/`Access` cannot prove how much of THIS batch landed,
/// so their prose stays the only account, as before.
#[allow(clippy::too_many_arguments)] // the batch's stream position, spread flat like call_inner's outer context
-fn import_refusal(
+pub(super) fn import_refusal(
state: &AppState,
batch: &crate::ingest::Batch,
refusal: crate::ingest::ApplyRefusal,
@@ -550,7 +553,7 @@ pub(super) fn schema_import_refusal(
/// refused-batch cases in [`import_batch`]'s loop, which differ only in
/// the verb for THIS batch and the exact fix to name. A single-batch
/// stream has nothing before it to report, so the note is empty.
-fn import_batch_note(
+pub(super) fn import_batch_note(
index: usize,
total: usize,
batch: &crate::ingest::Batch,
@@ -618,8 +621,12 @@ fn import_batch_note(
/// `?dry_run=true` reports the same `{batches: [...]}` shape without
/// writing anything — parsing and scope checks still run in full, so a
/// malformed or forbidden stream is refused exactly as it would be for
-/// real. Two counts per batch, `associations` and `aliases`, are
-/// optimistic (see [`crate::ingest::preview_batch`]); every other
+/// real. Each batch's checks are seeded with what the batches before
+/// it would intern and create ([`crate::ingest::PreviewSeeds`]), so a
+/// restore whose aliases trail their canonicals — every export — and
+/// a fresh-name restore's post-first batches preview clean, exactly
+/// as they apply. Two counts per batch, `associations` and `aliases`,
+/// are optimistic (see [`crate::ingest::preview_batch`]); every other
/// field is exact. `taguru_schema` and `taguru_group` records are both
/// a known gap: they apply through a path (`put_schema`,
/// `restore_groups`) that dry-run does not preview, so a stream
@@ -741,6 +748,10 @@ pub async fn import_batch(
// carries its individual count regardless of either total.
let mut warn_issues: Vec = Vec::new();
let mut warn_total: usize = 0;
+ // Dry run only: what earlier batches of this stream would have
+ // interned by the time each batch applies for real — see
+ // [`crate::ingest::PreviewSeeds`].
+ let mut seeds = crate::ingest::PreviewSeeds::default();
for (index, batch) in stream.batches.iter().enumerate() {
// Each landed batch is durable (retract-then-apply), so a
// budget that runs out partway is safe to report as a
@@ -804,7 +815,11 @@ pub async fn import_batch(
)));
}
let applied = if query.dry_run {
- crate::ingest::preview_batch(&state, batch)
+ let previewed = crate::ingest::preview_batch(&state, batch, &seeds);
+ if previewed.is_ok() {
+ seeds.absorb(batch);
+ }
+ previewed
} else {
crate::ingest::apply_batch(&state, batch)
};
diff --git a/src/api/promote.rs b/src/api/promote.rs
new file mode 100644
index 00000000..a04dda05
--- /dev/null
+++ b/src/api/promote.rs
@@ -0,0 +1,560 @@
+//! `POST /contexts/{name}/promote` (ADR 0018): graph-path promotion —
+//! the named scratch sources move into an established destination
+//! context as export → filter → re-head → import → audit, one
+//! request, built from the same machinery the manual runbook uses
+//! (docs/promotion.html). No LLM anywhere in the path: the scratch's
+//! structure is already right, so the transfer is the export/import
+//! round trip, never a re-extraction. The judgment points stay with
+//! the caller — WHICH sources to promote (this request's input), what
+//! to do with the audit's candidates (its output), and when to retire
+//! the promoted scratch (`retract_source`, never here).
+
+use std::collections::{BTreeSet, HashMap};
+use std::time::Instant;
+
+use axum::extract::State;
+use axum::response::Response;
+use serde::{Deserialize, Serialize};
+
+use taguru::deadline::Deadline;
+
+use crate::limits::HeavyOpsLimiter;
+use crate::metrics::ErrorKind;
+use crate::registry::{AccessError, AppState};
+
+use super::consolidation::{
+ CONSOLIDATION_DETECTOR, ConsolidationAudit, DEFAULT_EVIDENCE_CAP, contradiction_section,
+ merge_section, staleness_section,
+};
+use super::import::{
+ ImportOutcome, import_batch_note, import_outcome, import_refusal, schema_issues_in_batch,
+ stream_integrity,
+};
+use super::{
+ AppJson, AppPath, AppQuery, ErrorCode, Issue, RefusalDetail, access_error, deadline_exceeded,
+ error, key_name, ok_with_issues_total, validation_error,
+};
+
+#[derive(Debug, Deserialize)]
+pub struct PromoteRequest {
+ /// The destination context — must already exist; promote never
+ /// creates one.
+ pub into: String,
+ /// The scratch source ids to promote. Every one must exist in the
+ /// promoting context (a passage or a live attribution) or the
+ /// request refuses whole — under retract-then-apply a mistyped
+ /// session id would otherwise no-op silently.
+ pub sources: Vec,
+ /// Omitted means true: after a real apply, the destination gets
+ /// the default consolidation audit (ADR 0012 — all three checks,
+ /// default ceilings) and its candidates ride back under `audit`.
+ pub audit: Option,
+}
+
+/// `?dry_run=true`: preview the same `{batches: [...]}` with nothing
+/// written — [`crate::ingest::preview_batch`]'s counts, no audit
+/// (nothing landed to audit).
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct PromoteQuery {
+ pub dry_run: bool,
+}
+
+/// What one promotion accomplished: `/import`'s per-batch outcome
+/// shape (one entry per promoted source, source-id order), the alias
+/// accounting, and the landing zone's audit candidates.
+#[derive(Serialize)]
+pub struct PromoteOutcome {
+ pub batches: Vec,
+ /// Scratch aliases whose canonical had no live edge in the
+ /// promoted slice — left behind, and counted rather than silent
+ /// (the export render's own accounting).
+ pub aliases_dropped: usize,
+ /// The destination's consolidation audit after the apply —
+ /// candidates for the caller to judge, never applications. Absent
+ /// on `dry_run`, on `audit: false`, and when the audit itself
+ /// could not run (`audit_skipped` says why).
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub audit: Option,
+ /// Why `audit` is absent when it was supposed to run
+ /// (`"overloaded"` — the heavy-ops ceiling, `"deadline_exceeded"`,
+ /// `"no_context"`, `"metadata_unreadable"`, `"unavailable"`) — the
+ /// writes above are already durable by audit time, so an audit
+ /// failure degrades to this note instead of failing a request
+ /// whose batches landed. `audit_consolidation` re-runs it
+ /// standalone.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub audit_skipped: Option<&'static str>,
+}
+
+#[allow(clippy::too_many_arguments)] // one axum extractor per concern; the router supplies them all
+pub async fn promote_sources(
+ State(state): State,
+ AppPath(name): AppPath,
+ key: Option>,
+ scope: Option>,
+ axum::Extension(deadline): axum::Extension,
+ axum::Extension(heavy_ops): axum::Extension,
+ AppQuery(query): AppQuery,
+ AppJson(request): AppJson,
+) -> Response {
+ let started_at = Instant::now();
+ if request.sources.is_empty() {
+ return error(
+ ErrorCode::InvalidArgument,
+ "'sources' must name at least one source id to promote — choosing which \
+ sources are the keepers is the caller's judgment, never a default",
+ started_at,
+ );
+ }
+ if request.into == name {
+ return error(
+ ErrorCode::InvalidArgument,
+ format!(
+ "'into' names the promoting context '{name}' itself — promotion moves \
+ sources into a DIFFERENT, established context"
+ ),
+ started_at,
+ );
+ }
+ // The reserved export ids are stream artifacts, not sources: the
+ // unsourced residual deliberately cannot travel with a promotion
+ // (only NAMED sources' shares move), and the empty sentinel labels
+ // a header-only batch. Naming either is refused rather than left
+ // to surprise — without this, a literal `export:unsourced`
+ // attribution (a prior import round trip's residue) would slip
+ // through the filter as sourceless weight.
+ if let Some(reserved) = [
+ taguru::context::UNSOURCED_SOURCE,
+ crate::export::EMPTY_SOURCE,
+ ]
+ .iter()
+ .find(|id| request.sources.iter().any(|source| source == *id))
+ {
+ return error(
+ ErrorCode::InvalidArgument,
+ format!(
+ "source id '{reserved}' is reserved by export — promotion moves named \
+ sources' own shares; sourceless weight cannot travel"
+ ),
+ started_at,
+ );
+ }
+ // The destination lives in the BODY, out of the route-level
+ // authorization check's reach — a context-scoped key is judged
+ // here instead, before anything applies (`/import`'s discipline).
+ if let Some(axum::Extension(scope)) = &scope
+ && !scope.allows_context(&request.into)
+ {
+ return validation_error(
+ ErrorCode::Forbidden,
+ format!(
+ "key '{}' has no grant on context '{}' ('into'); nothing was applied",
+ key_name(&key),
+ request.into
+ ),
+ RefusalDetail {
+ integrity: Some("nothing_written"),
+ ..Default::default()
+ },
+ started_at,
+ );
+ }
+ if deadline.expired() {
+ return deadline_exceeded(started_at);
+ }
+ // Promote never creates the destination — checked up front so a
+ // typo'd name refuses before the scratch is even materialized (a
+ // deletion between here and the apply is still caught: the create
+ // blocks are stripped below, so `apply_batch` answers NoContext).
+ if !state.context_exists(&request.into) {
+ return validation_error(
+ ErrorCode::NoContext,
+ format!(
+ "context '{}' ('into') does not exist — promotion lands in an \
+ established context and never creates one; create it first",
+ request.into
+ ),
+ RefusalDetail {
+ integrity: Some("nothing_written"),
+ retryable_after_correction: Some(true),
+ ..Default::default()
+ },
+ started_at,
+ );
+ }
+ let requested: BTreeSet = request.sources.iter().cloned().collect();
+ let snapshot = match tokio::task::block_in_place(|| state.export_context(&name, deadline)) {
+ Ok(snapshot) => snapshot,
+ Err(failure) => return access_error(&state, failure, &name, started_at),
+ };
+ // Every requested id must exist in the scratch — as a passage or a
+ // live attribution — or the request refuses whole, naming the
+ // absentees path-addressed. Nothing has been written yet.
+ let available = crate::export::available_sources(&snapshot);
+ let issues: Vec = request
+ .sources
+ .iter()
+ .enumerate()
+ .filter(|(_, source)| !available.contains(source.as_str()))
+ .map(|(index, source)| {
+ Issue::unknown_reference(
+ format!("sources[{index}]"),
+ format!("'{source}' stored in context '{name}' as a passage or a live attribution"),
+ )
+ })
+ .collect();
+ if !issues.is_empty() {
+ return validation_error(
+ ErrorCode::NoSource,
+ format!(
+ "{} of the named source id(s) exist(s) nowhere in context '{name}' — \
+ under retract-then-apply a mistyped id would no-op silently, so the \
+ whole request refuses instead; nothing was applied",
+ issues.len()
+ ),
+ RefusalDetail {
+ issues,
+ integrity: Some("nothing_written"),
+ retryable_after_correction: Some(true),
+ ..Default::default()
+ },
+ started_at,
+ );
+ }
+ // The transfer IS the export/import round trip: filter the
+ // snapshot to the keepers, render an ordinary import stream headed
+ // at the destination, parse it back, apply batch by batch.
+ let filtered = crate::export::filter_to_sources(snapshot, &requested);
+ let rendered = match tokio::task::block_in_place(|| {
+ crate::export::render(&request.into, &filtered, deadline)
+ }) {
+ Ok(rendered) => rendered,
+ // The reserved-id refusal above leaves the deadline as
+ // render's only reachable error here; the fallback keeps a
+ // future render error honest rather than mislabeled.
+ Err(message) => return render_refusal(message, deadline, started_at),
+ };
+ let mut stream = match crate::ingest::parse_stream(rendered.stream.as_bytes()) {
+ Ok(stream) => stream,
+ // Unreachable by construction — the stream is our own render —
+ // but a refusal here must not pretend anything was applied.
+ Err(message) => {
+ state.metrics().record_error(ErrorKind::Io);
+ return error(
+ ErrorCode::Internal,
+ format!("promotion stream failed to re-parse: {message}"),
+ started_at,
+ );
+ }
+ };
+ let total = stream.batches.len();
+ let outcome = tokio::task::block_in_place(|| {
+ let mut outcomes: Vec = Vec::with_capacity(total);
+ // `warn`-mode schema violations from the DESTINATION's schema,
+ // accumulated exactly as `/import` accumulates them.
+ let mut warn_issues: Vec = Vec::new();
+ let mut warn_total: usize = 0;
+ // Dry run only: what earlier batches of this stream would have
+ // interned by the time each batch applies for real — see
+ // [`crate::ingest::PreviewSeeds`].
+ let mut seeds = crate::ingest::PreviewSeeds::default();
+ for (index, batch) in stream.batches.iter_mut().enumerate() {
+ // Promotion lands in an established context, never mints
+ // one — see [`crate::ingest::Batch::strip_create`].
+ batch.strip_create();
+ if deadline.expired() {
+ return Err(Box::new(budget_refusal(
+ index,
+ total,
+ batch,
+ outcomes.len(),
+ query.dry_run,
+ started_at,
+ )));
+ }
+ // The destination's storage-quota pre-check, `/import`'s
+ // own batch-granular discipline: only growth is gated, the
+ // lanes are live, and the stop is a resumable prefix.
+ if !query.dry_run
+ && batch.carries_growth()
+ && let Some((used, ceiling)) = state.storage_quota_refusal(&batch.context)
+ {
+ state.metrics().record_storage_quota_refusal();
+ return Err(Box::new(quota_refusal(
+ index,
+ total,
+ batch,
+ outcomes.len(),
+ used,
+ ceiling,
+ started_at,
+ )));
+ }
+ let applied = if query.dry_run {
+ let previewed = crate::ingest::preview_batch(&state, batch, &seeds);
+ if previewed.is_ok() {
+ seeds.absorb(batch);
+ }
+ previewed
+ } else {
+ crate::ingest::apply_batch(&state, batch)
+ };
+ match applied {
+ Ok(applied) => {
+ // Retract-then-apply into the destination is as
+ // destructive as an import, and both context and
+ // source live in the body — one audit line each,
+ // naming the scratch the truth came FROM.
+ tracing::info!(
+ target: "taguru::audit",
+ key = %key_name(&key),
+ from = %name,
+ context = %batch.context,
+ source = %batch.source,
+ retracted = applied.retracted,
+ associations = applied.associations,
+ dry_run = query.dry_run,
+ "promote batch applied",
+ );
+ outcomes.push(import_outcome(batch, &applied));
+ warn_total += applied.schema_violations;
+ // Unconditional extend-then-truncate: each batch's
+ // own issues are already capped, so the transient
+ // overshoot is bounded at one batch's worth — and
+ // unlike a length guard there is no boundary
+ // comparison whose off-by-one the truncate would
+ // silently mask.
+ warn_issues.extend(schema_issues_in_batch(index, applied.schema_issues));
+ warn_issues.truncate(crate::api::MAX_LISTED_ISSUES);
+ }
+ Err(refusal) => {
+ let note = import_batch_note(
+ index,
+ total,
+ batch,
+ outcomes.len(),
+ query.dry_run,
+ ("would be refused", "refused"),
+ (
+ "fixing the scratch (or the destination) and re-running the \
+ preview is exact",
+ "fixing the scratch (or the destination) and re-calling \
+ promote is exact (each batch replaces its own source)",
+ ),
+ );
+ return Err(Box::new(import_refusal(
+ &state,
+ batch,
+ refusal,
+ ¬e,
+ index,
+ outcomes.len(),
+ query.dry_run,
+ started_at,
+ )));
+ }
+ }
+ }
+ Ok((outcomes, warn_issues, warn_total))
+ });
+ let (outcomes, warn_issues, warn_total) = match outcome {
+ Ok(applied) => applied,
+ Err(refusal) => return *refusal,
+ };
+ // The landing zone's audit (runbook step 4), bundled so the step
+ // people skip runs by default — candidates only; judging them and
+ // applying judgments stay ordinary follow-up calls. By this point
+ // every batch above is durable, so an audit failure degrades to
+ // `audit_skipped` instead of failing a request that already wrote.
+ // The audit is the request's only heavy half (ADR 0012 §8: every
+ // consolidation section is O(edges) or worse), so it alone spends
+ // a heavy-ops permit — `audit_drift`'s conditional pattern — held
+ // for the whole `landing_audit` call; at the ceiling it degrades
+ // (`"overloaded"`) rather than shedding the completed write.
+ let (audit, audit_skipped) = if query.dry_run || !request.audit.unwrap_or(true) {
+ (None, None)
+ } else {
+ match heavy_ops.try_acquire() {
+ Err(_shed) => (None, Some("overloaded")),
+ Ok(_permit) => {
+ match tokio::task::block_in_place(|| landing_audit(&state, &request.into, deadline))
+ {
+ Ok(audit) => (Some(audit), None),
+ Err(reason) => (None, Some(reason)),
+ }
+ }
+ }
+ };
+ ok_with_issues_total(
+ PromoteOutcome {
+ batches: outcomes,
+ aliases_dropped: rendered.aliases_dropped,
+ audit,
+ audit_skipped,
+ },
+ warn_issues,
+ warn_total,
+ started_at,
+ )
+}
+
+/// The destination's consolidation audit with `audit_consolidation`'s
+/// own defaults — all three checks, limit 100, evidence cap
+/// [`DEFAULT_EVIDENCE_CAP`], floors 0.6. Tuned re-runs belong to the
+/// standalone endpoint (fingerprint reuse makes them cheap); the
+/// error is a machine-readable reason for [`PromoteOutcome::
+/// audit_skipped`], never a refusal.
+fn landing_audit(
+ state: &AppState,
+ into: &str,
+ deadline: Deadline,
+) -> Result {
+ let hidden = state.hidden_label(into);
+ let effective: HashMap = match state.source_effective_times(into) {
+ None => return Err("no_context"),
+ Some(Err(io_error)) => {
+ tracing::warn!(context = %into, "source metadata read failed: {io_error}");
+ state.metrics().record_error(ErrorKind::Load);
+ return Err("metadata_unreadable");
+ }
+ Some(Ok(map)) => map,
+ };
+ let merge = merge_section(
+ state,
+ into,
+ 0.6,
+ 0.6,
+ DEFAULT_EVIDENCE_CAP,
+ 100,
+ hidden,
+ deadline,
+ )
+ .map_err(audit_skip_reason)?;
+ let contradiction = state
+ .read_context(into, |context| {
+ contradiction_section(context, &effective, 100, deadline)
+ })
+ .and_then(std::convert::identity)
+ .map_err(audit_skip_reason)?;
+ let staleness = state
+ .read_context(into, |context| {
+ staleness_section(context, &effective, 0, 100)
+ })
+ .map_err(audit_skip_reason)?;
+ Ok(ConsolidationAudit {
+ detector: CONSOLIDATION_DETECTOR.to_string(),
+ merge: Some(merge),
+ contradiction: Some(contradiction),
+ staleness: Some(staleness),
+ })
+}
+
+/// Maps a render failure onto the response. Reachable only when the
+/// budget dies inside the render (the reserved-id refusal runs before
+/// it, so the Conflict fallback is future-proofing, not a live path).
+#[mutants::skip] // both arms need a deadline that expires mid-render; timing cannot be pinned deterministically in tests
+fn render_refusal(message: String, deadline: Deadline, started_at: Instant) -> Response {
+ if deadline.expired() {
+ deadline_exceeded(started_at)
+ } else {
+ error(ErrorCode::Conflict, message, started_at)
+ }
+}
+
+/// The destination-over-quota refusal, `/import`'s own batch-granular
+/// pre-check report. Every promote batch targets ONE destination and
+/// always carries growth, so the deterministic firing shape is batch 1
+/// against a destination already over its ceiling (`landed` 0,
+/// `nothing_written` — tested); the `durable_prefix` shape needs the
+/// stream itself to cross the ceiling mid-loop, a live-lane timing no
+/// test can pin.
+#[mutants::skip]
+// the durable>0 half of stream_integrity's output is reachable only via mid-stream timing; the landed==0 half is asserted in tests
+#[allow(clippy::too_many_arguments)]
+fn quota_refusal(
+ index: usize,
+ total: usize,
+ batch: &crate::ingest::Batch,
+ landed: usize,
+ used: u64,
+ ceiling: u64,
+ started_at: Instant,
+) -> Response {
+ let note = import_batch_note(
+ index,
+ total,
+ batch,
+ landed,
+ false,
+ ("not previewed", "not attempted"),
+ (
+ "re-running the preview against a shrunk destination is exact",
+ "retracting or compacting the destination (or raising its quota), then \
+ re-calling promote is exact (each batch replaces its own source)",
+ ),
+ );
+ let (integrity, durable_batches) = stream_integrity(landed, false);
+ validation_error(
+ ErrorCode::StorageFull,
+ format!(
+ "{note}{}",
+ crate::registry::storage_quota_message(&batch.context, used, ceiling)
+ ),
+ RefusalDetail {
+ integrity: Some(integrity),
+ durable_batches,
+ ..Default::default()
+ },
+ started_at,
+ )
+}
+
+/// The refusal a budget spent partway through the apply loop answers —
+/// `/import`'s own resumable-prefix contract, promote's wording.
+#[mutants::skip] // reachable only when the budget dies mid-loop; timing cannot be pinned deterministically in tests
+fn budget_refusal(
+ index: usize,
+ total: usize,
+ batch: &crate::ingest::Batch,
+ landed: usize,
+ dry_run: bool,
+ started_at: Instant,
+) -> Response {
+ let note = import_batch_note(
+ index,
+ total,
+ batch,
+ landed,
+ dry_run,
+ ("not previewed", "not attempted"),
+ (
+ "re-running the preview with more time or fewer sources is exact",
+ "re-calling promote with the same sources is exact (each batch replaces its \
+ own source)",
+ ),
+ );
+ let (integrity, durable_batches) = stream_integrity(landed, dry_run);
+ validation_error(
+ ErrorCode::Timeout,
+ format!(
+ "{note}request exceeded its budget partway through the promotion \
+ (TAGURU_REQUEST_TIMEOUT_SECS tunes this)"
+ ),
+ RefusalDetail {
+ integrity: Some(integrity),
+ durable_batches,
+ ..Default::default()
+ },
+ started_at,
+ )
+}
+
+#[mutants::skip] // every arm needs the audit to fail AFTER a durable apply — a deadline dying between the two, or the destination vanishing mid-request; neither race can be pinned deterministically in tests
+fn audit_skip_reason(failure: AccessError) -> &'static str {
+ match failure {
+ AccessError::DeadlineExceeded => "deadline_exceeded",
+ AccessError::NotFound => "no_context",
+ _ => "unavailable",
+ }
+}
diff --git a/src/auth.rs b/src/auth.rs
index 9633e4f1..203d700a 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -827,6 +827,12 @@ pub(crate) fn required_role(method: &Method, route: &str) -> Role {
| (&Method::DELETE, "/contexts/{name}/aliases")
| (&Method::POST, "/contexts/{name}/sources")
| (&Method::POST, "/contexts/{name}/sources/retract")
+ // Promotion (ADR 0018) is per-source re-sync between two
+ // established contexts — retract_source's classification, not
+ // `/import`'s Admin: it cannot create contexts and carries no
+ // group or schema records. The destination named in the body
+ // is checked against the key's scope in the handler.
+ | (&Method::POST, "/contexts/{name}/promote")
| (&Method::POST, "/contexts/{name}/embeddings/refresh") => Role::Write,
// Operator verbs — and everything unclassified.
_ => Role::Admin,
@@ -1646,6 +1652,13 @@ mod tests {
required_role(&Method::POST, "/contexts/{name}/sources/retract"),
Role::Write
);
+ // Promotion (ADR 0018) shares retract_source's classification —
+ // per-source re-sync between established contexts, not the
+ // context-creating, group-carrying `/import`.
+ assert_eq!(
+ required_role(&Method::POST, "/contexts/{name}/promote"),
+ Role::Write
+ );
assert_eq!(required_role(&Method::POST, "/import"), Role::Admin);
assert_eq!(
required_role(&Method::DELETE, "/contexts/{name}"),
diff --git a/src/export.rs b/src/export.rs
index e052b3fd..a7818e35 100644
--- a/src/export.rs
+++ b/src/export.rs
@@ -293,6 +293,82 @@ pub(crate) fn render_group(name: &str, record: &GroupRecord) -> String {
line
}
+/// Filters a snapshot down to the named sources — the promote verb's
+/// half of "export → filter → re-head → import" (ADR 0018). Each
+/// association keeps only the named sources' live attributions, its
+/// `count`/`weight` recomputed from what is kept so [`render`]'s
+/// residual arithmetic finds nothing unsourced (the reserved
+/// `export:unsourced` batch cannot travel with a promotion); edges
+/// left with no attribution drop. Passages filter by source id.
+/// Aliases pass through untouched — [`render`]'s own live-canonical
+/// rule already drops the ones the kept slice no longer interns, with
+/// the dropped count reported, not silent. The schema is cleared: a
+/// promotion must never install the scratch's schema into the
+/// destination, whose own installed schema judges the incoming
+/// batches instead.
+pub(crate) fn filter_to_sources(
+ snapshot: ExportSnapshot,
+ sources: &BTreeSet,
+) -> ExportSnapshot {
+ let associations = snapshot
+ .associations
+ .into_iter()
+ .filter_map(|mut association| {
+ association.attributions.retain(|attribution| {
+ attribution.count > 0 && sources.contains(&attribution.source)
+ });
+ let count: u64 = association
+ .attributions
+ .iter()
+ .map(|attribution| attribution.count)
+ .sum();
+ if count == 0 {
+ return None;
+ }
+ let sum: f64 = association
+ .attributions
+ .iter()
+ .map(|attribution| attribution.weight)
+ .sum();
+ association.count = count;
+ association.weight = sum / count as f64;
+ Some(association)
+ })
+ .collect();
+ ExportSnapshot {
+ meta: snapshot.meta,
+ associations,
+ concept_aliases: snapshot.concept_aliases,
+ label_aliases: snapshot.label_aliases,
+ passages: snapshot
+ .passages
+ .into_iter()
+ .filter(|(source, _)| sources.contains(source))
+ .collect(),
+ schema: None,
+ }
+}
+
+/// The source ids a snapshot actually holds — a passage, or a LIVE
+/// attribution (count-0 rows are retraction residue, not presence).
+/// The promote verb's existence check (ADR 0018): a requested id
+/// absent from this set refuses the request rather than silently
+/// no-opping under retract-then-apply.
+pub(crate) fn available_sources(snapshot: &ExportSnapshot) -> BTreeSet<&str> {
+ snapshot
+ .passages
+ .iter()
+ .map(|(source, _)| source.as_str())
+ .chain(snapshot.associations.iter().flat_map(|association| {
+ association
+ .attributions
+ .iter()
+ .filter(|attribution| attribution.count > 0)
+ .map(|attribution| attribution.source.as_str())
+ }))
+ .collect()
+}
+
/// One source's share of the stream, accumulated before rendering.
#[derive(Default)]
struct Bucket<'a> {
@@ -1486,6 +1562,126 @@ mod tests {
);
}
+ /// The promote verb's existence check (ADR 0018): a passage or a
+ /// LIVE attribution counts as presence; a count-0 attribution is
+ /// retraction residue and must not — an id present only that way
+ /// would promote as an empty no-op instead of refusing.
+ #[test]
+ fn available_sources_sees_passages_and_live_attributions_only() {
+ let edge = association(
+ 1,
+ vec![
+ Attribution {
+ source: "live.md".to_string(),
+ weight: 1.0,
+ count: 1,
+ paragraph: None,
+ },
+ Attribution {
+ source: "dead.md".to_string(),
+ weight: 0.0,
+ count: 0,
+ paragraph: None,
+ },
+ ],
+ );
+ let mut snapshot = snapshot(vec![edge]);
+ snapshot.passages = vec![("note.md".to_string(), PassageRecord::for_tests("原文"))];
+
+ let available = available_sources(&snapshot);
+ assert!(available.contains("note.md"), "a passage is presence");
+ assert!(
+ available.contains("live.md"),
+ "a live attribution is presence"
+ );
+ assert!(
+ !available.contains("dead.md"),
+ "retraction residue is not presence: {available:?}"
+ );
+ }
+
+ /// The promote verb's filter (ADR 0018): each edge keeps only the
+ /// named sources' live shares with count/weight recomputed from
+ /// what is kept, edges left with nothing drop, and — because
+ /// attributed count now equals total by construction — the
+ /// rendered stream carries no `export:unsourced` residual batch.
+ #[test]
+ fn filtering_to_sources_keeps_only_their_shares_and_no_residual() {
+ let keep: BTreeSet = ["a.md".to_string()].into();
+ let shared = association(
+ 4,
+ vec![
+ // Two assertions summing to 3.0: the recomputed weight
+ // must be the mean (1.5), never the product's shape.
+ Attribution {
+ source: "a.md".to_string(),
+ weight: 3.0,
+ count: 2,
+ paragraph: Some(4),
+ },
+ Attribution {
+ source: "b.md".to_string(),
+ weight: 1.0,
+ count: 1,
+ paragraph: None,
+ },
+ // The retracted share of the kept source stays dead.
+ Attribution {
+ source: "a.md".to_string(),
+ weight: 0.0,
+ count: 0,
+ paragraph: None,
+ },
+ ],
+ );
+ let mut theirs = association(
+ 1,
+ vec![Attribution {
+ source: "b.md".to_string(),
+ weight: 1.0,
+ count: 1,
+ paragraph: None,
+ }],
+ );
+ theirs.object = "別件".to_string();
+ let mut snapshot = snapshot(vec![shared, theirs]);
+ snapshot.passages = vec![
+ ("a.md".to_string(), PassageRecord::for_tests("甲")),
+ ("b.md".to_string(), PassageRecord::for_tests("乙")),
+ ];
+ snapshot.schema = Some(warn_schema_document());
+
+ let filtered = filter_to_sources(snapshot, &keep);
+ assert_eq!(filtered.associations.len(), 1, "b.md's own edge drops");
+ let edge = &filtered.associations[0];
+ assert_eq!(edge.count, 2, "only a.md's live share remains");
+ assert_eq!(edge.weight, 1.5, "sum 3.0 over count 2 — the mean");
+ assert_eq!(edge.attributions.len(), 1);
+ assert_eq!(edge.attributions[0].source, "a.md");
+ assert_eq!(
+ filtered.passages.len(),
+ 1,
+ "passages filter by source id too"
+ );
+ assert!(
+ filtered.schema.is_none(),
+ "a promotion never carries the scratch's schema"
+ );
+
+ let rendered = render("perm", &filtered, Deadline::unbounded()).unwrap();
+ assert_eq!(rendered.batches, 1, "{}", rendered.stream);
+ assert!(
+ !rendered.stream.contains(UNSOURCED_SOURCE),
+ "no residual may travel with a promotion: {}",
+ rendered.stream
+ );
+ assert!(
+ rendered.stream.contains("\"paragraph\":4"),
+ "the kept share's locator survives: {}",
+ rendered.stream
+ );
+ }
+
#[test]
fn fully_retracted_edges_render_as_nothing() {
let rendered = render(
diff --git a/src/ingest.rs b/src/ingest.rs
index 99829c97..c558cfca 100644
--- a/src/ingest.rs
+++ b/src/ingest.rs
@@ -100,7 +100,9 @@ use remote::{expand, run_remote};
use report::report;
pub(crate) use model::{Batch, parse_batch, parse_stream, split_batches};
-pub(crate) use rejection::{AliasRejection, Applied, ApplyRefusal, apply_batch, preview_batch};
+pub(crate) use rejection::{
+ AliasRejection, Applied, ApplyRefusal, PreviewSeeds, apply_batch, preview_batch,
+};
pub(crate) use report::init_logging;
pub(crate) use schema_apply::{SchemaApplyError, apply_schema_record};
diff --git a/src/ingest/model.rs b/src/ingest/model.rs
index ce389cd6..fa953956 100644
--- a/src/ingest/model.rs
+++ b/src/ingest/model.rs
@@ -62,6 +62,15 @@ impl Batch {
self.associations.len() + self.concepts.len() + self.labels.len()
}
+ /// Drops the header's create block. The promote verb (ADR 0018)
+ /// strips it from every batch of its re-headed stream so a
+ /// destination deleted mid-request refuses (`NoContext`) instead
+ /// of being resurrected under the scratch's meta — promotion lands
+ /// in an established context, never mints one.
+ pub(crate) fn strip_create(&mut self) {
+ self.create = None;
+ }
+
/// Whether applying this batch can grow the context: any passage
/// or graph payload counts (questions/sections/locators ride the
/// passage).
diff --git a/src/ingest/rejection.rs b/src/ingest/rejection.rs
index 250f69fd..2fd5827d 100644
--- a/src/ingest/rejection.rs
+++ b/src/ingest/rejection.rs
@@ -263,15 +263,33 @@ fn corrected_associations(batch: &Batch, paragraph_count: Option) -> (Vec
/// the value `AppState::create` seeds a new context with. A context
/// that does not exist and brings no `create` block is left to the
/// ordinary `NoContext` refusal that follows this check.
-fn predicted_alias_rejection(state: &AppState, batch: &Batch) -> Option {
+fn predicted_alias_rejection(
+ state: &AppState,
+ batch: &Batch,
+ seeds: Option<&PreviewSeeds>,
+) -> Option {
if batch.concepts.is_empty() && batch.labels.is_empty() {
return None;
}
+ // Only THIS context's seeds may vouch for a canonical: a stream
+ // can interleave contexts, and a name an earlier batch interned
+ // into a sibling context proves nothing about this one — the real
+ // apply would still refuse `UnknownCanonical` here.
+ let seeded = seeds.and_then(|seeds| seeds.interned_in(&batch.context));
let concepts = batch
.associations
.iter()
- .flat_map(|op| [op.subject.as_str(), op.object.as_str()]);
- let labels = batch.associations.iter().map(|op| op.label.as_str());
+ .flat_map(|op| [op.subject.as_str(), op.object.as_str()])
+ .chain(
+ seeded
+ .into_iter()
+ .flat_map(|seeds| seeds.concepts.iter().map(String::as_str)),
+ );
+ let labels = batch.associations.iter().map(|op| op.label.as_str()).chain(
+ seeded
+ .into_iter()
+ .flat_map(|seeds| seeds.labels.iter().map(String::as_str)),
+ );
let check = move |context: &Context| -> Option {
if let Err((alias, canonical, error)) =
context.check_concept_aliases(&batch.concepts, concepts)
@@ -295,7 +313,10 @@ fn predicted_alias_rejection(state: &AppState, batch: &Batch) -> Option Option,
+}
+
+/// One context's share of a [`PreviewSeeds`]: the names its earlier
+/// batches would intern.
+#[derive(Default)]
+pub(crate) struct ContextSeeds {
+ concepts: BTreeSet,
+ labels: BTreeSet,
+}
+
+impl PreviewSeeds {
+ /// Records what `batch` would intern once applied — call after the
+ /// batch previews clean, before the next batch previews.
+ pub(crate) fn absorb(&mut self, batch: &Batch) {
+ let seeds = self.contexts.entry(batch.context.clone()).or_default();
+ seeds.concepts.extend(batch.concept_vocabulary());
+ seeds.labels.extend(batch.label_vocabulary());
+ }
+
+ /// Whether an earlier batch of this previewed stream already
+ /// landed in `context` — creating it if it did not exist.
+ fn reaches(&self, context: &str) -> bool {
+ self.contexts.contains_key(context)
+ }
+
+ /// What this stream's earlier batches intern in `context` — and
+ /// ONLY there; a sibling context's names never vouch here.
+ fn interned_in(&self, context: &str) -> Option<&ContextSeeds> {
+ self.contexts.get(context)
+ }
+}
+
/// `warn`-mode schema violations this batch's own associations raised
/// (ADR 0009 §8.3), capped like every other collect-all pass — empty
/// whenever the batch is clean, the context has no schema, or the
@@ -489,7 +562,9 @@ fn predicted_schema_rejection(
/// retract-then-apply idempotency already makes the repair exact, so
/// detection is the remaining gap.
pub(crate) fn apply_batch(state: &AppState, batch: &Batch) -> Result {
- if let Some(rejection) = predicted_alias_rejection(state, batch) {
+ // No seeds: earlier batches of a real stream have actually landed,
+ // so the live context already holds whatever they interned.
+ if let Some(rejection) = predicted_alias_rejection(state, batch, None) {
return Err(ApplyRefusal::Rejected(rejection));
}
let schema_warnings = predicted_schema_rejection(state, batch, CheckPurpose::Apply)?;
@@ -705,32 +780,43 @@ pub(crate) fn apply_batch(state: &AppState, batch: &Batch) -> Result Result {
- if let Some(rejection) = predicted_alias_rejection(state, batch) {
+pub(crate) fn preview_batch(
+ state: &AppState,
+ batch: &Batch,
+ seeds: &PreviewSeeds,
+) -> Result {
+ if let Some(rejection) = predicted_alias_rejection(state, batch, Some(seeds)) {
return Err(ApplyRefusal::Rejected(rejection));
}
let schema_warnings = predicted_schema_rejection(state, batch, CheckPurpose::Preview)?;
- let created = state.directory_entry(&batch.context).is_none();
+ let exists = state.directory_entry(&batch.context).is_some();
+ // An earlier batch of this previewed stream reaching the context
+ // stands in for its create — the real stream's first batch will
+ // have created it by the time this one applies.
+ let seeded = !exists && seeds.reaches(&batch.context);
+ let created = !exists && !seeded;
if created && batch.create.is_none() {
return Err(ApplyRefusal::NoContext(batch.context.clone()));
}
- // A context about to be created has nothing to retract from yet.
- let retracted = if created {
- 0
- } else {
+ // A context about to be created — by this batch or an earlier one
+ // of the same previewed stream — has nothing to retract from yet.
+ let retracted = if exists {
state
.count_source_edges(&batch.context, &batch.source)
.map_err(ApplyRefusal::Access)?
+ } else {
+ 0
};
// Mirrors apply_batch's tolerance for a passage-store read that
// fails: retract_source warns and reports no removal rather than
// failing the whole batch, so the preview falls back the same way.
- let had_passage = state
- .passage_sources(&batch.context)
- .and_then(Result::ok)
- .is_some_and(|sources| sources.contains(&batch.source));
+ let had_passage = exists
+ && state
+ .passage_sources(&batch.context)
+ .and_then(Result::ok)
+ .is_some_and(|sources| sources.contains(&batch.source));
let passage_dropped = had_passage && batch.passage.is_none();
let paragraph_count = batch
diff --git a/src/ingest/tests.rs b/src/ingest/tests.rs
index 56476023..40112abd 100644
--- a/src/ingest/tests.rs
+++ b/src/ingest/tests.rs
@@ -931,6 +931,31 @@ fn empty_subject_label_or_object_is_refused() {
assert_eq!(batch.associations.len(), 1);
}
+/// `strip_create` is what lets promote guarantee "never mints the
+/// destination" (ADR 0018): the stripped batch must refuse a missing
+/// context outright where the unstripped one would create it.
+#[test]
+fn a_stripped_create_block_downgrades_creation_to_a_no_context_refusal() {
+ let dir = std::env::temp_dir().join(format!("taguru-ingest-strip-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ let state = AppState::boot(dir.clone(), usize::MAX, None).unwrap();
+
+ let mut batch = parse(
+ "{\"taguru_batch\": 1, \"context\": \"perm\", \"source\": \"doc-1\", \"create\": {}}\n\
+ {\"subject\": \"蔵\", \"label\": \"杜氏\", \"object\": \"高瀬\", \"weight\": 1.0}\n",
+ )
+ .unwrap();
+ batch.strip_create();
+ assert!(batch.create.is_none());
+ let refusal = apply_batch(&state, &batch).unwrap_err();
+ assert!(
+ matches!(refusal, ApplyRefusal::NoContext(ref context) if context == "perm"),
+ "{refusal:?}"
+ );
+
+ let _ = fs::remove_dir_all(&dir);
+}
+
#[test]
fn a_line_that_is_no_known_shape_names_the_known_shapes() {
let error = parse(&format!("{HEADER}\n{{\"foo\": 1}}\n")).unwrap_err();
@@ -1154,7 +1179,7 @@ fn apply_and_preview_agree_that_a_replaced_passage_is_not_dropped() {
)
.unwrap();
- let previewed = preview_batch(&state, &reimport).unwrap();
+ let previewed = preview_batch(&state, &reimport, &PreviewSeeds::default()).unwrap();
assert!(
!previewed.passage_dropped,
"preview: a replacement passage was carried, so nothing was dropped"
diff --git a/src/llm-protocol.md b/src/llm-protocol.md
index 950b219d..23052250 100644
--- a/src/llm-protocol.md
+++ b/src/llm-protocol.md
@@ -362,6 +362,7 @@ Source code takes the same discipline; only the naming changes.
| POST | `/contexts/{name}/schema/audit` | `{limit?, after?}` (body optional) → `{total, violations:[{association, issues}], untyped_concepts:{total, names}, undeclared_types:{total, names}, unknown_labels:{total, names}, reserved_alias_conflicts:{total, aliases}}` — judges every LIVE association against the installed document, the pre-existing violations `strict` can never surface on its own; candidates for review, never auto-fixed; only `violations` pages (`total` constant across pages, same cursor as recall/query); 404 `no_schema` without an installed document |
| POST | `/contexts/{name}/schema/validate` | `{document, limit?, after?}` → the same audit shape over the PROPOSED document, validated and evaluated without ever being persisted — the pre-flight before a `strict` flip; works identically with or without an installed schema |
| GET | `/contexts/{name}/export` | the context as an import batch stream (JSON Lines body, not the JSON envelope) — one batch per source, create block first, aliases last; `POST /import` (or `taguru import`) restores it, per-source retract-then-apply, answering `{batches: [...]}` in stream order (`taguru_group` records ride the same stream, restore after every batch as whole-record replaces, and answer under `groups: [...]`) |
+| POST | `/contexts/{name}/promote` | `{into, sources, audit?=true}`, `?dry_run=true` to preview → `{batches: [...], aliases_dropped, audit?, audit_skipped?}` graph-path memory promotion (ADR 0018, docs/promotion.html): move the named source ids from this (scratch) context into the established context `into` WITHOUT re-extraction — the export/import round trip in one call. Each source moves whole (passage, `date`, tags, only its own share of every edge's weight; aliases ride exactly when their canonical is live in the promoted slice, `aliases_dropped` counts the rest), source ids survive (promoted citations still name the session), and applying is per-source retract-then-apply — re-promoting is idempotent. `into` must exist (never created here; write grant checked like `/import`'s body contexts) and its own schema judges the incoming batches; a named source missing from the scratch refuses the WHOLE request path-addressed, `nothing_written`. After a real apply, `audit` carries `consolidation/audit`'s full default report on `into` (all three checks) — candidates to judge, never applied; `audit_skipped` names why it could not run (the batches are durable by then). The dry run previews the same `batches` shape, writes nothing, audits nothing. Retiring the promoted scratch stays an explicit `sources/retract` |
| POST | `/contexts/{name}/compact` | rebuild the image without dead records, and rewrite the passage log without retracted sources' text (admin; the context's requests wait out the rebuild) → `{bytes_before, bytes_after, dead_edges, aliases_dropped, passages_compacted}` |
| POST | `/flush` | force every context's unflushed state to disk now, ahead of the periodic flusher → the flushed context names; admin, server-wide (refused for a context-scoped key — the answer names every flushed context, grant or no grant) |
| POST | `/mcp` | the MCP Streamable HTTP transport, stateless profile: each POSTed JSON-RPC message answered as plain `application/json` (no SSE stream, no session id — the spec's stateless profile). Tool calls dispatch in process onto the routes above under the outer request's own auth, scope, deadline, and body cap — one client request, one budget, one log line; `initialize` hands out the same manual `GET /protocol` serves |
diff --git a/src/main.rs b/src/main.rs
index 5a6c39ee..45b2a423 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -804,6 +804,16 @@ fn routes(
// permit while its expensive branch actually runs.
let drift_audit_route = Router::new()
.route("/contexts/{name}/drift/audit", post(api::audit_drift))
+ // Promote (ADR 0018) is a write bundle whose default epilogue
+ // is the full consolidation audit — heavy only in that branch
+ // (`audit: false` and dry runs never touch it), so it carries
+ // the limiter as an extension like `audit_drift` rather than
+ // gating the write half behind the unconditional ceiling. At
+ // the ceiling the audit DEGRADES (`audit_skipped:
+ // "overloaded"`) instead of shedding: the batches are already
+ // durable by audit time, and a 503 would hide a completed
+ // write.
+ .route("/contexts/{name}/promote", post(api::promote_sources))
.route_layer(axum::Extension(heavy_ops_limiter));
Router::new()
diff --git a/src/mcp.rs b/src/mcp.rs
index a5da2177..fc642b19 100644
--- a/src/mcp.rs
+++ b/src/mcp.rs
@@ -63,7 +63,7 @@ mod tests {
"name": "ctx", "context": "ctx", "cue": "x", "concept": "x",
"origins": ["x"], "targets": ["y"], "associations": [], "passages": {},
"sources": ["s"], "source": "s", "query": "q", "paragraph": 0,
- "stream": "{}", "to": "ctx2", "expected": "x",
+ "stream": "{}", "to": "ctx2", "into": "ctx2", "expected": "x",
"subject": "s", "label": "l", "object": "o",
"checks": ["merge"],
"schema": 1, "mode": "strict", "closed_labels": false,
@@ -195,6 +195,55 @@ mod tests {
);
}
+ /// The promote tool (ADR 0018): `dry_run` rides the query string
+ /// like its `import`/`retract_source` siblings, the rest the body;
+ /// both required arguments refuse by name when omitted.
+ #[test]
+ fn promote_routes_with_dry_run_in_the_query_and_the_rest_in_the_body() {
+ let (method, path, body) = route_tool(
+ "promote",
+ &json!({
+ "context": "scratch-claude",
+ "into": "perm",
+ "sources": ["session:claude:a"],
+ "audit": false,
+ "dry_run": true
+ }),
+ )
+ .unwrap();
+ assert_eq!(method, "POST");
+ assert_eq!(path, "/contexts/scratch-claude/promote?dry_run=true");
+ let body = body.unwrap();
+ assert_eq!(body["into"], json!("perm"));
+ assert_eq!(body["sources"], json!(["session:claude:a"]));
+ assert_eq!(body["audit"], json!(false));
+ assert!(body.get("dry_run").is_none(), "{body}");
+
+ assert_eq!(
+ route_tool("promote", &json!({"context": "s", "sources": ["a"]})),
+ Err("missing required argument 'into'".to_string())
+ );
+ assert_eq!(
+ route_tool(
+ "promote",
+ &json!({"context": "s", "into": "p", "sources": null})
+ ),
+ Err("missing required argument 'sources'".to_string())
+ );
+
+ // The advertised schema matches the handler's own refusal of
+ // an empty list: schema-validating hosts stop `[]` client-side
+ // instead of paying a round trip for the same refusal.
+ let promote = tool_definitions()
+ .into_iter()
+ .find(|tool| tool["name"] == "promote")
+ .expect("promote is advertised");
+ assert_eq!(
+ promote["inputSchema"]["properties"]["sources"]["minItems"],
+ json!(1)
+ );
+ }
+
#[test]
fn unknown_tools_and_missing_arguments_are_refused() {
assert_eq!(
diff --git a/src/mcp/route.rs b/src/mcp/route.rs
index 3d36b66b..76650e55 100644
--- a/src/mcp/route.rs
+++ b/src/mcp/route.rs
@@ -369,6 +369,20 @@ pub fn route_tool(
)),
)
}
+ "promote" => {
+ let path = format!(
+ "{}/promote{}",
+ context_path("context")?,
+ query_string(arguments, &["dry_run"])?
+ );
+ need(arguments, "into")?;
+ need_present(arguments, "sources")?;
+ (
+ "POST",
+ path,
+ Some(pick(arguments, &["into", "sources", "audit"])),
+ )
+ }
"retract_source" => {
let path = format!(
"{}/sources/retract{}",
diff --git a/src/mcp/schema.rs b/src/mcp/schema.rs
index 99a888bc..835f08e2 100644
--- a/src/mcp/schema.rs
+++ b/src/mcp/schema.rs
@@ -572,6 +572,20 @@ pub(super) fn tool_definitions() -> Vec {
],
),
),
+ (
+ "promote",
+ "Graph-path memory promotion (docs/promotion.html, ADR 0018): move the named source ids from this (scratch) context into an established destination context, WITHOUT re-extraction — the transfer is the export/import round trip in one call. Each source moves whole (passage, date, tags, its share of every association's weight; aliases ride exactly when their canonical is live in the promoted slice, and aliases_dropped counts the rest), source ids survive so promoted citations still name the originating session, and applying is per-source retract-then-apply — re-promoting the same sources is idempotent. The destination must already exist (never created here) and its own schema judges the incoming batches. Every named source must exist in the scratch or the request refuses whole, path-addressed, nothing written. After a real apply the destination's consolidation audit (all three checks, default ceilings) rides back under `audit` — CANDIDATES to judge, never applied; `audit: false` skips it, and `audit_skipped` names why when it could not run (the batches are already durable by then). dry_run=true previews the same batches shape with nothing written and no audit. What stays yours: choosing WHICH sources to promote (review first), judging the audit's candidates, and retiring the promoted scratch afterwards (retract_source) — forgetting is always explicit.",
+ object_schema(
+ json!({
+ "context": context,
+ "into": { "type": "string", "description": "the destination context (from list_contexts) — must already exist" },
+ "sources": { "type": "array", "minItems": 1, "items": { "type": "string" }, "description": "the scratch source ids to promote — every one must exist here" },
+ "audit": { "type": "boolean", "description": "run the destination's consolidation audit after the apply (default true; dry_run never audits)" },
+ "dry_run": { "type": "boolean", "description": "preview only — report the same batches shape, write nothing, no audit" }
+ }),
+ &["context", "into", "sources"],
+ ),
+ ),
(
"retract_source",
"Withdraw one source's (document's) contributions from graph and passage store. Diff sync for updated documents: retract the old version, then re-ingest the new. Concepts and edges remain; only weights come down. dry_run=true previews the same {associations_touched, passage_removed} with nothing written. Full erasure (the withdrawn bytes physically off disk) is retract, then compact.",
diff --git a/src/route.rs b/src/route.rs
index 2c37b446..0b32a819 100644
--- a/src/route.rs
+++ b/src/route.rs
@@ -86,6 +86,11 @@
//! - `/metrics` is router-shaped (`taguru_router_*`), not server-shaped.
//! - Renaming a context through the router works but leaves the map
//! pointing at the old name until the operator edits it.
+//! - `/contexts/{name}/promote` (ADR 0018) proxies whole to the shard
+//! owning the scratch `{name}`, so a destination mapped to another
+//! shard refuses there (`no_context`) — promotion through the
+//! router requires the pair on one shard. Cross-shard moves stay
+//! what they were: export, delete, remap, re-import.
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
diff --git a/tests/fixtures/wire/http/promote.json b/tests/fixtures/wire/http/promote.json
new file mode 100644
index 00000000..8e7bf0e2
--- /dev/null
+++ b/tests/fixtures/wire/http/promote.json
@@ -0,0 +1,57 @@
+{
+ "contract": "http_contract",
+ "method": "POST",
+ "operation": "promote",
+ "request": {
+ "into": "corpus-p",
+ "sources": [
+ "session:w:a"
+ ]
+ },
+ "response": {
+ "result": {
+ "aliases_dropped": 1,
+ "audit": {
+ "contradiction": {
+ "candidates": [],
+ "total": 0
+ },
+ "detector": "consolidation/1",
+ "merge": {
+ "candidates": [],
+ "semantic_note": "ベクトル未生成のため意味的検出はスキップ (POST embeddings/refresh を実行)",
+ "total": 0
+ },
+ "staleness": {
+ "candidates": [],
+ "total": 0,
+ "undatable": 0
+ }
+ },
+ "batches": [
+ {
+ "aliases": 1,
+ "association_paragraphs_dropped": 0,
+ "associations": 1,
+ "context": "corpus-p",
+ "created": false,
+ "locators_dropped": 0,
+ "locators_stored": 0,
+ "passage_dropped": false,
+ "passage_stored": true,
+ "questions_dropped": 0,
+ "questions_stored": 0,
+ "retracted": 0,
+ "schema_violations": 0,
+ "sections_dropped": 0,
+ "sections_stored": 0,
+ "source": "session:w:a"
+ }
+ ]
+ },
+ "status": "ok",
+ "time": 0.0
+ },
+ "route": "/contexts/{name}/promote",
+ "status": 200
+}
diff --git a/tests/fixtures/wire/http/promote_dry_run.json b/tests/fixtures/wire/http/promote_dry_run.json
new file mode 100644
index 00000000..10f846c6
--- /dev/null
+++ b/tests/fixtures/wire/http/promote_dry_run.json
@@ -0,0 +1,40 @@
+{
+ "contract": "http_contract",
+ "method": "POST",
+ "operation": "promote_dry_run",
+ "request": {
+ "into": "corpus-p",
+ "sources": [
+ "session:w:a"
+ ]
+ },
+ "response": {
+ "result": {
+ "aliases_dropped": 1,
+ "batches": [
+ {
+ "aliases": 1,
+ "association_paragraphs_dropped": 0,
+ "associations": 1,
+ "context": "corpus-p",
+ "created": false,
+ "locators_dropped": 0,
+ "locators_stored": 0,
+ "passage_dropped": false,
+ "passage_stored": true,
+ "questions_dropped": 0,
+ "questions_stored": 0,
+ "retracted": 0,
+ "schema_violations": 0,
+ "sections_dropped": 0,
+ "sections_stored": 0,
+ "source": "session:w:a"
+ }
+ ]
+ },
+ "status": "ok",
+ "time": 0.0
+ },
+ "route": "/contexts/{name}/promote?dry_run=true",
+ "status": 200
+}
diff --git a/tests/http_api/contract.rs b/tests/http_api/contract.rs
index a910fc6e..3b2b45e0 100644
--- a/tests/http_api/contract.rs
+++ b/tests/http_api/contract.rs
@@ -1131,6 +1131,89 @@ fn shapes_required_request_fields_are_present_in_every_matching_fixture() {
}
}
+// --- HTTP: graph-path promotion (#466 S2, ADR 0018) ---
+
+/// A fully-dated promotion corpus, one source promoted and one left
+/// behind — so the pinned response shows the alias accounting and an
+/// all-sections audit without a wall-clock value anywhere (the audit
+/// sections come back empty on this tiny corpus; their SHAPE is what
+/// the fixture pins).
+#[test]
+fn promote_applies_and_a_dry_run_previews() {
+ let server = Server::start("contract-promote");
+ server.ok(
+ "PUT",
+ "/contexts/scratch-w",
+ Some(json!({"description": "wire-contract session notes"})),
+ );
+ server.ok(
+ "PUT",
+ "/contexts/corpus-p",
+ Some(json!({"description": "wire-contract permanent corpus"})),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-w/associations",
+ Some(json!([
+ {"subject": "蔵", "label": "杜氏", "object": "高瀬", "weight": 1.0,
+ "source": "session:w:a", "paragraph": 0},
+ {"subject": "蔵", "label": "銘柄", "object": "青嶺", "weight": 1.0,
+ "source": "session:w:stay"},
+ ])),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-w/sources",
+ Some(json!({
+ "passages": {"session:w:a": "蔵の杜氏は高瀬。"},
+ "dates": {"session:w:a": 1000},
+ "tags": {"session:w:a": ["酒"]}
+ })),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-w/aliases",
+ Some(json!({"concepts": {"たかせ": "高瀬", "あおみね": "青嶺"}})),
+ );
+
+ let request = json!({"into": "corpus-p", "sources": ["session:w:a"]});
+ let (status, body) = server.call(
+ "POST",
+ "/contexts/scratch-w/promote?dry_run=true",
+ Some(request.clone()),
+ );
+ assert_eq!(status, 200, "{body}");
+ assert!(
+ body["result"].get("audit").is_none() && body["result"].get("audit_skipped").is_none(),
+ "a dry run omits the audit half entirely: {body}"
+ );
+ http_fixture(
+ "promote_dry_run",
+ "POST",
+ "/contexts/{name}/promote?dry_run=true",
+ Some(request.clone()),
+ status,
+ body,
+ );
+
+ let (status, body) = server.call("POST", "/contexts/scratch-w/promote", Some(request.clone()));
+ assert_eq!(status, 200, "{body}");
+ assert_eq!(body["result"]["aliases_dropped"], json!(1), "{body}");
+ assert_eq!(
+ body["result"]["audit"]["detector"],
+ json!("consolidation/1"),
+ "{body}"
+ );
+ http_fixture(
+ "promote",
+ "POST",
+ "/contexts/{name}/promote",
+ Some(request),
+ status,
+ body,
+ );
+}
+
// --- HTTP + MCP: consolidation audit (ADR 0012) ---
/// A fully-dated seed — every effective time explicit, so the pinned
diff --git a/tests/http_api/main.rs b/tests/http_api/main.rs
index 6a27e737..c35d3040 100644
--- a/tests/http_api/main.rs
+++ b/tests/http_api/main.rs
@@ -34,6 +34,7 @@ mod metrics;
mod observability;
mod offline_import;
mod passages;
+mod promote;
mod quotas;
mod reload;
mod remote_compact;
diff --git a/tests/http_api/promote.rs b/tests/http_api/promote.rs
new file mode 100644
index 00000000..9e45aa9c
--- /dev/null
+++ b/tests/http_api/promote.rs
@@ -0,0 +1,638 @@
+//! Graph-path promotion (ADR 0018), end to end: named scratch sources
+//! move whole — passage, date, tags, only their share of every edge —
+//! into an established destination, idempotently, with the landing
+//! zone's audit riding back; previews write nothing; refusals name
+//! their cause before anything applies.
+
+use serde_json::json;
+
+use crate::support::*;
+
+/// Scratch under the runbook conventions: two promotable sessions, one
+/// that stays behind, corroboration crossing the boundary, and one
+/// alias per fate (carried / left behind).
+fn seed(server: &Server) {
+ server.ok(
+ "PUT",
+ "/contexts/scratch-claude",
+ Some(json!({"description": "session notes"})),
+ );
+ server.ok(
+ "PUT",
+ "/contexts/perm",
+ Some(json!({"description": "permanent"})),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-claude/associations",
+ Some(json!([
+ {"subject": "DB", "label": "採用", "object": "PostgreSQL 16", "weight": 1.0,
+ "source": "session:claude:a/note", "paragraph": 0},
+ // Corroborated across the promotion boundary: only the
+ // promoted source's share may travel.
+ {"subject": "CI", "label": "テストランナー", "object": "cargo-nextest", "weight": 1.0,
+ "source": "session:claude:b"},
+ {"subject": "CI", "label": "テストランナー", "object": "cargo-nextest", "weight": 1.0,
+ "source": "session:claude:stay"},
+ {"subject": "旧鍵", "label": "管理者", "object": "山科", "weight": 1.0,
+ "source": "session:claude:stay"},
+ ])),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-claude/sources",
+ Some(json!({
+ "passages": {"session:claude:a/note": "DB は PostgreSQL 16。"},
+ "dates": {"session:claude:a/note": 1785974400},
+ "tags": {"session:claude:a/note": ["infra", "決定"]}
+ })),
+ );
+ server.ok(
+ "POST",
+ "/contexts/scratch-claude/aliases",
+ Some(json!({"concepts": {
+ "Postgres": "PostgreSQL 16",
+ "従来鍵": "旧鍵"
+ }})),
+ );
+}
+
+#[test]
+fn promotion_moves_named_sources_whole_and_is_idempotent() {
+ let server = Server::start("promote-moves");
+ seed(&server);
+
+ let promoted = server.ok(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:a/note", "session:claude:b"]
+ })),
+ );
+ // One batch per source, source-id order, the import outcome shape.
+ let batches = promoted["batches"].as_array().expect("batches");
+ assert_eq!(batches.len(), 2, "{promoted}");
+ assert_eq!(batches[0]["source"], json!("session:claude:a/note"));
+ assert_eq!(batches[1]["source"], json!("session:claude:b"));
+ assert_eq!(batches[0]["context"], json!("perm"));
+ assert_eq!(
+ batches[0]["created"],
+ json!(false),
+ "promotion must never create the destination: {promoted}"
+ );
+ assert_eq!(batches[0]["passage_stored"], json!(true));
+ // The 旧鍵 alias's canonical has no live edge in the promoted
+ // slice — left behind, and counted rather than silent.
+ assert_eq!(promoted["aliases_dropped"], json!(1), "{promoted}");
+ // The landing zone's audit rides back by default, all sections.
+ assert_eq!(promoted["audit"]["detector"], json!("consolidation/1"));
+ assert!(
+ promoted["audit"]["merge"].is_object()
+ && promoted["audit"]["contradiction"].is_object()
+ && promoted["audit"]["staleness"].is_object(),
+ "all three sections run by default: {promoted}"
+ );
+
+ // Provenance travels whole: the passage with its date and tags,
+ // under the SAME session source id.
+ let looked_up = server.ok(
+ "POST",
+ "/contexts/perm/sources/lookup",
+ Some(json!({"sources": ["session:claude:a/note"]})),
+ );
+ assert_eq!(
+ looked_up["passages"]["session:claude:a/note"],
+ json!("DB は PostgreSQL 16。"),
+ "{looked_up}"
+ );
+ let sources = server.ok("GET", "/contexts/perm/sources", None);
+ let entry = &sources["entries"][0];
+ assert_eq!(entry["name"], json!("session:claude:a/note"), "{sources}");
+ assert_eq!(entry["date"], json!(1785974400), "{sources}");
+ assert_eq!(entry["tags"], json!(["infra", "決定"]), "{sources}");
+
+ // Only the promoted source's share of a corroborated edge travels:
+ // the edge exists in perm attributed to session b alone, while the
+ // stay-behind source's share and its own fact never left scratch.
+ let runner = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "CI", "label": "テストランナー"})),
+ );
+ assert_eq!(runner["matches"][0]["count"], json!(1), "{runner}");
+ let attributions = runner["matches"][0]["attributions"]
+ .as_array()
+ .expect("attributions");
+ assert_eq!(attributions.len(), 1, "{runner}");
+ assert_eq!(attributions[0]["source"], json!("session:claude:b"));
+ let stayed = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "旧鍵"})),
+ );
+ assert_eq!(stayed["total"], json!(0), "{stayed}");
+ // The carried alias resolves in perm; the paragraph locator rides.
+ let db = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "DB", "label": "採用"})),
+ );
+ assert_eq!(db["matches"][0]["attributions"][0]["paragraph"], json!(0));
+ let resolved = server.ok(
+ "POST",
+ "/contexts/perm/resolve",
+ Some(json!({"cue": "Postgres"})),
+ );
+ assert_eq!(
+ resolved[0]["name"],
+ json!("PostgreSQL 16"),
+ "the live-canonical alias must be carried: {resolved}"
+ );
+ assert_eq!(resolved[0]["kind"], json!("alias"), "{resolved}");
+
+ // The scratch is untouched — retiring it stays an explicit step.
+ let scratch = server.ok(
+ "POST",
+ "/contexts/scratch-claude/query",
+ Some(json!({"subject": "旧鍵"})),
+ );
+ assert_eq!(scratch["total"], json!(1), "{scratch}");
+
+ // Re-promoting the same sources is retract-then-apply: weights do
+ // not double, and the outcome says what was replaced.
+ let again = server.ok(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:a/note", "session:claude:b"],
+ "audit": false
+ })),
+ );
+ assert!(
+ again["batches"][1]["retracted"].as_u64().unwrap() > 0,
+ "{again}"
+ );
+ assert!(
+ again.get("audit").is_none(),
+ "audit: false opts out: {again}"
+ );
+ let runner = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "CI", "label": "テストランナー"})),
+ );
+ assert_eq!(runner["matches"][0]["count"], json!(1), "{runner}");
+ assert_eq!(runner["matches"][0]["weight"], json!(1.0), "{runner}");
+}
+
+#[test]
+fn a_dry_run_previews_the_same_shape_and_writes_nothing() {
+ let server = Server::start("promote-dry-run");
+ seed(&server);
+
+ let preview = server.ok(
+ "POST",
+ "/contexts/scratch-claude/promote?dry_run=true",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:a/note", "session:claude:b"]
+ })),
+ );
+ assert_eq!(preview["batches"].as_array().map(Vec::len), Some(2));
+ assert!(
+ preview.get("audit").is_none() && preview.get("audit_skipped").is_none(),
+ "nothing landed, so there is nothing to audit: {preview}"
+ );
+ // A passage-less source into a destination that never stored one:
+ // the preview must not report a phantom passage drop.
+ assert_eq!(
+ preview["batches"][1]["passage_dropped"],
+ json!(false),
+ "{preview}"
+ );
+
+ let sources = server.ok("GET", "/contexts/perm/sources", None);
+ assert_eq!(sources["total"], json!(0), "{sources}");
+ let db = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "DB"})),
+ );
+ assert_eq!(db["total"], json!(0), "a preview must write nothing: {db}");
+}
+
+#[test]
+fn promotion_refusals_name_their_cause_before_anything_applies() {
+ let server = Server::start("promote-refusals");
+ seed(&server);
+
+ // No sources: choosing the keepers is the caller's judgment.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": []})),
+ );
+ assert_eq!(status, 400, "{refused}");
+ assert_eq!(refused["code"], json!("invalid_argument"), "{refused}");
+
+ // Promoting into itself moves nothing anywhere.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "scratch-claude", "sources": ["session:claude:b"]})),
+ );
+ assert_eq!(status, 400, "{refused}");
+ assert!(
+ refused["error"].as_str().unwrap().contains("DIFFERENT"),
+ "{refused}"
+ );
+
+ // A missing destination refuses — promote never creates one.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "nope", "sources": ["session:claude:b"]})),
+ );
+ assert_eq!(status, 404, "{refused}");
+ assert_eq!(refused["code"], json!("no_context"), "{refused}");
+ assert_eq!(refused["integrity"], json!("nothing_written"), "{refused}");
+ assert_eq!(
+ refused["retryable_after_correction"],
+ json!(true),
+ "{refused}"
+ );
+
+ // The reserved export ids are stream artifacts, never promotable —
+ // sourceless weight cannot travel with a promotion.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": ["export:unsourced"]})),
+ );
+ assert_eq!(status, 400, "{refused}");
+ assert!(
+ refused["error"].as_str().unwrap().contains("reserved"),
+ "{refused}"
+ );
+
+ // A mistyped source id refuses whole, path-addressed — under
+ // retract-then-apply it would otherwise no-op silently.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:b", "session:claude:typo"]
+ })),
+ );
+ assert_eq!(status, 404, "{refused}");
+ assert_eq!(refused["code"], json!("no_source"), "{refused}");
+ assert_eq!(
+ refused["issues"][0]["path"],
+ json!("sources[1]"),
+ "{refused}"
+ );
+ assert_eq!(refused["integrity"], json!("nothing_written"), "{refused}");
+ assert_eq!(
+ refused["retryable_after_correction"],
+ json!(true),
+ "{refused}"
+ );
+ let db = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "DB"})),
+ );
+ assert_eq!(db["total"], json!(0), "nothing may have applied: {db}");
+
+ // A fully-retracted source is no longer promotable: its dead
+ // attribution rows (count 0) must not count as "exists here".
+ server.ok(
+ "POST",
+ "/contexts/scratch-claude/sources/retract",
+ Some(json!({"source": "session:claude:b"})),
+ );
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": ["session:claude:b"]})),
+ );
+ assert_eq!(
+ status, 404,
+ "a retracted source has nothing left to promote: {refused}"
+ );
+ assert_eq!(refused["code"], json!("no_source"), "{refused}");
+}
+
+#[test]
+fn the_destination_schema_judges_the_promoted_batches() {
+ let server = Server::start("promote-schema");
+ seed(&server);
+ // A strict, closed destination that never declared 採用: the
+ // scratch's a/note batch must refuse whole, exactly as an import.
+ server.ok(
+ "PUT",
+ "/contexts/perm/schema",
+ Some(json!({
+ "schema": 1, "mode": "strict", "closed_labels": true,
+ "types": {},
+ "relations": {"テストランナー": {"domain": [], "range": []}}
+ })),
+ );
+
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": ["session:claude:a/note"]})),
+ );
+ assert_eq!(status, 400, "{refused}");
+ assert_eq!(refused["integrity"], json!("nothing_written"), "{refused}");
+ let db = server.ok(
+ "POST",
+ "/contexts/perm/query",
+ Some(json!({"subject": "DB"})),
+ );
+ assert_eq!(
+ db["total"],
+ json!(0),
+ "strict refuses the whole batch: {db}"
+ );
+
+ // The scratch's OWN (absent) schema never installs into the
+ // destination: the strict document above must survive a promotion
+ // of the schema-clean source.
+ server.ok(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": ["session:claude:b"], "audit": false})),
+ );
+ let schema = server.ok("GET", "/contexts/perm/schema", None);
+ assert_eq!(schema["mode"], json!("strict"), "{schema}");
+}
+
+/// The destination lives in the body, out of the route check's reach:
+/// a context-scoped key needs `into` in its grant too, and a grant
+/// covering both contexts clears the gate.
+#[test]
+fn a_scoped_key_needs_the_destination_in_its_grant() {
+ let server = Server::start_with_env(
+ "promote-scopes",
+ &[
+ ("TAGURU_API_TOKENS", "boss:atok,pair:ptok,half:htok"),
+ (
+ "TAGURU_KEY_SCOPES",
+ r#"{"pair": {"role": "write", "contexts": ["scratch-claude", "perm"]},
+ "half": {"role": "write", "contexts": ["scratch-claude"]}}"#,
+ ),
+ ],
+ );
+ let call = |method: &str, path: &str, body: Option, token: &str| {
+ server.call_with_token(method, path, body, Some(token))
+ };
+ for (context, description) in [("scratch-claude", "notes"), ("perm", "permanent")] {
+ let (status, body) = call(
+ "PUT",
+ &format!("/contexts/{context}"),
+ Some(json!({"description": description})),
+ "atok",
+ );
+ assert_eq!(status, 200, "{body}");
+ }
+ let (status, body) = call(
+ "POST",
+ "/contexts/scratch-claude/associations",
+ Some(
+ json!([{"subject": "DB", "label": "採用", "object": "PostgreSQL 16",
+ "weight": 1.0, "source": "session:claude:a"}]),
+ ),
+ "atok",
+ );
+ assert_eq!(status, 200, "{body}");
+
+ let request = json!({"into": "perm", "sources": ["session:claude:a"], "audit": false});
+ let (status, refused) = call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(request.clone()),
+ "htok",
+ );
+ assert_eq!(status, 403, "{refused}");
+ assert!(
+ refused["error"].as_str().unwrap().contains("'into'"),
+ "the refusal names which grant is missing: {refused}"
+ );
+ assert_eq!(refused["integrity"], json!("nothing_written"), "{refused}");
+
+ let (status, promoted) = call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(request),
+ "ptok",
+ );
+ assert_eq!(
+ status, 200,
+ "a grant covering both contexts clears the gate: {promoted}"
+ );
+}
+
+/// The destination's storage quota gates promoted growth exactly as
+/// `/import` gates a stream: batch-granular, checked before the batch
+/// is attempted, and never on a dry run (a preview writes nothing to
+/// gate — its capacity answers are advisory by documented contract).
+#[test]
+fn the_destination_quota_gates_growth_before_the_batch_is_attempted() {
+ let server = Server::start_with_env(
+ "promote-quota",
+ &[(
+ "TAGURU_CONTEXT_QUOTAS",
+ r#"{"perm": {"storage_bytes": 1, "cache_bytes": 1048576}}"#,
+ )],
+ );
+ seed(&server);
+ // Put the destination at its ceiling before the promotion — the
+ // quotas.rs pattern: the direct write's WAL bytes are what the
+ // live-lane pre-check reads.
+ server.ok(
+ "POST",
+ "/contexts/perm/associations",
+ Some(json!([{"subject": "蔵", "label": "杜氏", "object": "高瀬",
+ "weight": 1.0, "source": "keep.md"}])),
+ );
+
+ // A preview writes nothing, so the ceiling has nothing to gate.
+ let preview = server.ok(
+ "POST",
+ "/contexts/scratch-claude/promote?dry_run=true",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:a/note", "session:claude:b"]
+ })),
+ );
+ assert_eq!(preview["batches"].as_array().map(Vec::len), Some(2));
+
+ // For real: the first batch is stopped BEFORE it is attempted —
+ // nothing written, and the fields say so machine-readably.
+ let (status, refused) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({
+ "into": "perm",
+ "sources": ["session:claude:a/note", "session:claude:b"]
+ })),
+ );
+ assert_eq!(status, 507, "{refused}");
+ assert_eq!(refused["code"], json!("storage_full"), "{refused}");
+ assert_eq!(refused["integrity"], json!("nothing_written"), "{refused}");
+ let message = refused["error"].as_str().unwrap();
+ assert!(message.contains("not attempted"), "{message}");
+ assert!(message.contains("storage quota"), "{message}");
+}
+
+/// A `warn`-mode destination schema lets the promoted batches land and
+/// reports the violations in the success envelope, `/import`'s own
+/// accounting — the exact true count, not a truncation artifact.
+#[test]
+fn a_warn_mode_destination_reports_schema_violations_in_the_envelope() {
+ let server = Server::start("promote-warn-schema");
+ seed(&server);
+ server.ok(
+ "PUT",
+ "/contexts/perm/schema",
+ Some(json!({
+ "schema": 1, "mode": "warn", "closed_labels": true,
+ "types": {},
+ "relations": {"テストランナー": {"domain": [], "range": []}}
+ })),
+ );
+
+ let (status, body) = server.call(
+ "POST",
+ "/contexts/scratch-claude/promote",
+ Some(json!({"into": "perm", "sources": ["session:claude:a/note"], "audit": false})),
+ );
+ assert_eq!(status, 200, "warn lets the batch land: {body}");
+ assert_eq!(body["schema_violations"], json!(1), "{body}");
+ assert_eq!(
+ body["issues"][0]["path"]
+ .as_str()
+ .map(|path| path.starts_with("batches[0].")),
+ Some(true),
+ "{body}"
+ );
+ assert_eq!(
+ body["result"]["batches"][0]["schema_violations"],
+ json!(1),
+ "{body}"
+ );
+}
+
+/// The cross-batch preview fix the promote dry run exposed, pinned on
+/// `/import?dry_run=true` itself: export puts every alias on the LAST
+/// batch while its canonical is interned by an earlier one, so a dry
+/// run of a restore into a fresh name must seed each batch's check
+/// with what the batches before it would intern — without that, the
+/// preview refuses (spurious `UnknownCanonical`) a stream the real
+/// import applies cleanly.
+#[test]
+fn an_export_stream_dry_runs_clean_when_aliases_trail_their_canonicals() {
+ let server = Server::start("promote-preview-seeds");
+ seed(&server);
+
+ let (status, exported) = server.call("GET", "/contexts/scratch-claude/export", None);
+ assert_eq!(status, 200, "{exported}");
+ let stream = exported.as_str().expect("NDJSON body").replace(
+ "\"context\":\"scratch-claude\"",
+ "\"context\":\"fresh-restore\"",
+ );
+ assert!(
+ stream.lines().last().unwrap().contains("\"alias\""),
+ "the premise: aliases ride the last batch: {stream}"
+ );
+
+ let (status, previewed) = server.call_raw(
+ "POST",
+ "/import?dry_run=true",
+ Some(&stream),
+ Some("application/x-ndjson"),
+ );
+ assert_eq!(
+ status, 200,
+ "a dry run must refuse exactly what the real import would: {previewed}"
+ );
+ assert_eq!(
+ previewed["result"]["batches"].as_array().map(Vec::len),
+ Some(3),
+ "{previewed}"
+ );
+ let (status, _) = server.call("GET", "/contexts/fresh-restore", None);
+ assert_eq!(status, 404, "the preview must create nothing");
+}
+
+/// The per-context half of the same preview fix: a stream can
+/// interleave contexts, and a name interned into a SIBLING context
+/// vouches for nothing — the real import refuses the alias, so the
+/// dry run must refuse it identically instead of letting the sibling's
+/// vocabulary leak across.
+#[test]
+fn a_cross_context_stream_does_not_let_sibling_vocabulary_vouch() {
+ let server = Server::start("promote-preview-contexts");
+ // alpha interns Foo; beta's alias names Foo without interning it.
+ let stream = concat!(
+ "{\"taguru_batch\": 1, \"context\": \"alpha\", \"source\": \"a.md\", ",
+ "\"create\": {\"description\": \"a\"}}\n",
+ "{\"subject\": \"Foo\", \"label\": \"関連\", \"object\": \"Bar\", \"weight\": 1.0}\n",
+ "{\"taguru_batch\": 1, \"context\": \"beta\", \"source\": \"b.md\", ",
+ "\"create\": {\"description\": \"b\"}}\n",
+ "{\"subject\": \"X\", \"label\": \"関連\", \"object\": \"Y\", \"weight\": 1.0}\n",
+ "{\"alias\": \"ふー\", \"canonical\": \"Foo\", \"kind\": \"concept\"}\n",
+ );
+
+ let (status, previewed) = server.call_raw(
+ "POST",
+ "/import?dry_run=true",
+ Some(stream),
+ Some("application/x-ndjson"),
+ );
+ assert_eq!(
+ status, 409,
+ "the preview must refuse exactly what the real import refuses: {previewed}"
+ );
+ let (status, refused) = server.call_raw(
+ "POST",
+ "/import",
+ Some(stream),
+ Some("application/x-ndjson"),
+ );
+ assert_eq!(status, 409, "{refused}");
+}
+
+/// The advertised MCP tool drives the same endpoint through `/mcp` —
+/// the runbook's graph path is reachable without leaving the agent's
+/// tool surface.
+#[test]
+fn the_mcp_promote_tool_reaches_the_endpoint() {
+ let server = Server::start("promote-mcp");
+ seed(&server);
+
+ let result = server.call_tool(
+ 7,
+ "promote",
+ json!({
+ "context": "scratch-claude",
+ "into": "perm",
+ "sources": ["session:claude:a/note"],
+ "audit": false,
+ "dry_run": true
+ }),
+ );
+ let text = result["content"][0]["text"].as_str().expect("text");
+ assert!(text.contains("\"batches\""), "{result}");
+ let sources = server.ok("GET", "/contexts/perm/sources", None);
+ assert_eq!(
+ sources["total"],
+ json!(0),
+ "dry_run through the tool writes nothing"
+ );
+}