The top-level `file_path` served the global-namespace-root path
("models/checkpoints/flux.safetensors") to every API client, duplicating what
`display_name` and `loader_path` already carry in the forms clients actually
consume. Nothing in the response contract needs the namespace-rooted form, so
it is surface no caller has to be given.
Dropped from schemas_out.Asset and from both response builders, and from the
Asset and AssetUpdated schemas in openapi.yaml. AssetCreated inherits Asset, so
list, detail, create/upload, from-hash and update all lose it together.
Every internal path stays: ReferenceData.file_path, AssetContent.path, the
preview URL computation, and system_metadata["file_path"] are untouched, as is
the persisted loader_path this file's tests guard.
Kept as a standalone top commit so the policy can be reverted on its own.
Comment-gate walk (M3): the docstrings on the two mismatch tests stated what
each test is evidence for. Under the gate's test rubric there is no keep for
tests — that claim belongs on the assertion it backs, where it surfaces on a
red build instead of only to someone already reading the source.
The metadata-swap test's docstring also claimed the row "stays a candidate
for the next enrichment pass"; dropped rather than moved, since this test
never exercises a retry and so is not evidence it provides.
register_file_in_place writes bytes at the locator BEFORE registering, then ran a
global lookup_for_upload_dedup. Both match branches could point at a different file
that merely shares bytes: the Asset branch returned that file's record, the
AssetContent branch created a record against that file's path. Either way the
just-written locator stayed untracked and the caller got back an asset describing
someone else's path.
Ruling (2026-08-27): /upload/image accepts weaker content-dedup than the multipart
endpoint. Drop the global lookup and fall through to create_content unconditionally.
_reconcile_live_content_at_path already leaves at most one live row at the locator,
so unchanged bytes reuse that content row and changed bytes retire it; a distinct
path holding equal bytes now gets its own content row and record, and the hash still
finds both. Re-registering an unchanged file writes a new delivery record against the
reused content row, which is what a repeat save through /upload/image is.
upload_from_temp_path keeps its global dedup-before-write unchanged.
drain_transition_queue branched on null-hash and changed-hash with no else, so the
success case — a recomputed digest equal to the stored one, which is proof the bytes are
unchanged — fell through and left size_bytes/mtime_ns untouched. A row whose stored stat
was stale then failed lookup._stat_consistent and stayed unservable by hash until the next
full scan, even though the drain had just verified it.
Adopt the observed stat on the equal-hash arm, matching what drain_pending_verifications
already does on its matching-hash path.
snapshot_hash returns the stat it verified brackets the bytes it read, and
enrich_asset threw it away. Metadata came from the stat taken on entry, the
digest from whatever the file was during the read, and nothing checked the two
described the same file. A writer landing between them welded old metadata to a
new-bytes hash — permanently, because a row with non-NULL system_metadata is
never an enrichment candidate again.
Keep the verified stat and reject the whole result when it disagrees with the
metadata's observation. The row stays NULL on both fields, so the next pass
retries it once change detection reconciles the stored stat.
setup_database logs and continues when assets are disabled, so a failed init
that keeps the lock blocks every other instance for its whole lifetime. The
assertion message says so; the acquire it replaces said nothing.
An escaped ValueError reaches setup_database, which sys.exit(1)s the app when
--enable-assets is set. Put that in the failure output rather than a docstring
nobody reads when the build goes red.
The live-path uniqueness race is reported differently by each backend: SQLite
names the column in the message, Postgres exposes the index on diag. Two named
predicates carry that at the call site instead of a comment.
The prior wording ('only hash mode permits upload deduplication') never
described the code. upload_from_temp_path hashes unconditionally and
lookup_for_upload_dedup never consults mode.hashing_enabled (unlike
lookup_for_from_hash, which does) — the deliberate product decision from
4bced38a. Only scanner/output hashing is gated by --enable-asset-hashing.
Ruling text updated to match; no behaviour change.
_reconcile_live_content_at_path adopted a freshly-computed hash onto an
existing hash=None row whenever the recorded size_bytes matched the file.
Equal size is not equal bytes: via /upload/image (which writes the
replacement BEFORE calling register_file_in_place) a same-sized overwrite
left the original record and content live while silently adopting the NEW
digest, so records describing the old bytes claimed the new ones.
Rather than guess, take the signal from the caller that has it. server.py
already computes image_is_duplicate - true exactly when compare_image_hash
proved the bytes were already on disk and the write was skipped - so
register_file_in_place now takes content_written and reconciles on fact:
written -> retire, not written -> adopt as before. The no-write path is
untouched, so genuinely unchanged unhashed files are still never retired.
upload_from_temp_path has no caller signal, but it has something better:
the incumbent bytes still exist when it runs. It now settles the
destination row against that file's OWN hash before both the dedup lookup
and the move, so the post-move reconciliation always compares known
hashes and never has to read size as identity.
Second defect: a matching hash returned without refreshing the stored
stat, so a same-bytes mtime-only re-registration left the row failing
lookup._stat_consistent - unservable by hash, and routed into creating a
DUPLICATE record. A matching hash IS proof of byte equality, so the
observed stat is now written back.
Tests: the existing unhashed-changed-file test used different-SIZED bytes
and passed under the old buggy logic too; it now uses same-sized bytes so
it actually discriminates. Adds stale-stat and same-size-different-bytes
coverage for both register_file_in_place and upload_from_temp_path.
CI runs bare `ruff check .` (whole tree); the earlier local gate only checked
changed files, so 10 pre-existing errors reached HEAD:
- 9x E701/E702 in tests-unit/assets_test/test_intended_behaviour.py: semicolon/
colon-chained statements (scenarios 7, 11, 17, 27) split onto separate lines.
Logic untouched; de-tautologising is deferred to a later todo.
- 1x F841 unused `owner_id` in server.py's /view blake3 branch.
/view unknown-user decision (F841): `get_request_user_id` returns "default" in
single-user mode but RAISES KeyError for unknown users and system-prefixed users
in multi-user mode. `resolve_hash_to_path(filename)` never consumed `owner_id`,
so the value is dead -- but the call is a load-bearing access gate: it rejects
unknown/system users before a blake3 hash is resolved to an on-disk path.
Removing the line entirely would silently weaken multi-user access control on
/view?filename=blake3:... . Decision: keep the call, drop the binding (bare call,
return discarded); a comment documents the deliberate side-effect-only call to
prevent a future "dead call" cleanup regressing the gate.
Verified: `uvx ruff check .` clean (whole tree); assets_test 378 passed, 1 skipped.
Re-measured inventory: 56 nested imports across 14 test files. All hoisted
to module scope. Empirically tested every import folder_paths hoist — none
caused regression. No folder_paths exceptions needed.
Side effect: hoisting contextmanager + SASession out of the
mock_create_session fixture body in services/conftest.py fixed 3 pre-existing
test failures (monkeypatch was targeting stale module references when the
imports were nested inside the fixture).
Ruff cleanup: removed duplicate import tempfile (services/conftest.py),
unused sqlalchemy.select (test_delete_b.py), unused os (test_lifecycle.py)
— all were pre-existing issues hidden by the nested-import pattern.
Zero assertion changes — only import lines moved.
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>