Pin 3b's projection refusal was asymmetric: close the reachable half

document_from_bundle refused a base-bearing bundle, but the public
project_text_document did not. A caller holding a directly constructed
TextDocument could therefore emit a (canonical-base ...) line that
parse_document then rejects — a projector able to produce what the
parser refuses, which is precisely the asymmetry pin 3b exists to close
and which req:textproj:roundtrip's second equation quantifies over.

The guard had been placed on the path the pin happened to name rather
than on every path a caller can reach, and the unguarded one was the
only reachable half: no live Bundle can carry a canonical base during
the S28 -> P13-S27 interval, so the bundle-side refusal cannot fire
today, while the document-side path is one public call away. The new
corpus vector proved the hole existed rather than closing it — it is
built by projecting a base-bearing document.

project_text_document now returns Result and refuses. A crate-private
render_text_document keeps the unchecked formatter for its one
legitimate caller, the canonical_base_present negative vector: a
negative vector still has to contain the spelling it asserts is
refused, and producing those bytes is not the same as permitting them.
Every other vector goes through the checked projector.

projecting_a_base_bearing_text_document_is_refused locks both halves —
that the public projector refuses, and that the private renderer still
emits the section, since the reject vector silently stops carrying its
spelling otherwise. Mutation-verified: removing the refusal fails that
test and nothing else. Restored by hand.

The corpus is byte-identical, so no vector regenerated. Workspace green
at 1570.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QjsEnYhm1gPpf6ii2iFxFV
This commit is contained in:
Levi Neuwirth 2026-08-07 13:06:40 -04:00
parent bc06706e41
commit be244df6a0
3 changed files with 129 additions and 23 deletions

View File

@ -557,14 +557,42 @@ pub fn document_from_bundle<S: BlockStore>(
// TextDocument -> text.
// ===========================================================================
/// Projects a whole [`TextDocument`] to its canonical text: the header, then
/// every present section in the normative `projection` sequence (header,
/// Projects a whole [`TextDocument`] to its canonical text, refusing a
/// base-bearing document (`CONTRACT_FORMAT_EPOCH_MAJOR1.md` pin 3b).
///
/// This is the **document-level** half of pin 3b's projection refusal.
/// [`document_from_bundle`] guards the bundle-level half, but guarding only
/// that one left the refusal asymmetric in a way the pin forbids: this
/// function is public and takes a directly constructed [`TextDocument`], so a
/// caller that never touches a [`Bundle`] could mint text carrying a
/// `(canonical-base ...)` line — text that [`crate::parse::parse_document`]
/// then rejects. A projector able to emit what the parser refuses is exactly
/// the asymmetry pin 3b exists to close, and `req:textproj:roundtrip`'s second
/// equation quantifies over every valid text.
///
/// Sections are written in the normative `projection` sequence (header,
/// document, lineage?, profile*, extension*, canonical-base?, blob*,
/// envelope*), one line per element, each terminated by a single LF
/// (`req:textproj:envelope-per-line`). Section order is normative and is
/// simply the order this function writes in; it introduces no ordering of its
/// own beyond `req:textproj:derived-ordering`'s blob sort.
pub fn project_text_document(document: &TextDocument) -> String {
/// simply the order [`render_text_document`] writes in; it introduces no
/// ordering of its own beyond `req:textproj:derived-ordering`'s blob sort.
pub fn project_text_document(document: &TextDocument) -> Result<String, ProjectError> {
if document.canonical_base.is_some() {
return Err(ProjectError::CanonicalBaseUnsupported);
}
Ok(render_text_document(document))
}
/// Renders a [`TextDocument`] to text **without** pin 3b's refusal, so it will
/// emit a `(canonical-base ...)` line when the document carries one.
///
/// Deliberately **not public**. Its only legitimate caller is the corpus's
/// `canonical_base_present` negative vector, which must contain the base
/// spelling in order to assert that a parser refuses it — the spelling has to
/// be produced by something, and producing it is not the same as permitting
/// it. Every other caller goes through [`project_text_document`], which
/// refuses first.
pub(crate) fn render_text_document(document: &TextDocument) -> String {
let mut lines: Vec<Sexp> = Vec::new();
lines.push(project_header());
lines.push(project_document(
@ -596,7 +624,7 @@ pub fn project_text_document(document: &TextDocument) -> String {
/// Composes both stages: reads `bundle` into a [`TextDocument`], then projects
/// it to its canonical text.
pub fn project_bundle<S: BlockStore>(bundle: &Bundle<S>) -> Result<String, ProjectError> {
Ok(project_text_document(&document_from_bundle(bundle)?))
project_text_document(&document_from_bundle(bundle)?)
}
#[cfg(test)]
@ -1045,7 +1073,8 @@ mod tests {
fn projected_text_never_emits_a_blob_line_and_orders_sections_correctly() {
let bundle = build_sample_bundle();
let document = document_from_bundle(&bundle).expect("bundle reads cleanly");
let text = project_text_document(&document);
let text =
project_text_document(&document).expect("this fixture carries no canonical base");
assert!(
!text.lines().any(|line| line.starts_with("(blob ")),
@ -1080,7 +1109,8 @@ mod tests {
fn project_bundle_composes_both_stages() {
let bundle = build_sample_bundle();
let via_two_stages =
project_text_document(&document_from_bundle(&bundle).expect("bundle reads cleanly"));
project_text_document(&document_from_bundle(&bundle).expect("bundle reads cleanly"))
.expect("the sample bundle carries no canonical base");
let via_one_call = project_bundle(&bundle).expect("bundle reads cleanly");
assert_eq!(via_one_call, via_two_stages);
}
@ -1170,6 +1200,49 @@ mod tests {
));
}
#[test]
fn projecting_a_base_bearing_text_document_is_refused() {
// Pin 3b's projection side, on the half that a caller can actually
// reach today. `project_text_document` is public and takes a directly
// constructed `TextDocument`, so guarding only `document_from_bundle`
// left the refusal asymmetric: this path could emit a
// `(canonical-base ...)` line that `parse_document` then rejects, and
// a projector able to produce what the parser refuses is exactly what
// pin 3b forbids. Unlike the bundle-level guard above, this one needs
// no live `Bundle` and is therefore reachable by any caller of this
// crate.
let document = TextDocument {
document_id: DocumentId([7; 16]),
manifest_schema_version: SchemaVersion::V0,
lineage_id: None,
profiles: vec![ProfileDeclaration::full()],
extensions: Vec::new(),
canonical_base: Some(TextCanonicalBase {
snapshot_id: SnapshotId([7; 16]),
covers_causal_frontier: FrontierBytes::empty(),
reduction_algorithm_version: ReductionAlgorithmVersion(0),
profile_id: ProfileId::Full,
root_schema_version: SchemaVersion::V0,
root_payload: b"snapshot-root".to_vec(),
}),
blobs: Vec::new(),
envelopes: Vec::new(),
};
assert!(matches!(
project_text_document(&document),
Err(ProjectError::CanonicalBaseUnsupported)
));
// And the crate-private formatter still emits the base line — the
// corpus's negative vector depends on it. The refusal is a property of
// the public projector, not of the renderer: if this ever stops
// emitting the section, the `canonical_base_present` reject vector
// silently stops carrying the spelling it exists to reject.
assert!(render_text_document(&document)
.lines()
.any(|line| line.starts_with("(canonical-base ")));
}
// -----------------------------------------------------------------
// Suite reach: count, don't just claim, coverage of an extension, a
// canonical base, and a multi-envelope document.

View File

@ -32,7 +32,7 @@ use epiphany_ops::{
};
use crate::parse::parse_document;
use crate::project::{project_bundle, project_text_document};
use crate::project::{project_bundle, project_text_document, render_text_document};
use crate::serialize::serialize_document;
use crate::{TextCanonicalBase, TextChunk, TextDocument, TextExtension};
@ -468,28 +468,52 @@ fn accept_documents() -> Vec<(&'static str, String)> {
};
vec![
("minimal", project_text_document(&minimal)),
("set_tuning_context", project_text_document(&tuning_context)),
(
"minimal",
project_text_document(&minimal).expect("an accept document carries no canonical base"),
),
(
"set_tuning_context",
project_text_document(&tuning_context)
.expect("an accept document carries no canonical base"),
),
(
"lineage_custom_profile",
project_text_document(&lineage_custom),
project_text_document(&lineage_custom)
.expect("an accept document carries no canonical base"),
),
(
"extension_base_two_envelopes",
project_text_document(&extension_two_envelopes),
project_text_document(&extension_two_envelopes)
.expect("an accept document carries no canonical base"),
),
(
"rich_document",
project_text_document(&rich).expect("an accept document carries no canonical base"),
),
(
"create_staff_group",
project_text_document(&staff_group)
.expect("an accept document carries no canonical base"),
),
("rich_document", project_text_document(&rich)),
("create_staff_group", project_text_document(&staff_group)),
(
"create_part_definition",
project_text_document(&part_definition),
project_text_document(&part_definition)
.expect("an accept document carries no canonical base"),
),
(
"create_analysis_layer",
project_text_document(&analysis_layer),
project_text_document(&analysis_layer)
.expect("an accept document carries no canonical base"),
),
(
"create_view",
project_text_document(&view).expect("an accept document carries no canonical base"),
),
(
"create_measure",
project_text_document(&measure).expect("an accept document carries no canonical base"),
),
("create_view", project_text_document(&view)),
("create_measure", project_text_document(&measure)),
]
}
@ -679,6 +703,13 @@ pub fn document_vectors() -> Vec<TextVector> {
// built from the pre-change base-bearing spelling, so the corpus keeps a
// base-bearing text as a negative rather than losing the spelling
// entirely.
//
// This is the one legitimate caller of `render_text_document`, the
// crate-private formatter that skips pin 3b's projection refusal:
// `project_text_document` now refuses a base-bearing document, and a
// negative vector still has to *contain* the spelling it asserts is
// refused. Producing the bytes is not the same as permitting them — every
// other vector above goes through the checked projector.
let canonical_base_present = TextDocument {
document_id: DocumentId([11; 16]),
manifest_schema_version: SchemaVersion::V0,
@ -694,7 +725,7 @@ pub fn document_vectors() -> Vec<TextVector> {
"reject",
"canonical-base-unsupported",
"canonical_base_present",
project_text_document(&canonical_base_present).into_bytes(),
render_text_document(&canonical_base_present).into_bytes(),
));
vectors
@ -992,7 +1023,8 @@ mod tests {
let document =
parse_document(&text).unwrap_or_else(|e| panic!("{name} must parse: {e}"));
assert_eq!(document.envelopes.len(), 1, "{name} carries one envelope");
let reprojected = project_text_document(&document);
let reprojected = project_text_document(&document)
.expect("an accept document carries no canonical base");
assert_eq!(
reprojected, text,
"{name}: project(serialize(parse(T))) == T must hold"
@ -1026,7 +1058,8 @@ mod tests {
.1;
let document = parse_document(&text).unwrap_or_else(|e| panic!("{name} must parse: {e}"));
assert_eq!(document.envelopes.len(), 1, "{name} carries one envelope");
let reprojected = project_text_document(&document);
let reprojected =
project_text_document(&document).expect("an accept document carries no canonical base");
assert_eq!(
reprojected, text,
"{name}: project(serialize(parse(T))) == T must hold"

File diff suppressed because one or more lines are too long