diff --git a/Justfile b/Justfile
index 49f39f9..cabaf08 100644
--- a/Justfile
+++ b/Justfile
@@ -54,6 +54,19 @@ assail:
verify-abi:
cd src/abi && idris2 --check Types.idr && idris2 --check Protocol.idr && idris2 --check Encryption.idr && idris2 --check Layout.idr && idris2 --check Bridge.idr
+# FFP conformance (requires standards checkout at $STANDARDS_DIR or ../standards)
+ffp-conformance:
+ #!/usr/bin/env bash
+ set -euo pipefail
+ STANDARDS="${STANDARDS_DIR:-../standards}"
+ if [ ! -d "$STANDARDS/1-formats/sub-specs/form-fill-provenance/spec/conformance" ]; then
+ echo "standards checkout not found at $STANDARDS"
+ echo "hint: git clone https://github.com/hyperpolymath/standards ../standards"
+ exit 1
+ fi
+ cargo build --bin ffp-classify -p presswerk-document
+ FFP_DETECTOR="./target/debug/ffp-classify" bash "$STANDARDS/1-formats/sub-specs/form-fill-provenance/spec/conformance/run-conformance.sh"
+
# Full CI check (test + lint + fmt)
ci: test-libs lint fmt-check
diff --git a/crates/presswerk-app/PrivacyInfo.xcprivacy b/crates/presswerk-app/PrivacyInfo.xcprivacy
new file mode 100644
index 0000000..a5ecd6f
--- /dev/null
+++ b/crates/presswerk-app/PrivacyInfo.xcprivacy
@@ -0,0 +1,25 @@
+
+
+
+
+ NSPrivacyAccessedAPITypes
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryFileTimestamp
+ NSPrivacyAccessedAPITypeReasons
+ C617.1
+
+
+ NSPrivacyAccessedAPIType
+ NSPrivacyAccessedAPICategoryUserDefaults
+ NSPrivacyAccessedAPITypeReasons
+ CA92.1
+
+
+ NSPrivacyCollectedDataTypes
+
+ NSPrivacyTracking
+
+
+
diff --git a/crates/presswerk-app/src/pages/audit.rs b/crates/presswerk-app/src/pages/audit.rs
index ff5fcd6..31a4d1d 100644
--- a/crates/presswerk-app/src/pages/audit.rs
+++ b/crates/presswerk-app/src/pages/audit.rs
@@ -8,6 +8,7 @@
use dioxus::prelude::*;
+use presswerk_core::provenance::{FfpRecord, FormProvenanceLegacy};
use presswerk_core::types::FormProvenance;
use presswerk_security::audit::AuditEntry;
@@ -103,10 +104,18 @@ fn summarise_details(action: &str, details: Option<&str>) -> String {
let Some(details) = details else {
return String::new();
};
- if action == "form_provenance"
- && let Ok(provenance) = serde_json::from_str::(details)
- {
- return provenance.summary();
+ if action == "form_provenance" {
+ if let Ok(provenance) = serde_json::from_str::(details) {
+ return provenance.summary();
+ }
+ if let Ok(legacy) = serde_json::from_str::(details) {
+ let rec: FfpRecord = legacy.into();
+ return rec.summary();
+ }
+ // Also try legacy alias via FormProvenance (which is now FfpRecord, but old JSON may still parse via alias fallback)
+ if let Ok(provenance) = serde_json::from_str::(details) {
+ return provenance.summary();
+ }
}
details.to_string()
}
diff --git a/crates/presswerk-app/src/pages/jobs.rs b/crates/presswerk-app/src/pages/jobs.rs
index f91a700..3ce21bc 100644
--- a/crates/presswerk-app/src/pages/jobs.rs
+++ b/crates/presswerk-app/src/pages/jobs.rs
@@ -63,7 +63,10 @@ pub fn Jobs() -> Element {
let can_release = matches!(job_status, JobStatus::Held);
let is_terminal = matches!(job_status, JobStatus::Completed | JobStatus::Failed | JobStatus::Cancelled);
let provenance_note = job.form_provenance.summary();
- let machine_filled = job.form_provenance.is_machine_filled();
+ let provenance_label = job.form_provenance.label();
+ let show_provenance = job.form_provenance.form != presswerk_core::provenance::FfpForm::Unknown;
+ let is_machine_or_suspected = job.form_provenance.is_machine_or_suspected();
+ let hazard = job.form_provenance.has_blank_print_hazard();
rsx! {
div { style: "padding: 12px; margin: 8px 0; border: 1px solid #e0e0e0; border-radius: 8px;",
@@ -74,9 +77,14 @@ pub fn Jobs() -> Element {
}
}
p { style: "color: #666; font-size: 14px; margin: 4px 0;", "{ts}" }
- if machine_filled {
- p { style: "color: #8a5300; font-size: 12px; margin: 4px 0; background: #fff4e5; border: 1px solid #ffd8a8; border-radius: 4px; padding: 2px 6px;",
- "Machine-filled: {provenance_note}"
+ if show_provenance {
+ p { style: "color: {provenance_color(&job.form_provenance)}; font-size: 12px; margin: 4px 0; background: {provenance_bg(&job.form_provenance)}; border: 1px solid {provenance_border(&job.form_provenance)}; border-radius: 4px; padding: 2px 6px;",
+ "{provenance_label}: {provenance_note}"
+ }
+ }
+ if hazard {
+ p { style: "color: #721c24; font-size: 12px; margin: 4px 0; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 4px; padding: 2px 6px;",
+ "⚠ Blank-print hazard: {job.form_provenance.filled_fields} field(s) have no appearance streams and may print empty (NeedAppearances is deprecated by ISO 32000-2)."
}
}
if let Some(ref uri) = job.printer_uri {
@@ -151,6 +159,37 @@ pub fn Jobs() -> Element {
}
}
+fn provenance_bg(p: &presswerk_core::provenance::FfpRecord) -> &'static str {
+ match p.classification {
+ presswerk_core::provenance::FfpClassification::MachineFilled => "#fff4e5",
+ presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#fff9db",
+ presswerk_core::provenance::FfpClassification::BlankForm => "#e8f5e9",
+ presswerk_core::provenance::FfpClassification::FilledUnknown => "#f3e5f5",
+ presswerk_core::provenance::FfpClassification::NoForm => "#f5f5f5",
+ presswerk_core::provenance::FfpClassification::Unreadable => "#ffebee",
+ }
+}
+fn provenance_border(p: &presswerk_core::provenance::FfpRecord) -> &'static str {
+ match p.classification {
+ presswerk_core::provenance::FfpClassification::MachineFilled => "#ffd8a8",
+ presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#ffec99",
+ presswerk_core::provenance::FfpClassification::BlankForm => "#c8e6c9",
+ presswerk_core::provenance::FfpClassification::FilledUnknown => "#ce93d8",
+ presswerk_core::provenance::FfpClassification::NoForm => "#e0e0e0",
+ presswerk_core::provenance::FfpClassification::Unreadable => "#ef9a9a",
+ }
+}
+fn provenance_color(p: &presswerk_core::provenance::FfpRecord) -> &'static str {
+ match p.classification {
+ presswerk_core::provenance::FfpClassification::MachineFilled => "#8a5300",
+ presswerk_core::provenance::FfpClassification::MachineFilledSuspected => "#6d4c00",
+ presswerk_core::provenance::FfpClassification::BlankForm => "#1b5e20",
+ presswerk_core::provenance::FfpClassification::FilledUnknown => "#4a148c",
+ presswerk_core::provenance::FfpClassification::NoForm => "#616161",
+ presswerk_core::provenance::FfpClassification::Unreadable => "#b71c1c",
+ }
+}
+
fn status_bg(s: JobStatus) -> &'static str {
match s {
JobStatus::Pending | JobStatus::Held => "#f0f0f0",
diff --git a/crates/presswerk-app/src/services/app_services.rs b/crates/presswerk-app/src/services/app_services.rs
index af12c9e..089e9e0 100644
--- a/crates/presswerk-app/src/services/app_services.rs
+++ b/crates/presswerk-app/src/services/app_services.rs
@@ -14,8 +14,9 @@ use std::sync::{Arc, Mutex};
use presswerk_core::AppConfig;
use presswerk_core::error::{PresswerkError, Result};
+use presswerk_core::provenance::FfpClassification;
use presswerk_core::types::{
- DiscoveredPrinter, DocumentType, FormOrigin, JobId, JobSource, JobStatus, PrintJob,
+ DiscoveredPrinter, DocumentType, JobId, JobSource, JobStatus, PrintJob,
PrintSettings, ServerStatus,
};
use presswerk_document::classify_for_print;
@@ -429,7 +430,7 @@ impl AppServices {
/// (issue #118, ruling D189).
pub fn machine_filled_jobs(&self) -> Result> {
let queue = acquire_lock(&self.job_queue);
- queue.get_jobs_with_form_origin(FormOrigin::Machine)
+ queue.get_jobs_with_form_origin(FfpClassification::MachineFilled)
}
/// Cancel a job.
diff --git a/crates/presswerk-core/src/lib.rs b/crates/presswerk-core/src/lib.rs
index ca0faf3..fbdd56e 100644
--- a/crates/presswerk-core/src/lib.rs
+++ b/crates/presswerk-core/src/lib.rs
@@ -7,8 +7,13 @@
pub mod config;
pub mod error;
pub mod human_errors;
+pub mod provenance;
pub mod types;
pub use config::AppConfig;
pub use error::PresswerkError;
+pub use provenance::{
+ FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord, FormOrigin,
+ FormProvenance, ProvenanceConfidence,
+};
pub use types::*;
diff --git a/crates/presswerk-core/src/provenance.rs b/crates/presswerk-core/src/provenance.rs
new file mode 100644
index 0000000..df42aae
--- /dev/null
+++ b/crates/presswerk-core/src/provenance.rs
@@ -0,0 +1,631 @@
+// SPDX-License-Identifier: MPL-2.0
+// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)
+//
+// FFP — Form-Fill Provenance, v1.0.0
+// Normative types for the print path (issue #118, ruling D189).
+// Wire vocabulary `ffp = "1.0"` is defined here; see
+// 1-formats/sub-specs/form-fill-provenance/ (spec).
+
+use serde::{Deserialize, Serialize};
+
+/// Classification lattice (DETECTION §6, README lattice).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum FfpClassification {
+ /// No interactive form or no fillable terminal field.
+ #[serde(rename = "no-form")]
+ NoForm,
+ /// Form present, zero meaningful values.
+ #[serde(rename = "blank-form")]
+ BlankForm,
+ /// ≥1 meaningful value and FFP marker declares `filledBy=machine`.
+ #[serde(rename = "machine-filled")]
+ MachineFilled,
+ /// ≥1 meaningful value, no marker, NeedAppearances && incomplete appearances.
+ #[serde(rename = "machine-filled-suspected")]
+ MachineFilledSuspected,
+ /// ≥1 meaningful value, none of the above.
+ #[serde(rename = "filled-unknown")]
+ FilledUnknown,
+ /// Could not be parsed.
+ #[default]
+ #[serde(rename = "unreadable")]
+ Unreadable,
+}
+
+impl FfpClassification {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::NoForm => "no-form",
+ Self::BlankForm => "blank-form",
+ Self::MachineFilled => "machine-filled",
+ Self::MachineFilledSuspected => "machine-filled-suspected",
+ Self::FilledUnknown => "filled-unknown",
+ Self::Unreadable => "unreadable",
+ }
+ }
+
+ pub fn from_str(s: &str) -> Self {
+ match s {
+ "no-form" => Self::NoForm,
+ "blank-form" => Self::BlankForm,
+ "machine-filled" => Self::MachineFilled,
+ "machine-filled-suspected" => Self::MachineFilledSuspected,
+ "filled-unknown" => Self::FilledUnknown,
+ "unreadable" => Self::Unreadable,
+ _ => Self::Unreadable,
+ }
+ }
+
+ /// Whether the job is machine-filled (declared).
+ pub fn is_machine(&self) -> bool {
+ matches!(self, Self::MachineFilled)
+ }
+
+ /// Whether the job is machine-filled or suspected (structural).
+ pub fn is_machine_or_suspected(&self) -> bool {
+ matches!(self, Self::MachineFilled | Self::MachineFilledSuspected)
+ }
+}
+
+impl std::fmt::Display for FfpClassification {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+/// Form presence (DETECTION record `form`).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "kebab-case")]
+pub enum FfpForm {
+ #[serde(rename = "present")]
+ Present,
+ #[serde(rename = "absent")]
+ Absent,
+ #[default]
+ #[serde(rename = "unknown")]
+ Unknown,
+}
+
+impl FfpForm {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Present => "present",
+ Self::Absent => "absent",
+ Self::Unknown => "unknown",
+ }
+ }
+}
+
+impl std::fmt::Display for FfpForm {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+/// Appearance state (DETECTION §5).
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub enum FfpAppearances {
+ #[serde(rename = "generated")]
+ Generated,
+ #[serde(rename = "incomplete")]
+ Incomplete,
+ #[serde(rename = "not-applicable")]
+ NotApplicable,
+ #[default]
+ #[serde(rename = "unknown")]
+ Unknown,
+}
+
+impl FfpAppearances {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::Generated => "generated",
+ Self::Incomplete => "incomplete",
+ Self::NotApplicable => "not-applicable",
+ Self::Unknown => "unknown",
+ }
+ }
+}
+
+impl std::fmt::Display for FfpAppearances {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_str())
+ }
+}
+
+/// Declared marker (MARKER.adoc).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub struct FfpDeclared {
+ /// Verbatim `filledBy` value.
+ #[serde(rename = "filledBy")]
+ pub filled_by: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool: Option,
+ #[serde(rename = "toolVersion", skip_serializing_if = "Option::is_none")]
+ pub tool_version: Option,
+ #[serde(rename = "filledAt", skip_serializing_if = "Option::is_none")]
+ pub filled_at: Option,
+ #[serde(rename = "appearancesGenerated", skip_serializing_if = "Option::is_none")]
+ pub appearances_generated: Option,
+}
+
+impl FfpDeclared {
+ pub fn machine(tool: Option, tool_version: Option, filled_at: Option, appearances_generated: Option) -> Self {
+ Self {
+ filled_by: "machine".to_string(),
+ tool,
+ tool_version,
+ filled_at,
+ appearances_generated,
+ }
+ }
+}
+
+/// The FFP record (DETECTION §The record). Wire version `ffp = "1.0"`.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FfpRecord {
+ /// Wire version, always `"1.0"`.
+ pub ffp: String,
+ pub classification: FfpClassification,
+ pub form: FfpForm,
+ #[serde(rename = "filled_fields")]
+ pub filled_fields: u32,
+ #[serde(rename = "total_fields")]
+ pub total_fields: u32,
+ pub appearances: FfpAppearances,
+ /// `null` when no marker parsed.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub declared: Option,
+ /// Evidence codes, sorted, deduped.
+ pub evidence: Vec,
+}
+
+impl Default for FfpRecord {
+ fn default() -> Self {
+ Self::unreadable("not inspected")
+ }
+}
+
+impl FfpRecord {
+ /// Create an `unreadable` record.
+ pub fn unreadable(reason: &str) -> Self {
+ let _ = reason;
+ Self {
+ ffp: "1.0".to_string(),
+ classification: FfpClassification::Unreadable,
+ form: FfpForm::Unknown,
+ filled_fields: 0,
+ total_fields: 0,
+ appearances: FfpAppearances::Unknown,
+ declared: None,
+ evidence: vec!["FFP-E-UNREADABLE".to_string()],
+ }
+ }
+
+ /// Create a `no-form` record.
+ pub fn no_form(evidence: Vec) -> Self {
+ let mut ev = evidence;
+ if !ev.contains(&"FFP-E-NO-ACROFORM".to_string()) {
+ ev.push("FFP-E-NO-ACROFORM".to_string());
+ }
+ ev.sort();
+ ev.dedup();
+ Self {
+ ffp: "1.0".to_string(),
+ classification: FfpClassification::NoForm,
+ form: FfpForm::Absent,
+ filled_fields: 0,
+ total_fields: 0,
+ appearances: FfpAppearances::NotApplicable,
+ declared: None,
+ evidence: ev,
+ }
+ }
+
+ /// Whether the document carries an interactive form.
+ pub fn is_form(&self) -> bool {
+ matches!(self.form, FfpForm::Present)
+ }
+
+ /// Whether the form was declared machine-filled.
+ pub fn is_machine_filled(&self) -> bool {
+ self.classification == FfpClassification::MachineFilled
+ }
+
+ /// Whether the form is machine-filled or suspected.
+ pub fn is_machine_or_suspected(&self) -> bool {
+ self.classification.is_machine_or_suspected()
+ }
+
+ /// Whether the record indicates the blank-print hazard (P5):
+ /// appearances incomplete and at least one filled field.
+ pub fn has_blank_print_hazard(&self) -> bool {
+ self.appearances == FfpAppearances::Incomplete && self.filled_fields > 0
+ }
+
+ /// Whether a job carrying this record should be held under `policy`.
+ pub fn should_hold(&self, policy: crate::types::FormProvenancePolicy) -> bool {
+ use crate::types::FormProvenancePolicy;
+ match policy {
+ FormProvenancePolicy::HoldForReview => self.is_machine_or_suspected() || self.has_blank_print_hazard(),
+ _ => false,
+ }
+ }
+
+ /// One-line human summary for UI/logs/audit.
+ pub fn summary(&self) -> String {
+ let head = match self.classification {
+ FfpClassification::NoForm => "not an interactive form".to_string(),
+ FfpClassification::BlankForm => "blank form — no values entered".to_string(),
+ FfpClassification::MachineFilled => {
+ if let Some(d) = &self.declared {
+ if let Some(tool) = &d.tool {
+ format!("machine-filled form ({tool})")
+ } else {
+ "machine-filled form (declared)".to_string()
+ }
+ } else {
+ "machine-filled form".to_string()
+ }
+ }
+ FfpClassification::MachineFilledSuspected => "machine-filled form (suspected — incomplete appearances)".to_string(),
+ FfpClassification::FilledUnknown => "form values present — provenance unknown".to_string(),
+ FfpClassification::Unreadable => "form provenance unreadable".to_string(),
+ };
+ if self.is_form() {
+ format!("{head}; {}/{} fields filled; appearances {}", self.filled_fields, self.total_fields, self.appearances.as_str())
+ } else {
+ head
+ }
+ }
+
+ /// Compact JSON for audit trail.
+ pub fn audit_json(&self) -> String {
+ serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
+ }
+
+ /// Canonical line for conformance runner:
+ /// `classification=… form=… filled=N/M appearances=… evidence=…`
+ pub fn canonical_line(&self) -> String {
+ let mut ev = self.evidence.clone();
+ ev.sort();
+ ev.dedup();
+ let evidence = ev.join(",");
+ format!(
+ "classification={} form={} filled={}/{} appearances={} evidence={}",
+ self.classification.as_str(),
+ self.form.as_str(),
+ self.filled_fields,
+ self.total_fields,
+ self.appearances.as_str(),
+ evidence
+ )
+ }
+
+ /// Short label for UI badges (P4).
+ pub fn label(&self) -> &'static str {
+ match self.classification {
+ FfpClassification::NoForm => "No form",
+ FfpClassification::BlankForm => "Blank form",
+ FfpClassification::MachineFilled => "Machine-filled",
+ FfpClassification::MachineFilledSuspected => "Machine-filled (suspected)",
+ FfpClassification::FilledUnknown => "Filled",
+ FfpClassification::Unreadable => "Unreadable",
+ }
+ }
+
+ // --- Compatibility shims for old code that used `origin`/`confidence`/`inspected` ---
+
+ /// Compatibility: `origin` alias for `classification` (old `FormOrigin` tokens).
+ /// Old tokens: NotAForm/Empty/Human/Machine/Unknown → new lattice.
+ pub fn origin_token(&self) -> &'static str {
+ match self.classification {
+ FfpClassification::NoForm => "NotAForm",
+ FfpClassification::BlankForm => "Empty",
+ FfpClassification::FilledUnknown => "Human",
+ FfpClassification::MachineFilled | FfpClassification::MachineFilledSuspected => "Machine",
+ FfpClassification::Unreadable => "Unknown",
+ }
+ }
+
+ /// Compatibility: `inspected` — false only for unreadable due to disabled policy.
+ pub fn inspected(&self) -> bool {
+ !matches!(self.classification, FfpClassification::Unreadable)
+ }
+
+ /// Compatibility: `field_count` alias.
+ pub fn field_count(&self) -> u32 {
+ self.total_fields
+ }
+ pub fn filled_field_count(&self) -> u32 {
+ self.filled_fields
+ }
+}
+
+// --- Legacy types for DB migration (pre-FFP-1.0) ------------------------
+// These are retained ONLY to deserialize rows written before 2026-10-04.
+// New code must use `Ffp*`. Do not construct these except in migration.
+
+/// Legacy origin (pre-1.0). Kept for `form_origin` column migration.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+pub enum FormOrigin {
+ NotAForm,
+ Empty,
+ Human,
+ Machine,
+ #[default]
+ Unknown,
+}
+
+impl FormOrigin {
+ pub fn as_token(&self) -> &'static str {
+ match self {
+ Self::NotAForm => "NotAForm",
+ Self::Empty => "Empty",
+ Self::Human => "Human",
+ Self::Machine => "Machine",
+ Self::Unknown => "Unknown",
+ }
+ }
+ pub fn from_token(token: &str) -> Self {
+ match token {
+ "NotAForm" => Self::NotAForm,
+ "Empty" => Self::Empty,
+ "Human" => Self::Human,
+ "Machine" => Self::Machine,
+ _ => Self::Unknown,
+ }
+ }
+ pub fn is_machine(&self) -> bool {
+ matches!(self, Self::Machine)
+ }
+}
+
+impl std::fmt::Display for FormOrigin {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_token())
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)]
+pub enum ProvenanceConfidence {
+ #[default]
+ None,
+ Speculative,
+ Probable,
+ Strong,
+ Explicit,
+}
+
+impl ProvenanceConfidence {
+ pub fn as_token(&self) -> &'static str {
+ match self {
+ Self::None => "None",
+ Self::Speculative => "Speculative",
+ Self::Probable => "Probable",
+ Self::Strong => "Strong",
+ Self::Explicit => "Explicit",
+ }
+ }
+}
+impl std::fmt::Display for ProvenanceConfidence {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ f.write_str(self.as_token())
+ }
+}
+
+/// Legacy provenance struct (pre-1.0). Only for deserializing old JSON.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct FormProvenanceLegacy {
+ pub origin: FormOrigin,
+ pub confidence: ProvenanceConfidence,
+ pub inspected: bool,
+ pub is_form: bool,
+ pub field_count: u32,
+ pub filled_field_count: u32,
+ pub values_without_appearance: u32,
+ pub filled_with_default: u32,
+ pub need_appearances: bool,
+ pub producer: Option,
+ pub creator: Option,
+ pub marker: Option,
+ pub evidence: Vec,
+}
+
+impl Default for FormProvenanceLegacy {
+ fn default() -> Self {
+ Self {
+ origin: FormOrigin::Unknown,
+ confidence: ProvenanceConfidence::None,
+ inspected: false,
+ is_form: false,
+ field_count: 0,
+ filled_field_count: 0,
+ values_without_appearance: 0,
+ filled_with_default: 0,
+ need_appearances: false,
+ producer: None,
+ creator: None,
+ marker: None,
+ evidence: vec!["document was not inspected".to_string()],
+ }
+ }
+}
+
+/// New code uses `FfpRecord`. This alias keeps `crate::FormProvenance` compiling
+/// for call sites that have migrated; it points to the spec type.
+pub type FormProvenance = FfpRecord;
+
+/// Convert legacy into new record (best-effort, for migration).
+impl From for FfpRecord {
+ fn from(legacy: FormProvenanceLegacy) -> Self {
+ // Map legacy origin → new classification
+ let classification = match legacy.origin {
+ FormOrigin::NotAForm => FfpClassification::NoForm,
+ FormOrigin::Empty => FfpClassification::BlankForm,
+ FormOrigin::Human => FfpClassification::FilledUnknown,
+ FormOrigin::Machine => {
+ // Old Machine covered both declared and suspected; map to suspected
+ // unless marker indicated explicit.
+ if legacy.marker.is_some() {
+ FfpClassification::MachineFilled
+ } else {
+ FfpClassification::MachineFilledSuspected
+ }
+ }
+ FormOrigin::Unknown => FfpClassification::Unreadable,
+ };
+ let form = if legacy.is_form {
+ FfpForm::Present
+ } else if legacy.origin == FormOrigin::NotAForm {
+ FfpForm::Absent
+ } else if legacy.origin == FormOrigin::Unknown && !legacy.inspected {
+ FfpForm::Unknown
+ } else {
+ FfpForm::Absent
+ };
+ let appearances = if legacy.filled_field_count == 0 {
+ FfpAppearances::NotApplicable
+ } else if legacy.values_without_appearance > 0 || legacy.need_appearances {
+ FfpAppearances::Incomplete
+ } else {
+ FfpAppearances::Generated
+ };
+ let declared = legacy.marker.map(|m| FfpDeclared {
+ filled_by: m,
+ tool: legacy.producer,
+ tool_version: None,
+ filled_at: None,
+ appearances_generated: None,
+ }).or_else(|| legacy.producer.map(|p| FfpDeclared {
+ filled_by: "machine".to_string(),
+ tool: Some(p),
+ tool_version: None,
+ filled_at: None,
+ appearances_generated: None,
+ }));
+ let mut evidence = Vec::new();
+ if legacy.need_appearances {
+ evidence.push("FFP-E-NEED-APPEARANCES".to_string());
+ }
+ if legacy.values_without_appearance > 0 {
+ evidence.push("FFP-E-AP-INCOMPLETE".to_string());
+ }
+ match legacy.origin {
+ FormOrigin::NotAForm => evidence.push("FFP-E-NO-ACROFORM".to_string()),
+ FormOrigin::Empty => evidence.push("FFP-E-NO-VALUES".to_string()),
+ FormOrigin::Unknown if !legacy.inspected => evidence.push("FFP-E-UNREADABLE".to_string()),
+ _ => {}
+ }
+ if legacy.marker.is_some() {
+ evidence.push("FFP-E-DECL-MACHINE".to_string());
+ }
+ if evidence.is_empty() && legacy.evidence.iter().any(|e| e.contains("not inspected")) {
+ evidence.push("FFP-E-UNREADABLE".to_string());
+ }
+ evidence.sort();
+ evidence.dedup();
+ if evidence.is_empty() {
+ evidence = legacy.evidence;
+ }
+ FfpRecord {
+ ffp: "1.0".to_string(),
+ classification,
+ form,
+ filled_fields: legacy.filled_field_count,
+ total_fields: legacy.field_count,
+ appearances,
+ declared,
+ evidence,
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn classification_tokens_roundtrip() {
+ for c in [
+ FfpClassification::NoForm,
+ FfpClassification::BlankForm,
+ FfpClassification::MachineFilled,
+ FfpClassification::MachineFilledSuspected,
+ FfpClassification::FilledUnknown,
+ FfpClassification::Unreadable,
+ ] {
+ let s = c.as_str();
+ assert_eq!(FfpClassification::from_str(s), c);
+ assert!(!s.is_empty());
+ }
+ }
+
+ #[test]
+ fn canonical_line_format() {
+ let r = FfpRecord {
+ ffp: "1.0".to_string(),
+ classification: FfpClassification::MachineFilledSuspected,
+ form: FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: FfpAppearances::Incomplete,
+ declared: None,
+ evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()],
+ };
+ assert_eq!(
+ r.canonical_line(),
+ "classification=machine-filled-suspected form=present filled=2/2 appearances=incomplete evidence=FFP-E-AP-INCOMPLETE,FFP-E-NEED-APPEARANCES"
+ );
+ // evidence is sorted
+ }
+
+ #[test]
+ fn json_roundtrip() {
+ let r = FfpRecord {
+ ffp: "1.0".to_string(),
+ classification: FfpClassification::MachineFilled,
+ form: FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: FfpAppearances::Incomplete,
+ declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: Some("blocky-writer".to_string()), tool_version: Some("0.2.0".to_string()), filled_at: None, appearances_generated: Some(false) }),
+ evidence: vec!["FFP-E-DECL-MACHINE".to_string()],
+ };
+ let json = r.audit_json();
+ let back: FfpRecord = serde_json::from_str(&json).unwrap();
+ assert_eq!(back, r);
+ }
+
+ #[test]
+ fn unreadable_line() {
+ let r = FfpRecord::unreadable("parse failed");
+ assert_eq!(r.canonical_line(), "classification=unreadable form=unknown filled=0/0 appearances=unknown evidence=FFP-E-UNREADABLE");
+ }
+
+ #[test]
+ fn should_hold() {
+ use crate::types::FormProvenancePolicy;
+ let mut r = FfpRecord {
+ ffp: "1.0".to_string(),
+ classification: FfpClassification::MachineFilled,
+ form: FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: FfpAppearances::Incomplete,
+ declared: Some(FfpDeclared { filled_by: "machine".to_string(), tool: None, tool_version: None, filled_at: None, appearances_generated: None }),
+ evidence: vec!["FFP-E-DECL-MACHINE".to_string()],
+ };
+ assert!(r.should_hold(FormProvenancePolicy::HoldForReview));
+ r.classification = FfpClassification::MachineFilledSuspected;
+ assert!(r.should_hold(FormProvenancePolicy::HoldForReview));
+ r.classification = FfpClassification::FilledUnknown;
+ // FilledUnknown with incomplete appearances is still hazard -> hold
+ r.appearances = FfpAppearances::Incomplete;
+ assert!(r.should_hold(FormProvenancePolicy::HoldForReview));
+ r.appearances = FfpAppearances::Generated;
+ assert!(!r.should_hold(FormProvenancePolicy::HoldForReview));
+ assert!(!r.should_hold(FormProvenancePolicy::Record));
+ }
+}
diff --git a/crates/presswerk-core/src/types.rs b/crates/presswerk-core/src/types.rs
index 778ff6b..d215c79 100644
--- a/crates/presswerk-core/src/types.rs
+++ b/crates/presswerk-core/src/types.rs
@@ -231,232 +231,18 @@ pub struct PageRange {
}
// ---------------------------------------------------------------------------
-// Form provenance — machine-filled vs hand-filled (issue #118, ruling D189)
+// Form provenance — FFP v1.0.0 (issue #118, ruling D189)
+//
+// The wire types live in `crate::provenance` (ffp = "1.0").
+// This module re-exports them and keeps the legacy `FormOrigin`/`FormProvenance`
+// names as aliases so existing DB rows and tests can migrate gradually.
+// New code should use `Ffp*` via `crate::provenance` or `crate::*`.
// ---------------------------------------------------------------------------
-/// How the values in an interactive (AcroForm) PDF came to be there.
-///
-/// Upstream form-fillers — for example
-/// [`blocky-writer`](https://github.com/hyperpolymath/blocky-writer), whose
-/// `fill_blocks` writes `/V`, `/DV` and `/AS` entries and sets
-/// `NeedAppearances` — emit an ordinary PDF byte stream. Nothing in those bytes
-/// says *who* wrote the values. Presswerk is usually the next thing that
-/// touches them: it prints them. So the print path classifies the document at
-/// the boundary and records the result for audit and routing.
-///
-/// The determination is heuristic unless an explicit marker is present; see
-/// `docs/ecosystem/FORM-PROVENANCE.adoc`.
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
-pub enum FormOrigin {
- /// The document carries no interactive form at all.
- NotAForm,
- /// The document is a form, but no field carries a value.
- Empty,
- /// Values are present and nothing suggests software wrote them.
- Human,
- /// Values are present and there is positive evidence software wrote them.
- Machine,
- /// Not determined: the document was not inspected, or inspection failed.
- #[default]
- Unknown,
-}
-
-impl FormOrigin {
- /// Stable single-word token used in the database, logs and audit trail.
- ///
- /// Never contains whitespace or punctuation, so it is safe to embed in SQL
- /// comparisons and log lines.
- pub fn as_token(&self) -> &'static str {
- match self {
- Self::NotAForm => "NotAForm",
- Self::Empty => "Empty",
- Self::Human => "Human",
- Self::Machine => "Machine",
- Self::Unknown => "Unknown",
- }
- }
-
- /// Inverse of [`Self::as_token`]. Unrecognised tokens map to `Unknown` —
- /// an unparseable provenance must never be mistaken for a determination.
- pub fn from_token(token: &str) -> Self {
- match token {
- "NotAForm" => Self::NotAForm,
- "Empty" => Self::Empty,
- "Human" => Self::Human,
- "Machine" => Self::Machine,
- _ => Self::Unknown,
- }
- }
-
- /// Whether this is a positive determination that software filled the form.
- pub fn is_machine(&self) -> bool {
- matches!(self, Self::Machine)
- }
-}
-
-impl std::fmt::Display for FormOrigin {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(self.as_token())
- }
-}
-
-/// How much weight a [`FormProvenance`] determination deserves.
-///
-/// Ordered weakest to strongest so callers can compare with `<`/`>=`.
-#[derive(
- Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
-)]
-pub enum ProvenanceConfidence {
- /// No determination was made.
- #[default]
- None,
- /// A weak signal; treat as a hint only.
- Speculative,
- /// A characteristic combination of signals, but spoofable.
- Probable,
- /// A named tool known to fill forms programmatically.
- Strong,
- /// An explicit machine-readable provenance marker was present.
- Explicit,
-}
-
-impl ProvenanceConfidence {
- /// Stable token for logs and the audit trail.
- pub fn as_token(&self) -> &'static str {
- match self {
- Self::None => "None",
- Self::Speculative => "Speculative",
- Self::Probable => "Probable",
- Self::Strong => "Strong",
- Self::Explicit => "Explicit",
- }
- }
-}
-
-impl std::fmt::Display for ProvenanceConfidence {
- fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
- f.write_str(self.as_token())
- }
-}
-
-/// What the print path concluded about a document's form values.
-///
-/// Attached to every [`PrintJob`] so that the queue, the audit trail and the
-/// routing policy can all see the same determination.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
-pub struct FormProvenance {
- /// The determination itself.
- pub origin: FormOrigin,
- /// How much weight the determination deserves.
- pub confidence: ProvenanceConfidence,
- /// Whether the document was actually inspected (`false` on parse failure,
- /// non-PDF input, or when the document exceeded the inspection budget).
- pub inspected: bool,
- /// Whether the document carries an interactive form (AcroForm) at all.
- pub is_form: bool,
- /// Number of form fields found.
- pub field_count: u32,
- /// Number of fields carrying a value.
- pub filled_field_count: u32,
- /// Fields with a value but no `/AP` appearance stream — the classic
- /// signature of a writer that set values without generating appearances.
- pub values_without_appearance: u32,
- /// Fields whose value was written alongside a `/DV` default. Interactive
- /// viewers write `/V` only; writing `/DV` too is a programmatic signature.
- pub filled_with_default: u32,
- /// Whether the AcroForm sets `/NeedAppearances` true.
- pub need_appearances: bool,
- /// `/Producer` from the document information dictionary, if any.
- pub producer: Option,
- /// `/Creator` from the document information dictionary, if any.
- pub creator: Option,
- /// An explicit upstream provenance marker, if one was present.
- pub marker: Option,
- /// Human-readable reasons for the determination, in order of weight.
- pub evidence: Vec,
-}
-
-impl Default for FormProvenance {
- fn default() -> Self {
- Self::not_inspected("document was not inspected")
- }
-}
-
-impl FormProvenance {
- /// Provenance for a document that was never inspected, with the reason.
- pub fn not_inspected(reason: &str) -> Self {
- Self {
- origin: FormOrigin::Unknown,
- confidence: ProvenanceConfidence::None,
- inspected: false,
- is_form: false,
- field_count: 0,
- filled_field_count: 0,
- values_without_appearance: 0,
- filled_with_default: 0,
- need_appearances: false,
- producer: None,
- creator: None,
- marker: None,
- evidence: vec![reason.to_string()],
- }
- }
-
- /// Whether the document carries an interactive form.
- pub fn is_form(&self) -> bool {
- self.is_form
- }
-
- /// Whether the form was determined to have been filled by software.
- pub fn is_machine_filled(&self) -> bool {
- self.origin.is_machine()
- }
-
- /// The tool responsible for the values, if one could be identified.
- ///
- /// Prefers the explicit marker over the `/Producer` string, since the
- /// latter can be set by anything.
- pub fn tool(&self) -> Option<&str> {
- self.marker.as_deref().or(self.producer.as_deref())
- }
-
- /// Whether a job carrying this provenance should be held for user review
- /// under `policy`.
- ///
- /// Only a *positive* machine determination holds a job: an uninspected or
- /// undetermined document must not stall somebody's printout.
- pub fn should_hold(&self, policy: FormProvenancePolicy) -> bool {
- matches!(policy, FormProvenancePolicy::HoldForReview) && self.is_machine_filled()
- }
-
- /// One-line human summary for the UI, logs and the audit trail.
- pub fn summary(&self) -> String {
- let head = match self.origin {
- FormOrigin::NotAForm => "not an interactive form".to_string(),
- FormOrigin::Empty => "interactive form; no values entered".to_string(),
- FormOrigin::Human => "form values present; no machine-fill evidence".to_string(),
- FormOrigin::Machine => match self.tool() {
- Some(tool) => format!("machine-filled form ({tool})"),
- None => "machine-filled form".to_string(),
- },
- FormOrigin::Unknown => "form provenance not determined".to_string(),
- };
-
- if self.is_form {
- format!(
- "{head}; {}/{} fields filled",
- self.filled_field_count, self.field_count
- )
- } else {
- head
- }
- }
-
- /// Compact JSON representation for the audit trail's `details` column.
- pub fn audit_json(&self) -> String {
- serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
- }
-}
+pub use crate::provenance::{
+ FfpAppearances, FfpClassification, FfpDeclared, FfpForm, FfpRecord,
+ FormOrigin, FormProvenance, ProvenanceConfidence,
+};
/// What the print path does with a machine-filled form (issue #118).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
@@ -811,22 +597,27 @@ mod tests {
// -- Form provenance (issue #118) ---------------------------------------
- /// Build a machine-filled determination the way the document crate does.
+ /// Build a machine-filled determination the way the document crate does (FFP v1.0).
fn machine_filled() -> FormProvenance {
FormProvenance {
- origin: FormOrigin::Machine,
- confidence: ProvenanceConfidence::Probable,
- inspected: true,
- is_form: true,
- field_count: 14,
- filled_field_count: 12,
- values_without_appearance: 12,
- filled_with_default: 12,
- need_appearances: true,
- producer: Some("blocky-writer 0.4.2".to_string()),
- creator: None,
- marker: None,
- evidence: vec!["12 of 14 fields carry values and /NeedAppearances is set".to_string()],
+ ffp: "1.0".to_string(),
+ classification: crate::provenance::FfpClassification::MachineFilled,
+ form: crate::provenance::FfpForm::Present,
+ filled_fields: 12,
+ total_fields: 14,
+ appearances: crate::provenance::FfpAppearances::Incomplete,
+ declared: Some(crate::provenance::FfpDeclared {
+ filled_by: "machine".to_string(),
+ tool: Some("blocky-writer 0.4.2".to_string()),
+ tool_version: Some("0.4.2".to_string()),
+ filled_at: None,
+ appearances_generated: Some(false),
+ }),
+ evidence: vec![
+ "FFP-E-DECL-MACHINE".to_string(),
+ "FFP-E-NEED-APPEARANCES".to_string(),
+ "FFP-E-AP-INCOMPLETE".to_string(),
+ ],
}
}
@@ -865,12 +656,12 @@ mod tests {
#[test]
fn test_form_provenance_default_is_not_inspected() {
let provenance = FormProvenance::default();
- assert!(!provenance.inspected);
+ // Default is unreadable (form unknown, not a form)
assert!(!provenance.is_form());
- assert_eq!(provenance.origin, FormOrigin::Unknown);
- assert_eq!(provenance.confidence, ProvenanceConfidence::None);
+ assert_eq!(provenance.classification, crate::provenance::FfpClassification::Unreadable);
+ assert_eq!(provenance.form, crate::provenance::FfpForm::Unknown);
assert!(!provenance.is_machine_filled());
- assert_eq!(provenance.evidence.len(), 1);
+ assert_eq!(provenance.evidence, vec!["FFP-E-UNREADABLE".to_string()]);
}
#[test]
@@ -881,8 +672,8 @@ mod tests {
"test.pdf".to_string(),
"hash".to_string(),
);
- assert!(!job.form_provenance.inspected);
- assert_eq!(job.form_provenance.origin, FormOrigin::Unknown);
+ assert_eq!(job.form_provenance.classification, crate::provenance::FfpClassification::Unreadable);
+ assert_eq!(job.form_provenance.form, crate::provenance::FfpForm::Unknown);
}
#[test]
@@ -896,28 +687,40 @@ mod tests {
#[test]
fn test_form_provenance_summary_not_a_form_omits_counts() {
let provenance = FormProvenance {
- origin: FormOrigin::NotAForm,
- confidence: ProvenanceConfidence::Strong,
- inspected: true,
- ..FormProvenance::default()
+ ffp: "1.0".to_string(),
+ classification: crate::provenance::FfpClassification::NoForm,
+ form: crate::provenance::FfpForm::Absent,
+ filled_fields: 0,
+ total_fields: 0,
+ appearances: crate::provenance::FfpAppearances::NotApplicable,
+ declared: None,
+ evidence: vec!["FFP-E-NO-ACROFORM".to_string()],
};
assert_eq!(provenance.summary(), "not an interactive form");
}
#[test]
- fn test_form_provenance_prefers_marker_over_producer() {
+ fn test_form_provenance_declared_tool() {
let mut provenance = machine_filled();
- assert_eq!(provenance.tool(), Some("blocky-writer 0.4.2"));
+ assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("blocky-writer 0.4.2"));
- provenance.marker = Some("blocky-writer fillOrigin=machine".to_string());
- assert_eq!(provenance.tool(), Some("blocky-writer fillOrigin=machine"));
+ // Declared tool is preferred
+ provenance.declared = Some(crate::provenance::FfpDeclared {
+ filled_by: "machine".to_string(),
+ tool: Some("other-tool".to_string()),
+ tool_version: None,
+ filled_at: None,
+ appearances_generated: None,
+ });
+ assert_eq!(provenance.declared.as_ref().and_then(|d| d.tool.as_deref()), Some("other-tool"));
}
#[test]
fn test_form_provenance_audit_json_roundtrips() {
let provenance = machine_filled();
let json = provenance.audit_json();
- assert!(json.contains("\"origin\":\"Machine\""), "{json}");
+ assert!(json.contains("\"classification\":\"machine-filled\""), "{json}");
+ assert!(json.contains("\"ffp\":\"1.0\""), "{json}");
let restored: FormProvenance = serde_json::from_str(&json).expect("deserialize");
assert_eq!(restored, provenance);
@@ -930,16 +733,47 @@ mod tests {
assert!(!machine.should_hold(FormProvenancePolicy::Record));
assert!(!machine.should_hold(FormProvenancePolicy::Off));
- // Undetermined provenance must never stall a printout.
+ // Undetermined (unreadable) must never stall a printout.
let unknown = FormProvenance::default();
assert!(!unknown.should_hold(FormProvenancePolicy::HoldForReview));
- // Neither must a hand-filled form.
+ // Filled-unknown with generated appearances must not be held (no hazard, no machine)
let human = FormProvenance {
- origin: FormOrigin::Human,
- ..FormProvenance::default()
+ ffp: "1.0".to_string(),
+ classification: crate::provenance::FfpClassification::FilledUnknown,
+ form: crate::provenance::FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: crate::provenance::FfpAppearances::Generated,
+ declared: None,
+ evidence: vec![],
};
assert!(!human.should_hold(FormProvenancePolicy::HoldForReview));
+
+ // But filled-unknown with incomplete appearances (hazard) SHOULD be held
+ let hazard = FormProvenance {
+ ffp: "1.0".to_string(),
+ classification: crate::provenance::FfpClassification::FilledUnknown,
+ form: crate::provenance::FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: crate::provenance::FfpAppearances::Incomplete,
+ declared: None,
+ evidence: vec!["FFP-E-AP-INCOMPLETE".to_string()],
+ };
+ assert!(hazard.should_hold(FormProvenancePolicy::HoldForReview));
+ // Machine-filled-suspected must be held
+ let suspected = FormProvenance {
+ ffp: "1.0".to_string(),
+ classification: crate::provenance::FfpClassification::MachineFilledSuspected,
+ form: crate::provenance::FfpForm::Present,
+ filled_fields: 2,
+ total_fields: 2,
+ appearances: crate::provenance::FfpAppearances::Incomplete,
+ declared: None,
+ evidence: vec!["FFP-E-NEED-APPEARANCES".to_string(), "FFP-E-AP-INCOMPLETE".to_string()],
+ };
+ assert!(suspected.should_hold(FormProvenancePolicy::HoldForReview));
}
#[test]
diff --git a/crates/presswerk-document/examples/ffp-classify.rs b/crates/presswerk-document/examples/ffp-classify.rs
new file mode 100644
index 0000000..68d1062
--- /dev/null
+++ b/crates/presswerk-document/examples/ffp-classify.rs
@@ -0,0 +1,27 @@
+// SPDX-License-Identifier: MPL-2.0
+// Thin CLI for FFP conformance runner: `FFP_DETECTOR=... ffp-classify`.
+// Usage: ffp-classify → prints canonical line to stdout.
+
+use std::env;
+use std::fs;
+use std::process;
+
+fn main() {
+ let args: Vec = env::args().collect();
+ if args.len() != 2 {
+ eprintln!("usage: {} ", args[0]);
+ process::exit(2);
+ }
+ let path = &args[1];
+ let bytes = fs::read(path).unwrap_or_else(|e| {
+ // Unreadable file is still a vector → classification unreadable
+ eprintln!("read {}: {}", path, e);
+ Vec::new()
+ });
+ // Use FFP v1.0 detector
+ let record = presswerk_document::provenance::classify_pdf(&bytes);
+ println!("{}", record.canonical_line());
+ if std::env::var("FFP_PROBE_RECORD").as_deref() == Ok("1") {
+ println!("{}", record.audit_json());
+ }
+}
diff --git a/crates/presswerk-document/src/bin/ffp-classify.rs b/crates/presswerk-document/src/bin/ffp-classify.rs
new file mode 100644
index 0000000..1863dc4
--- /dev/null
+++ b/crates/presswerk-document/src/bin/ffp-classify.rs
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: MPL-2.0
+// Binary target for FFP conformance: same as examples/ffp-classify.rs
+// so `cargo build --bin ffp-classify` produces `target/debug/ffp-classify`.
+
+use std::env;
+use std::fs;
+use std::process;
+
+fn main() {
+ let args: Vec = env::args().collect();
+ if args.len() != 2 {
+ eprintln!("usage: {} ", args[0]);
+ process::exit(2);
+ }
+ let path = &args[1];
+ let bytes = fs::read(path).unwrap_or_else(|e| {
+ eprintln!("read {}: {}", path, e);
+ Vec::new()
+ });
+ let record = presswerk_document::provenance::classify_pdf(&bytes);
+ println!("{}", record.canonical_line());
+ if std::env::var("FFP_PROBE_RECORD").as_deref() == Ok("1") {
+ println!("{}", record.audit_json());
+ }
+}
diff --git a/crates/presswerk-document/src/lib.rs b/crates/presswerk-document/src/lib.rs
index f9980f1..93a4e89 100644
--- a/crates/presswerk-document/src/lib.rs
+++ b/crates/presswerk-document/src/lib.rs
@@ -19,11 +19,12 @@
pub mod convert;
pub mod image;
pub mod pdf;
+pub mod provenance;
pub mod scan;
// CONVENIENCE: Primary interfaces for document transformation.
pub use image::processor::ImageProcessor;
-pub use pdf::form::{classify_for_print, classify_pdf};
+pub use provenance::{classify_for_print, classify_pdf};
pub use pdf::reader::PdfReader;
pub use pdf::writer::PdfWriter;
pub use scan::enhance::ScanEnhancer;
diff --git a/crates/presswerk-document/src/pdf/form.rs b/crates/presswerk-document/src/pdf/form.rs
index 2e13db3..8dfff6f 100644
--- a/crates/presswerk-document/src/pdf/form.rs
+++ b/crates/presswerk-document/src/pdf/form.rs
@@ -1,1139 +1,8 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath)
//
-// Form provenance — decide whether the values in a PDF form were written by
-// software or by a person.
-//
-// WHY THIS EXISTS (issue #118, ruling D189)
-// -----------------------------------------
-// Upstream form-fillers such as `blocky-writer` emit an ordinary PDF byte
-// stream: `/V`, `/DV` and `/AS` entries are written and `NeedAppearances` is
-// set, but nothing in the file says *who* wrote the values. Presswerk is
-// usually the next thing that touches those bytes — it prints them — so the
-// print path classifies the document at the boundary and records the result.
-//
-// The classification is deliberately conservative and evidence-based:
-//
-// Explicit an upstream provenance marker is present (`/FillOrigin` in
-// the document information dictionary, or a `fillOrigin`
-// attribute in an XMP packet)
-// Strong `/Producer` or `/Creator` names a tool known to fill forms
-// programmatically
-// Probable a characteristic combination of structural signals — values
-// without appearance streams, `NeedAppearances` set, values
-// written alongside `/DV` defaults
-//
-// Only a *positive* machine determination is ever acted upon; everything else
-// prints as normal. See `docs/ecosystem/FORM-PROVENANCE.adoc`.
-
-use std::collections::HashSet;
-
-use lopdf::{Dictionary, Document, Object, ObjectId};
-use presswerk_core::types::{
- DocumentType, FormOrigin, FormProvenance, FormProvenancePolicy, ProvenanceConfidence,
-};
-use tracing::{debug, instrument};
-
-/// Documents larger than this are not inspected.
-///
-/// Inspection happens inline on the print path, so the budget keeps a huge
-/// document from stalling the IPP accept loop. Documents over the budget are
-/// reported as "not inspected" rather than guessed at.
-pub const MAX_INSPECT_BYTES: usize = 8 * 1024 * 1024;
-
-/// Hard cap on field-tree nodes visited, so a malformed or hostile field tree
-/// cannot make inspection unbounded.
-const MAX_FIELD_NODES: u32 = 100_000;
-
-/// Hard cap on field-tree nesting depth.
-const MAX_FIELD_DEPTH: u32 = 32;
-
-/// How far down a field's own subtree to look for an appearance stream before
-/// concluding the value has none.
-const APPEARANCE_SEARCH_DEPTH: u32 = 3;
-
-/// Substrings that identify a tool which writes form values programmatically.
-///
-/// Matched case-insensitively against `/Producer` and `/Creator`. Document
-/// generators that merely *create* blank PDFs are deliberately absent: a blank
-/// form produced by such a tool is still an empty form.
-const KNOWN_MACHINE_FILLERS: &[&str] = &[
- "blocky-writer",
- "blocky_writer",
- "blockywriter",
- "fill_blocks",
- "lopdf",
- "itext",
- "openpdf",
- "pdf-lib",
- "pypdf",
- "pdftk",
- "pdfbox",
- "aspose",
- "pdfsharp",
- "docusign",
- "adobe livecycle",
- "livecycle",
-];
-
-/// Attribute name scanned for in raw bytes / XMP packets.
-const MARKER_ATTRIBUTE: &[u8] = b"fillorigin";
-
-// ---------------------------------------------------------------------------
-// Public entry points
-// ---------------------------------------------------------------------------
-
-/// Classify a document arriving on the print path.
-///
-/// This is the entry point both print paths (local printing and the embedded
-/// IPP server) call. It never fails: anything that stops inspection — a
-/// non-PDF, an oversized document, a parse error, a disabled policy — is
-/// reported inside the returned value with `inspected == false`.
-#[instrument(skip_all, fields(bytes = data.len(), policy = %policy.as_token()))]
-pub fn classify_for_print(
- data: &[u8],
- document_type: DocumentType,
- policy: FormProvenancePolicy,
-) -> FormProvenance {
- if !policy.inspects() {
- debug!("form provenance inspection disabled by policy");
- return FormProvenance::not_inspected("form provenance inspection is disabled");
- }
- if document_type != DocumentType::Pdf {
- return FormProvenance::not_inspected("document is not a PDF");
- }
- classify_pdf(data)
-}
-
-/// Classify the form provenance of raw PDF bytes.
-///
-/// Never fails; see [`classify_for_print`].
-#[instrument(skip_all, fields(bytes = data.len()))]
-pub fn classify_pdf(data: &[u8]) -> FormProvenance {
- if data.is_empty() {
- return FormProvenance::not_inspected("document is empty");
- }
- if data.len() > MAX_INSPECT_BYTES {
- return FormProvenance::not_inspected(&format!(
- "document is {} bytes, above the {} byte inspection budget",
- data.len(),
- MAX_INSPECT_BYTES
- ));
- }
-
- let document = match Document::load_mem(data) {
- Ok(document) => document,
- Err(err) => {
- debug!(error = %err, "PDF parse failed during provenance inspection");
- return FormProvenance::not_inspected(&format!("PDF parse failed: {err}"));
- }
- };
-
- classify_document(&document, data)
-}
-
-/// Classify an already-parsed document.
-///
-/// `raw` is the original byte stream and is used only to find unstructured
-/// provenance markers (for example inside an XMP packet); pass an empty slice
-/// to rely on the structured `/Info` marker alone.
-#[instrument(skip_all, fields(raw_bytes = raw.len()))]
-pub fn classify_document(document: &Document, raw: &[u8]) -> FormProvenance {
- let mut provenance = inspected_blank();
-
- let info = document_info(document);
- provenance.producer = info.producer.clone();
- provenance.creator = info.creator.clone();
-
- // -- Explicit marker ------------------------------------------------------
- let mut marker = structured_marker(&info);
- if marker.is_none() {
- marker = scan_for_marker(raw);
- }
- if marker.is_none() {
- for packet in xmp_packets(document) {
- if let Some(found) = scan_for_marker(&packet) {
- marker = Some(found);
- break;
- }
- }
- }
- if let Some(found) = marker
- && mentions_machine(&found)
- {
- provenance.marker = Some(found.clone());
- provenance
- .evidence
- .push(format!("explicit provenance marker present: {found}"));
- }
- let explicit = provenance.marker.is_some();
-
- // -- Known tool -----------------------------------------------------------
- let known_tool = known_filler(provenance.producer.as_deref(), provenance.creator.as_deref());
- if let Some(tool) = known_tool {
- provenance.evidence.push(format!(
- "document producer/creator names '{tool}', a known form-filling tool"
- ));
- }
-
- // -- Structure ------------------------------------------------------------
- let catalog = match document.catalog() {
- Ok(catalog) => catalog,
- Err(err) => {
- provenance.origin = FormOrigin::NotAForm;
- provenance.confidence = ProvenanceConfidence::Speculative;
- provenance
- .evidence
- .push(format!("document has no readable catalog ({err})"));
- return finish(provenance);
- }
- };
-
- let acroform = match catalog
- .get(b"AcroForm")
- .ok()
- .and_then(|obj| resolve_dict(document, obj))
- {
- Some(acroform) => acroform,
- None => {
- provenance.origin = FormOrigin::NotAForm;
- provenance.confidence = ProvenanceConfidence::Strong;
- provenance
- .evidence
- .push("document catalog has no /AcroForm entry".to_string());
- return finish(provenance);
- }
- };
-
- provenance.is_form = true;
- provenance.need_appearances =
- matches!(acroform.get(b"NeedAppearances"), Ok(Object::Boolean(true)));
-
- let scan = scan_fields(document, acroform);
- provenance.field_count = scan.field_count;
- provenance.filled_field_count = scan.filled;
- provenance.filled_with_default = scan.filled_with_default;
- provenance.values_without_appearance = scan.without_appearance;
-
- // -- Determination --------------------------------------------------------
- if explicit {
- // An explicit marker outranks every structural signal, including an
- // apparently empty form: the writer is telling us it filled the form.
- provenance.origin = FormOrigin::Machine;
- provenance.confidence = ProvenanceConfidence::Explicit;
- } else if provenance.filled_field_count == 0 {
- provenance.origin = FormOrigin::Empty;
- provenance.confidence = ProvenanceConfidence::Strong;
- provenance.evidence.push(format!(
- "/AcroForm declares {} field(s), none of which carries a value",
- provenance.field_count
- ));
- } else if known_tool.is_some() {
- provenance.origin = FormOrigin::Machine;
- provenance.confidence = ProvenanceConfidence::Strong;
- } else if provenance.need_appearances {
- provenance.origin = FormOrigin::Machine;
- provenance.confidence = ProvenanceConfidence::Probable;
- provenance.evidence.push(format!(
- "{} of {} field(s) carry values and /NeedAppearances is set — values were written without appearances",
- provenance.filled_field_count, provenance.field_count
- ));
- } else if provenance.values_without_appearance > 0 {
- provenance.origin = FormOrigin::Machine;
- provenance.confidence = ProvenanceConfidence::Probable;
- provenance.evidence.push(format!(
- "{} field value(s) have no /AP appearance stream",
- provenance.values_without_appearance
- ));
- } else if provenance.filled_with_default > 0 {
- provenance.origin = FormOrigin::Machine;
- provenance.confidence = ProvenanceConfidence::Speculative;
- provenance.evidence.push(format!(
- "{} field value(s) were written alongside a /DV default",
- provenance.filled_with_default
- ));
- } else {
- provenance.origin = FormOrigin::Human;
- provenance.confidence = ProvenanceConfidence::Probable;
- provenance.evidence.push(format!(
- "{} of {} field(s) carry values, each with an appearance stream and no machine-fill signal",
- provenance.filled_field_count, provenance.field_count
- ));
- }
-
- finish(provenance)
-}
-
-// ---------------------------------------------------------------------------
-// Determination bookkeeping
-// ---------------------------------------------------------------------------
-
-/// A provenance value for a document that *was* inspected, before any evidence
-/// has been gathered.
-fn inspected_blank() -> FormProvenance {
- FormProvenance {
- origin: FormOrigin::Unknown,
- confidence: ProvenanceConfidence::None,
- inspected: true,
- is_form: false,
- field_count: 0,
- filled_field_count: 0,
- values_without_appearance: 0,
- filled_with_default: 0,
- need_appearances: false,
- producer: None,
- creator: None,
- marker: None,
- evidence: Vec::new(),
- }
-}
-
-/// Log the determination and hand it back.
-fn finish(provenance: FormProvenance) -> FormProvenance {
- debug!(
- origin = %provenance.origin,
- confidence = %provenance.confidence,
- fields = provenance.field_count,
- filled = provenance.filled_field_count,
- "form provenance determined"
- );
- provenance
-}
-
-// ---------------------------------------------------------------------------
-// Object graph helpers
-// ---------------------------------------------------------------------------
-
-/// Resolve an object that is expected to be a dictionary (possibly indirect).
-fn resolve_dict<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Dictionary> {
- match object {
- Object::Dictionary(dict) => Some(dict),
- Object::Stream(stream) => Some(&stream.dict),
- Object::Reference(id) => document.get_object(*id).ok().and_then(|resolved| {
- match resolved {
- Object::Dictionary(dict) => Some(dict),
- Object::Stream(stream) => Some(&stream.dict),
- _ => None,
- }
- }),
- _ => None,
- }
-}
-
-/// Resolve an object that is expected to be an array (possibly indirect).
-fn resolve_array<'a>(document: &'a Document, object: &'a Object) -> Option<&'a Vec