2026-09-12 22:07 · 2 deliverable(s) · auto-closeout
Date: 2026-09-12. Target box: Nano, path /Users/SeanVargas/AI_OS. Nothing below has been run; this packet only documents what each step would do so Sean can approve or hold each one with a single word. Source read: SPEC.md, build/BUILD-NOTES.md, build/requirements.txt, build/scanbot/deps.py, build/scanbot/l1_redact/ocr.py, build/scanbot/l1_redact/detect.py, build/scanbot/contracts.py, build/scanbot/cli.py, and ops/scripts/vision_ocr.swift.
All four runtime-data paths below live under ~/Library/Application Support/AIOS/.... That tree is outside iCloud Drive's synced Desktop/Documents/Mobile Documents locations and outside the AIOS repo that Syncthing watches, matching SPEC §3's own runtime-root proposal.
Disk: creates /Users/SeanVargas/Library/Application Support/AIOS/scanbot-venv/ (a Python virtualenv, several hundred MB, dominated by spaCy/thinc/numpy). Touches nothing in the repo or in requirements.txt itself.
Commands:
python3 -m venv "/Users/SeanVargas/Library/Application Support/AIOS/scanbot-venv" VENV="/Users/SeanVargas/Library/Application Support/AIOS/scanbot-venv" "$VENV/bin/pip" install --upgrade pip "$VENV/bin/pip" install -r /Users/SeanVargas/AI_OS/ops/scripts/scanbot/requirements.txt
(If build/scanbot/ has not yet been promoted, point -r at the packet's build/requirements.txt instead — the venv itself doesn't care which copy it reads.)
Eight pinned packages: pymupdf==1.24.14 (imports as fitz, PDF render/rebuild), Pillow==11.0.0 (raster ops), pillow-heif==0.20.0 (HEIC decode, registers a Pillow opener), pytesseract==0.3.13 (Tesseract wrapper), presidio-analyzer==2.2.355 (NER), spacy==3.8.2 (NLP engine under Presidio), imap-tools==1.7.4 (Proton Bridge IMAP), osxphotos==0.68.4 (Photos inventory).
Compiler/Apple-framework flags: this was verified on bare python3.14.6, new enough that wheels for pymupdf, Pillow, and especially spacy (plus thinc/blis, which link Apple's Accelerate framework for BLAS on arm64) may not exist yet for cp314-macosx_arm64 — a missing wheel means a from-source build needing Xcode Command Line Tools. pillow-heif and pytesseract need Homebrew's libheif and tesseract respectively (not pip-installable; both stay non-functional until those brew installs happen separately). osxphotos is pure Python but pulls PyObjC bindings (Quartz, CoreServices) that bridge to Apple frameworks, usually as wheels. imap-tools and presidio-analyzer are pure Python.
Expected output: "$VENV/bin/pip" list shows the eight pins plus their transitive deps; db-init --root <root> (dry-run) still returns the same JSON as on bare python3, now with every deps.require() path importable.
Rollback: rm -rf "/Users/SeanVargas/Library/Application Support/AIOS/scanbot-venv" — deletes the venv only, nothing else changes.
Risk: a cp314 wheel gap on pymupdf/spacy turns a five-minute install into a stalled or failed from-source compile.
detect.py line 39 hard-codes model='en_core_web_lg' and checks spacy.util.is_package(self.model) before use; nothing else is accepted. This is a ~590 MB network download, per requirements.txt's own comment, and is deliberately not triggered by any code path — DependencyMissing('presidio_model','local_model_missing') fires instead of ever calling the download helper.
Disk: adds an en_core_web_lg package (~590 MB installed) inside the venv's site-packages/, nowhere else.
Commands:
"$VENV/bin/python3" -m spacy download en_core_web_lg
"$VENV/bin/python3" -c "import spacy; print(spacy.util.is_package('en_core_web_lg'))"
Offline alternative: on a networked machine, fetch the wheel matching spaCy 3.8.x (e.g. en_core_web_lg-3.8.0-py3-none-any.whl from the explosion/spacy-models GitHub releases), carry the file over by hand, then "$VENV/bin/pip" install /path/to/en_core_web_lg-3.8.0-py3-none-any.whl — a local wheel install registers the same importable package name and satisfies is_package() with no network call on Nano itself.
Expected output: the is_package check prints True; scanbot.cli detect dry-run JSON flips "presidio": false to "presidio": true.
Rollback: "$VENV/bin/pip" uninstall -y en_core_web_lg.
Risk: an interrupted 590 MB transfer can leave a half-installed package that still answers is_package() incorrectly, so verify with the one-liner above before trusting it.
Today's ops/scripts/vision_ocr.swift (1.4 KB, compiled to the 67 KB vision_ocr binary) runs VNRecognizeTextRequest at .accurate and prints observations.compactMap { $0.topCandidates(1).first?.string }.joined(separator: "\n") — plain text, one line per Vision observation, nothing else. ocr.py's vision_words() (lines 39-58) is how the adapter tells today's text-only output from tomorrow's box-emitting output: it always attempts json.loads(result.stdout); against today's binary that raises a JSONDecodeError (a ValueError subclass), which the adapter catches and turns into DependencyMissing('vision_boxes','helper_text_only'), then falls back to Tesseract. That fallback path keeps working unchanged until the helper is actually replaced, so this step is reversible by design.
The JSON the adapter requires (validate_words, lines 27-36, and the Word/OCRPage dataclasses in contracts.py): one object on stdout, nothing else —
{"schema_version": 1, "coordinate_space": "display_pixels",
"words": [{"text": str, "box": [x0,y0,x1,y1], "block": int, "line": int, "order": int, "confidence": float}]}
box must be top-left-origin, y-down pixel coordinates within [0,width]×[0,height] of the exact PNG the helper was handed — the same convention as image.width/image.height on the Python side. confidence in [0,1]. block/line/order only drive reconstruct()'s sort-and-join (newline between differing (block,line), space within); block can stay 0 and line can be a simple top-to-bottom index over Vision's observations.
Swift changes needed, as a spec: (1) for word-level boxes, split each observation's recognized string on whitespace and call VNRecognizedText.boundingBox(for:) on each word's Range<String.Index> instead of only the line-level box; skip a word whose lookup throws rather than fabricate one. (2) Vision returns normalized, bottom-left-origin quads (0...1, y-up); convert each word's min/max corners to pixels with x0=minXwidth, x1=maxXwidth, y0=(1-maxY)height, y1=(1-minY)height, clamp into bounds, drop non-positive results. (3) apply the observation's own confidence to every word split from it — Vision has no sub-line confidence. (4) no rotation handling needed in Swift: render_bytes() already bakes PDF /Rotate and EXIF orientation into the raster before the helper sees it. (5) print only the JSON object to stdout, diagnostics to stderr, keep today's exit codes — ocr.py treats any nonzero exit as vision_failed regardless of message text.
Disk: none from approving this block alone — it's a design, not a build. Once implemented, it replaces ops/scripts/vision_ocr and vision_ocr.swift (and the mirrored copy under ~/aios/ops/scripts/), which sit inside the Syncthing-watched repo; that's fine because this tool is stateless code with no PII, unlike the runtime-data paths above. Per this agent's own ops/ edit gate, that swap needs AIOS_PROTECTED_OK=1 and a dated .bak-2026-09-12 copy of both files before overwrite.
Commands (for the later implementation, not run now): back up first — cp ops/scripts/vision_ocr ops/scripts/vision_ocr.bak-2026-09-12 and same for the .swift source; compile with swiftc -O ops/scripts/vision_ocr.swift -o ops/scripts/vision_ocr; smoke-test with ./ops/scripts/vision_ocr sample.png | python3 -m json.tool.
Expected output: the smoke test above parses as valid JSON with schema_version:1, coordinate_space:"display_pixels", and a non-empty words list whose boxes fall inside the image; ocr.py's vision_words() then succeeds instead of raising DependencyMissing.
Rollback: copy the .bak-2026-09-12 files back over the live names.
Risk: a subtly wrong bottom-left-to-top-left flip still passes validate_words's range checks while burning the wrong pixels, so SPEC §5's mandatory human visual review is what actually catches it, not the schema check.
Corpus, synthetic-first, bounded: ~30 synthetic fixtures reusing SPEC §7.1's categories (scanned/native PDFs, hidden text, metadata/attachments, 90°/270° rotation, split-line phones, multiline names, faint/blank/unreadable pages, HEIC orientation, faces/signatures) — fabricated PII only, so a bug never leaks anything real. Once those pass: 10 real Proton messages (read-only, existing 50 MiB/100 MiB caps) and 15 hand-picked Photos assets covering all seven L3 categories including "uncertain." 55 items total, all hand-selected, no bulk pulls.
Commands, dry-run first (default, no flag) then --execute, from the venv:
ROOT="/Users/SeanVargas/Library/Application Support/AIOS/scanbot" "$VENV/bin/python3" -m scanbot.cli --root "$ROOT" db-init "$VENV/bin/python3" -m scanbot.cli --root "$ROOT" --vision-helper /Users/SeanVargas/AI_OS/ops/scripts/vision_ocr \ redact <fixture> --mime application/pdf --asset-id <id> --out /tmp/candidate-<id>.json "$VENV/bin/python3" -m scanbot.cli --root "$ROOT" verify /tmp/candidate-<id>.json --mime application/pdf --out /tmp/verify-<id>.json "$VENV/bin/python3" -m scanbot.cli --root "$ROOT" proton-ingest --account pilot --folder INBOX \ --trust-configured --username <addr> --password-env SCANBOT_PROTON_PW --limit 10 "$VENV/bin/python3" -m scanbot.cli --root "$ROOT" photos-inventory --library-id pilot --limit 15
Repeat each with --execute after the dry-run JSON confirms engine/config availability (presidio: true, vision helper configured, sync_excluded passed).
Metrics: per-page failures = count of OCRPage.status != 'succeeded', read from OCR/verify JSON. Detection misses = planted PII absent from DetectionSet on synthetic fixtures, plus reviewer-found residual PII on real output. Triage accuracy = correct TriageResult.category / total against hand-labeled ground truth for the 15 Photos assets. Latency = wall-clock via time around each CLI call (or events-table timestamps), reported p50/p95 per stage. Peak memory = /usr/bin/time -l <cmd> on macOS, reading "maximum resident set size," max across the run.
Expected output: a metrics table plus a reviewer sign-off on every rendered redacted page; zero unresolved leaks in the sample is the acceptance bar.
Rollback: rm -rf "$ROOT" then re-run db-init — this only removes local derived state; Proton ingestion is read-only against Bridge and Photos inventory never writes to the Photos library, so no source data is ever at risk.
Risk: real PII exists briefly in local OCR/detection JSON before verification runs, so every intermediate file must land inside $ROOT (never /tmp, never the repo) for the whole pilot.
| Step | GO word | Est. minutes | Blocks on |
|---|---|---|---|
| 1. Venv + pip install | GO-1 | 10-30 | none |
| 2. spaCy model download | GO-2 | 5-15 | GO-1 |
| 3. Vision helper JSON-box spec/swap | GO-3 | 45-90 | none |
| 4. Pilot (SPEC §7.5) | GO-4 | 120-180 | GO-1, GO-2, GO-3 |
Step 4 needs all three prior GOs: BUILD-NOTES flags the venv as "a hard prerequisite for any pilot" (without Presidio+spaCy, detect always returns needs_review and nothing ever reaches candidate), and the box-emitting Vision helper is BUILD-NOTES' own listed prerequisite before "pilot per SPEC §7.5."
Built 2026-09-12 on Nano; not yet promoted, not deployed, no venv, no packages installed, no network calls made.
Builders: OpenAI Codex (codex exec, high reasoning effort) built core (config/contracts/db/deps), L1, and L2, then hit its usage-limit wall.
Claude (receiver session) built L3, L4, scanbot/cli.py, the full test suite, and requirements.txt.
Tests: 212 tests, 1 skipped (needs pymupdf + Pillow), 0 failures, on bare python3 with zero third-party packages installed.
Bug fixed 1: db.py put_asset — 7 placeholders bound against the 6-column assets table; every insert would have failed.
Bug fixed 2: l1_redact/release.py approve_visual_review — 11 placeholders against the 10-column approvals table; human approval could never be recorded. Bug fixed 3: l2_proton/ingest.py validate_filename did not reject dot-leading or >255-byte filenames.
Working (this run) — the promoted package, its tests, and its own build log:
scanbot/cli.py, scanbot/config.py, scanbot/contracts.py, scanbot/db.py, scanbot/deps.py; scanbot/l1_redact/{ocr,patterns,detect,redact,verify,release}.py; scanbot/l2_proton/ingest.py; scanbot/l3_photos/{inventory,triage}.py; scanbot/l4_actions/{orchestrate,vcard,calendar_draft,listing_draft}.py; scanbot/migrations/001_initial.sql; requirements.txt; tests/test_*.py (11 files, 212 tests); BUILD-NOTES.md (build log, bugs fixed, known SPEC gaps, next steps).
Reference (every run) — read-only, does not move with this promotion:
comms/outputs/2026-09-09-scanbot-rebuild/SPEC.md — the approved specification; §3 storage layout, §4 contracts/ledger schema, §5 per-lane behavior and hard limits, §7 test plan, §8 build sequence. comms/outputs/2026-09-09-scanbot-rebuild/CODEX-BRIEF.md — original brief, only if build rationale/history is needed.
Do NOT load — superseded or historical, not authority for current behavior:
comms/outputs/2026-09-09-scanbot-rebuild/RESEARCH-{icloud,priorart,proton,scanbot-audit}.md — pre-SPEC research; SPEC §1 already corrected and overrides them (e.g. the disproven stored-Live-Text/detected_text assumption). comms/outputs/2026-09-09-scanbot-rebuild/codex-build-L1L4.log, codex-run.log, BUILD-PROMPT-L1L4.txt, CODEX-PROMPT-2026-09-09.txt — raw build transcripts and prompts (147–204 KB); history, not spec. reference/scanbot_pdf_redact.py, and the pre-existing scanbot_pdf_redact.py / scanbot_run_pipeline.sh / scanbot_batch_redact_extract.py / scanbot_pii_audit.py already in this directory — the retired tool and its callers (SPEC §6). Its raster-and-rebuild mechanics are already ported into scanbot/l1_redact/redact.py; the old file is kept only for md5 provenance, never a maintenance reference or import target.
1. Tests, bare python3, nothing installed:
cd ops/scripts/scanbot python3 -m unittest discover -s tests python3 -m compileall -q scanbot && echo COMPILE_OK
Expect Ran 212 tests ... OK (skipped=1) then COMPILE_OK. The one skip is PixelStackTests (needs pymupdf + Pillow, gated in step 3). Any other skip, failure, or import of a third-party module means the deps.py isolation contract broke.
2. CLI dry-run smoke. Every subcommand in scanbot/cli.py defaults to dry-run; --execute is required to act. Use a real, non-symlinked scratch root — plain mktemp -d on macOS resolves under /var, a symlink that config.py's guard correctly rejects: ROOT=$(mktemp -d /private/tmp/scanbot-smoke.XXXXXX).
| Subcommand | Dry-run default |
|---|---|
db-init | Reports would_initialize; creates nothing. |
db-status | Reports row counts, skips reconciliation. |
ocr | Reports which OCR engines are configured; does not read the file. |
detect | Reports page count and Presidio availability; runs no detection. |
redact | Reports max_passes (hard limit: 5) and dependency availability; touches nothing. |
verify | Reports the candidate's recorded hash only; does not reopen the file. |
release-check | Reports eligibility and dispatch_available (hard limit: always false — no dispatch code path exists in this build). |
proton-ingest | Validates settings and reports sync_exclusion_confirmed/imap_tools; opens no socket. Live ingest is loopback-only (127.0.0.1:1143 default, certificate trust required, never CERT_NONE) and caps at 50 MiB per attachment / 100 MiB per message. |
photos-inventory | Skips reconciliation; reads no photo bytes. |
photos-triage | Reports vlm: not_configured; does not read the image. |
draft-vcard / draft-calendar / draft-listing | Report line/field/review summaries; write no file even if --out is given. |
action-stage | Reports whether the idempotency key is already staged; inserts nothing. |
worker-run-once | Passes dry_run=True into the orchestrator; leases and executes no job. |
Caveat: db-status, release-check, photos-inventory, action-stage, and worker-run-once construct the Ledger unconditionally, which creates the runtime root and state/scanbot.sqlite3 even under the dry-run default — only the stage-specific action is gated by --execute. Always pass --root "$ROOT" for these five during smoke-testing, never the default path.
3. Gated prerequisites — each NEEDS SEAN GO; none done yet:
a. NEEDS SEAN GO — project venv: python3 -m venv .venv && .venv/bin/pip install -r requirements.txt, plus brew install libheif tesseract. Project venv only, never system-wide or the AIOS runtime python.
b. NEEDS SEAN GO — spaCy model (not in requirements.txt, ~590 MB, network): python -m spacy download en_core_web_lg. Code never invokes this itself; without it detect() reports needs_review and redact() never yields a candidate.
c. NEEDS SEAN GO — box-emitting Apple Vision helper. ops/scripts/vision_ocr currently emits plain text only; the adapter raises DependencyMissing(vision_boxes, helper_text_only) and falls back to Tesseract. Vision OCR cannot support redaction until a box-emitting helper (~1.4 KB .swift source) is built and passed via --vision-helper.
One runtime root (default ~/Library/Application Support/AIOS/scanbot/, override with --root), outside the repo, the Photos library, and any sync/cloud root — L2 and L3 both refuse to write until sync_exclusion_confirmed is set. All directories owner-only (0700/0600). Per SPEC §3:
originals/<sha256>/ — immutable source bytes, generated filenames only.private/ — OCR text, detections, labels, source metadata, review images, local action drafts. Never enters repo handoffs, telemetry, or Telegram summaries.work/, quarantine/ — incomplete writes and rejected/ambiguous inputs; local only.derivatives/<asset>/<pipeline-version>/ — redacted candidates; still private until released.release/<output-sha256>/ — the only directory any future cloud worker may see: exact approved derivative plus allowlisted dispatch metadata. Nothing lands here without an approvals row.state/ — the SQLite ledger (state/scanbot.sqlite3; WAL, foreign keys, synchronous=FULL) plus migrations and private diagnostic records. Tables per SPEC §4: mailboxes, messages, assets, message_parts, photo_sources, jobs, artifacts, verifications, approvals, actions, append-only events.Review reports: no rendered review UI exists yet — approve_visual_review records reviewer, reviewed pages, and destination into approvals, but renders no pixels (BUILD-NOTES gap 6). Until that UI is built, "review output" is the verifications/approvals rows in state/scanbot.sqlite3, read via scanbot db-status --execute --root <root> or a direct query — never a separate PII-bearing report file.
Before merging this promotion PR:
cd ops/scripts/scanbot && python3 -m unittest discover -s tests && python3 -m compileall -q scanbot && echo COMPILE_OK
Expect Ran 212 tests in <1s, then OK (skipped=1), then COMPILE_OK — with zero third-party packages installed. Any failure, any extra skip, or any import error blocks the merge; that clean-on-bare-python3 result is the entire point of deps.py. A pilot per SPEC §7.5 is a separate, later check that additionally needs all three GOs in Process step 3.