Commit Graph

184 Commits

Author SHA1 Message Date
Simon Pinfold
7e26c68d39 fix(assets): multipart upload always mints a delivery record; dedup reuses content only
`POST /api/assets` treated "same bytes, same display name" as "you already have
this" and handed back the existing record with `created_new=false`/HTTP 200.
That silently discarded everything the second request supplied - its tags, its
`user_metadata`, its `preview_id` - and made record identity depend on a
display name, which rulings 5 and 8 say is a label and may legitimately repeat.

An upload is a delivery event, so it now always mints its own record, exactly
as the executed-output path already does when a cached rerun reuses content.
Content dedup is untouched and is the only dedup left: identical bytes still
share one `AssetContent` row, in both hashing modes. `lookup_for_upload_dedup`
existed solely to find the same-name record and, with that arm gone, reduced to
`lookup_for_view`, so ingest calls `lookup_for_view` directly and the function
is deleted.

USER RULING: this makes a retried upload non-idempotent - a client that resends
after a timeout gets a second record. That is accepted deliberately, not
overlooked. No idempotency key or retry protection is introduced here; making
retry safe is a separate decision about a client-supplied identity, and the
multipart parser still rejects a client-provided `id`.

Concurrency safety - the content-reuse arm is a compare-and-swap, not a
read-then-write:

The old content-reuse arm selected a content row in one session, deleted the
uploaded bytes, then opened a fresh session to attach a record to the
now-detached id. A scanner pass or competing writer retiring that row in
between produced a record pointing at content already gone, with no copy of
the bytes left to fall back on. Removing the exact-record arm routes every
same-name upload through this arm instead of only cross-name ones, so the fix
ships in the same commit.

A first version of this fix replaced the two-session gap with a same-session
SELECT re-check (`content_still_qualified`) between the lookup and the insert.
An adversarial review reproduced two concurrency holes that survived it: (1)
the re-check never compared the row's hash, so a concurrent hash correction
(e.g. `detect_content_change`) went undetected; (2) a plain SELECT does not
hold SQLite's write lock, so a competing writer could still commit a
retirement in the window between the re-check passing and this session's own
commit - because pysqlite only opens an implicit transaction (acquiring the
write lock) before a DML statement, never before a SELECT.

`claim_qualified_content` (`lookup.py`) replaces that re-check with a
conditional UPDATE: `UPDATE asset_contents SET is_missing = is_missing WHERE
id = :id AND hash = :hash AND is_missing = 0`, a no-op write whose only job is
to take the write lock and prove the row's state atomically. `rowcount == 1`
means both facts were true at the instant of the write; the row's true hash is
now part of the check, and because it is a write (not a read), SQLite holds
this connection's lock continuously from that statement through this
session's own commit. That lock is database-file-wide, not row-scoped - this
app's default rollback-journal SQLite locking has no row-level granularity -
so it briefly serializes every writer in the app, not only writers to this
row, for the length of this short critical section (claim, filesystem
re-check, record creation, commit). That breadth is what makes the guarantee
hold, not an accident to narrow down. `refresh_qualified_content` then reruns
the unchanged filesystem-level predicate (`_qualifies`: exists,
stat-consistent, non-temp) against a forced re-read, since a DB transaction
cannot make the filesystem itself atomic against an external process. Either
check failing rolls back and falls through to the ordinary new-content path
with the upload still on disk - unchanged from before.

Proven with two tests using two independent file-backed SQLite connections
(not `:memory:`, which is a single shared connection): one where a second
connection commits a hash change between the lookup and the claim (must
reject and fall back), one where a second connection's retirement attempt
during the claim's held lock must itself fail (`sqlite3`/`OperationalError`
naming lock contention specifically), not merely lose a race. Both were
confirmed to reproduce against the prior (first-draft) mechanism via a
standalone repro before being written as this shape.

`openapi.yaml`: the multipart operation loses its HTTP 200 outcome (now
unreachable) and its `id` field (dead spec - the parser rejects `id` with
UNSUPPORTED_FIELD), and `created_new` is described as what it now is.

Rollback: reverting restores the exact-record dedup arm (and with it the
original race).
2026-08-28 21:50:44 -07:00
Simon Pinfold
740fc6f0cb fix(assets): updated_at tracks the last explicit edit, not any row write
`Asset.updated_at` carried `onupdate=get_utc_now`, so every write to the row
moved it — including writes the user never asked for. Serving a download or a
hash bumped it via `update_record_access_time`, and background enrichment
bumped it by assigning `system_metadata`/`mime_type`. Sorting the catalog by
`updated_at` therefore reordered on reads and on scanner passes. Master never
did this; the coupling is this branch's regression.

`onupdate` is gone and each explicit user/API mutation sets the column itself:
the `user_metadata`, `mime_type` and `preview_id` updates fold it into their
existing `.values()`, `rename_record` already did it, and manual tag writes now
bump it through `bump_record_updated_at` — once per call, and only when a link
was actually added or removed, so a no-op tag call stays inert.

Deliberately not full master parity: master's background fills also bumped
`updated_at`, and keeping that would let a future hashing-enabled enrichment
sweep silently reorder the whole catalog. Access-time bookkeeping, enrichment,
the automatic missing/recovered tag projection, content split/retire, and the
preview-target `SET NULL` cascade all leave the column alone.

`apply_tags`/`remove_tags` take no new parameter: their only production callers
are the two user-facing tag routes, so the bump is unconditional at the point a
link changes and no system caller can reach it.
2026-08-28 20:16:30 -07:00
Simon Pinfold
0b28b50ff2 refactor(assets): remove file_path from public asset responses
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.
2026-08-27 20:48:55 -07:00
Simon Pinfold
04e5796517 chore: comment cleanup
Comment-Gate: 3 quarantined
2026-08-27 18:56:52 -07:00
Simon Pinfold
39dafd63ff fix(assets): scope /upload/image dedup to the written path
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.
2026-08-27 16:54:14 -07:00
Simon Pinfold
4a7d6c012a fix(assets): refresh file facts when the off→on drain confirms an unchanged hash
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.
2026-08-27 14:56:40 -07:00
Simon Pinfold
ae65f1ac08 fix(assets): pair enrichment metadata and hash to one verified file observation
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.
2026-08-27 14:24:47 -07:00
Simon Pinfold
eee0e20e20 refactor(assets): name the two integrity-error shapes create_content recovers from
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.
2026-08-26 20:12:04 -07:00
Simon Pinfold
94ff05ce9e chore: comment cleanup
Comment-Gate: 454 quarantined
2026-08-26 20:12:04 -07:00
Simon Pinfold
5aa47063d5 fix(assets): stop treating size equality as byte equality in path reconciliation (round-3)
_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.
2026-08-26 18:05:50 -07:00
Simon Pinfold
d72c3feb77 fix(assets): drop the unverifiable hash when accepting a same-size mtime change (round-3) 2026-08-26 17:55:17 -07:00
Simon Pinfold
de1f1870d3 fix(assets): normalize stored content paths; release the DB lock on failed init (round-3) 2026-08-26 17:53:06 -07:00
Simon Pinfold
c7ca8ca5d0 fix(db): hold the file lock across migrations, diverging from master's incorrect rationale 2026-08-26 17:27:39 -07:00
Simon Pinfold
051154c0fc fix(assets): do not retire unhashed content on re-registration (regression from ef92de23) 2026-08-26 13:52:09 -07:00
Simon Pinfold
9ff3e18cbd fix(assets): make SQL path-prefix predicates case-sensitive and component-bounded (regression from 42463e99) 2026-08-26 13:44:57 -07:00
Simon Pinfold
10ab7a0907 fix(assets): refresh stored stat when a same-size mtime change is accepted (regression from 435dd323) 2026-08-26 13:44:38 -07:00
Simon Pinfold
5cfd1a7bd6 fix(assets): restore zero-record fallback in resolve_hash_to_path (regression from 632c5a49) 2026-08-26 13:42:02 -07:00
Simon Pinfold
e37f65a6a8 refactor(assets): sweep dead code and dead parameters; expose protected tag bucket (review2-18) 2026-08-26 12:36:09 -07:00
Simon Pinfold
42463e9938 perf(assets): push enrichment and temp-wipe filtering into SQL; skip empty-batch scan sleep (review-8, review-12) 2026-08-26 12:03:56 -07:00
Simon Pinfold
dd8cb74bf3 fix(assets): narrow create_content integrity handling; report protected tags honestly (review-12, review2-18) 2026-08-26 12:02:27 -07:00
Simon Pinfold
62641f9d12 fix(assets): clean orphan content on failed ingest (review-11) 2026-08-26 11:46:00 -07:00
Simon Pinfold
632c5a490f fix(assets): serve /view filename and Content-Type from the same record (review2-18) 2026-08-26 11:44:21 -07:00
Simon Pinfold
0ff3a47e14 fix(assets): never cascade record deletion into other assets; 4xx on unknown preview_id (review2-4) 2026-08-26 11:31:57 -07:00
Simon Pinfold
d849b8544b fix(assets): repair 0007 downgrade indexes and guard the OFF→ON drain path classification (review2-15, review2-16) 2026-08-26 11:31:34 -07:00
Simon Pinfold
435dd3233d fix(assets): unified split policy — mtime+size identity, NULL-metadata replacements, re-enrichable splits (review2-7, review2-12) 2026-08-26 11:14:50 -07:00
Simon Pinfold
6d55a5edae fix(assets): list missing records consistently across catalog surfaces (review-1, review2-11) 2026-08-26 11:10:35 -07:00
Simon Pinfold
2bd3a65acb docs(assets): document 0007 data discard; keep the pre-upgrade backup (review-3, review2-5, review2-14)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-26 09:45:03 -07:00
Simon Pinfold
bb5d8e9e64 fix(assets): store system_metadata None as SQL NULL so enrichment matches (review2-1) 2026-08-26 09:36:12 -07:00
Simon Pinfold
bb4dd0eb47 fix(assets): restore display_name and file_path as served fields; unify error envelopes (review-5, review2-8) 2026-08-26 09:29:50 -07:00
Simon Pinfold
ef92de23af fix(assets): retire the live content row before re-registering a path in place (review2-3) 2026-08-26 09:29:13 -07:00
Simon Pinfold
d541f47051 fix(db): acquire the file lock after migrations, matching the documented ordering
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-26 09:27:24 -07:00
Simon Pinfold
32f7237693 fix(assets): align openapi.yaml with served contract and drop dead response kwargs (review-5) 2026-08-26 08:13:51 -07:00
Simon Pinfold
473337dbed fix(assets): pair verified snapshot stat with its digest and stop hash-mode poisoning (review-2, review-7)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-26 08:11:37 -07:00
Simon Pinfold
3850c92baa fix(assets): make temp cleanup robust when DB is unavailable; keep startup ordering (review-4) 2026-08-26 08:08:44 -07:00
Simon Pinfold
ee55797677 refactor(assets): hoist mid-file imports to module scope in product code (D21)
Re-derived inventory: 47 branch-introduced nested import statements across 10
product files (plan said 48/11; delta: asset_enrichment.py deleted by D6 removed
2, D7 added 1). Two kept nested to break introduced cycles:

- lifecycle.py:start_asset_seeder keeps 'from app.assets.seeder import asset_seeder'
  (breaks lifecycle -> seeder -> scanner -> lifecycle)
- scanner_admission.py:tick_watch_list keeps 'from app.assets.scanner import ...'
  (breaks scanner -> scanner_admission -> scanner)

All other 45 statements hoisted. Three test patch paths updated to target the
importing module (app.assets.services.ingest.*) instead of the defining module.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-26 04:07:56 -07:00
Simon Pinfold
322d152e39 refactor(assets): delete dead pre-split code; relocate SeedAssetSpec (D18)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-26 03:51:23 -07:00
Simon Pinfold
058570c11a fix(assets): store blake3-prefixed hashes; unify every read and comparison (D7) 2026-08-26 03:26:58 -07:00
Simon Pinfold
2947eec3c2 fix(assets): clean temp files at startup when assets are disabled (D9) 2026-08-26 03:07:44 -07:00
Simon Pinfold
33be00c57e refactor(assets): register outputs at emission with an ID-free cache; retire post-hoc classification and its consumers (D6) 2026-08-26 02:50:50 -07:00
Simon Pinfold
8280187d9d refactor(assets): registration primitives — rename, defer output hash, synchronous metadata, no cached fallback (D6, D8, D14a) 2026-08-26 02:17:20 -07:00
Simon Pinfold
b6ee82e945 fix(assets): port tag listing and refine to the B schema (D3, D4) 2026-08-26 02:05:37 -07:00
Simon Pinfold
646690a583 fix(assets): survive files disappearing mid-scan without aborting the batch (D17) 2026-08-26 01:49:32 -07:00
Simon Pinfold
898bb78f7b fix(assets): restore the full GET /api/assets query contract (D2) 2026-08-26 01:49:27 -07:00
Simon Pinfold
5bbe32255f fix(assets): exclude temp content from hash lookups at the iterator (D10) 2026-08-26 01:46:52 -07:00
Simon Pinfold
f7196fe889 fix(assets): bound and dedup the scanner watch list per revised S20.2 (D12, D16) 2026-08-26 00:44:44 -07:00
Simon Pinfold
afc2fc4fb2 fix(assets): merge system_metadata on key presence during enrichment (D11) 2026-08-26 00:44:39 -07:00
Simon Pinfold
4981da42b3 fix(assets): remove metadata_filter from all surfaces with explicit 400 2026-08-26 00:44:35 -07:00
Simon Pinfold
812fed2cd0 fix(assets): port preview resolution to the B schema (D5) 2026-08-26 00:44:29 -07:00
Simon Pinfold
148d805d33 fix(assets): make asset_reference.py and tags.py import-safe under Python 3.12 (D1) 2026-08-26 00:14:19 -07:00
Simon Pinfold
8285e7b7aa fix(assets): route output registration from final execution state (S3.1/S3.2/S9/S10.3)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-08-25 09:54:16 -07:00