diff --git a/CHANGELOG.md b/CHANGELOG.md
index abc3a7a7..01c53222 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,16 @@ Entries that change an on-disk format or a response shape say so.
## [Unreleased]
### Added
+- `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
+ the `/{doc}` stem suffix across several documents, collisions
+ refused), and the passage line's `date` (`YYYY-MM-DD` or epoch
+ seconds) and `tags`. All three are manifest computation inputs
+ (`serde(default)` — older manifests keep matching default runs) but
+ deliberately not checkpoint inputs: a metadata change rewrites the
+ batch while reusing every cached chunk answer. `--date`/`--tag` with
+ `--no-passage` is a usage error (metadata rides the passage line).
- `taguru extract --coverage` / `TAGURU_EXTRACT_COVERAGE` (#496 S4,
ADR 0016): report every sentence that holds a candidate pair (two or
more deterministically segmented document names) yet is covered by
diff --git a/adr/0017-runbook-metadata-at-extract-time.md b/adr/0017-runbook-metadata-at-extract-time.md
new file mode 100644
index 00000000..88460cc7
--- /dev/null
+++ b/adr/0017-runbook-metadata-at-extract-time.md
@@ -0,0 +1,83 @@
+# 0017. Promotion-runbook metadata at extract time
+
+- **Status**: Accepted
+- **Date**: 2026-08-09
+- **Issue**: #466 (S1)
+- **Related**: #465 (the runbook whose conventions these flags encode),
+ ADR 0011 (why `date` is load-bearing), ADR 0005 (the batch contract
+ the metadata rides), issue #167 (the passage-line metadata fields)
+- **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
+
+How `taguru extract` writes the promotion runbook's source conventions
+— session source id, assertion date, topic tags — into the batches it
+emits (#466 S1). Out of scope: bundling the promotion sequence itself
+(a verb or MCP tool, #466's remaining splits), and the SDK producers.
+
+## 2. Context
+
+The 2026-08-08 runbook rehearsal (#466's gate record) found the single
+most mechanical step of every promotion: extract knows only document
+paths, so the operator rewrites each emitted batch's `source` to the
+runbook's `session:{agent}:{id}`, and hand-adds the `date` and `tags`
+the scratch conventions require — every time, purely mechanically,
+with a text editor against a generated file. The import wire format
+has carried all three fields since #167; extract simply never had a
+way to be told them.
+
+## 3. Decision
+
+**`--source-id ID`, `--date WHEN`, and `--tag TAG` (repeatable) bake
+the runbook's conventions into the written batch. All three are
+manifest computation inputs and none is a checkpoint input.**
+
+1. **`--source-id` replaces the header's source**: verbatim for a
+ single document; with several, each document gets `ID/{file stem}`
+ — the runbook's own `/{doc}` convention, made automatic because
+ import's retract-then-apply is per source id, and one id covering
+ two documents would silently fold them. Two documents whose stems
+ collide fail the second with the reason, before any model call.
+ The manifest stays keyed by the document PATH — the path names the
+ input; this names the output.
+2. **`--date` and `--tag` ride the passage line**, exactly where the
+ wire format carries source metadata. Requiring the passage is
+ therefore enforced as a usage error against `--no-passage`, not a
+ silent drop — an associations-only source stores no metadata and is
+ invisible to every windowed read (docs/promotion.html's own
+ warning). `--date` accepts epoch seconds (the wire unit) or
+ `YYYY-MM-DD` (what a session note records — that day's UTC
+ midnight, round-tripped through the rendering direction so a
+ non-existent date is refused rather than normalized).
+3. **Manifest inputs, not checkpoint inputs**: all three are baked
+ into the emitted file, so a change must rewrite the batch (the
+ `context`/`description` precedent — a skip would leave the old id
+ or date in place). But none of them reaches the prompt — the model
+ is still shown the document path — so cached chunk answers stay
+ reusable across a metadata change: the rewrite costs zero model
+ calls for checkpointed units. The fingerprint records the EFFECTIVE
+ written source (suffix included), so revising the suffix scheme
+ re-extracts too; `""`/`0`/`[]` are the off values, keeping pre-S1
+ manifest entries matching default runs.
+4. **Flag-only, no env counterparts**: a session id and its date are
+ per-invocation values, not deployment settings — the
+ `--context`/`--description` precedent, so `KNOWN_KEYS` and the
+ config dialect are untouched.
+
+## 4. Consequences
+
+- The runbook's step 2 loses its hand-editing: extract now emits an
+ import-ready promotion batch directly, and the `#496` controls
+ (`--vocabulary` for the resolve-first rule, `--coverage` for the
+ review's mechanical floor) compose with it — the flags are
+ orthogonal by construction.
+- Batches, manifests, and checkpoints from before this change parse
+ and match unchanged (`serde(default)` on the new manifest fields;
+ the no-flags batch is byte-for-byte identical).
+- Rust-only, like every extract control; the SDK producers inherit the
+ whole set together in their own follow-up.
+- The remaining #466 splits (an MCP promotion tool over the graph
+ path; a CLI text-path preset) build on this without changes here.
diff --git a/docs/extract.html b/docs/extract.html
index eebb811f..b5b3b9a3 100644
--- a/docs/extract.html
+++ b/docs/extract.html
@@ -163,6 +163,18 @@
The CLI shape
usage, latency, parse/validation issues), one per
document written (association/alias/duplicate/dropped/
uncovered counts); off by default, ignored under --dry-run
+--source-id ID write ID as the batch header's source instead of the
+ document path — the promotion runbook's
+ session:{agent}:{id} convention; several documents each
+ get ID/{file stem}, and a collision fails (import
+ retracts-then-applies per source id). Changing it
+ rewrites the batch but reuses cached chunk answers
+ (ADR 0017)
+--date WHEN the session's own date, written on the passage line:
+ YYYY-MM-DD (UTC midnight) or positive epoch seconds;
+ needs the passage
+--tag TAG tag the batch's source (repeatable, deduplicated),
+ written on the passage line; needs the passage
--context NAME the context every batch file targets
--description TEXT attach a create block (used only when the context is absent)
--schema FILE a context schema document (see below) — the same shape
diff --git a/docs/promotion.html b/docs/promotion.html
index d2da0e3a..52c4b490 100644
--- a/docs/promotion.html
+++ b/docs/promotion.html
@@ -115,9 +115,16 @@ The promotion procedure
scratch has grown.
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. Resolve
- spellings against the permanent context first (resolve /
- resolve_label): reuse its vocabulary, never fork it.
+ the session:{agent}:{id} source ids and the dates:
+ --source-id, --date, and --tag bake all three
+ conventions into the emitted batch directly (ADR 0017), so no hand-editing of the
+ generated file remains. Resolve spellings against the permanent context rather than
+ forking its vocabulary: export it (taguru export --out DIR) and hand the
+ export to --vocabulary (ADR 0015), which steers the extraction toward
+ the context's existing spellings and admits them through validation — the manual
+ resolve/resolve_label pass remains for hand-written
+ batches. --coverage (ADR 0016) reports what the extraction left behind,
+ sentence by sentence — the mechanical floor under the review this step owes.
Import via POST /import / taguru import —
retract-then-apply per source makes re-promoting the same session idempotent.
Audit the landing zone: taguru consolidation --context NAME
diff --git a/src/clock.rs b/src/clock.rs
index 88343ab9..402d4172 100644
--- a/src/clock.rs
+++ b/src/clock.rs
@@ -30,6 +30,21 @@ pub(crate) fn iso8601_utc(unix_secs: u64) -> String {
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
}
+/// [`civil_from_days`]'s inverse (Hinnant's `days_from_civil`, same
+/// source): (year, month, day) → days since the Unix epoch. Consumed
+/// by `extract --date`'s `YYYY-MM-DD` spelling (#466 S1), which
+/// round-trips the result through [`iso8601_utc`] to refuse
+/// non-existent dates rather than silently normalizing them.
+pub(crate) fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
+ let y = if m <= 2 { y - 1 } else { y };
+ let era = if y >= 0 { y } else { y - 399 } / 400;
+ let yoe = (y - era * 400) as u64;
+ let mp = if m > 2 { m - 3 } else { m + 9 } as u64;
+ let doy = (153 * mp + 2) / 5 + d as u64 - 1;
+ let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
+ era * 146097 + doe as i64 - 719468
+}
+
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
@@ -55,4 +70,16 @@ mod tests {
assert_eq!(iso8601_utc(1709164800), "2024-02-29T00:00:00Z");
assert_eq!(iso8601_utc(951868800), "2000-03-01T00:00:00Z");
}
+
+ #[test]
+ fn days_from_civil_inverts_the_rendering_direction() {
+ assert_eq!(days_from_civil(1970, 1, 1), 0);
+ assert_eq!(days_from_civil(2026, 7, 26), 1785057262 / 86400);
+ assert_eq!(days_from_civil(2024, 2, 29), 1709164800 / 86400);
+ assert_eq!(days_from_civil(2000, 3, 1), 951868800 / 86400);
+ assert_eq!(days_from_civil(1969, 12, 31), -1);
+ // The negative-year era branch (`y - 399`): year 0 is a leap
+ // year (≡ 2000 mod 400), so -1-03-01 → 0-03-01 spans 366 days.
+ assert_eq!(days_from_civil(0, 3, 1) - days_from_civil(-1, 3, 1), 366);
+ }
}
diff --git a/src/extract.rs b/src/extract.rs
index ccc674ff..eaf37245 100644
--- a/src/extract.rs
+++ b/src/extract.rs
@@ -158,6 +158,8 @@ use vocabulary::{ContextVocabulary, context_names_block, load_vocabulary};
#[cfg(test)]
use aggregate::{cross_output_issues, schema_output_issues};
#[cfg(test)]
+use args::parse_date;
+#[cfg(test)]
use candidates::{CANDIDATE_CAP, CANDIDATE_MAX_BYTES};
#[cfg(test)]
use chat_client::build_chat_body;
@@ -183,6 +185,7 @@ usage: taguru extract [--dry-run] [--force] [--no-passage] [--questions N]
[--structured-output MODE] [--max-output-tokens N]
[--lossy] [--candidates] [--vocabulary PATH] [--coverage]
[--diagnostics-out FILE] [--schema FILE]
+ [--source-id ID] [--date WHEN] [--tag TAG]...
--context NAME [--description TEXT] --out DIR FILE|DIR...
Reads documents (.md/.txt; a directory expands to its files, sorted by
@@ -280,6 +283,21 @@ chat endpoint:
across runs. Default (unset): no sidecar, stdout/
stderr unchanged. Ignored under --dry-run, which
calls nothing to record.
+ --source-id ID write ID as the batch header's source instead of the
+ document path — the promotion runbook's
+ session:{agent}:{id} convention (docs/promotion.html).
+ With several documents, each gets ID/{file stem}; two
+ documents landing on one source id is an error (import
+ retracts-then-applies per source id). Changing it
+ rewrites the batch but reuses cached chunk answers
+ --date WHEN the session's own date, written on the batch's passage
+ line (the assertion time windowed reads and the
+ staleness audit run on): YYYY-MM-DD (UTC midnight) or
+ positive epoch seconds. Needs the passage
+ --tag TAG tag the batch's source (repeatable, deduplicated) —
+ written on the passage line; how a later session finds
+ its trail via passage search's tags filter. Needs the
+ passage
--context NAME the context every batch file targets
--description TEXT add a create block (used only if the context is absent)
--schema FILE the target context's schema document (same shape as
@@ -718,6 +736,11 @@ pub fn run(args: &[String]) -> i32 {
.as_ref()
.map(|vocabulary| vocabulary.labels.clone())
.unwrap_or_default(),
+ source_id: args.source_id,
+ date: args.date,
+ tags: args.tags,
+ multi_document: files.len() > 1,
+ claimed_source_ids: BTreeMap::new(),
claimed: BTreeMap::new(),
parallel,
lossy,
diff --git a/src/extract/args.rs b/src/extract/args.rs
index 831f092a..040ccea2 100644
--- a/src/extract/args.rs
+++ b/src/extract/args.rs
@@ -64,6 +64,17 @@ pub(super) struct Args {
pub(super) schema: Option,
pub(super) context: String,
pub(super) description: Option,
+ /// #466 S1 (ADR 0017): the promotion runbook's `session:{agent}:{id}`
+ /// source id, replacing the document path in the written batch
+ /// header. `None` keeps the path (today's batch, byte for byte).
+ pub(super) source_id: Option,
+ /// #466 S1: the session's own date (epoch seconds), emitted on the
+ /// written batch's passage line — the assertion time every windowed
+ /// read and the staleness audit run on. `None` emits no field.
+ pub(super) date: Option,
+ /// #466 S1: the session's topic tags, emitted on the written
+ /// batch's passage line. Empty emits no field.
+ pub(super) tags: Vec,
pub(super) out: PathBuf,
pub(super) paths: Vec,
}
@@ -87,6 +98,9 @@ impl Args {
let mut schema: Option = None;
let mut context: Option = None;
let mut description: Option = None;
+ let mut source_id: Option = None;
+ let mut date: Option = None;
+ let mut tags: Vec = Vec::new();
let mut out: Option = None;
let mut paths: Vec = Vec::new();
let mut rest = args.iter();
@@ -285,6 +299,90 @@ impl Args {
));
}
},
+ "--source-id" => match rest.next() {
+ Some(id) if source_id.is_none() && !id.is_empty() => {
+ source_id = Some(id.clone());
+ }
+ Some(id) if id.is_empty() => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--source-id must not be empty",
+ ));
+ }
+ Some(_) => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--source-id given twice",
+ ));
+ }
+ None => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--source-id needs a source id (session:{agent}:{id})",
+ ));
+ }
+ },
+ "--date" => match rest.next() {
+ Some(_) if date.is_some() => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--date given twice",
+ ));
+ }
+ Some(when) => match parse_date(when) {
+ Some(seconds) => date = Some(seconds),
+ None => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--date takes YYYY-MM-DD (UTC midnight) or positive epoch seconds",
+ ));
+ }
+ },
+ None => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--date needs a date (YYYY-MM-DD or epoch seconds)",
+ ));
+ }
+ },
+ "--tag" => match rest.next() {
+ Some(tag) if tag.trim().is_empty() => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--tag must not be empty",
+ ));
+ }
+ Some(tag) if tag.len() > crate::api::MAX_TAG_BYTES => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ &format!(
+ "tag of {} bytes exceeds the {}-byte cap",
+ tag.len(),
+ crate::api::MAX_TAG_BYTES
+ ),
+ ));
+ }
+ Some(tag) => {
+ if !tags.contains(tag) {
+ tags.push(tag.clone());
+ }
+ if tags.len() > crate::api::MAX_TAGS_PER_SOURCE {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ &format!(
+ "more than {} tags where a source carries at most that many",
+ crate::api::MAX_TAGS_PER_SOURCE
+ ),
+ ));
+ }
+ }
+ None => {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--tag needs a tag",
+ ));
+ }
+ },
"--out" => match rest.next() {
Some(dir) if out.is_none() => out = Some(PathBuf::from(dir)),
Some(_) => {
@@ -352,6 +450,30 @@ impl Args {
questions would attach to)",
));
}
+ // Source metadata rides the batch's passage line (the import
+ // wire format has nowhere else to put it — an associations-only
+ // source stores no metadata and is invisible to every windowed
+ // read, docs/promotion.html's own warning), so stripping the
+ // passage while asking for metadata is a contradiction, not a
+ // silent drop.
+ if (date.is_some() || !tags.is_empty()) && no_passage {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ "--date/--tag ride the passage line (--no-passage strips it, and an \
+ associations-only source stores no metadata)",
+ ));
+ }
+ if let Some(id) = &source_id
+ && id.len() > MAX_NAME_BYTES
+ {
+ return Err(crate::config::subcommand_usage_error(
+ "extract",
+ &format!(
+ "source id of {} bytes exceeds the {MAX_NAME_BYTES}-byte cap",
+ id.len()
+ ),
+ ));
+ }
// TAGURU_CONFIG fallback (issue #248 item 2): --config wins,
// but a deployment file baked in via the environment still
// applies when it's absent — the same priority serve/health/
@@ -375,12 +497,62 @@ impl Args {
schema,
context,
description,
+ source_id,
+ date,
+ tags,
out,
paths,
})
}
}
+/// `--date`'s two spellings: bare digits are epoch seconds (the wire
+/// format's own unit), `YYYY-MM-DD` is that day's UTC midnight —
+/// what a session note actually records. Zero is rejected in both
+/// spellings so the manifest can keep 0 as its off sentinel, the
+/// `max_output_tokens` precedent.
+pub(super) fn parse_date(text: &str) -> Option {
+ if !text.is_empty() && text.bytes().all(|b| b.is_ascii_digit()) {
+ return text.parse::().ok().filter(|&seconds| seconds > 0);
+ }
+ let mut parts = text.split('-');
+ let year = parts.next()?.parse::().ok()?;
+ let month = parts.next()?.parse::().ok()?;
+ let day = parts.next()?.parse::().ok()?;
+ // Separate checks rather than one `||` chain: the round-trip below
+ // already refuses any out-of-range month/day (it renders as a
+ // different date), so a chained condition's operator is
+ // mutation-equivalent noise — and the range checks' real job is
+ // keeping the civil arithmetic's inputs in-domain (day 0 would
+ // underflow `d - 1` there).
+ if parts.next().is_some() {
+ return None;
+ }
+ // The year cap is what keeps the civil arithmetic in-domain: an
+ // i64-scale year overflows `era * 146097` before the round-trip
+ // below could refuse it, and a panic on external input is not a
+ // rejection. Four digits is also simply the YYYY-MM-DD contract.
+ if !(1..=9999).contains(&year) {
+ return None;
+ }
+ if !(1..=12).contains(&month) {
+ return None;
+ }
+ if !(1..=31).contains(&day) {
+ return None;
+ }
+ let days = crate::clock::days_from_civil(year, month, day);
+ // Round-trip through the rendering direction: a lexically plausible
+ // but non-existent date (2026-02-30) normalizes to a different day,
+ // so refusing the mismatch refuses the typo.
+ let seconds = u64::try_from(days.checked_mul(86400)?).ok()?;
+ if crate::clock::iso8601_utc(seconds).starts_with(&format!("{year:04}-{month:02}-{day:02}T")) {
+ Some(seconds).filter(|&seconds| seconds > 0)
+ } else {
+ None
+ }
+}
+
/// What one document's pipeline concluded; [`run`] only counts these
/// into the summary line.
pub(super) enum Outcome {
diff --git a/src/extract/manifest.rs b/src/extract/manifest.rs
index 29e6e81e..8dade583 100644
--- a/src/extract/manifest.rs
+++ b/src/extract/manifest.rs
@@ -93,6 +93,27 @@ pub(super) struct ManifestEntry {
/// computation input.
#[serde(default)]
pub(super) vocabulary_digest: String,
+ /// `--source-id`'s EFFECTIVE written source for this document
+ /// (`""` = off, the path was written) — #466 S1, ADR 0017: baked
+ /// into the emitted header like `context`/`description`, so a
+ /// change must rewrite the batch rather than skip with the old id
+ /// in place. The effective value (suffix included), not the flag,
+ /// so a revision of the multi-document suffix scheme re-extracts
+ /// too. Prompt-neutral on purpose: the model is still shown the
+ /// document path, which is why this field is NOT in the checkpoint
+ /// fingerprint — cached chunk answers stay reusable across a
+ /// source-id change.
+ #[serde(default)]
+ pub(super) source_id: String,
+ /// `--date` of the run that wrote this batch (0 = no field
+ /// emitted): baked into the passage line, same reasoning — and the
+ /// same checkpoint-fingerprint exemption — as `source_id`.
+ #[serde(default)]
+ pub(super) date: u64,
+ /// `--tag`s of the run that wrote this batch (empty = no field
+ /// emitted): likewise.
+ #[serde(default)]
+ pub(super) tags: Vec,
pub(super) output: String,
}
@@ -130,6 +151,9 @@ impl Manifest {
schema_digest: &str,
candidates: &str,
vocabulary_digest: &str,
+ source_id: &str,
+ date: u64,
+ tags: &[String],
) -> bool {
self.documents.get(source).is_some_and(|entry| {
entry.sha256 == sha256
@@ -146,6 +170,9 @@ impl Manifest {
&& entry.schema_digest == schema_digest
&& entry.candidates == candidates
&& entry.vocabulary_digest == vocabulary_digest
+ && entry.source_id == source_id
+ && entry.date == date
+ && entry.tags == tags
})
}
@@ -166,6 +193,9 @@ impl Manifest {
schema_digest: &str,
candidates: &str,
vocabulary_digest: &str,
+ source_id: &str,
+ date: u64,
+ tags: &[String],
output: &str,
) {
self.documents.insert(
@@ -185,6 +215,9 @@ impl Manifest {
schema_digest: schema_digest.to_string(),
candidates: candidates.to_string(),
vocabulary_digest: vocabulary_digest.to_string(),
+ source_id: source_id.to_string(),
+ date,
+ tags: tags.to_vec(),
output: output.to_string(),
},
);
diff --git a/src/extract/render.rs b/src/extract/render.rs
index 5ea94a11..01c24324 100644
--- a/src/extract/render.rs
+++ b/src/extract/render.rs
@@ -12,6 +12,8 @@ pub(super) fn render_batch(
description: Option<&str>,
extraction: &Extraction,
passage: Option<&str>,
+ date: Option,
+ tags: &[String],
) -> String {
let mut header = serde_json::json!({
"taguru_batch": 1,
@@ -23,7 +25,18 @@ pub(super) fn render_batch(
}
let mut lines = vec![header.to_string()];
if let Some(text) = passage {
- lines.push(serde_json::json!({ "passage": text }).to_string());
+ // #466 S1 (ADR 0017): the runbook's source metadata rides the
+ // passage line, exactly where the import wire format carries it
+ // (docs/import.html). Absent fields are omitted, keeping the
+ // no-flags batch byte for byte today's.
+ let mut line = serde_json::json!({ "passage": text });
+ if let Some(date) = date {
+ line["date"] = serde_json::json!(date);
+ }
+ if !tags.is_empty() {
+ line["tags"] = serde_json::json!(tags);
+ }
+ lines.push(line.to_string());
for (paragraph, question) in &extraction.questions {
lines.push(
serde_json::json!({ "paragraph": paragraph, "question": question }).to_string(),
diff --git a/src/extract/run.rs b/src/extract/run.rs
index 512f64e9..e6a17d1c 100644
--- a/src/extract/run.rs
+++ b/src/extract/run.rs
@@ -11,6 +11,28 @@ use super::*;
pub(super) struct Run {
pub(super) context: String,
pub(super) description: Option,
+ /// `--source-id` (#466 S1, ADR 0017): the promotion runbook's
+ /// session source id, written into the batch header in place of
+ /// the document path. `None` = the path, today's batch byte for
+ /// byte. The MANIFEST stays keyed by the document path either way
+ /// — the path names the input, this names the output.
+ pub(super) source_id: Option,
+ /// `--date` (#466 S1): epoch seconds for the passage line's
+ /// `date` field (`None` = no field).
+ pub(super) date: Option,
+ /// `--tag` (#466 S1): tags for the passage line (empty = no field).
+ pub(super) tags: Vec,
+ /// Whether this run extracts more than one document — under
+ /// `--source-id` that appends the runbook's `/{doc}` suffix
+ /// (the file stem) so per-source retract-then-apply cannot make
+ /// two documents silently replace each other.
+ pub(super) multi_document: bool,
+ /// Written-source claims (the batch HEADER's source), mirroring
+ /// `claimed`'s file-name check one level up: two documents whose
+ /// effective source ids collide would clobber each other at import
+ /// (retract-then-apply is per source id), so the second one fails
+ /// here instead.
+ pub(super) claimed_source_ids: BTreeMap,
pub(super) force: bool,
pub(super) dry_run: bool,
pub(super) no_passage: bool,
@@ -102,6 +124,26 @@ impl Run {
questions_requested: self.questions > 0,
})
}
+
+ /// The source id the batch header carries: the document path
+ /// (today's behavior), or `--source-id`'s override — verbatim for
+ /// a single document, with the runbook's `/{doc}` suffix (the file
+ /// stem) when the run extracts several, since one session id
+ /// covering two documents would make import's per-source
+ /// retract-then-apply fold them into one another.
+ pub(super) fn written_source(&self, path: &Path, source: &str) -> String {
+ match &self.source_id {
+ None => source.to_string(),
+ Some(id) if !self.multi_document => id.clone(),
+ Some(id) => {
+ let stem = path
+ .file_stem()
+ .map(|stem| stem.to_string_lossy())
+ .unwrap_or_default();
+ format!("{id}/{stem}")
+ }
+ }
+ }
}
/// [`Run::extract_chunks`]'s result: either every chunk completed, or a
@@ -180,8 +222,34 @@ impl Run {
self.claimed.insert(file_name.clone(), source.to_string());
let out_path = self.out.join(&file_name);
+ let written_source = self.written_source(path, source);
+ if written_source.len() > MAX_NAME_BYTES {
+ return Err(format!(
+ "its source id '{written_source}' is {} bytes, over the \
+ {MAX_NAME_BYTES}-byte source cap",
+ written_source.len()
+ ));
+ }
+ if let Some(other) = self.claimed_source_ids.get(&written_source) {
+ return Err(format!(
+ "its source id '{written_source}' collides with '{other}' — import's \
+ retract-then-apply is per source id, so one would silently replace the \
+ other; rename one of the documents"
+ ));
+ }
+ self.claimed_source_ids
+ .insert(written_source.clone(), source.to_string());
+
let text = read_document(path)?;
let hash = sha256_hex(text.as_bytes());
+ // The fingerprint's source-id value is the EFFECTIVE written
+ // source, but only under the flag — "" when off, so pre-S1
+ // manifest entries (no field) keep matching default runs.
+ let source_id_value = if self.source_id.is_some() {
+ written_source.as_str()
+ } else {
+ ""
+ };
if !self.force
&& self.manifest.matches(
source,
@@ -198,6 +266,9 @@ impl Run {
&self.schema_digest,
candidates_manifest_value(self.candidates),
&self.vocabulary_digest,
+ source_id_value,
+ self.date.unwrap_or(0),
+ &self.tags,
)
&& out_path.is_file()
{
@@ -328,10 +399,12 @@ impl Run {
);
let body = render_batch(
&self.context,
- source,
+ &written_source,
self.description.as_deref(),
&extraction,
(!self.no_passage).then_some(text.as_str()),
+ self.date,
+ &self.tags,
);
if let Err(message) = crate::ingest::parse_batch(Cursor::new(body.as_bytes())) {
return Err(format!(
@@ -357,6 +430,9 @@ impl Run {
&self.schema_digest,
candidates_manifest_value(self.candidates),
&self.vocabulary_digest,
+ source_id_value,
+ self.date.unwrap_or(0),
+ &self.tags,
&file_name,
);
// The batch is durably written and manifest-recorded — the
diff --git a/src/extract/tests.rs b/src/extract/tests.rs
index e0238092..98e87d46 100644
--- a/src/extract/tests.rs
+++ b/src/extract/tests.rs
@@ -1109,6 +1109,9 @@ fn manifests_reextract_when_the_candidates_mode_changes() {
"",
candidates_manifest_value(true),
"",
+ "",
+ 0,
+ &[],
"a.md.jsonl",
);
assert!(manifest.matches(
@@ -1125,12 +1128,31 @@ fn manifests_reextract_when_the_candidates_mode_changes() {
false,
"",
candidates_manifest_value(true),
- ""
+ "",
+ "",
+ 0,
+ &[]
));
// Turning the control off — or a future algorithm revision — is a
// computation-input change like any other.
assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
));
// Pre-S2 entries (no field) default to "" and keep matching
// default-off runs.
@@ -1150,10 +1172,29 @@ fn manifests_reextract_when_the_candidates_mode_changes() {
"",
"",
"",
+ "",
+ 0,
+ &[],
"b.md.jsonl",
);
assert!(legacy.matches(
- "b.md", "hash-2", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
));
assert!(!legacy.matches(
"b.md",
@@ -1169,7 +1210,10 @@ fn manifests_reextract_when_the_candidates_mode_changes() {
false,
"",
candidates_manifest_value(true),
- ""
+ "",
+ "",
+ 0,
+ &[]
));
}
@@ -1408,16 +1452,67 @@ fn manifests_reextract_when_the_vocabulary_digest_changes() {
"",
"",
"digest-a",
+ "",
+ 0,
+ &[],
"a.md.jsonl",
);
assert!(manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", "digest-a"
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "digest-a",
+ "",
+ 0,
+ &[]
));
assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", "digest-b"
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "digest-b",
+ "",
+ 0,
+ &[]
));
assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
));
}
@@ -2034,6 +2129,8 @@ fn rendered_batches_pass_the_import_parser() {
Some("酒蔵の記憶"),
&extraction,
Some("一段落目。\n\n二段落目。"),
+ None,
+ &[],
);
// A passage with newlines still serializes to one line each:
// header, passage, question, fact, alias.
@@ -2065,7 +2162,7 @@ fn a_stripped_passage_strips_the_paragraph_locators_too() {
0,
2,
);
- let body = render_batch("sake", "docs/aomine.md", None, &extraction, None);
+ let body = render_batch("sake", "docs/aomine.md", None, &extraction, None, None, &[]);
assert!(
!body.contains("\"paragraph\""),
"no passage line, no locators: {body}"
@@ -2094,6 +2191,8 @@ fn a_paragraph_survives_extract_through_ingest_into_a_queried_attribution() {
Some("配線テスト"),
&extraction,
Some("一段落目。\n\n二段落目。"),
+ None,
+ &[],
);
let batch = crate::ingest::parse_batch(Cursor::new(body.as_bytes()))
.expect("extract must never emit what import refuses");
@@ -2139,71 +2238,12 @@ fn manifests_skip_only_exact_recomputations() {
"",
"",
"",
+ "",
+ 0,
+ &[],
"a.md.jsonl",
);
assert!(manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- assert!(!manifest.matches(
- "a.md", "hash-2", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-2", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- assert!(!manifest.matches(
- "b.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- // A re-pointed --context must re-extract, not keep files whose
- // headers still name the old target.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "vats", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- // Toggling --no-passage changes whether the batch carries the
- // source passage at all — a skip would keep the stale shape.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, true, "", 0, "", 0, false, "", "", ""
- ));
- // A changed --description is baked into the batch header, so it
- // must re-extract too rather than skip with the old one.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "new desc", 0, "", 0, false, "", "", ""
- ));
- // A changed --fact-budget is folded into the system prompt like
- // --questions, so it must re-extract too rather than skip.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 5, "", 0, false, "", "", ""
- ));
- // A changed --structured-output or --max-output-tokens changes
- // what the model can answer — computation inputs like the rest.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "auto", 0, false, "", "", ""
- ));
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 2048, false, "", "", ""
- ));
- // Issue #199: a changed --lossy changes what the batch's facts
- // even are (dropped vs. corrected), so it must re-extract too.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, true, "", "", ""
- ));
-
- // A prompt bump invalidates entries recorded under the old one.
- manifest
- .documents
- .get_mut("a.md")
- .expect("just recorded")
- .prompt_version = PROMPT_VERSION + 1;
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
-
- let dir = std::env::temp_dir().join(format!("taguru-manifest-{}", std::process::id()));
- let _ = fs::remove_dir_all(&dir);
- fs::create_dir_all(&dir).unwrap();
- let path = dir.join(MANIFEST_NAME);
- assert!(Manifest::load(&path).documents.is_empty());
- let mut manifest = Manifest::default();
- manifest.record(
"a.md",
"hash-1",
"model-1",
@@ -2218,79 +2258,33 @@ fn manifests_skip_only_exact_recomputations() {
"",
"",
"",
- "a.md.jsonl",
- );
- manifest.save(&path).unwrap();
- assert!(Manifest::load(&path).matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- fs::write(&path, "not json").unwrap();
- assert!(Manifest::load(&path).documents.is_empty());
-
- // An entry written before the context/no_passage/description/
- // fact_budget fields existed still loads — and mismatches, so
- // it re-extracts exactly once.
- fs::write(
- &path,
- r#"{"documents": {"a.md": {"sha256": "hash-1", "model": "model-1",
- "prompt_version": 1, "output": "a.md.jsonl"}}}"#,
- )
- .unwrap();
- let legacy = Manifest::load(&path);
- assert_eq!(legacy.documents.len(), 1);
- assert!(!legacy.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
-
- // An entry written before the structured_output/
- // max_output_tokens/lossy fields existed (all other fields
- // current) must keep matching an all-defaults run — the new
- // controls default to their zero/false values precisely so old
- // manifests don't force a spurious re-extraction of everything.
- fs::write(
- &path,
- format!(
- r#"{{"documents": {{"a.md": {{"sha256": "hash-1", "model": "model-1",
- "prompt_version": {PROMPT_VERSION}, "context": "sake",
- "output": "a.md.jsonl"}}}}}}"#
- ),
- )
- .unwrap();
- let pre_ladder = Manifest::load(&path);
- assert!(pre_ladder.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
+ "",
+ 0,
+ &[]
));
- assert!(!pre_ladder.matches(
+ assert!(!manifest.matches(
"a.md",
- "hash-1",
+ "hash-2",
"model-1",
"sake",
0,
false,
"",
0,
- "json-schema",
+ "",
0,
false,
"",
"",
- ""
- ));
- // Issue #199: an entry from before --lossy existed defaults to
- // `false` (strict) and must NOT match a --lossy run.
- assert!(!pre_ladder.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, true, "", "", ""
+ "",
+ "",
+ 0,
+ &[]
));
- let _ = fs::remove_dir_all(&dir);
-}
-
-#[test]
-fn manifests_reextract_when_the_schema_digest_changes() {
- let mut manifest = Manifest::default();
- manifest.record(
+ assert!(!manifest.matches(
"a.md",
"hash-1",
- "model-1",
+ "model-2",
"sake",
0,
false,
@@ -2299,33 +2293,16 @@ fn manifests_reextract_when_the_schema_digest_changes() {
"",
0,
false,
- "digest-1",
"",
"",
- "a.md.jsonl",
- );
- assert!(manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "digest-1", "", ""
- ));
- // A different --schema document — even with everything else
- // identical — must re-extract: the prompt's schema block and
- // self-validation both changed under it.
- assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "digest-2", "", ""
+ "",
+ "",
+ 0,
+ &[]
));
- // Dropping --schema entirely (querying with "") must also
- // re-extract a schema-recorded entry, not just swap it.
assert!(!manifest.matches(
- "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
-
- // An entry written before `--schema` existed defaults to "" —
- // matches a schema-less rerun, mismatches once --schema is
- // engaged, the same precedent structured_output/lossy set.
- let mut legacy = Manifest::default();
- legacy.record(
"b.md",
- "hash-2",
+ "hash-1",
"model-1",
"sake",
0,
@@ -2338,29 +2315,502 @@ fn manifests_reextract_when_the_schema_digest_changes() {
"",
"",
"",
- "b.md.jsonl",
- );
- assert!(legacy.matches(
- "b.md", "hash-2", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", ""
- ));
- assert!(!legacy.matches(
- "b.md", "hash-2", "model-1", "sake", 0, false, "", 0, "", 0, false, "digest-1", "", ""
+ "",
+ 0,
+ &[]
));
-}
-
-#[test]
-fn request_options_default_adds_no_keys_to_the_body() {
- let messages = [serde_json::json!({"role": "user", "content": "hi"})];
- // The pre-ladder body, byte for byte: serde_json orders keys
- // alphabetically, so nothing about the base three moves when
- // the optional keys are absent.
- assert_eq!(
- build_chat_body("m", &messages, &RequestOptions::default()),
- r#"{"messages":[{"content":"hi","role":"user"}],"model":"m","temperature":0}"#
- );
- let with_options = build_chat_body(
- "m",
- &messages,
+ // A re-pointed --context must re-extract, not keep files whose
+ // headers still name the old target.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "vats",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // Toggling --no-passage changes whether the batch carries the
+ // source passage at all — a skip would keep the stale shape.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ true,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // A changed --description is baked into the batch header, so it
+ // must re-extract too rather than skip with the old one.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "new desc",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // A changed --fact-budget is folded into the system prompt like
+ // --questions, so it must re-extract too rather than skip.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 5,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // A changed --structured-output or --max-output-tokens changes
+ // what the model can answer — computation inputs like the rest.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "auto",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 2048,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // Issue #199: a changed --lossy changes what the batch's facts
+ // even are (dropped vs. corrected), so it must re-extract too.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ true,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+
+ // A prompt bump invalidates entries recorded under the old one.
+ manifest
+ .documents
+ .get_mut("a.md")
+ .expect("just recorded")
+ .prompt_version = PROMPT_VERSION + 1;
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+
+ let dir = std::env::temp_dir().join(format!("taguru-manifest-{}", std::process::id()));
+ let _ = fs::remove_dir_all(&dir);
+ fs::create_dir_all(&dir).unwrap();
+ let path = dir.join(MANIFEST_NAME);
+ assert!(Manifest::load(&path).documents.is_empty());
+ let mut manifest = Manifest::default();
+ manifest.record(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[],
+ "a.md.jsonl",
+ );
+ manifest.save(&path).unwrap();
+ assert!(Manifest::load(&path).matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ fs::write(&path, "not json").unwrap();
+ assert!(Manifest::load(&path).documents.is_empty());
+
+ // An entry written before the context/no_passage/description/
+ // fact_budget fields existed still loads — and mismatches, so
+ // it re-extracts exactly once.
+ fs::write(
+ &path,
+ r#"{"documents": {"a.md": {"sha256": "hash-1", "model": "model-1",
+ "prompt_version": 1, "output": "a.md.jsonl"}}}"#,
+ )
+ .unwrap();
+ let legacy = Manifest::load(&path);
+ assert_eq!(legacy.documents.len(), 1);
+ assert!(!legacy.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+
+ // An entry written before the structured_output/
+ // max_output_tokens/lossy fields existed (all other fields
+ // current) must keep matching an all-defaults run — the new
+ // controls default to their zero/false values precisely so old
+ // manifests don't force a spurious re-extraction of everything.
+ fs::write(
+ &path,
+ format!(
+ r#"{{"documents": {{"a.md": {{"sha256": "hash-1", "model": "model-1",
+ "prompt_version": {PROMPT_VERSION}, "context": "sake",
+ "output": "a.md.jsonl"}}}}}}"#
+ ),
+ )
+ .unwrap();
+ let pre_ladder = Manifest::load(&path);
+ assert!(pre_ladder.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ assert!(!pre_ladder.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "json-schema",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // Issue #199: an entry from before --lossy existed defaults to
+ // `false` (strict) and must NOT match a --lossy run.
+ assert!(!pre_ladder.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ true,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ let _ = fs::remove_dir_all(&dir);
+}
+
+#[test]
+fn manifests_reextract_when_the_schema_digest_changes() {
+ let mut manifest = Manifest::default();
+ manifest.record(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "digest-1",
+ "",
+ "",
+ "",
+ 0,
+ &[],
+ "a.md.jsonl",
+ );
+ assert!(manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "digest-1",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // A different --schema document — even with everything else
+ // identical — must re-extract: the prompt's schema block and
+ // self-validation both changed under it.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "digest-2",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ // Dropping --schema entirely (querying with "") must also
+ // re-extract a schema-recorded entry, not just swap it.
+ assert!(!manifest.matches(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+
+ // An entry written before `--schema` existed defaults to "" —
+ // matches a schema-less rerun, mismatches once --schema is
+ // engaged, the same precedent structured_output/lossy set.
+ let mut legacy = Manifest::default();
+ legacy.record(
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[],
+ "b.md.jsonl",
+ );
+ assert!(legacy.matches(
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+ assert!(!legacy.matches(
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "digest-1",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+}
+
+#[test]
+fn request_options_default_adds_no_keys_to_the_body() {
+ let messages = [serde_json::json!({"role": "user", "content": "hi"})];
+ // The pre-ladder body, byte for byte: serde_json orders keys
+ // alphabetically, so nothing about the base three moves when
+ // the optional keys are absent.
+ assert_eq!(
+ build_chat_body("m", &messages, &RequestOptions::default()),
+ r#"{"messages":[{"content":"hi","role":"user"}],"model":"m","temperature":0}"#
+ );
+ let with_options = build_chat_body(
+ "m",
+ &messages,
&RequestOptions {
response_format: Some(json_object_response_format()),
max_tokens: Some(512),
@@ -3197,3 +3647,280 @@ fn a_gap_quote_is_capped_at_a_char_boundary() {
"{quote}"
);
}
+
+// ---- promotion runbook conventions (#466 S1, ADR 0017) ----
+
+#[test]
+fn dates_parse_as_epoch_seconds_or_utc_civil_days() {
+ assert_eq!(parse_date("1785974400"), Some(1785974400));
+ // A civil date is that day's UTC midnight, round-tripped through
+ // the rendering direction.
+ let seconds = parse_date("2026-08-06").expect("a real date parses");
+ assert_eq!(crate::clock::iso8601_utc(seconds), "2026-08-06T00:00:00Z");
+ assert_eq!(parse_date("1970-01-01"), None); // 0 is the manifest's off sentinel
+ assert_eq!(parse_date("0"), None);
+ assert_eq!(parse_date("2026-02-30"), None); // normalizes ≠ as-written → refused
+ assert_eq!(parse_date("2026-13-01"), None);
+ assert_eq!(parse_date("session-note"), None);
+ assert_eq!(parse_date(""), None);
+}
+
+#[test]
+fn runbook_flags_parse_and_their_contradictions_are_usage_errors() {
+ fn parse(words: &[&str]) -> Result {
+ Args::parse(&words.iter().map(|s| s.to_string()).collect::>())
+ }
+ let base = ["--context", "c", "--out", "o"];
+ let mut ok = base.to_vec();
+ ok.extend([
+ "--source-id",
+ "session:claude:abc",
+ "--date",
+ "2026-08-06",
+ "--tag",
+ "ops",
+ "--tag",
+ "リリース",
+ "--tag",
+ "ops", // duplicates fold instead of erroring or double-writing
+ "doc.md",
+ ]);
+ let parsed = parse(&ok).expect("the runbook flags parse");
+ assert_eq!(parsed.source_id.as_deref(), Some("session:claude:abc"));
+ assert_eq!(
+ parsed.date,
+ Some(parse_date("2026-08-06").expect("a real date parses"))
+ );
+ assert_eq!(parsed.tags, vec!["ops".to_string(), "リリース".to_string()]);
+
+ let mut twice = base.to_vec();
+ twice.extend(["--source-id", "a", "--source-id", "b", "doc.md"]);
+ assert!(matches!(parse(&twice), Err(2)));
+ let mut empty = base.to_vec();
+ empty.extend(["--source-id", "", "doc.md"]);
+ assert!(matches!(parse(&empty), Err(2)));
+ let mut bad_date = base.to_vec();
+ bad_date.extend(["--date", "yesterday", "doc.md"]);
+ assert!(matches!(parse(&bad_date), Err(2)));
+ // Metadata rides the passage line, so stripping the passage while
+ // asking for it is a contradiction, not a silent drop.
+ let mut stripped = base.to_vec();
+ stripped.extend(["--no-passage", "--date", "2026-08-06", "doc.md"]);
+ assert!(matches!(parse(&stripped), Err(2)));
+ let mut stripped_tag = base.to_vec();
+ stripped_tag.extend(["--no-passage", "--tag", "ops", "doc.md"]);
+ assert!(matches!(parse(&stripped_tag), Err(2)));
+}
+
+#[test]
+fn the_passage_line_carries_date_and_tags_exactly_when_given() {
+ let extraction = merge(
+ vec![parse_model_output(
+ r#"{"associations": [{"subject": "a", "label": "l", "object": "b", "weight": 1.0}]}"#,
+ )
+ .unwrap()],
+ 0,
+ 1,
+ );
+ let plain = render_batch("c", "s", None, &extraction, Some("本文。"), None, &[]);
+ let passage_line = plain.lines().nth(1).expect("header then passage");
+ // No flags → the passage line stays byte-for-byte pre-S1.
+ assert_eq!(passage_line, r#"{"passage":"本文。"}"#);
+
+ let tagged = render_batch(
+ "c",
+ "session:claude:abc",
+ None,
+ &extraction,
+ Some("本文。"),
+ Some(1785974400),
+ &["ops".to_string(), "リリース".to_string()],
+ );
+ let header: serde_json::Value = serde_json::from_str(tagged.lines().next().unwrap()).unwrap();
+ assert_eq!(header["source"], "session:claude:abc");
+ let passage: serde_json::Value = serde_json::from_str(tagged.lines().nth(1).unwrap()).unwrap();
+ assert_eq!(passage["date"], 1785974400u64);
+ assert_eq!(passage["tags"], serde_json::json!(["ops", "リリース"]));
+ // What extract writes, import accepts.
+ crate::ingest::parse_batch(Cursor::new(tagged.as_bytes())).unwrap();
+}
+
+#[test]
+fn manifests_rewrite_when_the_runbook_metadata_changes() {
+ let mut manifest = Manifest::default();
+ manifest.record(
+ "a.md",
+ "hash-1",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "session:claude:abc",
+ 1785974400,
+ &["ops".to_string()],
+ "a.md.jsonl",
+ );
+ let matches_with = |source_id: &str, date: u64, tags: &[String]| {
+ manifest.matches(
+ "a.md", "hash-1", "model-1", "sake", 0, false, "", 0, "", 0, false, "", "", "",
+ source_id, date, tags,
+ )
+ };
+ assert!(matches_with(
+ "session:claude:abc",
+ 1785974400,
+ &["ops".to_string()]
+ ));
+ // Any of the three changing must rewrite the batch — they are all
+ // baked into the emitted file.
+ assert!(!matches_with(
+ "session:claude:xyz",
+ 1785974400,
+ &["ops".to_string()]
+ ));
+ assert!(!matches_with("session:claude:abc", 0, &["ops".to_string()]));
+ assert!(!matches_with("session:claude:abc", 1785974400, &[]));
+
+ // Pre-S1 entries (no fields) keep matching default runs.
+ let mut legacy = Manifest::default();
+ legacy.record(
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[],
+ "b.md.jsonl",
+ );
+ let json = serde_json::to_string(&legacy).unwrap();
+ let stripped = json
+ .replace(r#""source_id":"","#, "")
+ .replace(r#""date":0,"#, "")
+ .replace(r#""tags":[],"#, "");
+ // The replaces must actually have removed the fields — a silent
+ // no-match would leave all three in place and this test would
+ // "pass" without exercising the legacy shape at all.
+ for key in [r#""source_id""#, r#""date""#, r#""tags""#] {
+ assert!(!stripped.contains(key), "{key} survived in: {stripped}");
+ }
+ let reloaded: Manifest = serde_json::from_str(&stripped).unwrap();
+ assert!(reloaded.matches(
+ "b.md",
+ "hash-2",
+ "model-1",
+ "sake",
+ 0,
+ false,
+ "",
+ 0,
+ "",
+ 0,
+ false,
+ "",
+ "",
+ "",
+ "",
+ 0,
+ &[]
+ ));
+}
+
+#[test]
+fn runbook_flag_boundaries_hold_exactly() {
+ fn parse(words: Vec) -> Result {
+ Args::parse(&words)
+ }
+ fn base() -> Vec {
+ ["--context", "c", "--out", "o"]
+ .iter()
+ .map(|s| s.to_string())
+ .collect()
+ }
+ // A duplicate --date is a usage error, never last-wins.
+ let mut dated = base();
+ dated.extend(
+ ["--date", "2026-08-06", "--date", "2026-08-07", "doc.md"]
+ .iter()
+ .map(|s| s.to_string()),
+ );
+ assert!(matches!(parse(dated), Err(2)));
+ // An empty tag is refused, not stored.
+ let mut empty_tag = base();
+ empty_tag.extend(["--tag", "", "doc.md"].iter().map(|s| s.to_string()));
+ assert!(matches!(parse(empty_tag), Err(2)));
+ // Tag bytes: exactly at the cap passes, one over fails.
+ let mut at_cap = base();
+ at_cap.extend([
+ "--tag".to_string(),
+ "t".repeat(crate::api::MAX_TAG_BYTES),
+ "doc.md".to_string(),
+ ]);
+ assert!(parse(at_cap).is_ok());
+ let mut over_cap = base();
+ over_cap.extend([
+ "--tag".to_string(),
+ "t".repeat(crate::api::MAX_TAG_BYTES + 1),
+ "doc.md".to_string(),
+ ]);
+ assert!(matches!(parse(over_cap), Err(2)));
+ // Tag count: exactly the per-source cap passes, one more fails.
+ let mut full = base();
+ for i in 0..crate::api::MAX_TAGS_PER_SOURCE {
+ full.extend(["--tag".to_string(), format!("t{i}")]);
+ }
+ full.push("doc.md".to_string());
+ assert!(parse(full).is_ok());
+ let mut overfull = base();
+ for i in 0..=crate::api::MAX_TAGS_PER_SOURCE {
+ overfull.extend(["--tag".to_string(), format!("t{i}")]);
+ }
+ overfull.push("doc.md".to_string());
+ assert!(matches!(parse(overfull), Err(2)));
+ // Source id bytes: exactly at the name cap passes, one over fails.
+ let mut id_at_cap = base();
+ id_at_cap.extend([
+ "--source-id".to_string(),
+ "s".repeat(MAX_NAME_BYTES),
+ "doc.md".to_string(),
+ ]);
+ assert!(parse(id_at_cap).is_ok());
+ let mut id_over = base();
+ id_over.extend([
+ "--source-id".to_string(),
+ "s".repeat(MAX_NAME_BYTES + 1),
+ "doc.md".to_string(),
+ ]);
+ assert!(matches!(parse(id_over), Err(2)));
+ // A fourth dash-separated part is refused even when the first
+ // three name a real date; day 0 is refused before the civil
+ // arithmetic ever sees it.
+ assert_eq!(parse_date("2026-08-06-07"), None);
+ assert_eq!(parse_date("2026-08-00"), None);
+ // The year cap keeps the civil arithmetic in-domain: an i64-scale
+ // year would overflow inside days_from_civil (a panic on external
+ // input, not a rejection), and five digits is outside the
+ // YYYY-MM-DD contract anyway.
+ assert_eq!(parse_date("9223372036854775807-01-01"), None);
+ assert_eq!(parse_date("10000-01-01"), None);
+ assert_eq!(parse_date("0000-01-01"), None);
+ assert_eq!(parse_date("-0001-01-01"), None);
+}
diff --git a/tests/http_api/extract.rs b/tests/http_api/extract.rs
index f2a2d970..cad90add 100644
--- a/tests/http_api/extract.rs
+++ b/tests/http_api/extract.rs
@@ -1119,6 +1119,256 @@ fn extract_coverage_reports_uncovered_candidate_pair_sentences() {
let _ = std::fs::remove_dir_all(&out);
}
+/// The `/{stem}` suffix can push a within-cap `--source-id` over the
+/// 1024-byte source cap only at extract time — exactly at the cap
+/// both documents pass; one byte over fails them before any model
+/// call.
+#[test]
+fn extract_suffixed_source_ids_respect_the_name_cap() {
+ let docs = batch_dir("extract-source-cap-docs");
+ std::fs::write(docs.join("aa.md"), "壱。").unwrap();
+ std::fs::write(docs.join("bb.md"), "弐。").unwrap();
+ let out = batch_dir("extract-source-cap-out");
+
+ // 1022 + "/" + 2-byte stem = 1025: one over MAX_NAME_BYTES (1024).
+ let over = "s".repeat(1022);
+ let (code, stdout, stderr) = run_extract(
+ &out,
+ &[
+ ("TAGURU_EXTRACT_URL", "http://127.0.0.1:9/v1"),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ],
+ &[
+ "--context",
+ "ops",
+ "--source-id",
+ &over,
+ docs.to_str().unwrap(),
+ ],
+ );
+ assert_eq!(code, 1, "stdout: {stdout}\nstderr: {stderr}");
+ assert!(stderr.contains("source cap"), "{stderr}");
+
+ // 1021 + "/" + 2 = 1024: exactly at the cap, both documents land.
+ let at_cap = "s".repeat(1021);
+ let reply = |name: &str, text: &str| {
+ json!({"associations": [
+ {"subject": name, "label": "l", "object": text, "weight": 1.0, "paragraph": 0}
+ ]})
+ .to_string()
+ };
+ let (url, requests) = stub_chat_server(vec![reply("壱", "壱。"), reply("弐", "弐。")]);
+ let (code, stdout, stderr) = run_extract(
+ &out,
+ &[
+ ("TAGURU_EXTRACT_URL", url.as_str()),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ],
+ &[
+ "--context",
+ "ops",
+ "--source-id",
+ &at_cap,
+ docs.to_str().unwrap(),
+ ],
+ );
+ assert_eq!(code, 0, "stdout: {stdout}\nstderr: {stderr}");
+ requests.join().unwrap();
+
+ let _ = std::fs::remove_dir_all(&docs);
+ let _ = std::fs::remove_dir_all(&out);
+}
+
+/// The `--source-id` usage errors name their actual cause: a
+/// duplicate says "given twice", an empty id says "must not be
+/// empty" — both exit 2, so only the wording tells the operator
+/// which mistake they made.
+#[test]
+fn extract_source_id_usage_errors_name_their_cause() {
+ let out = batch_dir("extract-source-id-usage-out");
+ let (code, _, stderr) = run_extract(
+ &out,
+ &[],
+ &[
+ "--context",
+ "c",
+ "--source-id",
+ "a",
+ "--source-id",
+ "b",
+ "doc.md",
+ ],
+ );
+ assert_eq!(code, 2, "{stderr}");
+ assert!(stderr.contains("--source-id given twice"), "{stderr}");
+ let (code, _, stderr) =
+ run_extract(&out, &[], &["--context", "c", "--source-id", "", "doc.md"]);
+ assert_eq!(code, 2, "{stderr}");
+ assert!(stderr.contains("--source-id must not be empty"), "{stderr}");
+ let _ = std::fs::remove_dir_all(&out);
+}
+
+/// #466 S1 (ADR 0017): `--source-id`/`--date`/`--tag` bake the
+/// promotion runbook's conventions into the written batch — the
+/// session source id (with the `/{doc}` stem suffix across several
+/// documents), the passage line's date and tags — and all three are
+/// manifest computation inputs: same flags skip, a changed date
+/// rewrites. A source-id collision between two documents fails the
+/// second instead of letting import fold them into one another.
+#[test]
+fn extract_bakes_the_runbook_conventions_into_the_batch() {
+ let docs = batch_dir("extract-runbook-docs");
+ std::fs::write(docs.join("s1.md"), "青嶺酒造は1907年に創業した。").unwrap();
+ std::fs::write(docs.join("s2.md"), "杜氏は高瀬。").unwrap();
+ let out = batch_dir("extract-runbook-out");
+
+ let replies = [
+ json!({"associations": [
+ {"subject": "青嶺酒造", "label": "創業年", "object": "1907年", "weight": 1.0, "paragraph": 0}
+ ]})
+ .to_string(),
+ json!({"associations": [
+ {"subject": "高瀬", "label": "役職", "object": "杜氏", "weight": 1.0, "paragraph": 0}
+ ]})
+ .to_string(),
+ ];
+ let flags = [
+ "--context",
+ "ops",
+ "--source-id",
+ "session:claude:abc",
+ "--date",
+ "2026-08-06",
+ "--tag",
+ "ops",
+ "--tag",
+ "リリース",
+ ];
+ let (url, requests) = stub_chat_server(replies.to_vec());
+ let provider = [
+ ("TAGURU_EXTRACT_URL", url.as_str()),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ];
+ let mut args: Vec<&str> = flags.to_vec();
+ let docs_arg = docs.to_str().unwrap().to_string();
+ args.push(&docs_arg);
+ let (code, stdout, stderr) = run_extract(&out, &provider, &args);
+ assert_eq!(code, 0, "stdout: {stdout}\nstderr: {stderr}");
+ requests.join().unwrap();
+
+ // Several documents: each header carries ID/{stem}; the passage
+ // line carries the date (2026-08-06 UTC midnight) and the tags.
+ for (file, expected_source) in [
+ ("s1.md", "session:claude:abc/s1"),
+ ("s2.md", "session:claude:abc/s2"),
+ ] {
+ let batch_file = std::fs::read_dir(&out)
+ .unwrap()
+ .filter_map(Result::ok)
+ .map(|entry| entry.path())
+ .find(|path| {
+ path.file_name()
+ .is_some_and(|name| name.to_string_lossy().contains(&file.replace(".md", "")))
+ && path.extension().is_some_and(|ext| ext == "jsonl")
+ })
+ .unwrap_or_else(|| panic!("a batch file for {file}"));
+ let body = std::fs::read_to_string(&batch_file).unwrap();
+ let header: Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
+ assert_eq!(header["source"], expected_source, "{body}");
+ let passage: Value = serde_json::from_str(body.lines().nth(1).unwrap()).unwrap();
+ assert_eq!(passage["date"], 1785974400u64, "{body}");
+ assert_eq!(passage["tags"], json!(["ops", "リリース"]), "{body}");
+ }
+
+ // Same flags again: both documents skip (the metadata is in the
+ // fingerprint and unchanged). No model call — the stub above
+ // accepted exactly two connections.
+ let (code, stdout, stderr) = run_extract(&out, &provider, &args);
+ assert_eq!(code, 0, "stdout: {stdout}\nstderr: {stderr}");
+ assert_eq!(stdout.matches("unchanged, skipped").count(), 2, "{stdout}");
+
+ // A changed date must rewrite — a skip would leave the old date in
+ // the emitted file.
+ let (url, requests) = stub_chat_server(replies.to_vec());
+ let mut redated: Vec<&str> = args.clone();
+ let position = redated.iter().position(|a| *a == "2026-08-06").unwrap();
+ redated[position] = "2026-08-07";
+ let (code, stdout, stderr) = run_extract(
+ &out,
+ &[
+ ("TAGURU_EXTRACT_URL", url.as_str()),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ],
+ &redated,
+ );
+ assert_eq!(code, 0, "stdout: {stdout}\nstderr: {stderr}");
+ assert!(!stdout.contains("unchanged, skipped"), "{stdout}");
+ requests.join().unwrap();
+
+ // A single document takes the id verbatim — no suffix to invent.
+ let (url, requests) = stub_chat_server(vec![replies[0].clone()]);
+ let single = docs.join("s1.md");
+ let mut single_args: Vec<&str> = flags.to_vec();
+ let single_arg = single.to_str().unwrap().to_string();
+ single_args.push(&single_arg);
+ let (code, stdout, stderr) = run_extract(
+ &out,
+ &[
+ ("TAGURU_EXTRACT_URL", url.as_str()),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ],
+ &single_args,
+ );
+ assert_eq!(code, 0, "stdout: {stdout}\nstderr: {stderr}");
+ requests.join().unwrap();
+ let body = std::fs::read_to_string(out.join(format!(
+ "{}.jsonl",
+ single.to_str().unwrap().replace(['/', ':'], "__")
+ )))
+ .unwrap();
+ let header: Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
+ assert_eq!(header["source"], "session:claude:abc");
+
+ // Two documents whose stems collide would land on ONE source id —
+ // import's per-source retract-then-apply would fold them, so the
+ // second fails before any call is spent on it.
+ let nested_a = docs.join("a");
+ let nested_b = docs.join("b");
+ std::fs::create_dir_all(&nested_a).unwrap();
+ std::fs::create_dir_all(&nested_b).unwrap();
+ std::fs::write(nested_a.join("x.md"), "壱。").unwrap();
+ std::fs::write(nested_b.join("x.md"), "弐。").unwrap();
+ let (url, requests) = stub_chat_server(vec![
+ json!({"associations": [
+ {"subject": "壱", "label": "l", "object": "壱。", "weight": 1.0, "paragraph": 0}
+ ]})
+ .to_string(),
+ ]);
+ let collide_out = batch_dir("extract-runbook-collide-out");
+ let (code, stdout, stderr) = run_extract(
+ &collide_out,
+ &[
+ ("TAGURU_EXTRACT_URL", url.as_str()),
+ ("TAGURU_EXTRACT_MODEL", "stub-model"),
+ ],
+ &[
+ "--context",
+ "ops",
+ "--source-id",
+ "session:claude:abc",
+ nested_a.join("x.md").to_str().unwrap(),
+ nested_b.join("x.md").to_str().unwrap(),
+ ],
+ );
+ assert_eq!(code, 1, "stdout: {stdout}\nstderr: {stderr}");
+ assert!(stderr.contains("collides"), "{stderr}");
+ requests.join().unwrap();
+
+ let _ = std::fs::remove_dir_all(&docs);
+ let _ = std::fs::remove_dir_all(&out);
+ let _ = std::fs::remove_dir_all(&collide_out);
+}
+
/// TAGURU_EXTRACT_COVERAGE resolves like its boolean siblings: `1`
/// turns the report on, `0` keeps it off (and keeps every report line
/// free of an "uncovered" note — the count note must not render at