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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ jobs:
- uses: actions/checkout@v7.0.1
- uses: dtolnay/rust-toolchain@v1
with:
toolchain: v1
# Was `v1`, which is not a rustup toolchain specification: the step
# failed on every run (including main) before a single cargo command
# ran, so check/test/clippy/fmt never executed. See the action's
# `toolchain` input: a channel or version, e.g. stable / 1.85.0.
toolchain: stable
components: clippy, rustfmt
- uses: Swatinem/rust-cache@v2.9.2

Expand Down
21 changes: 21 additions & 0 deletions .machine_readable/6a2/ECOSYSTEM.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,24 @@ ecosystem = "hyperpolymath"

[position]
type = "component"
role = "terminal consumer of documents — the print path"
last-updated = "2026-10-04"

[upstream.blocky-writer]
relationship = "one step upstream"
what-they-send = "ordinary PDF byte streams with AcroForm /V, /DV and /AS written and NeedAppearances set"
what-they-do-not-send = "any marker distinguishing a machine-filled form from a hand-completed one"
issue = "https://github.com/hyperpolymath/presswerk/issues/118"
ruling = "D189 (2026-09-30) — record machine-filled provenance as metadata in the print path so audit/routing can distinguish it"
our-response = "implemented: classify on every print path, persist on the job, write to the audit trail, optional hold-for-review routing"
our-ask = "write /FillOrigin (machine) and /FillTool (blocky-writer VERSION) into the document /Info dictionary in fill_blocks"
detail = "docs/ecosystem/FORM-PROVENANCE.adoc"

[position.form-provenance]
implemented = true
detection = "crates/presswerk-document/src/pdf/form.rs"
domain-types = "crates/presswerk-core/src/types.rs (FormOrigin, ProvenanceConfidence, FormProvenance, FormProvenancePolicy)"
persistence = "jobs.form_origin + jobs.form_provenance, migrated in place"
audit-actions = "form_provenance, job_held_machine_filled, job_released"
default-policy = "Record"
authority = "heuristic unless an explicit marker is present; upstream remains the authoritative source"
2 changes: 1 addition & 1 deletion 0-AI-MANIFEST.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ All Rust source code lives in the `crates/` workspace directory. The six crates
- `presswerk-core` — Shared types, errors, config (no internal deps)
- `presswerk-security` — Encrypted storage, audit trail, TLS certs (depends: core)
- `presswerk-document` — PDF ops, image processing, scanning, OCR (depends: core)
- `presswerk-print` — IPP client/server, mDNS discovery, job queue (depends: core, security)
- `presswerk-print` — IPP client/server, mDNS discovery, job queue (depends: core, security, document)
- `presswerk-bridge` — iOS (objc2) + Android (JNI) native bridges (depends: core)
- `presswerk-app` — Dioxus UI with 10 pages (depends: all above)

Expand Down
31 changes: 31 additions & 0 deletions CHANGELOG.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ The format is based on https://keepachangelog.com/en/1.1.0/[Keep a
Changelog], and this project adheres to
https://semver.org/spec/v2.0.0.html[Semantic Versioning].

=== [Unreleased]

==== Added

* *Machine-filled form provenance* (https://github.com/hyperpolymath/presswerk/issues/118[#118],
ruling D189): every PDF entering the print path is classified as machine-filled or
hand-completed, and the determination — with its confidence and evidence — is stored on the
job and written to the audit trail
** New domain types `FormOrigin`, `ProvenanceConfidence`, `FormProvenance` and
`FormProvenancePolicy` in `presswerk-core`
** New detector `presswerk_document::pdf::form` — reads `/AcroForm` field values,
`/NeedAppearances`, missing `/AP` appearances, `/DV` rewrites, the `/Producer` and `/Creator`
strings, and an explicit `/FillOrigin` marker
** Both print paths classify: locally selected documents (`AppServices::print_document`) and
network-received IPP jobs (`handle_print_job`)
** `form_origin` and `form_provenance` columns on the job queue, migrated in place for
existing databases, queryable via `get_jobs_with_form_origin`
** Audit actions `form_provenance`, `job_held_machine_filled` and `job_released`
** Optional `HoldForReview` routing policy: a machine-filled form is held for the user rather
than sent, with a `Release` action on the jobs page
** Provenance badge on the jobs page, plain-language summary in the audit trail, policy
selector in Settings
** Position and marker convention recorded in `docs/ecosystem/FORM-PROVENANCE.adoc`

==== Changed

* `presswerk-print` now depends on `presswerk-document`: the network print path must classify
documents itself, since a job received over IPP never passes through the application layer
* A Print-Job response now reports the job's actual `job-state` — a job held for review is
reported as held (4) instead of pending (3)

=== [0.3.0] - 2026-02-14

==== Added
Expand Down
3 changes: 2 additions & 1 deletion README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ toc::[]
* **Retry Engine** — Exponential backoff with jitter, circuit breaker, network self-healing
* **Universal Connectivity** — USB, Bluetooth, Wi-Fi Direct, NFC, Serial, Parallel, FireWire, Thunderbolt, and more
* **Document Processing** — PDF read/merge/split/rotate/crop, image resize/enhance, text-to-PDF, format auto-conversion
* **Form Provenance** — every PDF is classified as machine-filled or hand-completed, recorded on the job and in the audit trail, with optional hold-for-review routing
* **Document Scanning** — Camera capture with edge detection, perspective correction, binarisation, OCR
* **Print Server Mode** — Phone advertises itself as a network printer; other LAN devices can print to it
* **Encrypted Storage** — age (X25519) encryption at rest for all local data
Expand Down Expand Up @@ -65,7 +66,7 @@ presswerk-security (→ core; age, ring, rustls, rusqlite)
↑
presswerk-document (→ core; lopdf, printpdf, image, imageproc, ocrs)
↑
presswerk-print (→ core, security; ipp, mdns-sd, tokio)
presswerk-print (→ core, security, document; ipp, mdns-sd, tokio)
↑
presswerk-bridge (→ core; objc2 [iOS], jni [Android])
↑
Expand Down
26 changes: 24 additions & 2 deletions crates/presswerk-app/src/pages/audit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// Audit page — view the append-only audit trail backed by SQLite.
//
// Form-provenance entries (issue #118, ruling D189) carry a JSON determination
// in `details`; those are rendered as a one-line human summary.

use dioxus::prelude::*;

use presswerk_core::types::FormProvenance;
use presswerk_security::audit::AuditEntry;

use crate::services::app_services::AppServices;
Expand Down Expand Up @@ -61,6 +65,8 @@ pub fn Audit() -> Element {
} else {
entry.document_hash.clone()
};
let details_text =
summarise_details(&entry.action, entry.details.as_deref());

rsx! {
div { style: "padding: 10px; margin: 4px 0; border: 1px solid #f0f0f0; border-radius: 6px; font-size: 14px;",
Expand All @@ -74,9 +80,9 @@ pub fn Audit() -> Element {
p { style: "color: #888; font-size: 12px; margin: 2px 0 0; font-family: monospace;",
"{hash_short}"
}
if let Some(ref details) = entry.details {
if !details_text.is_empty() {
p { style: "color: #666; font-size: 12px; margin: 2px 0 0;",
"{details}"
"{details_text}"
}
}
}
Expand All @@ -88,3 +94,19 @@ pub fn Audit() -> Element {
}
}
}

/// Render an audit entry's `details` for a human.
///
/// Form-provenance entries store a JSON determination; everything else is
/// already human-readable text.
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::<FormProvenance>(details)
{
return provenance.summary();
}
details.to_string()
}
32 changes: 31 additions & 1 deletion crates/presswerk-app/src/pages/jobs.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// Jobs page — view all print jobs, their status, cancel/retry, and delete.
// Jobs page — view all print jobs, their status, cancel/retry, release, and
// delete. Jobs whose document was classified as a machine-filled form carry a
// provenance badge (issue #118, ruling D189).

use dioxus::prelude::*;

Expand Down Expand Up @@ -58,7 +60,10 @@ pub fn Jobs() -> Element {
let job_status = job.status;
let ts = job.created_at.format("%Y-%m-%d %H:%M").to_string();
let can_cancel = matches!(job_status, JobStatus::Pending | JobStatus::Held);
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();

rsx! {
div { style: "padding: 12px; margin: 8px 0; border: 1px solid #e0e0e0; border-radius: 8px;",
Expand All @@ -69,6 +74,11 @@ 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 let Some(ref uri) = job.printer_uri {
p { style: "color: #999; font-size: 12px;", "{uri}" }
}
Expand All @@ -94,6 +104,26 @@ pub fn Jobs() -> Element {
"Cancel"
}
}
if can_release {
button {
style: "padding: 4px 12px; border-radius: 4px; border: 1px solid #007aff; color: #007aff; background: white; font-size: 12px;",
onclick: {
let svc = svc.clone();
move |_| {
let svc = svc.clone();
spawn(async move {
if let Err(e) = svc.release_job(&job_id).await {
tracing::error!(error = %e, "release failed");
}
if let Ok(jobs) = svc.all_jobs() {
state.write().jobs = jobs;
}
});
}
},
"Release"
}
}
if is_terminal {
button {
style: "padding: 4px 12px; border-radius: 4px; border: 1px solid #ccc; color: #666; background: white; font-size: 12px;",
Expand Down
26 changes: 25 additions & 1 deletion crates/presswerk-app/src/pages/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

use dioxus::prelude::*;

use presswerk_core::types::PaperSize;
use presswerk_core::types::{FormProvenancePolicy, PaperSize};

use crate::services::app_services::AppServices;
use crate::state::AppState;
Expand Down Expand Up @@ -78,6 +78,30 @@ pub fn Settings() -> Element {
}
}

section { style: "margin: 16px 0;",
h3 { "Forms" }
p { style: "color: #666; font-size: 13px; margin: 0 0 8px;",
"Presswerk checks whether a PDF form was filled in by a program rather than by a person, and records that in the audit trail."
}
div { style: "display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px solid #f0f0f0;",
span { "Machine-filled forms" }
select {
style: "padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px;",
value: state.read().config.form_provenance_policy.as_token(),
onchange: move |evt| {
let policy = FormProvenancePolicy::from_token(&evt.value());
state.write().config.form_provenance_policy = policy;
},
option { value: "Off", "Do not check" }
option { value: "Record", "Record in audit trail" }
option { value: "HoldForReview", "Record and hold for review" }
}
}
p { style: "color: #999; font-size: 12px; margin: 8px 0 0;",
"For the print server, the choice applies the next time it starts."
}
}

section { style: "margin: 16px 0;",
h3 { "Security" }
SettingRow {
Expand Down
Loading
Loading