Skip to content

feat: add tag endpoints end-to-end (REST + HTTP backend + MCP)#7

Merged
vigneshnarayanaswamy merged 1 commit intomainfrom
feat/tag-endpoints
Apr 17, 2026
Merged

feat: add tag endpoints end-to-end (REST + HTTP backend + MCP)#7
vigneshnarayanaswamy merged 1 commit intomainfrom
feat/tag-endpoints

Conversation

@vigneshnarayanaswamy
Copy link
Copy Markdown
Collaborator

Summary

Completes the tag I/O surface across the stack. Previously Ledger.tag() worked against in-memory / SQLite / JSON-file / Snowflake backends, but was a silent no-op against HttpLedgerBackend and had no matching REST endpoints. This PR closes that gap end-to-end.

What's new

Schemas (tools/schemas.py)

  • TagInput, TagOutput, TagListOutput

Tool functions (tools/tag.py)

  • tag(input, ledger) — create or move a tag to the latest snapshot
  • list_tags(model_name, ledger) — return all tags for a model

REST API (rest/app.py)

  • POST /tag — body {model_name, tag_name}TagOutput
  • GET /tags/{model_name}TagListOutput
  • Both return 404 via ModelNotFoundError when the model is unknown.

HTTP backend (backends/http.py)

  • Real set_tag / get_tag / list_tags implementations (replace the previous stubs).
  • Bridges the LedgerBackend.set_tag(tag: Tag) protocol (which takes model_hash) to the name-keyed REST API.

MCP server (mcp/server.py)

  • New tag and list_tags tools in both the direct SDK-backed server and the HTTP pass-through server.
  • Tool count goes from 6 → 8; schema resource exposes the new Tag* models.

Docs

  • CLAUDE.md gains generic Extension Points and Known Gaps sections.
  • CHANGELOG.md updated under Unreleased.

Bonus bugfix

HttpLedgerBackend.get_model(model_hash) was silently returning None for valid models. Root cause: list_models() reconstructs ModelRef from /query responses, but ModelSummary omits created_at, so _compute_model_hash() produces a different hash every call. This broke any downstream code that went hash → name (list_snapshots, latest_snapshot, the new set_tag).

Fix: lazy hash → name cache populated on successful save_model and get_model_by_name calls. get_model(hash) consults the cache first, falls back to the old list_models() iteration. Documented in CLAUDE.md Known Gaps.

Test plan

  • .venv/bin/python -m pytest tests/693 pass, 4 skip (+25 new tests)
  • .venv/bin/python -m ruff check src/ tests/ — clean
  • .venv/bin/python -m mypy src/ — clean
  • End-to-end: Ledger.tag() backed by HttpLedgerBackend writes through the REST API and round-trips via list_tags() (see TestLedgerTagOverHttp in tests/test_backends/test_http_backend.py)

Non-goals

  • No DELETE /tagLedger.tag() overwrites on re-set, so explicit deletion isn't needed yet.
  • No tagging of non-latest snapshots — the SDK doesn't support this, so the API doesn't either.
  • No cross-model tag query endpoint — walk list_models() + per-model GET /tags/{name} if you need one.

🤖 Generated with Claude Code

The Ledger SDK's tag() method works on in-memory, SQLite, JSON-file,
and Snowflake backends, but was silently no-op on the HTTP backend
(set_tag was pass, get_tag returned None, list_tags returned []).
The REST API had no /tag endpoints at all.

This adds the missing tag surface across the stack:

- TagInput, TagOutput, TagListOutput Pydantic schemas
- tools/tag.py with tag() and list_tags() tool functions
- POST /tag and GET /tags/{model_name} REST endpoints
- Real set_tag/get_tag/list_tags on HttpLedgerBackend
- tag and list_tags MCP tools in both direct and HTTP pass-through
  modes (tool count 6 -> 8)
- Schema resource exposes the new Tag models

Bugfix along the way: HttpLedgerBackend.get_model(model_hash) was
broken because list_models() reconstructs ModelRefs without created_at
(ModelSummary omits it), producing mismatched model_hash values. Fixed
with a lazy hash->name cache populated on successful name lookups in
save_model and get_model_by_name. Documented in CLAUDE.md Known Gaps.

25 new tests, 693 total pass, ruff and mypy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@vigneshnarayanaswamy vigneshnarayanaswamy merged commit e5f4e0d into main Apr 17, 2026
5 checks passed
@vigneshnarayanaswamy vigneshnarayanaswamy deleted the feat/tag-endpoints branch April 17, 2026 18:46
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 892c8f77e4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +55 to +59
self._hash_to_name[model.model_hash] = model.name

def get_model(self, model_hash: str) -> ModelRef | None:
# HTTP backend searches by name via query, not by hash directly.
# This is a fallback — investigate is the primary read path.
models = self.list_models()
for m in models:
name = self._hash_to_name.get(model_hash)
if name is not None:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Cache server hash instead of client hash on save

save_model() records model.model_hash before any round-trip to fetch the canonical server identity, but ModelRef hashes include created_at (core/ledger_models.py), so the caller-side hash from Ledger.register() will usually differ from the hash persisted by the REST server. With the new cache path in get_model(), that stale hash is then treated as valid and resolved by name, which can make follow-up tag flows return model_hash/snapshot_hash values that do not exist remotely and fail to round-trip in a new HttpLedgerBackend instance.

Useful? React with 👍 / 👎.

vigneshnarayanaswamy pushed a commit that referenced this pull request Apr 17, 2026
Codex review on PR #7 (#7)
flagged that save_model was caching the client-side model_hash from
the incoming ModelRef, but the server computes its own hash from a
fresh created_at. The client hash and server hash therefore diverge,
and the hash-to-name cache would map a stale client hash to the name.

Any downstream hash-based flow (get_model, list_snapshots, set_tag)
would either fail to resolve in a fresh backend instance or silently
resolve to a ModelRef carrying the server's hash, making returned
model_hash / snapshot_hash values inconsistent with what the caller
just "saved."

Fix:
- RecordOutput now carries `model_hash` so /record returns the
  server's canonical identity
- HttpLedgerBackend.save_model reads the server's hash from the
  response, reassigns it onto the incoming ModelRef in place (so
  retained references see the authoritative identity), and caches
  only the server hash

Tests:
- Schema tests updated to supply model_hash in RecordOutput
  fixtures + assertion that the field is required
- TestSaveModelCanonicalHash suite covers ref mutation, cache
  contents, and the fresh-backend tag round-trip scenario Codex
  described

697 pass, 4 skip; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
vigneshnarayanaswamy added a commit that referenced this pull request Apr 17, 2026
* fix: HttpLedgerBackend caches server's canonical model_hash

Codex review on PR #7 (#7)
flagged that save_model was caching the client-side model_hash from
the incoming ModelRef, but the server computes its own hash from a
fresh created_at. The client hash and server hash therefore diverge,
and the hash-to-name cache would map a stale client hash to the name.

Any downstream hash-based flow (get_model, list_snapshots, set_tag)
would either fail to resolve in a fresh backend instance or silently
resolve to a ModelRef carrying the server's hash, making returned
model_hash / snapshot_hash values inconsistent with what the caller
just "saved."

Fix:
- RecordOutput now carries `model_hash` so /record returns the
  server's canonical identity
- HttpLedgerBackend.save_model reads the server's hash from the
  response, reassigns it onto the incoming ModelRef in place (so
  retained references see the authoritative identity), and caches
  only the server hash

Tests:
- Schema tests updated to supply model_hash in RecordOutput
  fixtures + assertion that the field is required
- TestSaveModelCanonicalHash suite covers ref mutation, cache
  contents, and the fresh-backend tag round-trip scenario Codex
  described

697 pass, 4 skip; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address Copilot review — fail loudly on /record errors

Addresses all 3 review comments on PR #8 from copilot-pull-request-reviewer:

1. save_model now calls resp.raise_for_status() before mutating
   cache state, so a 4xx/5xx response no longer leaves the
   hash-to-name cache pointing at a model that was never persisted.

2. save_model now raises ValueError when a 2xx response lacks a
   valid model_hash string, instead of silently falling back to the
   client-computed hash. That fallback would reintroduce the exact
   stale-hash bug this PR was fixing, which could trigger during a
   client/server version mismatch where the server predates the
   required RecordOutput.model_hash field.

3. test_tag_flow_survives_fresh_backend_instance now asserts
   model is not None before dereferencing, for a clearer failure
   message.

Added defensive-path coverage:
- test_http_error_raises_and_does_not_cache — 500 response raises
  HTTPStatusError and leaves the cache empty
- test_success_without_model_hash_raises — 200 response missing
  model_hash raises ValueError and leaves the cache empty

Both use httpx.MockTransport to exercise the failure paths without
needing a live server.

699 pass, 4 skip; ruff + mypy clean.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Vignesh Narayanaswamy <Vigneshn@squareup.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant