Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
126 changes: 126 additions & 0 deletions adr/0018-graph-path-promotion.md
Original file line number Diff line number Diff line change
@@ -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).
21 changes: 19 additions & 2 deletions docs/promotion.html
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,22 @@ <h2 id="procedure">The promotion procedure</h2>
<li><b>Review</b> the scratch context: <code>recall</code>/<code>query</code>, or
<code>taguru communities --context scratch-…</code> for a themed overview once the
scratch has grown.</li>
<li><b>Graph path — one call when the structure is already right</b> (ADR 0018):
when the keepers are the scratch's own structured associations (written by the
session itself, nothing left to extract), the <code>promote</code> MCP tool
(<code>POST /contexts/{scratch}/promote</code>, body
<code>{into, sources, audit?}</code>, <code>?dry_run=true</code> to preview)
bundles steps 4–5: each named source moves whole — passage, <code>date</code>,
tags, and only its <em>own</em> 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 (<code>audit: false</code>
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 <b>text path</b>, needed only
when the keepers exist as prose that still wants extraction.</li>
<li><b>Extract the keepers</b> with <code>taguru extract</code> over the session
passages (or hand-write the batch), targeting the <b>permanent</b> context — keeping
the <code>session:{agent}:{id}</code> source ids and the <code>date</code>s:
Expand Down Expand Up @@ -143,8 +159,9 @@ <h2 id="procedure">The promotion procedure</h2>
<section>
<h2 id="pitfalls">What not to do</h2>
<ul>
<li>Don't promote into a fresh spelling universe — the resolve-first rule in step 2 is
what keeps one referent one spelling.</li>
<li>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.</li>
<li>Don't invent an end date for a superseded fact: assert the new fact with its own
date; as-of queries (<code>until</code>) and the audit's dated rows sort the regimes
out (ADR 0011 §6).</li>
Expand Down
39 changes: 28 additions & 11 deletions examples/skills/memory-promotion/SKILL.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -45,29 +45,46 @@ 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.

## 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).
Expand Down
3 changes: 3 additions & 0 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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};
Expand Down
8 changes: 4 additions & 4 deletions src/api/consolidation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<String, u64>,
limit: usize,
Expand Down Expand Up @@ -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<String, u64>,
floor_secs: u64,
Expand Down
Loading