feat: thing manifest schema + metadata assembler#226
Conversation
- openforge/thingiverse/manifest.py: per-thing YAML manifests with strict validation (typo-hostile: unknown keys fail fast). Models come from tag select queries (require=exact-ALL, accept=prefix-ANY, deny=exact exclude — matching the catalog's tag search engine) or explicit md5/full_name refs; images/zips/others are local paths. - openforge/thingiverse/templates/openforge2.yaml: per-line defaults (license, category, base tags, description boilerplate) ported from the old v1 tooling's new.openforge2.json. Boilerplate is stored once so a copy change re-syncs every managed thing's description. - openforge/thingiverse/assembler.py: assemble_thing(curs, manifest) -> API-neutral payload (metadata + resolved blueprint rows + local files) + metadata_hash (sha256 of canonical metadata JSON, feeds thingiverse_things.remote_metadata_hash for drift detection). Fail-fast on unknown templates, empty selectors, missing refs, missing local files, and selector-ceiling truncation. - 42 tests (13 manifest validation, 29 assembler incl. DB-backed selector semantics) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
devonjones
left a comment
There was a problem hiding this comment.
Review scope
Domain correctness of the manifest → assembled thing payload for the Thingiverse epic (openforge_catalog-d7a, unblocking ad5), selector semantics vs. openforge/db/sql/tags.py, metadata_hash stability, and house style. Complexity/error-handling/test-coverage/dead-code/importlib-resources/credentials are covered by other passes and skipped here. Verified several claims experimentally against a live test DB (pytest + test_db fixture), not just by reading.
Critical
-
select.acceptwith multiple entries is ANDed, not ORed — contradicts the manifest.py docstring's documented "ANY" semantics.The docstring says: "
accept: model must carry at least one tag in the listed subtree, matched by hierarchical prefix ANY." That's true for a single accept entry (verified:accept: ['shape|floor']correctly matches a blueprint taggedshape|floor|cornervia prefix match). But with multiple entries it's a different story._query_tags_include(openforge/db/sql/tags.py) adds oneJOIN tags AS tags_Nper accept entry and just concatenates all the WHERE conditions — there's no OR grouping across entries, only within a single entry's own position checks. That means a blueprint must satisfy every listed accept subtree simultaneously (same AND behavior asrequire), not any one of them.I confirmed this by inserting two blueprints, each carrying exactly one of two sibling texture tags, and running:
select: accept: ['texture|cave', 'texture|dungeon_stone']
Expected (per docstring): both blueprints match (OR). Actual:
AssemblyError: selector matched no models— because no single blueprint carries both texture subtrees at once.This means the most natural use of a multi-value
acceptlist — "any of these sibling subtrees" (e.g. several textures, several sizes) — can never match anything unless a blueprint happens to be tagged under every listed subtree at once. Since the assembler fails fast on empty selector results, this surfaces as an immediate hard error rather than silent corruption, but it means an entire class of manifest expression the docstring promises doesn't work. There is no test anywhere (test_thingiverse_assembler.py,test_thingiverse_manifest.py) exercisingacceptwith more than one entry, so this shipped unnoticed.This needs a decision before
ad5/lpemanifests start relying on it: either_query_tags_include/_query_tags_basicsneed real OR-across-entries support foraccept(touches shared tag-search code used elsewhere by the app, e.g. the search API), or the assembler needs to run one sub-select per accept entry and union the results itself, or (minimal fix) the docstring should be corrected to state accept is ALL-across-entries like require (in which caseacceptdiffers fromrequireonly by prefix-vs-exact matching, which is a much narrower and less useful primitive than advertised).
Medium
- Model row shape differs depending on resolution path.
_resolve_selectgets rows viatag_search_blueprints'sSELECT *(allblueprintscolumns, includingsearch_text, added in schema v4)._resolve_md5/_resolve_full_nameget rows viaget_blueprints_by_md5/get_blueprints_by_full_name's explicit column list (nosearch_text). Both paths run rows through_convert_config, so the fields the sync engine actually needs (file_md5,file_name,full_name,storage_address) are present and consistent either way — but a manifest mixingselect:entries withmd5:/full_name:entries for the same thing produces afiles.modelslist whose dicts don't all have the same keys. Low risk today since nothing downstream inspectssearch_text, but worth normalizing to one explicit projection sofiles.modelsis shape-stable regardless of how each entry was resolved.
Minor
-
_resolve_md5/_resolve_full_namedon't filter byblueprint_type='model'the way_resolve_selectexplicitly does (models=True, blueprints=False, with a comment explaining why). I traced the fixtures/scanner pipeline (openforge/db/fixtures/utils.py:munge_blueprint,openforge/db/fixtures/incremental.py) and confirmed compositeblueprint_type='blueprint'rows never populatefile_md5/full_nametoday (they're config-only compositions, keyed byblueprint_nameinstead), so this is currently unreachable — a real md5/full_name literal can never equal aNULLcolumn. But nothing enforces that invariant at the schema level (no CHECK constraint), and the admin blueprint API (openforge/app/routes/blueprints.py,openforge/openapi/schemas/blueprint.yaml) would accept a row withblueprint_type='blueprint'andfull_name/file_md5both set if anyone ever created one by hand. Adding the same type filter to_resolve_md5/_resolve_full_nameis a one-line-each defense-in-depth fix. -
_dedupe_modelskeys onrow.get("file_md5"), which trusts every resolved row has a non-null, unique md5. If that were ever violated (only reachable via #3), two distinct rows withfile_md5 = Nonewould silently collapse into one instead of erroring. Moot once #3 is closed, but the function has no explicit assertion of the invariant it relies on. -
manifest["license"] or template.get("license")(and same pattern forcategory) treats an explicit-but-falsy manifest override (e.g.license: "") the same as "not set," silently falling back to the template value. Edge case, probably never hit since these fields are either meaningfully set or omitted (None) in practice, but it's a subtle""vsNonetrap worth a one-line comment for future maintainers.
Positive
storage_address,file_md5, andfile_nameare present on every resolved model row (verified for both selector and explicit-ref paths), soad5has everything it needs to know where to fetch a model's bytes from (R2 viastorage_address) and what to name it on upload (file_name) without extra lookups.- The
{"tag": t}wrap fortag_search_blueprintsis correct and well-commented. Verified againsttags.py: bare strings really would silently no-op, since_query_tags_include/_query_tags_denygate onif "tag" in req/if "tag" in d, and a bare string like"texture|cave"makes"tag" in "texture|cave"a truthy substring check that then crashes/misbehaves rather than doing a real filter. Good catch, correctly guarded against. require(exact ALL across entries) anddeny(exact match, excluded if any listed tag present) semantics both match the docstring exactly — verified experimentally and by reading_query_tag_require/_query_tags_deny.metadata_hashis properly decoupled from file selection — only themetadatasub-dict is hashed, notfiles, so selector/file drift isn't conflated with "should this thing's metadata re-push," matchingad5's separate file-vs-metadata diffing design.sort_keys=Truehandles dict-key ordering, and tag-list order is deterministic (template-then-manifest, stable case-insensitive dedupe), so a template boilerplate or tag change reliably changes the hash as intended.- Fail-fast validation in
manifest.py(unknown top-level keys, unknown file sections, unknown select keys, ambiguousfull_name) is thorough and in keeping with the project's "no silent failures" philosophy. SELECT_LIMITtruncation-ceiling check (len(rows) >= SELECT_LIMIT) is correctly reasoned giventag_search_blueprints's LIMIT-based pagination and the catalog's known size (~1,400 designs).ruff check/ruff format --checkboth clean; docstrings, private-helper decomposition, and function sizes matchopenforge/CLAUDE.md's style guide.
Recommendations
- Get a ruling from Devon on
accept's intended algebra (ANY vs. ALL across list entries) and fix either the SQL or the docstring to match — this is the one item that actually blocks safe consumption of manifests by anyone writing selectors forad5/lpe. - Add a regression test for multi-entry
accept(none exists today) once semantics are settled — this gap is squarely why the mismatch shipped. - Normalize model row shape between
_resolve_selectand_resolve_md5/_resolve_full_nameto one explicit column projection. - Add
blueprint_type='model'filtering to_resolve_md5/_resolve_full_namefor defense-in-depth.
Overall
The plumbing that ad5 actually depends on — storage_address/file_md5/file_name flow-through, metadata_hash design, fail-fast validation — is solid and gives the sync engine what it needs. The one real correctness issue is the accept selector's actual AND semantics contradicting its documented ANY semantics; that's worth resolving before other manifests start writing multi-value accept lists expecting OR behavior. Everything else here is minor hardening, not a blocker.
Response to Claude ReviewCritical (multi-accept ANDed, contradicting docs): Fixed in code — the documented ANY semantics are correct for manifests (and consistent with multiple select entries unioning), so Medium (row shape differs by resolution path): Fixed — all resolved models are projected to an explicit Minor (no model-type filter on explicit refs): Fixed — Minor (dedupe trusts file_md5): Fixed — Minor ( |
- accept selector: engine ANDs multiple accepts; assembler now unions per-accept queries to deliver the documented ANY-of-subtrees semantics (critical — was shipping wrong behavior for multi-accept) - stable model row shape: explicit MODEL_FIELDS projection across all resolution paths; fail-fast on models missing file_md5 - explicit md5/full_name refs must resolve to blueprint_type='model' - narrow manifest YAML error handling to (YAMLError, UnicodeDecodeError) - tests: 29 -> 35 (multi-accept ANY, prefix accept, ambiguous full_name, SELECT_LIMIT ceiling, shape stability, non-model ref) Part of openforge_catalog-d7a Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
Verification of round-1 fixes (commit df77937)1. Accept-ANY fix — VERIFIED CORRECT. Traced The fix in Edge cases:
Ran the repro directly: Verdict: LGTM, critical resolved. 2. Row shape (medium) — VERIFIED FIXED. Verdict: LGTM. 3. Won't-fix rationale ( 4. Scan of df77937 for new issues:
No other correctness regressions found in the diff. Summary
LGTM — no remaining concerns block merge. |
…estly Part of openforge_catalog-d7a Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
* feat: formalize general-reviewer in the agent pack This repo has no Gemini/Cursor, so the pack gains a whole-PR generalist that spawns on every PR, every round: logic correctness verified empirically, design soundness against upcoming tickets, contract fidelity, cross-cutting interactions, docs accuracy. Explicitly non-overlapping with the specialists. Previously run ad-hoc in PRs #224-#226, where this pass produced the highest-value catches (invalid ruff flag, JWT AttributeError, multi-accept AND-vs-ANY). Also documents the no-external-bots reality so future loops don't poll for Gemini. Closes openforge_catalog-aq5 (via bd close on merge) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: address general-reviewer dogfood findings - annotate the skip rule (general-reviewer always matches by construction) - correct ruff-flag catch attribution to PR #223 in ticket aq5 Part of openforge_catalog-aq5 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Ticket
openforge_catalog-d7a— the "what goes in a thing and what it says" layer of the Thingiverse epic (4kx). Unblocks the sync engine (ad5) together with #225.openforge/thingiverse/manifest.py— per-thing YAML manifests, strictly validated (unknown keys/sections/select-keys all fail fast — typos die at load, not at publish). Model entries:select:tag queries — the killer feature: newly scanned files matching the query flow into the thing on the next sync run, zero manual entry. Semantics match the catalog's tag engine:require= exact-match ALL,accept= hierarchical-prefix ANY,deny= exact exclude.md5:/full_name:refs (ambiguous full_names rejected with a pointer to use md5)images/zips/othersas local paths resolved relative to the manifestopenforge/thingiverse/templates/openforge2.yaml— product-line defaults (license, category, base tags, description boilerplate) ported from the old v1 tooling'snew.openforge2.json. Boilerplate lives once in the template, so changing the copy re-syncs every managed thing's description — the oldtv_update_descriptionbulk-edit workflow, automated.openforge/thingiverse/assembler.py—assemble_thing(curs, manifest)→ API-neutral payload: merged metadata (template ← manifest overrides), resolved blueprint rows (deduped by md5), verified local files, andmetadata_hash(canonical sha256 that feedsthingiverse_things.remote_metadata_hashfor drift detection). Fail-fast everywhere: unknown template, selector matching nothing, selector hitting the truncation ceiling, missing refs, missing files.One subtle bug caught during dev worth flagging:
tag_search_blueprintstakes{"tag": ...}dicts — bare strings silently no-op the filters ("tag" in "texture|cave"is just a substring check). The assembler wraps correctly and carries a comment.Test plan
ruff check .clean; full suite 402 passed (same 4 pre-existingtest_incrementallocal failures; CI green ontest)Beads:
openforge_catalog-d7a(closed viabd closeon merge).🤖 Generated with Claude Code