# SEC-008 attachment consistency verification

Updated: 2026-08-02

Status: `IMPLEMENTED — NOT VERIFIED`. Repository, real-SQLite integration and isolated HTTP checks pass. Browser and production checks remain blocked.

## Revalidated execution path

The former upload path copied files before validating the full batch and before the database commit; rename moved bytes before metadata commit; delete committed metadata before a best-effort file delete. These were confirmed JT-010 split-brain windows. Existing mitigations were owner-scoped queries, generated storage names, size/type/quota validation and derived attachment flags; there was no durable recovery state.

The implemented invariant uses the generated final path as the operation identity:

- `<final>.uploading` is a durable staged upload. Startup promotes it when its database row exists and purges it when no row exists.
- `<final>.deleting` is quarantined deletion data. Startup restores it when its row exists and purges it when no row exists.
- plain unknown files are counted and preserved; missing rows/files and unsafe stored paths are counted for review.
- rename changes display metadata only.
- attachment paths must remain under the configured root without child symlink/junction traversal.

Transaction failure cleanup occurs only after a confirmed rollback. An uncertain commit/rollback outcome preserves the suffix marker so restart reconciliation, rather than an unsafe guess, decides from durable database state.

## Automated evidence

| Check | Result |
|---|---|
| `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --no-restore --filter "FullyQualifiedName~AttachmentConsistencyTests|FullyQualifiedName~AttachmentFlagsRecomputeTests|FullyQualifiedName~AttachmentsControllerTests" --logger "console;verbosity=minimal"` | PASS — 20/20 |
| `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --no-restore --logger "console;verbosity=minimal"` | PASS — 525/525 |
| `git diff --check` | PASS — no whitespace errors; only repository line-ending notices |

The real-SQLite tests cover invalid later files, cancelled copy, database rollback, upload-promotion failure and restart, delete-purge failure and restart, quarantined-delete restore, idempotent reconciliation, metadata-only rename with atomic flags, repeated same-name uploads, exact 10 MiB boundary, one-byte-over rejection, outside-root rejection, unknown-orphan preservation and two-user isolation. Failure injection uses no malicious documents.

## Runtime evidence

An isolated development API ran on `127.0.0.1:5305` with disposable synthetic SQLite data:

- User A login, `.txt` upload, list, download, metadata rename/purpose update and delete succeeded (`200/204` as applicable).
- User B received `404` for User A's attachment download and delete.
- the final owner list was empty after deletion.
- startup reconciliation completed before the service accepted requests.
- the exact API PID was stopped and port 5305 was confirmed closed.

Logs are `docs/audits/evidence/core-001-runtime/sec-008.stdout.log` and `sec-008.stderr.log`; the source fixture is `synthetic-attachment.txt`. They contain synthetic local data only.

## Unverified gates and rollback

- In-app browser access to localhost is administrator-policy blocked, so upload/rename/delete/refresh was not browser-tested.
- This Windows host denied creation of a disposable symbolic link; child reparse-point refusal was code-inspected but not executed. Outside-root traversal is tested.
- No production report-only orphan inventory, counter monitoring, multi-replica exercise or MariaDB runtime was performed.
- Periodic reconciliation is deliberately deferred; startup retry is the current recovery trigger.
- Entitlement quota behavior is owned by POL-001; the unchanged quota calculation was not reclassified as verified here.

Before rollback, reconcile or manually review all `.uploading` and `.deleting` markers. Never delete unknown plain orphans automatically. No database/configuration migration was introduced.
