Skip to content

feat(registry): mirror spec documents to disk via opt-in spec_mirror config - #1383

Open
ren-jentic wants to merge 2 commits into
mainfrom
feat/spec-mirror
Open

ren-jentic wants to merge 2 commits into
mainfrom
feat/spec-mirror

Conversation

@ren-jentic

@ren-jentic ren-jentic commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Spec documents live only in the registry DB, so other services that want direct access to specs (e.g. to bundle for Arazzo runs) must go through the HTTP API. This adds an opt-in spec_mirror config section: when enabled, the registry mirrors every spec document to a local directory that can be mounted rw into jentic-one and ro into consumer services. The DB stays the source of truth — mirroring is post-commit, best-effort, idempotent, and self-healing (syncs verify mirrored bytes, not just file presence). Plan: jentic-one-plans/research/spec-mirror-to-disk.md.

Changes

  • shared/config.py: new SpecMirrorConfig (enabled default off, path — must be absolute, reconcile_on_startup) on AppConfig; regenerated config/config-schema.json, docs/reference/config.md, and the CLI vendored schema + generated struct
  • registry/services/spec_mirror_service.py (new): sync_api idempotently rewrites one API's directory from DB state — one JSON doc per revision grouped by lifecycle state (published/, imported/, draft/, archived/) plus a <revision_id>.meta.json sidecar carrying mirror_digest (sha256 of the mirrored bytes, verifiable by consumers); reconcile_all (backfill + prune, prune set re-read after syncs so a mid-reconcile import is never pruned); atomic writes via unique temp names, per-API async locks so racing syncs can't publish interleaved bytes or stale state, desired-before-prune ordering, percent-encoded path segments (incl. case-insensitive-FS and Windows reserved-name hardening), file I/O via asyncio.to_thread
  • Post-commit hooks (registry surface): ImportHandler.execute after each successful ingest (covers manual, catalog, and overlay-materialize imports), RevisionService.promote/archive/delete, OverlayService.rollback (A5b flips revision states outside the promote/archive paths — without the hook the mirror would keep serving a rolled-back overlay spec as live), and ApiService.delete (a DB-driven sync, so it stays correct when a delete races a re-import) — all best-effort, never fail the registry operation; failures log at error level and count on the spec_mirror.failures metric
  • wiring.py + __main__.py + registry/web/app.py: spec_mirror_lifespan (fail-fast directory validation + startup reconcile as a lifespan-supervised background task, so a large registry never delays readiness) wired for registry-surface shapes — combined and standalone/parts-mode (standalone registry now carries the composition container, same as control/auth)
  • Repos: ApiRepository.list_identifiers, ApiRevisionRepository.list_for_api_with_spec_files
  • Docs: spec_mirror section in docs/development/context-and-config.md, incl. the single-writer constraint (write serialization is per process — exactly one registry-surface writer per mirror directory; replicas sharing a rw mount are unsupported)

Out of scope: no delete-side event seam (hooks call the service directly, matching the existing best-effort post-commit posture), no periodic re-sync job (drift heals on the next mutation or startup reconcile), and no lazy content loading in the reconcile (it runs in the background off the readiness path; per-API reads stay bounded).

Risk & rollback

  • New config section, default off: omitting the block wires nothing and never touches the filesystem — zero behavior change for existing deployments
  • When enabled, boot fails fast if spec_mirror.path is missing, relative, or unwritable (deliberate, mirrors the config validator posture)
  • Mirror writes are wrapped: a full disk or permission error never fails an import/delete — it logs at error level and increments spec_mirror.failures
  • Rollback: revert the squash commit (or just disable the flag)

Test plan

  • make lint (ruff + mypy) and make test-fast (unit + arch, 4062 tests) green
  • make test-integration-sqlite green (850 passed), including tests/integration/registry/services/test_spec_mirror.py: import lands the spec in the state dir, promote moves draft/ -> published/, API delete removes the directory, overlay rollback re-syncs the live/archived split, a corrupted mirrored file heals on the next sync, reconcile backfills and prunes, disabled config is a no-op
  • tests/unit/registry/services/test_spec_mirror_helpers.py: segment encoding (incl. ./.. traversal guard, uppercase/case-insensitive-FS collisions, Windows reserved names, round-trips), state-dir placement/moves/prune, unchanged-content skip, corrupted-file heal, mirror_digest verification, out-of-enum state-dir and orphaned-temp pruning, unknown-dir pruning
  • tests/unit/test_app_factory.py: spec-mirror lifespan wired for registry shapes (combined + standalone regression guard), not for broker-only or disabled configs
  • tests/unit/test_config.py: defaults, YAML, env override, enabled-requires-path and absolute-path validation
  • cd cli && GOWORK=off go build ./... green after config-struct regen

Made with Cursor

ren-jentic and others added 2 commits September 16, 2026 13:50
…config

When spec_mirror.enabled is set, import/promote/archive/delete rewrite the
affected API's directory under spec_mirror.path so the filesystem mirrors
the registry DB (one JSON doc per revision, grouped by lifecycle state,
plus a meta sidecar), and a startup lifespan reconciles the whole mirror.
Mirroring is post-commit, best-effort, and idempotent; the DB stays the
source of truth. Defaults to off — omitting the block wires nothing.

Co-authored-by: Cursor <cursoragent@cursor.com>
…verage

Post-review hardening of the unshipped spec_mirror feature:

- Standalone registry processes now carry the composition container, so
  the spec-mirror lifespan (directory validation + startup reconcile)
  reaches parts-mode deploys, not just the combined app
- OverlayService.rollback gains the post-commit sync hook — the mirror
  no longer keeps serving a rolled-back overlay spec as live
- Syncs verify mirrored bytes (not just file presence), so corrupted or
  truncated spec files heal on the next sync/reconcile; the sidecar
  carries mirror_digest (sha256 of the mirrored bytes) for consumers
- Atomic writes use unique temp names and syncs serialize on a per-API
  lock, so racing syncs can't publish interleaved bytes or stale state
- Startup reconcile runs as a lifespan-supervised background task and
  re-reads the identifier set before pruning, so a large registry never
  delays readiness and a mid-reconcile import is never pruned
- API delete syncs from DB state (safe against delete/re-import races);
  failures log at error level and count on spec_mirror.failures
- Segment encoding covers case-insensitive filesystems and Windows
  reserved names; pruning covers out-of-enum state dirs and orphaned
  temp files
- spec_mirror.path must be absolute; single-writer constraint documented

Co-authored-by: Cursor <cursoragent@cursor.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