Vibecode Sejda Web
track this build4 phases, 12 steps, beginner friendly0%The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Sejda Web, perform a practical set of PDF edits and conversions in a local browser app. The hard boundary is excellent pdf operations, hosted convenience, desktop app, and edge-case handling, plus document fidelity, identity, and compliance.
You are building a lean indie version of Sejda Web. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Sejda Web · indie build A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A local FastAPI service with a browser interface, nothing hosted | the documents people bring to a tool like this are exactly the ones they will not upload | | PDF engine | A structural library over qpdf for object work, with a separate renderer for previews | structure and rendering are different problems and one library rarely does both well | | Store | A content-addressed folder of originals plus SQLite for the operation log | the same file dragged in twice is the same file, and the hash says so | | Conversion | An office converter headless in a subprocess with a timeout, a fresh profile and no network | conversion is the step that hangs and the step that runs other people's macros | | Output | Every operation writes a new file and records input hash, arguments and output hash | the log is what makes a wrong output explainable rather than arguable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the service and the PDF work. - Get it: python.org or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A PDF structural library built on qpdf** · free - Why: Object-level work, full file rewrites and dropping unreferenced objects all come from here, and it is the part that makes redaction verifiable. - Get it: Install the library and confirm the underlying qpdf version it links against. - Verify: A round-trip rewrite of a test PDF drops a deliberately orphaned object - [ ] **An office suite that can run headless** · free - Why: Office to PDF conversion is one of the operations people actually want, and it is also the one that hangs, so it needs its own containment. - Get it: Install the suite and confirm the headless conversion command runs. - Verify: A document converts with a per-run profile directory and no network access - [ ] **A test corpus including an encrypted, a signed and a form PDF** · free - Why: Every hazard this build warns about needs a fixture, and collecting them afterwards means the warnings were never tested. - Get it: Create or collect one PDF of each kind and keep them in the repository. - Verify: The suite covers encrypted, signed, form-bearing and scanned documents - [ ] **Whether you offer redaction at all** · free - Why: Redaction is the operation with real consequences if it is wrong, and offering it means committing to the extraction check on every output rather than treating it as a nice extra. - Get it: Decide, and if yes, write the verification requirement into the release gate before building it. - Verify: The release gate names both redaction checks ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. - excellent PDF operations, hosted convenience, desktop app, and edge-case handling - pixel-perfect proprietary PDF engine - identity verification - qualified trust services - large template and integration ecosystem If one of those is essential to you, that is the reason to keep paying for Sejda Web, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sejda Web The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local PDF workbench that keeps every original untouched and writes each operation to a new file to replace Sejda Web. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both exist because this category fails quietly rather than loudly. A black rectangle is not a redaction. Drawing a filled shape over text leaves the text sitting in the content stream, where copy and paste, any text extractor and any parser read it straight out, and this has embarrassed law firms, governments and newspapers repeatedly. If this tool offers redaction, redaction means removing the text objects and re-encoding the image regions, and then proving it by extracting text from the output and finding nothing. A redaction you have not verified by extraction is a rectangle. That proof needs a full rewrite, because PDF is append-only by design: an incremental save leaves the previous version of every object still in the file, reachable by anyone who looks at the objects the trailer no longer points at. The qpdf manual states the behaviour you want plainly, that ordinarily any object not referenced in a traversal from the trailer dictionary is discarded, with a flag to preserve them instead. So write a fresh file, never append, and never pass that flag. Second: originals are immutable. Every operation reads one document and writes another. A tool that edits in place will, on the day somebody drags in their only copy of something, be the reason it is gone. ### Stack (fixed, do not substitute) - Runtime: A local FastAPI service with a browser interface, nothing hosted · the documents people bring to a tool like this are exactly the ones they will not upload - PDF engine: A structural library over qpdf for object work, with a separate renderer for previews · structure and rendering are different problems and one library rarely does both well - Store: A content-addressed folder of originals plus SQLite for the operation log · the same file dragged in twice is the same file, and the hash says so - Conversion: An office converter headless in a subprocess with a timeout, a fresh profile and no network · conversion is the step that hangs and the step that runs other people's macros - Output: Every operation writes a new file and records input hash, arguments and output hash · the log is what makes a wrong output explainable rather than arguable ### Data model (create this before Phase 1) - documents(id, sha256, filename, bytes, page_count, encrypted, has_signatures, has_forms, imported_at) · sha256 is the identity, not the filename - operations(id, input_document_id, output_document_id, kind, args_json, started_at, ended_at, exit_state, tool_version, warnings_json) - pages(document_id, index, width_pt, height_pt, rotation, has_text_layer) · probed once on import, so a page range can be validated before anything runs - redactions(id, document_id, page_index, rect_json, matched_text_hash, applied_at, verified_at) · verified_at is set only after the output has been re-extracted and checked, and never by the code that did the removal - retention(document_id, expires_at, deleted_at) · a workbench full of other people's documents needs an end date - There is no statement anywhere in this application that changes a document's bytes. An operation reads one row and writes another, and the graph of operations is the history of the file. ### Phase 1 · Import, identity and a preview that warns Build: Hash on import and store the file under its hash. Probe page count, page sizes, rotation, text layers, encryption, signatures and form fields. Render thumbnails. Before offering an operation, warn about anything in the document that the operation will break. Trap: Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. Done when: Importing the same file twice produces one document row and both filenames recorded, tested. A document with an owner password is detected and no operation silently removes its encryption, tested with an encrypted fixture. Signatures, form fields and text layers are detected on import and shown before any operation is offered. A file that is not a PDF is rejected by structure rather than by extension, tested by renaming a text file. Do not build yet: No structural operations, redaction, conversion or signing yet. ### Phase 2 · The structural operations Build: Parse page ranges strictly against the real page count and show the resulting page list before running. Merge, split, reorder, rotate and extract, each writing a new document row with the input hash recorded. Detect form field name collisions on merge and handle them deliberately. Trap: Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. Done when: A page range referring to a page that does not exist fails with the offending part named and produces no output, tested. The page list resulting from a range is shown before the operation runs. Merging two documents with colliding form field names either renames them or refuses with the collisions listed, verified by filling the merged form. Every operation writes a new document row whose input hash matches the original, asserted for each operation kind. Do not build yet: No redaction, conversion, OCR or signing yet. ### Phase 3 · Redaction that actually removes Build: Select text or a region, remove the text objects that intersect it and re-encode any image region, then write a complete new file so unreferenced objects are dropped. Extract text from the output and search the raw bytes, including decompressed streams, before marking it verified. Trap: Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. Done when: Extracting text from a redacted output returns none of the redacted strings, asserted in the suite. Searching the output's raw bytes, including every decompressed stream, finds none of the redacted strings, asserted separately from the extraction check. A redaction that fails either check is reported as failed and the output is not offered for download, tested by stubbing the removal to do nothing. Document metadata, attachments and embedded thumbnails are checked for the same strings. Do not build yet: No conversion, OCR, signing or retention yet. ### Phase 4 · Conversion and OCR, in a box Build: Run the converter in a subprocess with a per-file timeout, a fresh profile directory it is given rather than one it shares, macro execution off and no network access. Record the engine version with the output. OCR writes a text layer and records the engine and language used. Trap: Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. Done when: A converter run that exceeds the timeout is killed and recorded as a timeout with its partial output discarded, tested with a deliberately slow fixture. The converter runs with a per-run profile directory and cannot see the previous run's, asserted by checking the path. An attempt to reach the network from inside the conversion fails, tested with a document containing an external reference. The engine version and OCR language are recorded on every output they produced. Do not build yet: No signing flow, retention or export yet. ### Phase 5 · Signing, retention and the honest limit Build: Place fields, collect a typed or drawn mark, record consent with a timestamp, hash the final file and append it to a log that is only ever appended to. Expire documents on a retention schedule while keeping the operation log. State the limitation in the interface, not only the README. Trap: Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. Done when: The signature screen states, where the signer sees it, that no identity was verified and what that means. A structural operation on a document with an existing signature requires an explicit confirmation naming the consequence, tested. Documents past their retention are deleted and their operation rows survive with hashes intact, tested. Export produces every document still present plus the full operation log as CSV, and the log alone is enough to describe what happened to a deleted document. Do not build yet: Nothing after this. ### Out of scope (and why) - Qualified or otherwise regulated electronic signatures, which need a trust service provider and an audit trail nobody can build in a side project. - Government identity verification for signers, which is a contracted service with real obligations attached rather than a feature. - Full page-content editing at the level of a professional editor, which is a text layout engine and a font problem and years of work. - A hosted service with accounts, because the documents people bring here are the ones they specifically do not want to upload anywhere. ### README must contain - That a black rectangle is not a redaction, and that this tool verifies removal by extracting text from the output - That every operation writes a new file and no code path changes an original's bytes - That outputs are written as complete new files rather than incremental updates, and why unreferenced objects are dropped - That the signature flow verifies nobody's identity and is not a qualified or regulated signature - Where documents live, what the retention period is, and that the operation log outlives the files ===== AGENTS.md ===== # Agent instructions · Sejda Web indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A local FastAPI service with a browser interface, nothing hosted, A structural library over qpdf for object work, with a separate renderer for previews, A content-addressed folder of originals plus SQLite for the operation log, An office converter headless in a subprocess with a timeout, a fresh profile and no network, Every operation writes a new file and records input hash, arguments and output hash. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ===== BUILD_PLAN.md ===== # Build plan · Sejda Web A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Import, identity and a preview that warns A document's hash, structure and hazards are known before any operation is offered. ### Steps 1. Hash first, store under the hash Filenames lie; the bytes do not. Files: `app/import.py`, `tests/test_dedupe.py` 2. Probe hazards on import, not at operation time Encryption, signatures and forms all change what is safe to run. Files: `app/probe.py` 3. Require an explicit decrypt operation Removing somebody's restrictions is a decision, not a side effect. Files: `app/ops/decrypt.py`, `tests/test_encrypted.py` ### Done when - [ ] The same file twice is one document - [ ] Encryption is detected and never silently removed - [ ] Signatures, forms and text layers are reported on import - [ ] A non-PDF is rejected by structure ### Watch out - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. ## Phase 2 · The structural operations Merge, split, reorder, rotate and extract produce new documents and never surprise you. ### Steps 1. Validate ranges against the real page count And show the resulting pages before anything runs. Files: `app/ranges.py`, `tests/test_ranges.py` 2. Detect form field collisions before merging Test it by filling the merged form, not by reading the spec. Files: `app/ops/merge.py`, `tests/test_merge_forms.py` 3. Write a new document for every operation The input row is never touched, by any code path. Files: `app/ops/base.py`, `tests/test_immutable.py` ### Done when - [ ] An impossible page range fails with the reason - [ ] The resulting page list is shown first - [ ] Form field collisions are handled deliberately - [ ] Every operation writes a new document ### Watch out - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. ## Phase 3 · Redaction that actually removes Text marked for redaction is absent from the output file, proved by extraction rather than by looking. ### Steps 1. Remove objects, do not cover them The rectangle is the last step, not the operation. Files: `app/ops/redact.py` 2. Write a complete new file and drop unreferenced objects An incremental save keeps the version you were removing. Files: `app/ops/redact.py`, `docs/redaction.md` 3. Verify by extraction and by raw byte search Two different checks, because they miss different things. Files: `tests/test_redaction_verified.py` ### Done when - [ ] Text extraction finds no redacted string - [ ] A raw byte search of decompressed streams finds none - [ ] An unverified redaction is never offered for download - [ ] Metadata, attachments and thumbnails are checked too ### Watch out - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. ## Phase 4 · Conversion and OCR, in a box Converting an office document cannot hang the service, reach the network or run anything the file asked it to. ### Steps 1. Give the converter a fresh profile and a timeout Shared profiles are how one bad file poisons the next run. Files: `app/convert.py`, `tests/test_timeout.py` 2. Cut its network access explicitly A document should not be able to make outbound requests. Files: `app/sandbox.py` 3. Record the engine version with every output Rendering differences between versions are real and surprising. Files: `app/ops/base.py` ### Done when - [ ] A slow conversion is killed and recorded - [ ] Each run gets its own profile directory - [ ] Network access from conversion fails - [ ] Engine version and OCR language are recorded ### Watch out - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ## Not in this build - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. ## After v1, if you want it - A batch mode that applies one recorded operation to a folder, which is where the arguments-in-the-log design pays off - A comparison view between an input and its output, which catches more mistakes than any warning - Attachment and embedded file extraction, since that is where documents hide things nobody looked for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Content-addressed originals and outputs. The largest thing here. STORAGE_DIR=./data/documents # Required. Documents, operations, redactions and retention. Outlives the files. DATABASE_URL=sqlite:///./data/workbench.db # Required. After this the converter is killed and the run recorded as a timeout. CONVERT_TIMEOUT_SECONDS=120 # Required. A fresh subdirectory per run, removed afterwards. CONVERT_PROFILE_ROOT=./data/convert-profiles # Required. Documents are deleted after this. Operation rows and hashes remain. RETENTION_DAYS=30 # Required. Refused above this, with the limit named, rather than failing part way. MAX_UPLOAD_MB=200 # Required. Strict means an unverified redaction is never offered. There is no other sensible value. REDACTION_VERIFY=strict
You are building a lean indie version of Sejda Web. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Sejda Web · indie build A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A local FastAPI service with a browser interface, nothing hosted | the documents people bring to a tool like this are exactly the ones they will not upload | | PDF engine | A structural library over qpdf for object work, with a separate renderer for previews | structure and rendering are different problems and one library rarely does both well | | Store | A content-addressed folder of originals plus SQLite for the operation log | the same file dragged in twice is the same file, and the hash says so | | Conversion | An office converter headless in a subprocess with a timeout, a fresh profile and no network | conversion is the step that hangs and the step that runs other people's macros | | Output | Every operation writes a new file and records input hash, arguments and output hash | the log is what makes a wrong output explainable rather than arguable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the service and the PDF work. - Get it: python.org or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A PDF structural library built on qpdf** · free - Why: Object-level work, full file rewrites and dropping unreferenced objects all come from here, and it is the part that makes redaction verifiable. - Get it: Install the library and confirm the underlying qpdf version it links against. - Verify: A round-trip rewrite of a test PDF drops a deliberately orphaned object - [ ] **An office suite that can run headless** · free - Why: Office to PDF conversion is one of the operations people actually want, and it is also the one that hangs, so it needs its own containment. - Get it: Install the suite and confirm the headless conversion command runs. - Verify: A document converts with a per-run profile directory and no network access - [ ] **A test corpus including an encrypted, a signed and a form PDF** · free - Why: Every hazard this build warns about needs a fixture, and collecting them afterwards means the warnings were never tested. - Get it: Create or collect one PDF of each kind and keep them in the repository. - Verify: The suite covers encrypted, signed, form-bearing and scanned documents - [ ] **Whether you offer redaction at all** · free - Why: Redaction is the operation with real consequences if it is wrong, and offering it means committing to the extraction check on every output rather than treating it as a nice extra. - Get it: Decide, and if yes, write the verification requirement into the release gate before building it. - Verify: The release gate names both redaction checks ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. - excellent PDF operations, hosted convenience, desktop app, and edge-case handling - pixel-perfect proprietary PDF engine - identity verification - qualified trust services - large template and integration ecosystem If one of those is essential to you, that is the reason to keep paying for Sejda Web, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Sejda Web The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local PDF workbench that keeps every original untouched and writes each operation to a new file to replace Sejda Web. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both exist because this category fails quietly rather than loudly. A black rectangle is not a redaction. Drawing a filled shape over text leaves the text sitting in the content stream, where copy and paste, any text extractor and any parser read it straight out, and this has embarrassed law firms, governments and newspapers repeatedly. If this tool offers redaction, redaction means removing the text objects and re-encoding the image regions, and then proving it by extracting text from the output and finding nothing. A redaction you have not verified by extraction is a rectangle. That proof needs a full rewrite, because PDF is append-only by design: an incremental save leaves the previous version of every object still in the file, reachable by anyone who looks at the objects the trailer no longer points at. The qpdf manual states the behaviour you want plainly, that ordinarily any object not referenced in a traversal from the trailer dictionary is discarded, with a flag to preserve them instead. So write a fresh file, never append, and never pass that flag. Second: originals are immutable. Every operation reads one document and writes another. A tool that edits in place will, on the day somebody drags in their only copy of something, be the reason it is gone. ### Stack (fixed, do not substitute) - Runtime: A local FastAPI service with a browser interface, nothing hosted · the documents people bring to a tool like this are exactly the ones they will not upload - PDF engine: A structural library over qpdf for object work, with a separate renderer for previews · structure and rendering are different problems and one library rarely does both well - Store: A content-addressed folder of originals plus SQLite for the operation log · the same file dragged in twice is the same file, and the hash says so - Conversion: An office converter headless in a subprocess with a timeout, a fresh profile and no network · conversion is the step that hangs and the step that runs other people's macros - Output: Every operation writes a new file and records input hash, arguments and output hash · the log is what makes a wrong output explainable rather than arguable ### Data model (create this before Phase 1) - documents(id, sha256, filename, bytes, page_count, encrypted, has_signatures, has_forms, imported_at) · sha256 is the identity, not the filename - operations(id, input_document_id, output_document_id, kind, args_json, started_at, ended_at, exit_state, tool_version, warnings_json) - pages(document_id, index, width_pt, height_pt, rotation, has_text_layer) · probed once on import, so a page range can be validated before anything runs - redactions(id, document_id, page_index, rect_json, matched_text_hash, applied_at, verified_at) · verified_at is set only after the output has been re-extracted and checked, and never by the code that did the removal - retention(document_id, expires_at, deleted_at) · a workbench full of other people's documents needs an end date - There is no statement anywhere in this application that changes a document's bytes. An operation reads one row and writes another, and the graph of operations is the history of the file. ### Phase 1 · Import, identity and a preview that warns Build: Hash on import and store the file under its hash. Probe page count, page sizes, rotation, text layers, encryption, signatures and form fields. Render thumbnails. Before offering an operation, warn about anything in the document that the operation will break. Trap: Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. Done when: Importing the same file twice produces one document row and both filenames recorded, tested. A document with an owner password is detected and no operation silently removes its encryption, tested with an encrypted fixture. Signatures, form fields and text layers are detected on import and shown before any operation is offered. A file that is not a PDF is rejected by structure rather than by extension, tested by renaming a text file. Do not build yet: No structural operations, redaction, conversion or signing yet. ### Phase 2 · The structural operations Build: Parse page ranges strictly against the real page count and show the resulting page list before running. Merge, split, reorder, rotate and extract, each writing a new document row with the input hash recorded. Detect form field name collisions on merge and handle them deliberately. Trap: Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. Done when: A page range referring to a page that does not exist fails with the offending part named and produces no output, tested. The page list resulting from a range is shown before the operation runs. Merging two documents with colliding form field names either renames them or refuses with the collisions listed, verified by filling the merged form. Every operation writes a new document row whose input hash matches the original, asserted for each operation kind. Do not build yet: No redaction, conversion, OCR or signing yet. ### Phase 3 · Redaction that actually removes Build: Select text or a region, remove the text objects that intersect it and re-encode any image region, then write a complete new file so unreferenced objects are dropped. Extract text from the output and search the raw bytes, including decompressed streams, before marking it verified. Trap: Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. Done when: Extracting text from a redacted output returns none of the redacted strings, asserted in the suite. Searching the output's raw bytes, including every decompressed stream, finds none of the redacted strings, asserted separately from the extraction check. A redaction that fails either check is reported as failed and the output is not offered for download, tested by stubbing the removal to do nothing. Document metadata, attachments and embedded thumbnails are checked for the same strings. Do not build yet: No conversion, OCR, signing or retention yet. ### Phase 4 · Conversion and OCR, in a box Build: Run the converter in a subprocess with a per-file timeout, a fresh profile directory it is given rather than one it shares, macro execution off and no network access. Record the engine version with the output. OCR writes a text layer and records the engine and language used. Trap: Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. Done when: A converter run that exceeds the timeout is killed and recorded as a timeout with its partial output discarded, tested with a deliberately slow fixture. The converter runs with a per-run profile directory and cannot see the previous run's, asserted by checking the path. An attempt to reach the network from inside the conversion fails, tested with a document containing an external reference. The engine version and OCR language are recorded on every output they produced. Do not build yet: No signing flow, retention or export yet. ### Phase 5 · Signing, retention and the honest limit Build: Place fields, collect a typed or drawn mark, record consent with a timestamp, hash the final file and append it to a log that is only ever appended to. Expire documents on a retention schedule while keeping the operation log. State the limitation in the interface, not only the README. Trap: Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. Done when: The signature screen states, where the signer sees it, that no identity was verified and what that means. A structural operation on a document with an existing signature requires an explicit confirmation naming the consequence, tested. Documents past their retention are deleted and their operation rows survive with hashes intact, tested. Export produces every document still present plus the full operation log as CSV, and the log alone is enough to describe what happened to a deleted document. Do not build yet: Nothing after this. ### Out of scope (and why) - Qualified or otherwise regulated electronic signatures, which need a trust service provider and an audit trail nobody can build in a side project. - Government identity verification for signers, which is a contracted service with real obligations attached rather than a feature. - Full page-content editing at the level of a professional editor, which is a text layout engine and a font problem and years of work. - A hosted service with accounts, because the documents people bring here are the ones they specifically do not want to upload anywhere. ### README must contain - That a black rectangle is not a redaction, and that this tool verifies removal by extracting text from the output - That every operation writes a new file and no code path changes an original's bytes - That outputs are written as complete new files rather than incremental updates, and why unreferenced objects are dropped - That the signature flow verifies nobody's identity and is not a qualified or regulated signature - Where documents live, what the retention period is, and that the operation log outlives the files ===== AGENTS.md ===== # Agent instructions · Sejda Web indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A local FastAPI service with a browser interface, nothing hosted, A structural library over qpdf for object work, with a separate renderer for previews, A content-addressed folder of originals plus SQLite for the operation log, An office converter headless in a subprocess with a timeout, a fresh profile and no network, Every operation writes a new file and records input hash, arguments and output hash. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ===== BUILD_PLAN.md ===== # Build plan · Sejda Web A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Import, identity and a preview that warns A document's hash, structure and hazards are known before any operation is offered. ### Steps 1. Hash first, store under the hash Filenames lie; the bytes do not. Files: `app/import.py`, `tests/test_dedupe.py` 2. Probe hazards on import, not at operation time Encryption, signatures and forms all change what is safe to run. Files: `app/probe.py` 3. Require an explicit decrypt operation Removing somebody's restrictions is a decision, not a side effect. Files: `app/ops/decrypt.py`, `tests/test_encrypted.py` ### Done when - [ ] The same file twice is one document - [ ] Encryption is detected and never silently removed - [ ] Signatures, forms and text layers are reported on import - [ ] A non-PDF is rejected by structure ### Watch out - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. ## Phase 2 · The structural operations Merge, split, reorder, rotate and extract produce new documents and never surprise you. ### Steps 1. Validate ranges against the real page count And show the resulting pages before anything runs. Files: `app/ranges.py`, `tests/test_ranges.py` 2. Detect form field collisions before merging Test it by filling the merged form, not by reading the spec. Files: `app/ops/merge.py`, `tests/test_merge_forms.py` 3. Write a new document for every operation The input row is never touched, by any code path. Files: `app/ops/base.py`, `tests/test_immutable.py` ### Done when - [ ] An impossible page range fails with the reason - [ ] The resulting page list is shown first - [ ] Form field collisions are handled deliberately - [ ] Every operation writes a new document ### Watch out - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. ## Phase 3 · Redaction that actually removes Text marked for redaction is absent from the output file, proved by extraction rather than by looking. ### Steps 1. Remove objects, do not cover them The rectangle is the last step, not the operation. Files: `app/ops/redact.py` 2. Write a complete new file and drop unreferenced objects An incremental save keeps the version you were removing. Files: `app/ops/redact.py`, `docs/redaction.md` 3. Verify by extraction and by raw byte search Two different checks, because they miss different things. Files: `tests/test_redaction_verified.py` ### Done when - [ ] Text extraction finds no redacted string - [ ] A raw byte search of decompressed streams finds none - [ ] An unverified redaction is never offered for download - [ ] Metadata, attachments and thumbnails are checked too ### Watch out - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. ## Phase 4 · Conversion and OCR, in a box Converting an office document cannot hang the service, reach the network or run anything the file asked it to. ### Steps 1. Give the converter a fresh profile and a timeout Shared profiles are how one bad file poisons the next run. Files: `app/convert.py`, `tests/test_timeout.py` 2. Cut its network access explicitly A document should not be able to make outbound requests. Files: `app/sandbox.py` 3. Record the engine version with every output Rendering differences between versions are real and surprising. Files: `app/ops/base.py` ### Done when - [ ] A slow conversion is killed and recorded - [ ] Each run gets its own profile directory - [ ] Network access from conversion fails - [ ] Engine version and OCR language are recorded ### Watch out - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ## Not in this build - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. ## After v1, if you want it - A batch mode that applies one recorded operation to a folder, which is where the arguments-in-the-log design pays off - A comparison view between an input and its output, which catches more mistakes than any warning - Attachment and embedded file extraction, since that is where documents hide things nobody looked for ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Content-addressed originals and outputs. The largest thing here. STORAGE_DIR=./data/documents # Required. Documents, operations, redactions and retention. Outlives the files. DATABASE_URL=sqlite:///./data/workbench.db # Required. After this the converter is killed and the run recorded as a timeout. CONVERT_TIMEOUT_SECONDS=120 # Required. A fresh subdirectory per run, removed afterwards. CONVERT_PROFILE_ROOT=./data/convert-profiles # Required. Documents are deleted after this. Operation rows and hashes remain. RETENTION_DAYS=30 # Required. Refused above this, with the limit named, rather than failing part way. MAX_UPLOAD_MB=200 # Required. Strict means an unverified redaction is never offered. There is no other sensible value. REDACTION_VERIFY=strict
You are building a production product version of Sejda Web. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Sejda Web · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Sejda Web, perform a practical set of PDF edits and conversions in a local browser app. The hard boundary is excellent pdf operations, hosted convenience, desktop app, and edge-case handling, plus document fidelity, identity, and compliance. ## Product outcome A PDF workbench where originals are never modified, every operation is a new file with its inputs recorded, redaction is proved by extraction rather than assumed, and the office converter cannot hang the service or reach the network. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - LibreOffice headless - local or S3-compatible storage - public HTTPS for signature links ## Explicit non-goals for v1 - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. - excellent PDF operations, hosted convenience, desktop app, and edge-case handling - pixel-perfect proprietary PDF engine - identity verification - qualified trust services - large template and integration ecosystem ## Success criteria - No code path is confirmed to modify an original's bytes - Text extraction is confirmed to find no redacted string - A raw byte search of decompressed streams is confirmed clean - A slow conversion is confirmed killed and recorded - The signer is confirmed to see the identity limitation ===== BRIEF.md ===== # Build brief · Sejda Web The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local PDF workbench that keeps every original untouched and writes each operation to a new file to replace Sejda Web. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both exist because this category fails quietly rather than loudly. A black rectangle is not a redaction. Drawing a filled shape over text leaves the text sitting in the content stream, where copy and paste, any text extractor and any parser read it straight out, and this has embarrassed law firms, governments and newspapers repeatedly. If this tool offers redaction, redaction means removing the text objects and re-encoding the image regions, and then proving it by extracting text from the output and finding nothing. A redaction you have not verified by extraction is a rectangle. That proof needs a full rewrite, because PDF is append-only by design: an incremental save leaves the previous version of every object still in the file, reachable by anyone who looks at the objects the trailer no longer points at. The qpdf manual states the behaviour you want plainly, that ordinarily any object not referenced in a traversal from the trailer dictionary is discarded, with a flag to preserve them instead. So write a fresh file, never append, and never pass that flag. Second: originals are immutable. Every operation reads one document and writes another. A tool that edits in place will, on the day somebody drags in their only copy of something, be the reason it is gone. ### Stack (fixed, do not substitute) - Runtime: A local FastAPI service with a browser interface, nothing hosted · the documents people bring to a tool like this are exactly the ones they will not upload - PDF engine: A structural library over qpdf for object work, with a separate renderer for previews · structure and rendering are different problems and one library rarely does both well - Store: A content-addressed folder of originals plus SQLite for the operation log · the same file dragged in twice is the same file, and the hash says so - Conversion: An office converter headless in a subprocess with a timeout, a fresh profile and no network · conversion is the step that hangs and the step that runs other people's macros - Output: Every operation writes a new file and records input hash, arguments and output hash · the log is what makes a wrong output explainable rather than arguable ### Data model (create this before Phase 1) - documents(id, sha256, filename, bytes, page_count, encrypted, has_signatures, has_forms, imported_at) · sha256 is the identity, not the filename - operations(id, input_document_id, output_document_id, kind, args_json, started_at, ended_at, exit_state, tool_version, warnings_json) - pages(document_id, index, width_pt, height_pt, rotation, has_text_layer) · probed once on import, so a page range can be validated before anything runs - redactions(id, document_id, page_index, rect_json, matched_text_hash, applied_at, verified_at) · verified_at is set only after the output has been re-extracted and checked, and never by the code that did the removal - retention(document_id, expires_at, deleted_at) · a workbench full of other people's documents needs an end date - There is no statement anywhere in this application that changes a document's bytes. An operation reads one row and writes another, and the graph of operations is the history of the file. ### Phase 1 · Import, identity and a preview that warns Build: Hash on import and store the file under its hash. Probe page count, page sizes, rotation, text layers, encryption, signatures and form fields. Render thumbnails. Before offering an operation, warn about anything in the document that the operation will break. Trap: Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. Done when: Importing the same file twice produces one document row and both filenames recorded, tested. A document with an owner password is detected and no operation silently removes its encryption, tested with an encrypted fixture. Signatures, form fields and text layers are detected on import and shown before any operation is offered. A file that is not a PDF is rejected by structure rather than by extension, tested by renaming a text file. Do not build yet: No structural operations, redaction, conversion or signing yet. ### Phase 2 · The structural operations Build: Parse page ranges strictly against the real page count and show the resulting page list before running. Merge, split, reorder, rotate and extract, each writing a new document row with the input hash recorded. Detect form field name collisions on merge and handle them deliberately. Trap: Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. Done when: A page range referring to a page that does not exist fails with the offending part named and produces no output, tested. The page list resulting from a range is shown before the operation runs. Merging two documents with colliding form field names either renames them or refuses with the collisions listed, verified by filling the merged form. Every operation writes a new document row whose input hash matches the original, asserted for each operation kind. Do not build yet: No redaction, conversion, OCR or signing yet. ### Phase 3 · Redaction that actually removes Build: Select text or a region, remove the text objects that intersect it and re-encode any image region, then write a complete new file so unreferenced objects are dropped. Extract text from the output and search the raw bytes, including decompressed streams, before marking it verified. Trap: Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. Done when: Extracting text from a redacted output returns none of the redacted strings, asserted in the suite. Searching the output's raw bytes, including every decompressed stream, finds none of the redacted strings, asserted separately from the extraction check. A redaction that fails either check is reported as failed and the output is not offered for download, tested by stubbing the removal to do nothing. Document metadata, attachments and embedded thumbnails are checked for the same strings. Do not build yet: No conversion, OCR, signing or retention yet. ### Phase 4 · Conversion and OCR, in a box Build: Run the converter in a subprocess with a per-file timeout, a fresh profile directory it is given rather than one it shares, macro execution off and no network access. Record the engine version with the output. OCR writes a text layer and records the engine and language used. Trap: Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. Done when: A converter run that exceeds the timeout is killed and recorded as a timeout with its partial output discarded, tested with a deliberately slow fixture. The converter runs with a per-run profile directory and cannot see the previous run's, asserted by checking the path. An attempt to reach the network from inside the conversion fails, tested with a document containing an external reference. The engine version and OCR language are recorded on every output they produced. Do not build yet: No signing flow, retention or export yet. ### Phase 5 · Signing, retention and the honest limit Build: Place fields, collect a typed or drawn mark, record consent with a timestamp, hash the final file and append it to a log that is only ever appended to. Expire documents on a retention schedule while keeping the operation log. State the limitation in the interface, not only the README. Trap: Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. Done when: The signature screen states, where the signer sees it, that no identity was verified and what that means. A structural operation on a document with an existing signature requires an explicit confirmation naming the consequence, tested. Documents past their retention are deleted and their operation rows survive with hashes intact, tested. Export produces every document still present plus the full operation log as CSV, and the log alone is enough to describe what happened to a deleted document. Do not build yet: Nothing after this. ### Out of scope (and why) - Qualified or otherwise regulated electronic signatures, which need a trust service provider and an audit trail nobody can build in a side project. - Government identity verification for signers, which is a contracted service with real obligations attached rather than a feature. - Full page-content editing at the level of a professional editor, which is a text layout engine and a font problem and years of work. - A hosted service with accounts, because the documents people bring here are the ones they specifically do not want to upload anywhere. ### README must contain - That a black rectangle is not a redaction, and that this tool verifies removal by extracting text from the output - That every operation writes a new file and no code path changes an original's bytes - That outputs are written as complete new files rather than incremental updates, and why unreferenced objects are dropped - That the signature flow verifies nobody's identity and is not a qualified or regulated signature - Where documents live, what the retention period is, and that the operation log outlives the files ===== ARCHITECTURE.md ===== # Architecture · Sejda Web ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A local FastAPI service with a browser interface, nothing hosted | the documents people bring to a tool like this are exactly the ones they will not upload | | PDF engine | A structural library over qpdf for object work, with a separate renderer for previews | structure and rendering are different problems and one library rarely does both well | | Store | A content-addressed folder of originals plus SQLite for the operation log | the same file dragged in twice is the same file, and the hash says so | | Conversion | An office converter headless in a subprocess with a timeout, a fresh profile and no network | conversion is the step that hangs and the step that runs other people's macros | | Output | Every operation writes a new file and records input hash, arguments and output hash | the log is what makes a wrong output explainable rather than arguable | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Hashing, probing, hazard detection. | Any importer, provided the hash is the identity. | | Operations | Structural work, each writing a new document. | Any engine, provided no operation modifies its input. | | Redaction | Object removal, full rewrite, verification. | Not swappable for a drawing operation, which is the failure this exists to prevent. | | Sandbox | Converter isolation, timeouts, profiles. | Any containment, provided the network is cut and the timeout bites. | | Log | The operation graph, hashes, retention. | Any store, provided the log survives the documents. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STORAGE_DIR` · required · Content-addressed originals and outputs. The largest thing here. - `DATABASE_URL` · required · Documents, operations, redactions and retention. Outlives the files. - `CONVERT_TIMEOUT_SECONDS` · required · After this the converter is killed and the run recorded as a timeout. - `CONVERT_PROFILE_ROOT` · required · A fresh subdirectory per run, removed afterwards. - `RETENTION_DAYS` · required · Documents are deleted after this. Operation rows and hashes remain. - `MAX_UPLOAD_MB` · required · Refused above this, with the limit named, rather than failing part way. - `REDACTION_VERIFY` · required · Strict means an unverified redaction is never offered. There is no other sensible value. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Sejda Web product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A local FastAPI service with a browser interface, nothing hosted, A structural library over qpdf for object work, with a separate renderer for previews, A content-addressed folder of originals plus SQLite for the operation log, An office converter headless in a subprocess with a timeout, a fresh profile and no network, Every operation writes a new file and records input hash, arguments and output hash. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. - Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. ===== MILESTONES.md ===== # Delivery milestones · Sejda Web Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Import, identity and a preview that warns A document's hash, structure and hazards are known before any operation is offered. ### Steps 1. Hash first, store under the hash Filenames lie; the bytes do not. Files: `app/import.py`, `tests/test_dedupe.py` 2. Probe hazards on import, not at operation time Encryption, signatures and forms all change what is safe to run. Files: `app/probe.py` 3. Require an explicit decrypt operation Removing somebody's restrictions is a decision, not a side effect. Files: `app/ops/decrypt.py`, `tests/test_encrypted.py` ### Done when - [ ] The same file twice is one document - [ ] Encryption is detected and never silently removed - [ ] Signatures, forms and text layers are reported on import - [ ] A non-PDF is rejected by structure ### Watch out - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. ## M2 · The structural operations Merge, split, reorder, rotate and extract produce new documents and never surprise you. ### Steps 1. Validate ranges against the real page count And show the resulting pages before anything runs. Files: `app/ranges.py`, `tests/test_ranges.py` 2. Detect form field collisions before merging Test it by filling the merged form, not by reading the spec. Files: `app/ops/merge.py`, `tests/test_merge_forms.py` 3. Write a new document for every operation The input row is never touched, by any code path. Files: `app/ops/base.py`, `tests/test_immutable.py` ### Done when - [ ] An impossible page range fails with the reason - [ ] The resulting page list is shown first - [ ] Form field collisions are handled deliberately - [ ] Every operation writes a new document ### Watch out - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. ## M3 · Redaction that actually removes Text marked for redaction is absent from the output file, proved by extraction rather than by looking. ### Steps 1. Remove objects, do not cover them The rectangle is the last step, not the operation. Files: `app/ops/redact.py` 2. Write a complete new file and drop unreferenced objects An incremental save keeps the version you were removing. Files: `app/ops/redact.py`, `docs/redaction.md` 3. Verify by extraction and by raw byte search Two different checks, because they miss different things. Files: `tests/test_redaction_verified.py` ### Done when - [ ] Text extraction finds no redacted string - [ ] A raw byte search of decompressed streams finds none - [ ] An unverified redaction is never offered for download - [ ] Metadata, attachments and thumbnails are checked too ### Watch out - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. ## M4 · Conversion and OCR, in a box Converting an office document cannot hang the service, reach the network or run anything the file asked it to. ### Steps 1. Give the converter a fresh profile and a timeout Shared profiles are how one bad file poisons the next run. Files: `app/convert.py`, `tests/test_timeout.py` 2. Cut its network access explicitly A document should not be able to make outbound requests. Files: `app/sandbox.py` 3. Record the engine version with every output Rendering differences between versions are real and surprising. Files: `app/ops/base.py` ### Done when - [ ] A slow conversion is killed and recorded - [ ] Each run gets its own profile directory - [ ] Network access from conversion fails - [ ] Engine version and OCR language are recorded ### Watch out - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ## M5 · Signing, retention and the honest limit (production only) The signature flow says what it is, the documents expire, and the log survives them. ### Steps 1. Put the limitation where the signer reads it The README is read by you, not by them. Files: `app/ui/sign.tsx` 2. Warn before touching a signed document Any structural change invalidates the signature already there. Files: `app/ops/base.py`, `tests/test_signed_warning.py` 3. Expire documents, keep the log The hashes outlive the bytes and that is the point. Files: `app/retention.py` ### Done when - [ ] The signer sees the identity limitation - [ ] Operating on a signed document needs confirmation - [ ] Retention deletes bytes and keeps the log - [ ] Export includes the full operation log ### Watch out - Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. ===== OPERATIONS.md ===== # Operations · Sejda Web ## Backup The operation log first and the document store second, which is the opposite of what people expect. The log is small and irreplaceable, while any output can be regenerated from an original plus its recorded arguments, provided you still have both. ## Restore Restore the database, then the document store, then confirm that every operation row's input and output hashes resolve to files that are present or knowingly expired. A restore where hashes do not resolve tells you exactly which outputs you can no longer explain. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Converter timeouts, redactions whose verification failed, operations that ended in an error state, and the storage directory size against the retention schedule. A failed redaction verification is the only one that should wake anybody. ## Incident checklist The incident is a document leaving the building with something still in it. The operation log and the redaction verification records are what let you answer which output was affected and which version was given to whom, so keep them long after the files themselves have expired. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No code path is confirmed to modify an original's bytes - [ ] Text extraction is confirmed to find no redacted string - [ ] A raw byte search of decompressed streams is confirmed clean - [ ] A slow conversion is confirmed killed and recorded - [ ] The signer is confirmed to see the identity limitation ## Launch constraint Do not market omitted Sejda Web capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Content-addressed originals and outputs. The largest thing here. STORAGE_DIR=./data/documents # Required. Documents, operations, redactions and retention. Outlives the files. DATABASE_URL=sqlite:///./data/workbench.db # Required. After this the converter is killed and the run recorded as a timeout. CONVERT_TIMEOUT_SECONDS=120 # Required. A fresh subdirectory per run, removed afterwards. CONVERT_PROFILE_ROOT=./data/convert-profiles # Required. Documents are deleted after this. Operation rows and hashes remain. RETENTION_DAYS=30 # Required. Refused above this, with the limit named, rather than failing part way. MAX_UPLOAD_MB=200 # Required. Strict means an unverified redaction is never offered. There is no other sensible value. REDACTION_VERIFY=strict
# Sejda Web · indie build A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A local FastAPI service with a browser interface, nothing hosted | the documents people bring to a tool like this are exactly the ones they will not upload | | PDF engine | A structural library over qpdf for object work, with a separate renderer for previews | structure and rendering are different problems and one library rarely does both well | | Store | A content-addressed folder of originals plus SQLite for the operation log | the same file dragged in twice is the same file, and the hash says so | | Conversion | An office converter headless in a subprocess with a timeout, a fresh profile and no network | conversion is the step that hangs and the step that runs other people's macros | | Output | Every operation writes a new file and records input hash, arguments and output hash | the log is what makes a wrong output explainable rather than arguable | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the service and the PDF work. - Get it: python.org or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **A PDF structural library built on qpdf** · free - Why: Object-level work, full file rewrites and dropping unreferenced objects all come from here, and it is the part that makes redaction verifiable. - Get it: Install the library and confirm the underlying qpdf version it links against. - Verify: A round-trip rewrite of a test PDF drops a deliberately orphaned object - [ ] **An office suite that can run headless** · free - Why: Office to PDF conversion is one of the operations people actually want, and it is also the one that hangs, so it needs its own containment. - Get it: Install the suite and confirm the headless conversion command runs. - Verify: A document converts with a per-run profile directory and no network access - [ ] **A test corpus including an encrypted, a signed and a form PDF** · free - Why: Every hazard this build warns about needs a fixture, and collecting them afterwards means the warnings were never tested. - Get it: Create or collect one PDF of each kind and keep them in the repository. - Verify: The suite covers encrypted, signed, form-bearing and scanned documents - [ ] **Whether you offer redaction at all** · free - Why: Redaction is the operation with real consequences if it is wrong, and offering it means committing to the extraction check on every output rather than treating it as a nice extra. - Get it: Decide, and if yes, write the verification requirement into the release gate before building it. - Verify: The release gate names both redaction checks ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. - excellent PDF operations, hosted convenience, desktop app, and edge-case handling - pixel-perfect proprietary PDF engine - identity verification - qualified trust services - large template and integration ecosystem If one of those is essential to you, that is the reason to keep paying for Sejda Web, and the README should say so rather than pretend.
# Build brief · Sejda Web The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local PDF workbench that keeps every original untouched and writes each operation to a new file to replace Sejda Web. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both exist because this category fails quietly rather than loudly. A black rectangle is not a redaction. Drawing a filled shape over text leaves the text sitting in the content stream, where copy and paste, any text extractor and any parser read it straight out, and this has embarrassed law firms, governments and newspapers repeatedly. If this tool offers redaction, redaction means removing the text objects and re-encoding the image regions, and then proving it by extracting text from the output and finding nothing. A redaction you have not verified by extraction is a rectangle. That proof needs a full rewrite, because PDF is append-only by design: an incremental save leaves the previous version of every object still in the file, reachable by anyone who looks at the objects the trailer no longer points at. The qpdf manual states the behaviour you want plainly, that ordinarily any object not referenced in a traversal from the trailer dictionary is discarded, with a flag to preserve them instead. So write a fresh file, never append, and never pass that flag. Second: originals are immutable. Every operation reads one document and writes another. A tool that edits in place will, on the day somebody drags in their only copy of something, be the reason it is gone. ### Stack (fixed, do not substitute) - Runtime: A local FastAPI service with a browser interface, nothing hosted · the documents people bring to a tool like this are exactly the ones they will not upload - PDF engine: A structural library over qpdf for object work, with a separate renderer for previews · structure and rendering are different problems and one library rarely does both well - Store: A content-addressed folder of originals plus SQLite for the operation log · the same file dragged in twice is the same file, and the hash says so - Conversion: An office converter headless in a subprocess with a timeout, a fresh profile and no network · conversion is the step that hangs and the step that runs other people's macros - Output: Every operation writes a new file and records input hash, arguments and output hash · the log is what makes a wrong output explainable rather than arguable ### Data model (create this before Phase 1) - documents(id, sha256, filename, bytes, page_count, encrypted, has_signatures, has_forms, imported_at) · sha256 is the identity, not the filename - operations(id, input_document_id, output_document_id, kind, args_json, started_at, ended_at, exit_state, tool_version, warnings_json) - pages(document_id, index, width_pt, height_pt, rotation, has_text_layer) · probed once on import, so a page range can be validated before anything runs - redactions(id, document_id, page_index, rect_json, matched_text_hash, applied_at, verified_at) · verified_at is set only after the output has been re-extracted and checked, and never by the code that did the removal - retention(document_id, expires_at, deleted_at) · a workbench full of other people's documents needs an end date - There is no statement anywhere in this application that changes a document's bytes. An operation reads one row and writes another, and the graph of operations is the history of the file. ### Phase 1 · Import, identity and a preview that warns Build: Hash on import and store the file under its hash. Probe page count, page sizes, rotation, text layers, encryption, signatures and form fields. Render thumbnails. Before offering an operation, warn about anything in the document that the operation will break. Trap: Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. Done when: Importing the same file twice produces one document row and both filenames recorded, tested. A document with an owner password is detected and no operation silently removes its encryption, tested with an encrypted fixture. Signatures, form fields and text layers are detected on import and shown before any operation is offered. A file that is not a PDF is rejected by structure rather than by extension, tested by renaming a text file. Do not build yet: No structural operations, redaction, conversion or signing yet. ### Phase 2 · The structural operations Build: Parse page ranges strictly against the real page count and show the resulting page list before running. Merge, split, reorder, rotate and extract, each writing a new document row with the input hash recorded. Detect form field name collisions on merge and handle them deliberately. Trap: Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. Done when: A page range referring to a page that does not exist fails with the offending part named and produces no output, tested. The page list resulting from a range is shown before the operation runs. Merging two documents with colliding form field names either renames them or refuses with the collisions listed, verified by filling the merged form. Every operation writes a new document row whose input hash matches the original, asserted for each operation kind. Do not build yet: No redaction, conversion, OCR or signing yet. ### Phase 3 · Redaction that actually removes Build: Select text or a region, remove the text objects that intersect it and re-encode any image region, then write a complete new file so unreferenced objects are dropped. Extract text from the output and search the raw bytes, including decompressed streams, before marking it verified. Trap: Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. Done when: Extracting text from a redacted output returns none of the redacted strings, asserted in the suite. Searching the output's raw bytes, including every decompressed stream, finds none of the redacted strings, asserted separately from the extraction check. A redaction that fails either check is reported as failed and the output is not offered for download, tested by stubbing the removal to do nothing. Document metadata, attachments and embedded thumbnails are checked for the same strings. Do not build yet: No conversion, OCR, signing or retention yet. ### Phase 4 · Conversion and OCR, in a box Build: Run the converter in a subprocess with a per-file timeout, a fresh profile directory it is given rather than one it shares, macro execution off and no network access. Record the engine version with the output. OCR writes a text layer and records the engine and language used. Trap: Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. Done when: A converter run that exceeds the timeout is killed and recorded as a timeout with its partial output discarded, tested with a deliberately slow fixture. The converter runs with a per-run profile directory and cannot see the previous run's, asserted by checking the path. An attempt to reach the network from inside the conversion fails, tested with a document containing an external reference. The engine version and OCR language are recorded on every output they produced. Do not build yet: No signing flow, retention or export yet. ### Phase 5 · Signing, retention and the honest limit Build: Place fields, collect a typed or drawn mark, record consent with a timestamp, hash the final file and append it to a log that is only ever appended to. Expire documents on a retention schedule while keeping the operation log. State the limitation in the interface, not only the README. Trap: Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. Done when: The signature screen states, where the signer sees it, that no identity was verified and what that means. A structural operation on a document with an existing signature requires an explicit confirmation naming the consequence, tested. Documents past their retention are deleted and their operation rows survive with hashes intact, tested. Export produces every document still present plus the full operation log as CSV, and the log alone is enough to describe what happened to a deleted document. Do not build yet: Nothing after this. ### Out of scope (and why) - Qualified or otherwise regulated electronic signatures, which need a trust service provider and an audit trail nobody can build in a side project. - Government identity verification for signers, which is a contracted service with real obligations attached rather than a feature. - Full page-content editing at the level of a professional editor, which is a text layout engine and a font problem and years of work. - A hosted service with accounts, because the documents people bring here are the ones they specifically do not want to upload anywhere. ### README must contain - That a black rectangle is not a redaction, and that this tool verifies removal by extracting text from the output - That every operation writes a new file and no code path changes an original's bytes - That outputs are written as complete new files rather than incremental updates, and why unreferenced objects are dropped - That the signature flow verifies nobody's identity and is not a qualified or regulated signature - Where documents live, what the retention period is, and that the operation log outlives the files
# Agent instructions · Sejda Web indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: A local FastAPI service with a browser interface, nothing hosted, A structural library over qpdf for object work, with a separate renderer for previews, A content-addressed folder of originals plus SQLite for the operation log, An office converter headless in a subprocess with a timeout, a fresh profile and no network, Every operation writes a new file and records input hash, arguments and output hash. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human.
# Build plan · Sejda Web A local PDF workbench whose hard problems are redaction that actually removes rather than covers, a document graph where no operation ever overwrites its input, and running an office converter on other people's files without it hanging or reaching the network. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Import, identity and a preview that warns A document's hash, structure and hazards are known before any operation is offered. ### Steps 1. Hash first, store under the hash Filenames lie; the bytes do not. Files: `app/import.py`, `tests/test_dedupe.py` 2. Probe hazards on import, not at operation time Encryption, signatures and forms all change what is safe to run. Files: `app/probe.py` 3. Require an explicit decrypt operation Removing somebody's restrictions is a decision, not a side effect. Files: `app/ops/decrypt.py`, `tests/test_encrypted.py` ### Done when - [ ] The same file twice is one document - [ ] Encryption is detected and never silently removed - [ ] Signatures, forms and text layers are reported on import - [ ] A non-PDF is rejected by structure ### Watch out - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. ## Phase 2 · The structural operations Merge, split, reorder, rotate and extract produce new documents and never surprise you. ### Steps 1. Validate ranges against the real page count And show the resulting pages before anything runs. Files: `app/ranges.py`, `tests/test_ranges.py` 2. Detect form field collisions before merging Test it by filling the merged form, not by reading the spec. Files: `app/ops/merge.py`, `tests/test_merge_forms.py` 3. Write a new document for every operation The input row is never touched, by any code path. Files: `app/ops/base.py`, `tests/test_immutable.py` ### Done when - [ ] An impossible page range fails with the reason - [ ] The resulting page list is shown first - [ ] Form field collisions are handled deliberately - [ ] Every operation writes a new document ### Watch out - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. ## Phase 3 · Redaction that actually removes Text marked for redaction is absent from the output file, proved by extraction rather than by looking. ### Steps 1. Remove objects, do not cover them The rectangle is the last step, not the operation. Files: `app/ops/redact.py` 2. Write a complete new file and drop unreferenced objects An incremental save keeps the version you were removing. Files: `app/ops/redact.py`, `docs/redaction.md` 3. Verify by extraction and by raw byte search Two different checks, because they miss different things. Files: `tests/test_redaction_verified.py` ### Done when - [ ] Text extraction finds no redacted string - [ ] A raw byte search of decompressed streams finds none - [ ] An unverified redaction is never offered for download - [ ] Metadata, attachments and thumbnails are checked too ### Watch out - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. ## Phase 4 · Conversion and OCR, in a box Converting an office document cannot hang the service, reach the network or run anything the file asked it to. ### Steps 1. Give the converter a fresh profile and a timeout Shared profiles are how one bad file poisons the next run. Files: `app/convert.py`, `tests/test_timeout.py` 2. Cut its network access explicitly A document should not be able to make outbound requests. Files: `app/sandbox.py` 3. Record the engine version with every output Rendering differences between versions are real and surprising. Files: `app/ops/base.py` ### Done when - [ ] A slow conversion is killed and recorded - [ ] Each run gets its own profile directory - [ ] Network access from conversion fails - [ ] Engine version and OCR language are recorded ### Watch out - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ## Not in this build - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. ## After v1, if you want it - A batch mode that applies one recorded operation to a folder, which is where the arguments-in-the-log design pays off - A comparison view between an input and its output, which catches more mistakes than any warning - Attachment and embedded file extraction, since that is where documents hide things nobody looked for
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Content-addressed originals and outputs. The largest thing here. STORAGE_DIR=./data/documents # Required. Documents, operations, redactions and retention. Outlives the files. DATABASE_URL=sqlite:///./data/workbench.db # Required. After this the converter is killed and the run recorded as a timeout. CONVERT_TIMEOUT_SECONDS=120 # Required. A fresh subdirectory per run, removed afterwards. CONVERT_PROFILE_ROOT=./data/convert-profiles # Required. Documents are deleted after this. Operation rows and hashes remain. RETENTION_DAYS=30 # Required. Refused above this, with the limit named, rather than failing part way. MAX_UPLOAD_MB=200 # Required. Strict means an unverified redaction is never offered. There is no other sensible value. REDACTION_VERIFY=strict
# Sejda Web · product brief ## Problem The core loop is small enough for a capable coding agent to produce a useful local version in one sitting. For Sejda Web, perform a practical set of PDF edits and conversions in a local browser app. The hard boundary is excellent pdf operations, hosted convenience, desktop app, and edge-case handling, plus document fidelity, identity, and compliance. ## Product outcome A PDF workbench where originals are never modified, every operation is a new file with its inputs recorded, redaction is proved by extraction rather than assumed, and the office converter cannot hang the service or reach the network. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - LibreOffice headless - local or S3-compatible storage - public HTTPS for signature links ## Explicit non-goals for v1 - Qualified or regulated electronic signatures. - Government identity verification. - Full page-content editing. - A hosted service with accounts. - excellent PDF operations, hosted convenience, desktop app, and edge-case handling - pixel-perfect proprietary PDF engine - identity verification - qualified trust services - large template and integration ecosystem ## Success criteria - No code path is confirmed to modify an original's bytes - Text extraction is confirmed to find no redacted string - A raw byte search of decompressed streams is confirmed clean - A slow conversion is confirmed killed and recorded - The signer is confirmed to see the identity limitation
# Build brief · Sejda Web The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local PDF workbench that keeps every original untouched and writes each operation to a new file to replace Sejda Web. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules, and both exist because this category fails quietly rather than loudly. A black rectangle is not a redaction. Drawing a filled shape over text leaves the text sitting in the content stream, where copy and paste, any text extractor and any parser read it straight out, and this has embarrassed law firms, governments and newspapers repeatedly. If this tool offers redaction, redaction means removing the text objects and re-encoding the image regions, and then proving it by extracting text from the output and finding nothing. A redaction you have not verified by extraction is a rectangle. That proof needs a full rewrite, because PDF is append-only by design: an incremental save leaves the previous version of every object still in the file, reachable by anyone who looks at the objects the trailer no longer points at. The qpdf manual states the behaviour you want plainly, that ordinarily any object not referenced in a traversal from the trailer dictionary is discarded, with a flag to preserve them instead. So write a fresh file, never append, and never pass that flag. Second: originals are immutable. Every operation reads one document and writes another. A tool that edits in place will, on the day somebody drags in their only copy of something, be the reason it is gone. ### Stack (fixed, do not substitute) - Runtime: A local FastAPI service with a browser interface, nothing hosted · the documents people bring to a tool like this are exactly the ones they will not upload - PDF engine: A structural library over qpdf for object work, with a separate renderer for previews · structure and rendering are different problems and one library rarely does both well - Store: A content-addressed folder of originals plus SQLite for the operation log · the same file dragged in twice is the same file, and the hash says so - Conversion: An office converter headless in a subprocess with a timeout, a fresh profile and no network · conversion is the step that hangs and the step that runs other people's macros - Output: Every operation writes a new file and records input hash, arguments and output hash · the log is what makes a wrong output explainable rather than arguable ### Data model (create this before Phase 1) - documents(id, sha256, filename, bytes, page_count, encrypted, has_signatures, has_forms, imported_at) · sha256 is the identity, not the filename - operations(id, input_document_id, output_document_id, kind, args_json, started_at, ended_at, exit_state, tool_version, warnings_json) - pages(document_id, index, width_pt, height_pt, rotation, has_text_layer) · probed once on import, so a page range can be validated before anything runs - redactions(id, document_id, page_index, rect_json, matched_text_hash, applied_at, verified_at) · verified_at is set only after the output has been re-extracted and checked, and never by the code that did the removal - retention(document_id, expires_at, deleted_at) · a workbench full of other people's documents needs an end date - There is no statement anywhere in this application that changes a document's bytes. An operation reads one row and writes another, and the graph of operations is the history of the file. ### Phase 1 · Import, identity and a preview that warns Build: Hash on import and store the file under its hash. Probe page count, page sizes, rotation, text layers, encryption, signatures and form fields. Render thumbnails. Before offering an operation, warn about anything in the document that the operation will break. Trap: Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. Done when: Importing the same file twice produces one document row and both filenames recorded, tested. A document with an owner password is detected and no operation silently removes its encryption, tested with an encrypted fixture. Signatures, form fields and text layers are detected on import and shown before any operation is offered. A file that is not a PDF is rejected by structure rather than by extension, tested by renaming a text file. Do not build yet: No structural operations, redaction, conversion or signing yet. ### Phase 2 · The structural operations Build: Parse page ranges strictly against the real page count and show the resulting page list before running. Merge, split, reorder, rotate and extract, each writing a new document row with the input hash recorded. Detect form field name collisions on merge and handle them deliberately. Trap: Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. Done when: A page range referring to a page that does not exist fails with the offending part named and produces no output, tested. The page list resulting from a range is shown before the operation runs. Merging two documents with colliding form field names either renames them or refuses with the collisions listed, verified by filling the merged form. Every operation writes a new document row whose input hash matches the original, asserted for each operation kind. Do not build yet: No redaction, conversion, OCR or signing yet. ### Phase 3 · Redaction that actually removes Build: Select text or a region, remove the text objects that intersect it and re-encode any image region, then write a complete new file so unreferenced objects are dropped. Extract text from the output and search the raw bytes, including decompressed streams, before marking it verified. Trap: Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. Done when: Extracting text from a redacted output returns none of the redacted strings, asserted in the suite. Searching the output's raw bytes, including every decompressed stream, finds none of the redacted strings, asserted separately from the extraction check. A redaction that fails either check is reported as failed and the output is not offered for download, tested by stubbing the removal to do nothing. Document metadata, attachments and embedded thumbnails are checked for the same strings. Do not build yet: No conversion, OCR, signing or retention yet. ### Phase 4 · Conversion and OCR, in a box Build: Run the converter in a subprocess with a per-file timeout, a fresh profile directory it is given rather than one it shares, macro execution off and no network access. Record the engine version with the output. OCR writes a text layer and records the engine and language used. Trap: Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. Done when: A converter run that exceeds the timeout is killed and recorded as a timeout with its partial output discarded, tested with a deliberately slow fixture. The converter runs with a per-run profile directory and cannot see the previous run's, asserted by checking the path. An attempt to reach the network from inside the conversion fails, tested with a document containing an external reference. The engine version and OCR language are recorded on every output they produced. Do not build yet: No signing flow, retention or export yet. ### Phase 5 · Signing, retention and the honest limit Build: Place fields, collect a typed or drawn mark, record consent with a timestamp, hash the final file and append it to a log that is only ever appended to. Expire documents on a retention schedule while keeping the operation log. State the limitation in the interface, not only the README. Trap: Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature. Done when: The signature screen states, where the signer sees it, that no identity was verified and what that means. A structural operation on a document with an existing signature requires an explicit confirmation naming the consequence, tested. Documents past their retention are deleted and their operation rows survive with hashes intact, tested. Export produces every document still present plus the full operation log as CSV, and the log alone is enough to describe what happened to a deleted document. Do not build yet: Nothing after this. ### Out of scope (and why) - Qualified or otherwise regulated electronic signatures, which need a trust service provider and an audit trail nobody can build in a side project. - Government identity verification for signers, which is a contracted service with real obligations attached rather than a feature. - Full page-content editing at the level of a professional editor, which is a text layout engine and a font problem and years of work. - A hosted service with accounts, because the documents people bring here are the ones they specifically do not want to upload anywhere. ### README must contain - That a black rectangle is not a redaction, and that this tool verifies removal by extracting text from the output - That every operation writes a new file and no code path changes an original's bytes - That outputs are written as complete new files rather than incremental updates, and why unreferenced objects are dropped - That the signature flow verifies nobody's identity and is not a qualified or regulated signature - Where documents live, what the retention period is, and that the operation log outlives the files
# Architecture · Sejda Web ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | A local FastAPI service with a browser interface, nothing hosted | the documents people bring to a tool like this are exactly the ones they will not upload | | PDF engine | A structural library over qpdf for object work, with a separate renderer for previews | structure and rendering are different problems and one library rarely does both well | | Store | A content-addressed folder of originals plus SQLite for the operation log | the same file dragged in twice is the same file, and the hash says so | | Conversion | An office converter headless in a subprocess with a timeout, a fresh profile and no network | conversion is the step that hangs and the step that runs other people's macros | | Output | Every operation writes a new file and records input hash, arguments and output hash | the log is what makes a wrong output explainable rather than arguable | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Import | Hashing, probing, hazard detection. | Any importer, provided the hash is the identity. | | Operations | Structural work, each writing a new document. | Any engine, provided no operation modifies its input. | | Redaction | Object removal, full rewrite, verification. | Not swappable for a drawing operation, which is the failure this exists to prevent. | | Sandbox | Converter isolation, timeouts, profiles. | Any containment, provided the network is cut and the timeout bites. | | Log | The operation graph, hashes, retention. | Any store, provided the log survives the documents. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `STORAGE_DIR` · required · Content-addressed originals and outputs. The largest thing here. - `DATABASE_URL` · required · Documents, operations, redactions and retention. Outlives the files. - `CONVERT_TIMEOUT_SECONDS` · required · After this the converter is killed and the run recorded as a timeout. - `CONVERT_PROFILE_ROOT` · required · A fresh subdirectory per run, removed afterwards. - `RETENTION_DAYS` · required · Documents are deleted after this. Operation rows and hashes remain. - `MAX_UPLOAD_MB` · required · Refused above this, with the limit named, rather than failing part way. - `REDACTION_VERIFY` · required · Strict means an unverified redaction is never offered. There is no other sensible value. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Sejda Web product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: A local FastAPI service with a browser interface, nothing hosted, A structural library over qpdf for object work, with a separate renderer for previews, A content-addressed folder of originals plus SQLite for the operation log, An office converter headless in a subprocess with a timeout, a fresh profile and no network, Every operation writes a new file and records input hash, arguments and output hash. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. - Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature.
# Delivery milestones · Sejda Web Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Import, identity and a preview that warns A document's hash, structure and hazards are known before any operation is offered. ### Steps 1. Hash first, store under the hash Filenames lie; the bytes do not. Files: `app/import.py`, `tests/test_dedupe.py` 2. Probe hazards on import, not at operation time Encryption, signatures and forms all change what is safe to run. Files: `app/probe.py` 3. Require an explicit decrypt operation Removing somebody's restrictions is a decision, not a side effect. Files: `app/ops/decrypt.py`, `tests/test_encrypted.py` ### Done when - [ ] The same file twice is one document - [ ] Encryption is detected and never silently removed - [ ] Signatures, forms and text layers are reported on import - [ ] A non-PDF is rejected by structure ### Watch out - Many PDFs carry an owner password with permission flags and open with no prompt at all, so a naive open-and-save writes an unencrypted copy and silently strips the restrictions the sender set, which is a decision you have made on somebody else's behalf without telling them; detect encryption on import, record it on the document row, and require an explicit decrypt operation that states what is being removed and appears in the log. ## M2 · The structural operations Merge, split, reorder, rotate and extract produce new documents and never surprise you. ### Steps 1. Validate ranges against the real page count And show the resulting pages before anything runs. Files: `app/ranges.py`, `tests/test_ranges.py` 2. Detect form field collisions before merging Test it by filling the merged form, not by reading the spec. Files: `app/ops/merge.py`, `tests/test_merge_forms.py` 3. Write a new document for every operation The input row is never touched, by any code path. Files: `app/ops/base.py`, `tests/test_immutable.py` ### Done when - [ ] An impossible page range fails with the reason - [ ] The resulting page list is shown first - [ ] Form field collisions are handled deliberately - [ ] Every operation writes a new document ### Watch out - Merging two documents that both contain a form field called 'Name' produces a file where filling one field fills both, because form fields are addressed by their qualified name across the whole document rather than per page, and nobody notices until a filled form comes back wrong; detect name collisions before merging, rename or refuse with the collision listed, and test it by filling the merged form rather than by reading the specification. ## M3 · Redaction that actually removes Text marked for redaction is absent from the output file, proved by extraction rather than by looking. ### Steps 1. Remove objects, do not cover them The rectangle is the last step, not the operation. Files: `app/ops/redact.py` 2. Write a complete new file and drop unreferenced objects An incremental save keeps the version you were removing. Files: `app/ops/redact.py`, `docs/redaction.md` 3. Verify by extraction and by raw byte search Two different checks, because they miss different things. Files: `tests/test_redaction_verified.py` ### Done when - [ ] Text extraction finds no redacted string - [ ] A raw byte search of decompressed streams finds none - [ ] An unverified redaction is never offered for download - [ ] Metadata, attachments and thumbnails are checked too ### Watch out - Drawing a filled rectangle over the text and saving leaves every one of those characters in the content stream for anyone who selects the region or runs an extractor, and saving incrementally leaves the pre-redaction version of the objects in the file as well, so you have shipped the secret twice; remove the objects, write a fresh file rather than an incremental update, do not preserve unreferenced objects, and treat the extraction check as the only evidence the redaction worked. ## M4 · Conversion and OCR, in a box Converting an office document cannot hang the service, reach the network or run anything the file asked it to. ### Steps 1. Give the converter a fresh profile and a timeout Shared profiles are how one bad file poisons the next run. Files: `app/convert.py`, `tests/test_timeout.py` 2. Cut its network access explicitly A document should not be able to make outbound requests. Files: `app/sandbox.py` 3. Record the engine version with every output Rendering differences between versions are real and surprising. Files: `app/ops/base.py` ### Done when - [ ] A slow conversion is killed and recorded - [ ] Each run gets its own profile directory - [ ] Network access from conversion fails - [ ] Engine version and OCR language are recorded ### Watch out - Running an office converter with its default profile against a file somebody sent you means it can follow external references, attempt macro execution depending on how it is configured, and sit forever on a document that wants a password while your queue backs up behind it; give it a fresh profile directory per run, cut its network access, disable macros explicitly, set a timeout, and kill and record rather than waiting for a process that has already decided to wait for a human. ## M5 · Signing, retention and the honest limit (production only) The signature flow says what it is, the documents expire, and the log survives them. ### Steps 1. Put the limitation where the signer reads it The README is read by you, not by them. Files: `app/ui/sign.tsx` 2. Warn before touching a signed document Any structural change invalidates the signature already there. Files: `app/ops/base.py`, `tests/test_signed_warning.py` 3. Expire documents, keep the log The hashes outlive the bytes and that is the point. Files: `app/retention.py` ### Done when - [ ] The signer sees the identity limitation - [ ] Operating on a signed document needs confirmation - [ ] Retention deletes bytes and keeps the log - [ ] Export includes the full operation log ### Watch out - Calling what this produces a signature invites somebody to rely on it for something that matters, when it is a picture on a page plus a log saying who typed what and when, with no identity verification behind it and none of the standing a qualified or regulated electronic signature has; put the limitation next to the button where the signer will see it rather than in a README only the builder reads, and refuse to run structural operations on an already signed document without an explicit warning that doing so invalidates the existing signature.
# Operations · Sejda Web ## Backup The operation log first and the document store second, which is the opposite of what people expect. The log is small and irreplaceable, while any output can be regenerated from an original plus its recorded arguments, provided you still have both. ## Restore Restore the database, then the document store, then confirm that every operation row's input and output hashes resolve to files that are present or knowingly expired. A restore where hashes do not resolve tells you exactly which outputs you can no longer explain. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Converter timeouts, redactions whose verification failed, operations that ended in an error state, and the storage directory size against the retention schedule. A failed redaction verification is the only one that should wake anybody. ## Incident checklist The incident is a document leaving the building with something still in it. The operation log and the redaction verification records are what let you answer which output was affected and which version was given to whom, so keep them long after the files themselves have expired. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] No code path is confirmed to modify an original's bytes - [ ] Text extraction is confirmed to find no redacted string - [ ] A raw byte search of decompressed streams is confirmed clean - [ ] A slow conversion is confirmed killed and recorded - [ ] The signer is confirmed to see the identity limitation ## Launch constraint Do not market omitted Sejda Web capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Content-addressed originals and outputs. The largest thing here. STORAGE_DIR=./data/documents # Required. Documents, operations, redactions and retention. Outlives the files. DATABASE_URL=sqlite:///./data/workbench.db # Required. After this the converter is killed and the run recorded as a timeout. CONVERT_TIMEOUT_SECONDS=120 # Required. A fresh subdirectory per run, removed afterwards. CONVERT_PROFILE_ROOT=./data/convert-profiles # Required. Documents are deleted after this. Operation rows and hashes remain. RETENTION_DAYS=30 # Required. Refused above this, with the limit named, rather than failing part way. MAX_UPLOAD_MB=200 # Required. Strict means an unverified redaction is never offered. There is no other sensible value. REDACTION_VERIFY=strict
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Sejda Web because the visible editor is reproducible, but reliable rendering and legally defensible signature workflows are not a casual side project. The recurring cost buys format edge cases, fonts, rendering, encryption, signatures, evidence, retention, storage, backups, and legal requirements, not just the visible interface.
xexcellent PDF operations, hosted convenience, desktop app, and edge-case handling
xpixel-perfect proprietary PDF engine
xidentity verification
xqualified trust services
xlarge template and integration ecosystem
Don't feel like building it? These folks already made it free.
all 3 free alternatives to Sejda Web →· no votes, no pay-to-list · just what's real
Vibecode Sejda Web
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Sejda Web replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Sejda Web cost?
Sejda Web costs about $7.5/month (Web Week Pass, checked 2026-07-31), which is $90 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Sejda Web?
Honestly: excellent PDF operations, hosted convenience, desktop app, and edge-case handling; pixel-perfect proprietary PDF engine; identity verification; qualified trust services; large template and integration ecosystem. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Sejda Web?
Yes: Stirling PDF (The same practical browser toolbox, only private and without Sejda's daily limits; install the desktop build or run the container.) PDF24 (Common PDF edits and conversions with no page quota, watermark, or subscription; offline use requires Windows.) PDFgear (Free local editing, annotation, conversion, OCR, signing, and organization in a notably less homemade interface.) All 3 curated free alternatives are at vibecodeit.com/sejda-web/alternatives. The prompt is for when you want it exactly your way.