Skip to content

feat(schema): add Git-backed remote schema sources - #1444

Closed
Patodo wants to merge 7 commits into
Fission-AI:mainfrom
Patodo:feat/remote-schema-sources
Closed

feat(schema): add Git-backed remote schema sources#1444
Patodo wants to merge 7 commits into
Fission-AI:mainfrom
Patodo:feat/remote-schema-sources

Conversation

@Patodo

@Patodo Patodo commented Jul 26, 2026

Copy link
Copy Markdown

Summary

Add explicit Git-backed remote schema sources so teams can share one schema across repositories without copying bundles or allowing ordinary OpenSpec commands to access the network.

Projects keep the existing scalar schema selection and may declare a source separately:

schema: qeda-sdd

schemaSources:
  qeda-sdd:
    git: https://github.com/example/QEDASDD.git
    ref: v1.0.0
    path: schemas/qeda-sdd

openspec schema sync [name] resolves the requested branch, tag, or commit to an immutable commit SHA, validates the selected schema bundle, installs it in a content-addressed local cache, and atomically updates openspec/schemas.lock.yaml. --locked restores or verifies the exact lock state without upgrading it.

Relates to #1131.

Behavior and impact

  • preserves schema: <name> and existing project, user, and package schemas
  • resolves schemas in project-local → locked remote → user → package order
  • keeps normal schema resolution offline and network-free
  • fails closed when a declared remote lock or cache is missing, stale, or corrupt
  • reports remote source, immutable revision, integrity, and shadowing through schema inspection commands
  • supports human and single-document JSON synchronization output
  • registers schema sync in shell completion metadata

Security and reproducibility

  • uses system Git so HTTPS credential helpers and SSH agents/configuration continue to work
  • rejects credential-bearing URLs and unsupported Git remote-helper transports
  • accepts only repository-relative portable bundle paths
  • rejects traversal, absolute/drive/UNC paths, symlinks, submodules, non-UTF-8 paths, case-folded path collisions, and Windows-unsafe names
  • limits bundles to 1,000 regular files and 10 MiB
  • verifies a deterministic SHA-256 digest before every remote-cache resolution
  • validates downloads before atomic cache activation and preserves the previous lock/cache on failure

Documentation and release tracking

  • adds an OpenSpec proposal, design, capability specs, and completed task plan
  • documents public/private repository setup, CI restoration, offline behavior, upgrades, precedence, and security boundaries
  • adds a minor changeset

The configuration and lockfile representation remain experimental and can be adjusted based on maintainer feedback without changing the behavioral model.

Validation

  • pnpm lint
  • pnpm exec tsc --noEmit
  • pnpm run build
  • pnpm test — 120 test files, 2,328 tests passed
  • pnpm exec openspec validate add-remote-schema-sources --strict
  • git diff --check

Integration coverage uses temporary local Git repositories and does not depend on public network access.

Summary by CodeRabbit

  • New Features
    • Added Git-backed remote schema sources via schemaSources, with openspec schema sync supporting update and deterministic --locked restore, plus structured --json reporting.
    • Remote schemas participate in listing/inspection (including availability/conflict diagnostics) and keep normal operations network-free when using the lock/cache.
  • Documentation
    • Expanded CLI and customization docs for schema sync, templates/schema output details, precedence/authority rules, and offline/locking behavior.
  • Tests
    • Added unit and CLI e2e coverage for sync, lock acquisition/restoration, concurrency, cache integrity, bundle boundary validation, schema directory validation, and credential-leak prevention.
  • Refactor
    • Improved schema/validation reporting and introduced schema-root aware resolution for validation, instructions, and change workflows.

@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Git-backed remote schema sources with explicit synchronization, immutable lockfiles, integrity-verified caching, offline fail-closed resolution, remote-aware discovery, schema-root propagation, and schema sync CLI support.

Changes

Remote schema sources

Layer / File(s) Summary
Configuration, lock, bundle, and validation contracts
src/core/remote-schema/*, src/core/project-config.ts, src/core/artifact-graph/schema-directory.ts, src/core/artifact-graph/schema.ts, test/core/remote-schema/*, test/core/project-config.test.ts
Adds validated schemaSources, strict lockfile parsing and atomic writes, portable bundle validation, deterministic SHA-256 integrity, and diagnostic schema inspection.
Git synchronization and cache installation
src/core/remote-schema/git.ts, src/core/remote-schema/sync.ts, src/core/remote-schema/cache.ts, src/core/remote-schema/sync-lock.ts, test/core/remote-schema/*, test/cli-e2e/*
Fetches Git refs, validates and extracts bundles, supports update and locked restoration modes, serializes concurrent syncs, and preserves prior lock/cache state on failures.
Remote resolution and schema-root integration
src/core/artifact-graph/resolver.ts, src/core/root-selection.ts, src/core/artifact-graph/instruction-loader.ts, src/utils/*, test/core/artifact-graph/*, test/commands/store-root-selection.test.ts
Adds remote precedence and fail-closed discovery while propagating the consumer-owned schema root through resolution, validation, instructions, status, archive, listing, and change creation.
Schema CLI and reporting
src/commands/schema.ts, src/commands/workflow/schemas.ts, src/commands/workflow/templates.ts, src/core/completions/command-registry.ts, src/cli/index.ts, test/commands/schema-sync.test.ts
Adds schema sync [name] with --locked and --json, and extends schema discovery and schema which with remote metadata and structured errors.
Specifications, documentation, and release tracking
openspec/changes/add-remote-schema-sources/*, docs/cli.md, docs/customization.md, .changeset/*
Documents remote source configuration, synchronization, precedence, offline behavior, validation rules, testing tasks, and the minor release change.

Estimated code review effort: 5 (Critical) | ~120 minutes

Sequence Diagram(s)

sequenceDiagram
  participant CLI
  participant Sync
  participant Git
  participant Cache
  participant Lockfile
  CLI->>Sync: Run schema sync
  Sync->>Git: Fetch and validate bundle
  Git-->>Sync: Return commit and integrity
  Sync->>Cache: Install verified bundle
  Sync->>Lockfile: Write resolved metadata
  Sync-->>CLI: Return sync result
Loading

Possibly related PRs

Suggested reviewers: tabishb

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.43% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding Git-backed remote schema sources.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Patodo
Patodo marked this pull request as ready for review July 26, 2026 05:15
@Patodo
Patodo requested a review from TabishB as a code owner July 26, 2026 05:15

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/commands/schema.ts (1)

82-153: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Avoid index-based shadow computation in getSchemaResolution.

getRemoteSchemaDir can throw before returning a path, so checkAllLocations can report a non-existent remote location while getSchemaDir never reaches it. The fallback active has no matching entry, activeIndex is -1, and JS slices from 0 up, reporting every existing location after project as shadows. Also, the fallback drops requestedRef, resolvedCommit, bundlePath, and integrity, which the remote schema JSON spec requires. Use precedence-rank filtering for shadows and carry remote lock metadata through, or merge shadow computation with getSchemaDir so the active path cannot diverge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/schema.ts` around lines 82 - 153, Update getSchemaResolution to
avoid relying on activeIndex when the active path is absent from
checkAllLocations; compute shadows by filtering existing locations to only
lower-precedence entries than the resolved active source/path. Preserve the
remote lock metadata (requestedRef, resolvedCommit, bundlePath, and integrity)
in the fallback active result by carrying through the remote entry’s metadata,
while keeping getSchemaDir resolution behavior unchanged.
🧹 Nitpick comments (11)
src/core/remote-schema/lockfile.ts (1)

16-47: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

.strict() is deprecated in Zod 4.

Object schemas in Zod 4 still strip unknown keys by default, but .passthrough(), .strict(), and .strip() are all deprecated in favor of z.strictObject()/z.looseObject(). Functionally equivalent today, but worth migrating to avoid future removal.

♻️ Optional migration to `z.strictObject()`
-const LockEntrySchema = z
-  .object({
+const LockEntrySchema = z
+  .strictObject({
     git: z...
-  })
-  .strict();
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/lockfile.ts` around lines 16 - 47, Replace the
deprecated .strict() calls in LockEntrySchema and LockSchema with
z.strictObject() while preserving all existing fields, validators, and strict
unknown-key behavior.
src/core/remote-schema/git.ts (1)

179-205: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Use bulk blob extraction instead of one git cat-file blob per file.

The 1,000-file bundle limit makes this loop spawn up to 1,000 sequential Git subprocesses, each with its own process-spawn cost and timeout budget. Switch to git cat-file --batch/--batch-command to read all blobs through a single long-lived subprocess instead.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/git.ts` around lines 179 - 205, Replace the per-entry
runGit cat-file calls in the blob extraction loop with a single git cat-file
--batch or --batch-command subprocess, feeding all entry.objectId values and
parsing each returned header/content record. Preserve totalBytes enforcement,
blob-to-entry association, timeout handling, and subsequent destination writes
while ensuring only one Git subprocess is spawned.
src/core/artifact-graph/schema-directory.ts (1)

59-64: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Duplicate candidate expression.

Both branches recompute path.join(templatesDir, ...normalizedTemplate.split('/')). Hoisting the split and the templates candidate reads better.

♻️ Proposed fix
-    const candidates = options.requireTemplatesDirectory
-      ? [path.join(templatesDir, ...normalizedTemplate.split('/'))]
-      : [
-          path.join(templatesDir, ...normalizedTemplate.split('/')),
-          path.join(schemaDir, ...normalizedTemplate.split('/')),
-        ];
+    const segments = normalizedTemplate.split('/');
+    const candidates = [path.join(templatesDir, ...segments)];
+    if (!options.requireTemplatesDirectory) {
+      candidates.push(path.join(schemaDir, ...segments));
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/artifact-graph/schema-directory.ts` around lines 59 - 64, Refactor
the candidate construction to compute the normalized template path segments and
the templates-directory candidate once before the requireTemplatesDirectory
conditional. Reuse that candidate in both branches, while retaining the
schema-directory candidate only when templates are not required.
test/core/remote-schema/cache.test.ts (1)

51-61: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

ino is not a reliable identity signal on Windows.

fs.Stats.ino is 0/unstable for directories on Windows, so this assertion degrades to 0 === 0 there and stops detecting a replaced cache dir. Writing a marker file into installed before the second install and asserting it survives is portable.

💚 Proposed fix
     const installed = installRemoteSchemaCache(source, integrity, dataDir);
-    const before = fs.statSync(installed).ino;
+    const marker = path.join(installed, '.marker');
+    fs.writeFileSync(marker, 'kept');

     expect(installRemoteSchemaCache(source, integrity, dataDir)).toBe(installed);
-    expect(fs.statSync(installed).ino).toBe(before);
+    expect(fs.existsSync(marker)).toBe(true);

Note this also changes the directory contents, so it must be written after the integrity check — which is the case here.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/cache.test.ts` around lines 51 - 61, Replace the
platform-dependent fs.statSync(installed).ino identity check in the “does not
replace an existing valid cache entry” test with a portable persistence check:
write a marker file inside installed after the initial install and assert it
still exists after the second install. Keep the existing integrity computation
and install calls unchanged.
src/core/remote-schema/bundle.ts (2)

128-150: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Length prefix can desynchronize from hashed content.

contentLength is written from the statSync size captured during traversal, while the hashed bytes come from a later readFileSync. If the file changes in between, the digest embeds a length that doesn't match the content it prefixes. Hashing content.length from the buffer you actually read keeps the framing self-consistent.

🐛 Proposed fix
-    const contentLength = Buffer.allocUnsafe(8);
-    contentLength.writeBigUInt64BE(BigInt(file.size));
     hash.update(pathLength);
     hash.update(pathBytes);
+    const content = fs.readFileSync(file.absolutePath);
+    const contentLength = Buffer.allocUnsafe(8);
+    contentLength.writeBigUInt64BE(BigInt(content.length));
     hash.update(contentLength);
-    hash.update(fs.readFileSync(file.absolutePath));
+    hash.update(content);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/bundle.ts` around lines 128 - 150, Update the hashing
loop in the bundle integrity calculation to read each file into a buffer before
constructing its length prefix, and write contentLength from that buffer’s
actual byte length instead of file.size. Continue hashing the path, length
prefix, and same content buffer in the existing order.

116-126: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Enforce file-count/byte limits during traversal, not after.

visit() fully enumerates the tree before the limits are checked, so a hostile or accidentally huge bundle is walked (and its statSync calls performed) in its entirety before rejection. Contents are still read only after the check, so this is bounded-memory, but the walk itself is unbounded. Checking incrementally inside visit() fails fast.

♻️ Sketch
       const size = fs.statSync(absolutePath).size;
       files.push({ relativePath, absolutePath, size });
+      if (files.length > limits.maxFiles) {
+        throw new Error(`Remote schema bundle contains more than ${limits.maxFiles} files`);
+      }
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/bundle.ts` around lines 116 - 126, Move the maxFiles
and maxBytes enforcement into the recursive visit() traversal so each discovered
entry is counted and its size accumulated immediately, throwing as soon as
either limit is exceeded. Preserve assertPortableBundleEntries and the existing
post-traversal flow, but remove reliance on checking limits only after files has
been fully enumerated.
src/core/remote-schema/sync.ts (1)

33-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

status: [] is a dead field typed as an empty tuple.

The type literal [] permits only the empty array, and the implementation always returns [], so consumers (including the JSON sync output) can never receive anything. Either drop it or give it a real element type now — widening a published field later is a breaking change for the JSON contract.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/sync.ts` around lines 33 - 39, Update the
SyncRemoteSchemasResult interface by removing the dead status field or replacing
its empty-tuple type with the intended concrete status element type, and align
all returned values and JSON sync output with that decision. Preserve the
existing result fields and ensure the published contract can represent actual
status values if the field is retained.
test/core/remote-schema/bundle.test.ts (1)

100-111: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Test leaks a file outside the temp bundle dir and passes silently when symlinks are unavailable.

outside is created in os.tmpdir() (the parent of bundleDir) and never cleaned up, since afterEach only removes bundleDir. Also, the bare return in the catch makes the test green on platforms where symlinkSync fails; ctx.skip() makes that visible.

♻️ Proposed fix
-  it('rejects symlinks without reading their targets', () => {
-    const outside = path.join(path.dirname(bundleDir), 'outside-schema-secret.txt');
-    fs.writeFileSync(outside, 'do-not-read');
-    const link = path.join(bundleDir, 'templates', 'linked.md');
-    try {
-      fs.symlinkSync(outside, link, 'file');
-    } catch {
-      return;
-    }
-
-    expect(() => computeBundleIntegrity(bundleDir)).toThrow(/symbolic link/);
-  });
+  it('rejects symlinks without reading their targets', (ctx) => {
+    const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openspec-schema-outside-'));
+    const outside = path.join(outsideDir, 'outside-schema-secret.txt');
+    try {
+      fs.writeFileSync(outside, 'do-not-read');
+      const link = path.join(bundleDir, 'templates', 'linked.md');
+      try {
+        fs.symlinkSync(outside, link, 'file');
+      } catch {
+        ctx.skip();
+        return;
+      }
+
+      expect(() => computeBundleIntegrity(bundleDir)).toThrow(/symbolic link/);
+    } finally {
+      fs.rmSync(outsideDir, { recursive: true, force: true });
+    }
+  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/bundle.test.ts` around lines 100 - 111, Update the
symlink test around computeBundleIntegrity to create the outside target within
the test’s managed cleanup scope, or explicitly remove it during cleanup, so no
file remains in the temporary parent directory. Replace the bare return in the
symlinkSync catch with the test framework’s visible skip mechanism, such as
ctx.skip(), so unsupported platforms report the test as skipped rather than
passing silently.
test/core/artifact-graph/schema-directory.test.ts (1)

43-62: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add nested/backslash template coverage and symlink rejection cases.

Two gaps:

  1. Every case uses a flat proposal.md, so the normalizedTemplate.split('/')path.join(...) conversion in schema-directory.ts Lines 59-64 — the part that is actually separator-sensitive — is never exercised. A nested/proposal.md positive case (and a templates\proposal.md rejection case) would cover it.
  2. The symlink guards at schema-directory.ts Lines 26 and 68 have no coverage.
💚 Suggested additions
+  it('resolves a nested template path across platform separators', () => {
+    fs.mkdirSync(path.join(schemaDir, 'templates', 'nested'), { recursive: true });
+    fs.writeFileSync(
+      path.join(schemaDir, 'schema.yaml'),
+      schemaYaml('qeda-sdd', 'nested/proposal.md')
+    );
+    fs.writeFileSync(
+      path.join(schemaDir, 'templates', 'nested', 'proposal.md'),
+      '# Proposal\n'
+    );
+
+    const result = validateSchemaDirectory(schemaDir, {
+      expectedName: 'qeda-sdd',
+      requireTemplatesDirectory: true,
+    });
+    expect(result.templatePaths.proposal).toBe(
+      path.join(schemaDir, 'templates', 'nested', 'proposal.md')
+    );
+  });

And add to the it.each table:

+    ['backslash template separator', true, true, String.raw`nested\proposal.md`, /unsafe template path/],

As per path instructions: "When touching path behavior, add coverage that would fail on Windows path separators".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/artifact-graph/schema-directory.test.ts` around lines 43 - 62, Add
nested template coverage and symlink rejection cases to the schema-directory
tests. Extend the existing valid-case coverage with a nested template such as
nested/proposal.md, add a rejection case using a backslash-separated templates
path such as templates\proposal.md, and create symlink cases targeting both the
schema file and template file so validateSchemaDirectory rejects each through
its symlink guards.

Source: Path instructions

test/core/project-config.test.ts (1)

486-519: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Pollution assertion is vacuous.

polluted is never a key in the fixture, so line 509 passes regardless. Assert against a property the fixture could actually have injected via __proto__.

💚 Proposed fix
-        expect(({} as Record<string, unknown>).polluted).toBeUndefined();
+        const probe = {} as Record<string, unknown>;
+        expect(probe.git).toBeUndefined();
+        expect(probe.ref).toBeUndefined();
+        expect(probe.path).toBeUndefined();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/project-config.test.ts` around lines 486 - 519, Update the
prototype-pollution assertion in the test “rejects prototype keys explicitly
without mutating object prototypes” to check a property that the __proto__
fixture could actually inject, such as the configured git/ref/path data, rather
than the absent polluted key. Keep the existing config and warning assertions
unchanged.
test/commands/schema-sync.test.ts (1)

38-46: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Verify XDG_DATA_HOME reliably isolates the global schema cache across platforms.

Based on learnings, this repo has an established, adapter-agnostic pattern for redirecting global writes during tests: "OPENSPEC_GLOBAL_ROOT should unconditionally redirect global writes to the specified directory for testing/CI, regardless of adapter support." This test instead sets XDG_DATA_HOME. If getUserSchemasDir()/the global schema-cache directory doesn't honor XDG_DATA_HOME consistently on every CI platform (Windows/macOS), these tests could write into the real global cache instead of tempDir, risking flaky or polluted CI runs — especially relevant given tasks.md calls for Windows CI coverage of this feature.

Can you confirm the schema-cache/global-data-dir resolution used here honors XDG_DATA_HOME on all supported platforms, or should this test also set OPENSPEC_GLOBAL_ROOT for reliable isolation?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/commands/schema-sync.test.ts` around lines 38 - 46, Update the test
setup in beforeEach to set OPENSPEC_GLOBAL_ROOT to the temporary directory using
the repository’s established global-write isolation mechanism. Ensure
schema-cache/global-data-dir resolution uses this override across platforms,
while preserving the existing temporary working directory and environment setup.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/cli.md`:
- Around line 903-919: Update the “Example JSON success” output in docs/cli.md
to include the fields emitted by the schema sync action: add git and
requestedRef to each schemas entry and add the top-level lockfile field,
matching the structure shown in design.md Decision `#8`.

In `@src/commands/schema.ts`:
- Around line 181-204: Update validateSchema and the validateSchemaDirectory
flow to preserve and return every schema validation failure as a separate
ValidationIssue instead of converting only the first thrown error into a
schema.yaml issue. Ensure failures from duplicate artifact IDs, invalid
dependencies, cycles, and broken templates are all surfaced, retaining accurate
paths or context when available.

In `@src/core/remote-schema/cache.ts`:
- Around line 49-114: Add a Windows-compatible test covering
installRemoteSchemaCache replacing an existing stale cache directory: create a
cache entry for a different integrity, invoke installRemoteSchemaCache with the
intended source and integrity, then verify the new cache is valid and no
displaced directory remains. Place the test alongside the existing remote-schema
cache tests and ensure resources or file handles are closed before asserting
replacement behavior.

In `@src/core/remote-schema/git.ts`:
- Around line 38-72: Update the execFile invocation in runGit to force
non-interactive Git authentication by providing an environment that includes
GIT_TERMINAL_PROMPT=0, while preserving the existing process environment and
other execution options.
- Around line 147-166: Update the fetch flow around fetchTarget and
resolvedCommit so options.lockedCommit is not passed directly to git fetch as a
raw SHA. Resolve the locked commit through an advertised ref or another
reachable-history/clone path, then verify the resulting commit matches the
requested lockedCommit; preserve the existing requestedRef fetch behavior and
commit-SHA validation for normal refs.

---

Outside diff comments:
In `@src/commands/schema.ts`:
- Around line 82-153: Update getSchemaResolution to avoid relying on activeIndex
when the active path is absent from checkAllLocations; compute shadows by
filtering existing locations to only lower-precedence entries than the resolved
active source/path. Preserve the remote lock metadata (requestedRef,
resolvedCommit, bundlePath, and integrity) in the fallback active result by
carrying through the remote entry’s metadata, while keeping getSchemaDir
resolution behavior unchanged.

---

Nitpick comments:
In `@src/core/artifact-graph/schema-directory.ts`:
- Around line 59-64: Refactor the candidate construction to compute the
normalized template path segments and the templates-directory candidate once
before the requireTemplatesDirectory conditional. Reuse that candidate in both
branches, while retaining the schema-directory candidate only when templates are
not required.

In `@src/core/remote-schema/bundle.ts`:
- Around line 128-150: Update the hashing loop in the bundle integrity
calculation to read each file into a buffer before constructing its length
prefix, and write contentLength from that buffer’s actual byte length instead of
file.size. Continue hashing the path, length prefix, and same content buffer in
the existing order.
- Around line 116-126: Move the maxFiles and maxBytes enforcement into the
recursive visit() traversal so each discovered entry is counted and its size
accumulated immediately, throwing as soon as either limit is exceeded. Preserve
assertPortableBundleEntries and the existing post-traversal flow, but remove
reliance on checking limits only after files has been fully enumerated.

In `@src/core/remote-schema/git.ts`:
- Around line 179-205: Replace the per-entry runGit cat-file calls in the blob
extraction loop with a single git cat-file --batch or --batch-command
subprocess, feeding all entry.objectId values and parsing each returned
header/content record. Preserve totalBytes enforcement, blob-to-entry
association, timeout handling, and subsequent destination writes while ensuring
only one Git subprocess is spawned.

In `@src/core/remote-schema/lockfile.ts`:
- Around line 16-47: Replace the deprecated .strict() calls in LockEntrySchema
and LockSchema with z.strictObject() while preserving all existing fields,
validators, and strict unknown-key behavior.

In `@src/core/remote-schema/sync.ts`:
- Around line 33-39: Update the SyncRemoteSchemasResult interface by removing
the dead status field or replacing its empty-tuple type with the intended
concrete status element type, and align all returned values and JSON sync output
with that decision. Preserve the existing result fields and ensure the published
contract can represent actual status values if the field is retained.

In `@test/commands/schema-sync.test.ts`:
- Around line 38-46: Update the test setup in beforeEach to set
OPENSPEC_GLOBAL_ROOT to the temporary directory using the repository’s
established global-write isolation mechanism. Ensure
schema-cache/global-data-dir resolution uses this override across platforms,
while preserving the existing temporary working directory and environment setup.

In `@test/core/artifact-graph/schema-directory.test.ts`:
- Around line 43-62: Add nested template coverage and symlink rejection cases to
the schema-directory tests. Extend the existing valid-case coverage with a
nested template such as nested/proposal.md, add a rejection case using a
backslash-separated templates path such as templates\proposal.md, and create
symlink cases targeting both the schema file and template file so
validateSchemaDirectory rejects each through its symlink guards.

In `@test/core/project-config.test.ts`:
- Around line 486-519: Update the prototype-pollution assertion in the test
“rejects prototype keys explicitly without mutating object prototypes” to check
a property that the __proto__ fixture could actually inject, such as the
configured git/ref/path data, rather than the absent polluted key. Keep the
existing config and warning assertions unchanged.

In `@test/core/remote-schema/bundle.test.ts`:
- Around line 100-111: Update the symlink test around computeBundleIntegrity to
create the outside target within the test’s managed cleanup scope, or explicitly
remove it during cleanup, so no file remains in the temporary parent directory.
Replace the bare return in the symlinkSync catch with the test framework’s
visible skip mechanism, such as ctx.skip(), so unsupported platforms report the
test as skipped rather than passing silently.

In `@test/core/remote-schema/cache.test.ts`:
- Around line 51-61: Replace the platform-dependent fs.statSync(installed).ino
identity check in the “does not replace an existing valid cache entry” test with
a portable persistence check: write a marker file inside installed after the
initial install and assert it still exists after the second install. Keep the
existing integrity computation and install calls unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 3239137f-b2dd-47e2-b797-300e37ab65f4

📥 Commits

Reviewing files that changed from the base of the PR and between 19d4171 and c73df61.

📒 Files selected for processing (34)
  • .changeset/remote-schema-sources.md
  • docs/cli.md
  • docs/customization.md
  • openspec/changes/add-remote-schema-sources/.openspec.yaml
  • openspec/changes/add-remote-schema-sources/design.md
  • openspec/changes/add-remote-schema-sources/proposal.md
  • openspec/changes/add-remote-schema-sources/specs/config-loading/spec.md
  • openspec/changes/add-remote-schema-sources/specs/remote-schema-sources/spec.md
  • openspec/changes/add-remote-schema-sources/specs/schema-resolution/spec.md
  • openspec/changes/add-remote-schema-sources/specs/schema-which-command/spec.md
  • openspec/changes/add-remote-schema-sources/tasks.md
  • src/commands/schema.ts
  • src/commands/workflow/schemas.ts
  • src/core/artifact-graph/index.ts
  • src/core/artifact-graph/resolver.ts
  • src/core/artifact-graph/schema-directory.ts
  • src/core/completions/command-registry.ts
  • src/core/project-config.ts
  • src/core/remote-schema/bundle.ts
  • src/core/remote-schema/cache.ts
  • src/core/remote-schema/config.ts
  • src/core/remote-schema/git.ts
  • src/core/remote-schema/lockfile.ts
  • src/core/remote-schema/sync.ts
  • src/core/remote-schema/types.ts
  • test/commands/schema-sync.test.ts
  • test/core/artifact-graph/remote-resolver.test.ts
  • test/core/artifact-graph/schema-directory.test.ts
  • test/core/project-config.test.ts
  • test/core/remote-schema/bundle.test.ts
  • test/core/remote-schema/cache.test.ts
  • test/core/remote-schema/git.test.ts
  • test/core/remote-schema/lockfile.test.ts
  • test/core/remote-schema/sync.test.ts

Comment thread docs/cli.md
Comment thread src/commands/schema.ts
Comment thread src/core/remote-schema/cache.ts
Comment thread src/core/remote-schema/git.ts
Comment thread src/core/remote-schema/git.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/core/remote-schema/git.ts (1)

46-59: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Make SSH remote-schema fetches fail non-interactively

GIT_TERMINAL_PROMPT: '0' only affects Git’s own prompts. SSH fetches can still require host-key verification or a key passphrase, which will hang until timeoutMs and report as a generic Git fetch failure. Preserve any existing Git SSH command but add BatchMode=yes, then add the host-key policy you want for automation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/git.ts` around lines 46 - 59, Update the git execFile
invocation to preserve any existing GIT_SSH_COMMAND while adding SSH
BatchMode=yes and an explicit non-interactive host-key policy suitable for
automation. Ensure remote-schema fetches cannot prompt for host-key verification
or key passphrases, while keeping the existing timeout and Git environment
behavior unchanged.
🧹 Nitpick comments (5)
src/core/artifact-graph/schema-directory.ts (1)

33-35: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Surface all issues, not just the first.

inspectSchemaDirectory now collects multiple diagnostics, but the throwing wrapper discards all but issues[0]. Sync callers (src/core/remote-schema/sync.ts) would benefit from the full list in the error message.

♻️ Proposed change
   if (!inspection.schema || inspection.issues.length > 0) {
-    throw new Error(inspection.issues[0]?.message ?? 'Schema validation failed');
+    throw new Error(
+      inspection.issues.map((issue) => issue.message).join('; ') || 'Schema validation failed'
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/artifact-graph/schema-directory.ts` around lines 33 - 35, Update the
throwing validation wrapper in inspectSchemaDirectory to include every
diagnostic from inspection.issues in the thrown error message, while preserving
the existing fallback message when no issues are available and the current
invalid-schema condition.
src/core/remote-schema/bundle.ts (1)

113-121: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Check size before reading the whole file into memory.

fs.readFileSync completes before the maxBytes check, so a single oversized file in a locally-supplied bundle dir is fully buffered before rejection. The Git path pre-checks sizes, but direct callers (e.g. cache verification) don't. A statSync pre-check keeps the guard cheap.

♻️ Proposed guard
-      const content = fs.readFileSync(absolutePath);
-      files.push({ relativePath, content });
-      if (files.length > limits.maxFiles) {
+      if (files.length + 1 > limits.maxFiles) {
         throw new Error(`Remote schema bundle contains more than ${limits.maxFiles} files`);
       }
-      totalBytes += content.length;
-      if (totalBytes > limits.maxBytes) {
+      const size = fs.statSync(absolutePath).size;
+      if (totalBytes + size > limits.maxBytes) {
         throw new Error(`Remote schema bundle contains more than ${limits.maxBytes} bytes`);
       }
+      const content = fs.readFileSync(absolutePath);
+      files.push({ relativePath, content });
+      totalBytes += content.length;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/bundle.ts` around lines 113 - 121, Update the bundle
file-processing flow around fs.readFileSync to stat each file and validate its
size against limits.maxBytes before reading it into memory. Account for the
file’s size in totalBytes during this pre-check, while preserving the existing
maxFiles validation, file collection, and oversized-bundle error behavior.
test/core/remote-schema/git.test.ts (1)

122-142: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Test may not actually exercise the merge-base --is-ancestor rejection path.

git fetch origin main only transfers objects reachable from main; otherCommit (created on the unfetched other branch) likely never lands in the temp repo. That means the earlier rev-parse ${lockedCommit}^{commit} call probably fails with "unknown revision" before merge-base --is-ancestor ever runs — the assertion on /locked commit verification/i passes either way, so this test doesn't clearly prove the ancestor check itself rejects a fetched-but-non-ancestor commit.

Consider also covering the case where the commit is present locally (e.g., fetch other too, or merge other into main then reset main back) but is not an ancestor of the resolved ref, to directly exercise merge-base --is-ancestor.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/git.test.ts` around lines 122 - 142, Update the test
case around fetchSchemaBundleFromGit so otherCommit is present in the temporary
repository while remaining unreachable from requestedRef 'main'; fetch the other
branch or otherwise retain its commit object without advancing main. Keep the
lockedCommit value set to otherCommit and assert the rejection from the locked
commit verification path, ensuring rev-parse succeeds and merge-base
--is-ancestor performs the rejection.
test/core/artifact-graph/schema-directory.test.ts (1)

84-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Symlink-skip test setup duplicates a pattern also used in bundle.test.ts.

The try/create-symlink → catch/cleanup+ctx.skip() scaffolding here is functionally identical to the one in test/core/remote-schema/bundle.test.ts (lines 114-125). Worth extracting into a shared test helper (e.g. trySymlinkOrSkip(ctx, target, link)) to avoid drift between the two copies. See consolidated comment.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/artifact-graph/schema-directory.test.ts` around lines 84 - 120,
Extract the duplicated symlink setup and skip handling from this test and the
corresponding bundle test into a shared helper such as trySymlinkOrSkip(ctx,
target, link). Update both tests to use the helper, preserving cleanup and
ctx.skip() behavior when symlink creation is unavailable.
test/core/remote-schema/bundle.test.ts (1)

100-132: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Solid coverage of traversal byte-limit and symlink rejection. Same symlink-skip scaffolding duplication noted in schema-directory.test.ts; see consolidated comment for the DRY suggestion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/bundle.test.ts` around lines 100 - 132, Deduplicate
the repeated symlink setup and cleanup used by “enforces byte limits while
traversing the bundle” and “rejects symlinks without reading their targets.”
Extract the shared scaffolding into an existing or local test helper, then reuse
it here and in schema-directory.test.ts while preserving each test’s distinct
assertions and skip behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/core/remote-schema/git.ts`:
- Around line 46-59: Update the git execFile invocation to preserve any existing
GIT_SSH_COMMAND while adding SSH BatchMode=yes and an explicit non-interactive
host-key policy suitable for automation. Ensure remote-schema fetches cannot
prompt for host-key verification or key passphrases, while keeping the existing
timeout and Git environment behavior unchanged.

---

Nitpick comments:
In `@src/core/artifact-graph/schema-directory.ts`:
- Around line 33-35: Update the throwing validation wrapper in
inspectSchemaDirectory to include every diagnostic from inspection.issues in the
thrown error message, while preserving the existing fallback message when no
issues are available and the current invalid-schema condition.

In `@src/core/remote-schema/bundle.ts`:
- Around line 113-121: Update the bundle file-processing flow around
fs.readFileSync to stat each file and validate its size against limits.maxBytes
before reading it into memory. Account for the file’s size in totalBytes during
this pre-check, while preserving the existing maxFiles validation, file
collection, and oversized-bundle error behavior.

In `@test/core/artifact-graph/schema-directory.test.ts`:
- Around line 84-120: Extract the duplicated symlink setup and skip handling
from this test and the corresponding bundle test into a shared helper such as
trySymlinkOrSkip(ctx, target, link). Update both tests to use the helper,
preserving cleanup and ctx.skip() behavior when symlink creation is unavailable.

In `@test/core/remote-schema/bundle.test.ts`:
- Around line 100-132: Deduplicate the repeated symlink setup and cleanup used
by “enforces byte limits while traversing the bundle” and “rejects symlinks
without reading their targets.” Extract the shared scaffolding into an existing
or local test helper, then reuse it here and in schema-directory.test.ts while
preserving each test’s distinct assertions and skip behavior.

In `@test/core/remote-schema/git.test.ts`:
- Around line 122-142: Update the test case around fetchSchemaBundleFromGit so
otherCommit is present in the temporary repository while remaining unreachable
from requestedRef 'main'; fetch the other branch or otherwise retain its commit
object without advancing main. Keep the lockedCommit value set to otherCommit
and assert the rejection from the locked commit verification path, ensuring
rev-parse succeeds and merge-base --is-ancestor performs the rejection.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9e060cc2-af5b-40a4-b0e6-14e01071a418

📥 Commits

Reviewing files that changed from the base of the PR and between c73df61 and 2cbbe92.

📒 Files selected for processing (14)
  • docs/cli.md
  • src/commands/schema.ts
  • src/core/artifact-graph/schema-directory.ts
  • src/core/artifact-graph/schema.ts
  • src/core/remote-schema/bundle.ts
  • src/core/remote-schema/git.ts
  • src/core/remote-schema/lockfile.ts
  • src/core/remote-schema/sync.ts
  • test/commands/schema-sync.test.ts
  • test/core/artifact-graph/schema-directory.test.ts
  • test/core/project-config.test.ts
  • test/core/remote-schema/bundle.test.ts
  • test/core/remote-schema/cache.test.ts
  • test/core/remote-schema/git.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • test/core/project-config.test.ts
  • src/core/remote-schema/sync.ts
  • docs/cli.md
  • src/core/remote-schema/lockfile.ts

@clay-good
clay-good requested a review from a team as a code owner July 27, 2026 17:31

@alfred-openspec alfred-openspec left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The current head still aborts schema which --all on one unsynchronized remote, concurrent named syncs can lose lockfile updates, remote-strict path validation still changes legacy local schemas, and schema sync still assumes the current directory rather than the selected OpenSpec root. Please resolve the authority/root contract and add per-entry diagnostics, project-scoped interprocess locking, and the compatibility/concurrency regressions before merge.

@clay-good

Copy link
Copy Markdown
Collaborator

Code review — verdict: do not merge as-is; needs a maintainer product decision first

Let me lead with the important part: this is good work. It's careful, well-tested, security-conscious, and the problem it solves is real. My objection is not to the quality — it's that the change is far larger in architectural scope than can be approved on engineering review alone, and the feature it introduces hasn't been agreed to yet.

I reviewed it against four criteria. It passes three and fails the fourth decisively.

1. Real need — yes, but nobody has agreed to solve it this way

#1131 is a legitimate report: teams copy a shared schema across repos and it drifts. You describe the same pain from your own multi-repo setup, and that's credible.

But #1131 is titled "Feature Request / Discussion: Best practices for sharing a centralized team-schema", it's open, unlabeled, and has no maintainer response at all. The only replies are from community members, one of whom suggests an existing external answer (skillshare's extras). And in your own comment on that issue you wrote:

The exact configuration and lockfile shape should remain provisional until maintainers agree on the public contract

I think that's exactly right, and it's the reason I can't sign this off. The public contract here — a new config key, a new committed lockfile, a new cache location, a new command — hasn't been agreed to. Merging it is the agreement.

2. Does it work — as far as I can tell, yes

  • Full suite on the branch: 120 files / 2335 tests, all passing (base main is 112 / 2253, so ~82 new tests). That's real coverage, not decoration.
  • Integration tests use temporary local Git repos, so they don't depend on network access. Good call.
  • The getSchemaResolution shadow-detection issue CodeRabbit raised is already fixed in the current head — it now uses SCHEMA_SOURCE_PRECEDENCE[location.source] > activeRank rank filtering rather than index slicing, and carries remote metadata through the fallback. I checked specifically because an activeIndex === -1 would have mis-reported shadows for ordinary users; it doesn't.

One item CodeRabbit raised that is not yet addressed: src/core/remote-schema/git.ts:53 sets GIT_TERMINAL_PROMPT: '0' but never sets GIT_SSH_COMMAND with BatchMode=yes. GIT_TERMINAL_PROMPT only suppresses Git's own prompts — an SSH fetch that needs host-key confirmation or a key passphrase will still block until timeoutMs and then surface as a generic fetch failure. Since the PR explicitly advertises "SSH agents/configuration continue to work" for private repos, this is worth fixing whatever happens to the rest of the PR.

3. Breaking changes — none that I could find, and I did look

This was my biggest worry given the change to schema resolution order, so I verified it directly rather than reasoning about it:

  • schema which --all --json on an ordinary project produces a byte-identical shape on this branch vs. main (name/source/path/shadows, resolving to package).
  • The new remote tier sits between project and user, but a remote location only exists when openspec/schemas.lock.yaml is present. No existing user has one, so the tier is inert for every current project.
  • schemaSources is additive, and parseSchemaSources warns rather than throws on malformed input — correct fail-soft behaviour for config.
  • One genuine behaviour change: schema which --json with no name now emits a structured JSON error object instead of the plain-text Error: Schema name is required. That's an improvement and schema is flagged experimental, so I'd allow it — but it should be called out rather than left implicit.

So on the "must not break a user" bar specifically: it passes.

4. Scope — this is where it fails

The criterion is that a PR should be surgical and fix or enhance a small aspect, not expand a core design. This PR introduces:

New surface What it is
src/core/remote-schema/ A new 7-module subsystem (~970 LOC): git, bundle, cache, config, lockfile, sync, types
openspec schema sync A new CLI command, plus completion-registry entries
openspec/schemas.lock.yaml A new committed artifact format users are expected to check in
schemaSources: A new project-config key
Git subprocess + network A new trust boundary — credential helpers, SSH, remote fetch, URL/transport validation
Content-addressed cache A new on-disk cache with SHA-256 integrity verification

That's not an enhancement to schemas; it's OpenSpec taking ownership of schema distribution as a product capability. Lockfiles in particular are a long-term commitment — once users commit schemas.lock.yaml, its format is effectively frozen, and "experimental" in the PR description won't protect you from that in practice.

There's also a prior question the issue raises and this PR answers by fiat: should OpenSpec own this at all? #1131 floats git submodules and CI-injection as workarounds, and a commenter points at skillshare's extras. Those may well be the right answer, and they cost zero maintenance. Deciding otherwise is a roadmap call.

Recommendation

Park this PR; don't close it. Concretely:

  1. Get a maintainer decision on Feature Request / Discussion: Best practices for sharing a centralized team-schema/template across multiple repositories #1131 first — should OpenSpec natively distribute schemas, or point users at submodules / external tooling? That's the blocking question, and this PR shouldn't be the forum for it.
  2. If the answer is yes, the config key, lockfile schema, and cache location should be reviewed as a design proposal on its own before implementation review. You've already written one (openspec/changes/add-remote-schema-sources/design.md) — splitting that out as a standalone discussion, without 970 lines of implementation attached, would make it far easier to say yes to.
  3. Fix the SSH BatchMode issue regardless.

I'd also note the PR commits openspec/changes/add-remote-schema-sources/ into the repo; if this ever does land, that change needs archiving as part of it rather than sitting active indefinitely.

To be explicit, since I know this represents a lot of effort: I am not saying this is bad code or a bad idea. I'm saying it's a roadmap decision wearing a pull request's clothes, and it needs the maintainers to make that call before an engineering review means anything.

@alfred-openspec

Copy link
Copy Markdown
Collaborator

Maintainer product call: please advance this as a native OpenSpec capability. External tooling is a workable stopgap, but OpenSpec owns schema semantics and resolution, and the repeated team use case needs deterministic, reviewable cross-repo distribution. Keep V1 narrow: complete Git-backed schema bundles, explicit sync, a committed immutable lock, and an integrity-checked offline cache; no runtime fetching, inheritance, registry, or general plugin packaging.

For the public contract, the consumer repository owns openspec/config.yaml and openspec/schemas.lock.yaml; selected planning stores do not own schema sources in V1. A declared remote owns its schema name, so a same-name local bundle should produce a clear conflict rather than silently shadowing it, and schema sync must resolve the project OpenSpec root rather than use raw cwd.

The PR should remain blocked until the current changes-requested items are covered by regressions: per-entry unavailable diagnostics, project-scoped interprocess locking, isolation of remote-strict validation from legacy local schemas, and correct project-root resolution. Also preserve any existing GIT_SSH_COMMAND while enforcing non-interactive SSH with BatchMode=yes and an explicit host-key policy, with a focused test, before approval.

@Patodo

Patodo commented Jul 28, 2026

Copy link
Copy Markdown
Author

Addressed the remaining review blockers in commit 521dce0.

Area Resolution Verification coverage
Consumer schema ownership Separated the consumer-owned schemaRoot from the planning root and propagated it through new/status/instructions/apply/list/validate/archive/schema flows, including store-backed workflows. Store-root selection and nested command tests
Remote name authority A declared remote now owns its schema name. Project-local collisions fail consistently with schema_name_conflict across resolution, discovery, sync, fork, init, and schema which; no silent fallback remains. Resolver and CLI conflict tests
Per-schema inspection schema which --all keeps healthy entries and reports unavailable remote schemas as structured per-entry diagnostics instead of aborting the whole command. Mixed availability tests
Validation compatibility and diagnostics Retained legacy local-schema validation behavior, added strict remote validation, and preserved every validation issue and path in sync JSON/human output. Schema-directory and invalid remote sync tests
Sync concurrency Serialized the complete sync transaction with a project-scoped filesystem bakery lock, token-owned tickets, exact same-host stale cleanup, and bounded schema_sync_locked timeouts. Real two-process CLI concurrency test, including an abandoned ticket and preservation of both lock entries
SSH hardening Preserved existing GIT_SSH_COMMAND, normalized conflicting quoted/unquoted policies, and enforced BatchMode=yes plus StrictHostKeyChecking=accept-new. Git environment policy tests
Bundle size bounds Added stat preflight before reading and a post-read byte-count recheck to close the stat/read race. Oversized unreadable bundle and size-race tests
Locked commit ancestry Added direct locked-commit presence and merge-base ancestry verification. Ancestor, non-ancestor, and missing-commit integration tests
Config snapshot consistency Reused the command-boundary ProjectConfig snapshot throughout metadata and schema resolution, avoiding repeated reads/warnings after the upstream merge. Malformed config emits exactly one warning per command
OpenSpec artifacts and docs Updated proposal, design, delta specs, tasks 9.1–9.12, and CLI/customization docs through the repository OpenSpec flow. openspec validate add-remote-schema-sources --strict

Final verification after rebasing onto the updated PR branch:

  • openspec validate add-remote-schema-sources --strict
  • pnpm run build
  • pnpm exec tsc --noEmit
  • pnpm lint
  • pnpm test — 124 files, 3,363 tests passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/commands/workflow/templates.ts (1)

41-45: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Report templates from remote schemas as remote.

A declared remote resolves to its cache directory, which is outside both project and user schema directories; the fallback therefore reports it as package in human and JSON output. Add a remote classification branch and extend the source type accordingly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/commands/workflow/templates.ts` around lines 41 - 45, Update the schema
source classification used by the report templates around schemaDir and the
resolved schema path so declared remote schemas are identified as remote when
their cache directory falls outside project and user schema directories. Extend
the source type and both human-readable and JSON output handling to support the
new remote classification, while preserving existing package and local
classifications.
src/utils/change-utils.ts (1)

136-145: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep generated configuration at the consumer schema root.

When schemaRoot !== projectRoot, this resolves the schema from the consumer but still scaffolds projectRoot/openspec/config.yaml. Thus openspec new change --store ... can create a store-owned config, violating consumer-owned schema configuration. Keep planning directories under projectRoot, but initialize config under schemaRoot (or skip initialization when they differ), and assert the store config is not created.

Suggested direction
- const openspecDir = path.join(projectRoot, 'openspec');
+ const planningOpenSpecDir = path.join(projectRoot, 'openspec');
+ const configOpenSpecDir = path.join(schemaRoot, 'openspec');

- const configPath = path.join(openspecDir, 'config.yaml');
+ await FileSystemUtils.createDirectory(configOpenSpecDir);
+ const configPath = path.join(configOpenSpecDir, 'config.yaml');

As per PR objectives, V1 requires consumer-owned configuration and lockfiles.

Also applies to: 190-190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/change-utils.ts` around lines 136 - 145, Update configuration
initialization in the change-generation flow around schemaRoot, projectRoot, and
readProjectConfig so generated config.yaml is created under schemaRoot rather
than projectRoot when they differ, or initialization is skipped in that case.
Keep planning directories rooted at projectRoot, preserve schema resolution
behavior, and ensure the store-owned projectRoot/openspec/config.yaml is never
created.
🧹 Nitpick comments (7)
test/cli-e2e/remote-schema-concurrency.test.ts (2)

100-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Surface CLI stderr on failure and give this e2e case an explicit timeout.

Two CLI processes plus two Git fetches under a contended lock can exceed the default Vitest timeout on slow CI, and a bare expect(alpha.exitCode).toBe(0) gives no clue why.

♻️ Suggested change
-    expect(alpha.exitCode).toBe(0);
-    expect(beta.exitCode).toBe(0);
+    expect(alpha.exitCode, alpha.stderr).toBe(0);
+    expect(beta.exitCode, beta.stderr).toBe(0);

And widen the case timeout:

-  it('reclaims an abandoned ticket while concurrent named sync processes preserve both entries', async () => {
+  it('reclaims an abandoned ticket while concurrent named sync processes preserve both entries', async () => {
+    // ...
+  }, 60_000);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli-e2e/remote-schema-concurrency.test.ts` around lines 100 - 123,
Update the concurrent CLI test around the alpha and beta runCLI results to
include each process’s stderr in failure diagnostics when asserting exitCode 0.
Give this e2e test an explicit, sufficiently generous timeout to accommodate
concurrent Git fetches and lock contention.

10-24: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Isolate the test repos from the developer's global Git config.

gitEnv inherits HOME, so a contributor with commit.gpgsign=true, a core.hooksPath, or templates configured globally will see git commit fail here for reasons unrelated to the change. Pinning the config sources makes the fixture hermetic.

💚 Suggested change
 const gitEnv = {
   ...process.env,
+  GIT_CONFIG_GLOBAL: '/dev/null',
+  GIT_CONFIG_SYSTEM: '/dev/null',
   GIT_AUTHOR_NAME: 'OpenSpec Test',
   GIT_AUTHOR_EMAIL: 'openspec@example.test',
   GIT_COMMITTER_NAME: 'OpenSpec Test',
   GIT_COMMITTER_EMAIL: 'openspec@example.test',
 };
 
 function git(cwd: string, ...args: string[]): void {
-  execFileSync('git', args, {
+  execFileSync('git', ['-c', 'commit.gpgsign=false', ...args], {
     cwd,
     env: gitEnv,
     stdio: ['ignore', 'pipe', 'pipe'],
   });
 }

On Windows /dev/null isn't valid; use path.join(tempDir, 'gitconfig') (a nonexistent file) if this suite runs cross-platform.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli-e2e/remote-schema-concurrency.test.ts` around lines 10 - 24, Update
the gitEnv and git helper setup to isolate fixture repositories from global Git
configuration by disabling the global/system config sources and pointing Git to
a temporary, nonexistent config file using a cross-platform path such as
path.join(tempDir, 'gitconfig'). Ensure the temporary directory is available to
this setup and preserve the existing author, committer, and stdio settings.
src/core/artifact-graph/schema-directory.ts (1)

49-59: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against an empty issues list when schema is missing.

If an inspector ever returns schema === undefined with no issues, the thrown error carries an empty issues array. schemaSyncFailureStatus in src/commands/schema.ts maps error.issues one-to-one, so the CLI would fail with zero reported error entries. Synthesizing a fallback issue keeps the failure diagnosable.

♻️ Suggested hardening
 function validatedResult(
   inspection: InspectedSchemaDirectory
 ): ValidatedSchemaDirectory {
   if (!inspection.schema || inspection.issues.length > 0) {
-    throw new SchemaDirectoryValidationError(inspection.issues);
+    throw new SchemaDirectoryValidationError(
+      inspection.issues.length > 0
+        ? inspection.issues
+        : [{ path: 'schema.yaml', message: 'Schema could not be parsed' }]
+    );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/artifact-graph/schema-directory.ts` around lines 49 - 59, Update
validatedResult so that when inspection.schema is missing and inspection.issues
is empty, it throws SchemaDirectoryValidationError with a synthesized issue;
preserve the existing issues when present and keep the successful return path
unchanged.
test/core/remote-schema/sync-lock.test.ts (1)

134-144: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add coverage for an unparseable ticket file.

The reclaim test only exercises a well-formed ticket with a dead pid. A truncated/corrupt *.ticket.json (crash mid-write) takes a different branch — readParticipant returns null, so it is neither reclaimed nor skipped — and currently wedges acquisition permanently. A test asserting the intended behavior here would lock in the fix suggested on src/core/remote-schema/sync-lock.ts.

💚 Suggested test
it('recovers from a corrupt ticket file', async () => {
  const lockPath = getSchemaSyncLockPath(projectRoot);
  fs.mkdirSync(lockPath, { recursive: true });
  fs.writeFileSync(path.join(lockPath, 'claim-corrupt.ticket.json'), '{"token"');

  await expect(
    withSchemaSyncLock(projectRoot, async () => 'acquired', {
      timeoutMs: 200,
      retryDelayMs: 5,
    })
  ).resolves.toBe('acquired');
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/sync-lock.test.ts` around lines 134 - 144, Extend the
sync-lock test coverage with a corrupt-ticket scenario near “reclaims an
abandoned same-host lock”: create the lock directory, write truncated JSON to a
*.ticket.json file, and assert withSchemaSyncLock resolves successfully. Update
the corresponding acquisition logic in readParticipant handling so an
unparseable ticket is recovered rather than skipped indefinitely.
test/core/artifact-graph/schema-directory.test.ts (1)

104-118: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assertion flow relies on the sentinel error falling into the same catch.

If validation unexpectedly succeeds, the sentinel Error is caught by this block and only fails on the toBeInstanceOf assertion — readable enough, but expect(...).toThrowError(SchemaDirectoryValidationError) plus a captured-error inspection (or expect.hasAssertions()) is clearer.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/artifact-graph/schema-directory.test.ts` around lines 104 - 118,
Update the validation test around validateRemoteSchemaDirectory to use an
assertion flow that explicitly expects SchemaDirectoryValidationError and then
inspects the captured error’s issues, rather than throwing a sentinel Error
inside the same catch block. Preserve the existing three issue-message and
length assertions, and ensure the test cannot pass without making the expected
validation-error assertions.
src/core/remote-schema/bundle.ts (1)

113-125: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Size race is correctly closed by the post-read re-check.

statSync pre-check avoids reading obviously oversized files, and the second check on content.length rejects files that grew between stat and read before they are accepted into files/totalBytes. Good.

One optional consideration: contents of the whole bundle are buffered in memory (bounded by maxBytes) before hashing. If you later raise MAX_SCHEMA_BUNDLE_BYTES, consider a two-pass approach (collect sorted relative paths first, then stream each file into the hash) so peak memory stays flat.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/bundle.ts` around lines 113 - 125, No code change is
required; the existing stat pre-check and post-read content-length validation
correctly close the file-size race. If increasing the bundle size limit later,
refactor the bundling flow to collect sorted paths first and stream file
contents into hashing rather than buffering the entire bundle in memory.
test/commands/schema-sync.test.ts (1)

390-459: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Path handling here is guideline-compliant (realpath canonicalization + path.join).

Consider also adding a symlink/alias-path regression case for this consumer-root resolution logic, since this PR's core surface is path identity for remote sync.

As per path instructions: "Add an alias-path regression test when touching path identity logic. If preserving user-typed path spelling is intentional, assert it separately from identity comparisons."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/commands/schema-sync.test.ts` around lines 390 - 459, Add a regression
test alongside the nested-directory test that runs schema synchronization from a
symlink or alias path to the consumer root. Assert that path identity uses the
canonical realpath and writes the lockfile under the resolved consumer root,
while separately preserving any intentionally user-typed path spelling in output
if applicable.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/remote-schema/git.ts`:
- Around line 51-62: Update buildNonInteractiveGitEnvironment to preserve any
user-provided StrictHostKeyChecking value, including stricter settings, instead
of removing and replacing it with accept-new; only append -o
StrictHostKeyChecking=accept-new when no such option is already present.
Continue removing and enforcing BatchMode through removeSshOption, and update
the affected tests to expect explicit host-key policies to remain unchanged.

In `@src/core/remote-schema/sync-lock.ts`:
- Around line 157-164: Update readParticipant validation so ticket files must
contain a defined number before they reach precedes; reject or ignore
participants with missing number, while preserving existing handling for valid
tickets and ensuring every locally written ticket remains valid.
- Around line 130-151: Update createParticipantFile to write participant data
through a uniquely named staging file that does not end in .ticket.json or
.choosing.json, then atomically rename it to filePath after the write completes
while preserving exclusive creation semantics. Update the lock
acquisition/reclamation logic, including readParticipant, isAbandonedSameHost,
and acquireSchemaSyncLock, to treat unparseable participant files as reclaimable
after the acquisition timeout so corrupt entries cannot permanently block
synchronization.
- Around line 33-35: Add the runtime lock directory pattern for
openspec/.schemas.lock to the repository’s ignore configuration, including all
nested .ticket.json files, so stale lock claims and tickets remain untracked.
Use getSchemaSyncLockPath as the reference for the directory and preserve
existing ignore rules.

In `@test/core/artifact-graph/schema-directory.test.ts`:
- Around line 67-82: Update the assertion in the `preserves legacy project-local
template lookup outside templates/` test to canonicalize the expected
`sharedTemplate` path with `fs.realpathSync.native()` before comparing it to
`validateLocalSchemaDirectory(schemaDir).templatePaths.proposal`; keep the
production path unchanged and preserve the existing cleanup.

---

Outside diff comments:
In `@src/commands/workflow/templates.ts`:
- Around line 41-45: Update the schema source classification used by the report
templates around schemaDir and the resolved schema path so declared remote
schemas are identified as remote when their cache directory falls outside
project and user schema directories. Extend the source type and both
human-readable and JSON output handling to support the new remote
classification, while preserving existing package and local classifications.

In `@src/utils/change-utils.ts`:
- Around line 136-145: Update configuration initialization in the
change-generation flow around schemaRoot, projectRoot, and readProjectConfig so
generated config.yaml is created under schemaRoot rather than projectRoot when
they differ, or initialization is skipped in that case. Keep planning
directories rooted at projectRoot, preserve schema resolution behavior, and
ensure the store-owned projectRoot/openspec/config.yaml is never created.

---

Nitpick comments:
In `@src/core/artifact-graph/schema-directory.ts`:
- Around line 49-59: Update validatedResult so that when inspection.schema is
missing and inspection.issues is empty, it throws SchemaDirectoryValidationError
with a synthesized issue; preserve the existing issues when present and keep the
successful return path unchanged.

In `@src/core/remote-schema/bundle.ts`:
- Around line 113-125: No code change is required; the existing stat pre-check
and post-read content-length validation correctly close the file-size race. If
increasing the bundle size limit later, refactor the bundling flow to collect
sorted paths first and stream file contents into hashing rather than buffering
the entire bundle in memory.

In `@test/cli-e2e/remote-schema-concurrency.test.ts`:
- Around line 100-123: Update the concurrent CLI test around the alpha and beta
runCLI results to include each process’s stderr in failure diagnostics when
asserting exitCode 0. Give this e2e test an explicit, sufficiently generous
timeout to accommodate concurrent Git fetches and lock contention.
- Around line 10-24: Update the gitEnv and git helper setup to isolate fixture
repositories from global Git configuration by disabling the global/system config
sources and pointing Git to a temporary, nonexistent config file using a
cross-platform path such as path.join(tempDir, 'gitconfig'). Ensure the
temporary directory is available to this setup and preserve the existing author,
committer, and stdio settings.

In `@test/commands/schema-sync.test.ts`:
- Around line 390-459: Add a regression test alongside the nested-directory test
that runs schema synchronization from a symlink or alias path to the consumer
root. Assert that path identity uses the canonical realpath and writes the
lockfile under the resolved consumer root, while separately preserving any
intentionally user-typed path spelling in output if applicable.

In `@test/core/artifact-graph/schema-directory.test.ts`:
- Around line 104-118: Update the validation test around
validateRemoteSchemaDirectory to use an assertion flow that explicitly expects
SchemaDirectoryValidationError and then inspects the captured error’s issues,
rather than throwing a sentinel Error inside the same catch block. Preserve the
existing three issue-message and length assertions, and ensure the test cannot
pass without making the expected validation-error assertions.

In `@test/core/remote-schema/sync-lock.test.ts`:
- Around line 134-144: Extend the sync-lock test coverage with a corrupt-ticket
scenario near “reclaims an abandoned same-host lock”: create the lock directory,
write truncated JSON to a *.ticket.json file, and assert withSchemaSyncLock
resolves successfully. Update the corresponding acquisition logic in
readParticipant handling so an unparseable ticket is recovered rather than
skipped indefinitely.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 1a914327-8c32-46e9-8738-042016f2e54d

📥 Commits

Reviewing files that changed from the base of the PR and between fa197ca and 521dce0.

📒 Files selected for processing (43)
  • docs/cli.md
  • docs/customization.md
  • openspec/changes/add-remote-schema-sources/design.md
  • openspec/changes/add-remote-schema-sources/proposal.md
  • openspec/changes/add-remote-schema-sources/specs/remote-schema-sources/spec.md
  • openspec/changes/add-remote-schema-sources/specs/schema-resolution/spec.md
  • openspec/changes/add-remote-schema-sources/specs/schema-which-command/spec.md
  • openspec/changes/add-remote-schema-sources/tasks.md
  • src/cli/index.ts
  • src/commands/schema.ts
  • src/commands/validate.ts
  • src/commands/workflow/instructions.ts
  • src/commands/workflow/new-change.ts
  • src/commands/workflow/schemas.ts
  • src/commands/workflow/status.ts
  • src/commands/workflow/templates.ts
  • src/core/archive.ts
  • src/core/artifact-graph/instruction-loader.ts
  • src/core/artifact-graph/resolver.ts
  • src/core/artifact-graph/schema-directory.ts
  • src/core/completions/completion-provider.ts
  • src/core/list.ts
  • src/core/remote-schema/authority.ts
  • src/core/remote-schema/bundle.ts
  • src/core/remote-schema/consumer-root.ts
  • src/core/remote-schema/git.ts
  • src/core/remote-schema/sync-lock.ts
  • src/core/remote-schema/sync.ts
  • src/core/root-selection.ts
  • src/core/validation/validator.ts
  • src/utils/change-metadata.ts
  • src/utils/change-utils.ts
  • src/utils/task-progress.ts
  • test/cli-e2e/remote-schema-concurrency.test.ts
  • test/commands/schema-sync.test.ts
  • test/commands/store-root-selection.test.ts
  • test/core/artifact-graph/remote-resolver.test.ts
  • test/core/artifact-graph/schema-directory.test.ts
  • test/core/remote-schema/bundle-size-race.test.ts
  • test/core/remote-schema/bundle.test.ts
  • test/core/remote-schema/git.test.ts
  • test/core/remote-schema/sync-lock.test.ts
  • test/core/remote-schema/sync.test.ts
🚧 Files skipped from review as they are similar to previous changes (9)
  • openspec/changes/add-remote-schema-sources/design.md
  • openspec/changes/add-remote-schema-sources/proposal.md
  • openspec/changes/add-remote-schema-sources/specs/schema-resolution/spec.md
  • docs/customization.md
  • test/core/artifact-graph/remote-resolver.test.ts
  • docs/cli.md
  • src/core/remote-schema/sync.ts
  • openspec/changes/add-remote-schema-sources/specs/remote-schema-sources/spec.md
  • src/core/artifact-graph/resolver.ts

Comment thread src/core/remote-schema/git.ts
Comment thread src/core/remote-schema/sync-lock.ts
Comment thread src/core/remote-schema/sync-lock.ts
Comment thread test/core/artifact-graph/schema-directory.test.ts
@clay-good

Copy link
Copy Markdown
Collaborator

Thanks @Patodo — this is a lot of careful work (git resolution, content-addressed cache, lockfile, path-safety hardening, real integration tests).

@TabishB flagging for your call. TL;DR:

What it does: lets a project point schema: <name> at a Git repo via a new schemaSources block, then openspec schema sync pins it to a commit SHA, verifies + caches the bundle, and writes openspec/schemas.lock.yaml. Normal commands stay offline; only schema sync touches the network. (Relates to #1131.)

Why we are parking it for now: it is ~5,800 lines, and beyond the new feature it introduces a schemaRoot concept threaded through root-selection, archive, validator, list, and task-progress — so it changes the code path for every user, including the ones who never use a remote schema. There are also unresolved review items (schema which --all shadow computation, SSH fetches that can hang without BatchMode=yes, remote templates reported as package).

Not a rejection — it needs a design decision from you on whether schemaRoot is the right contract, and probably a split (schemaRoot plumbing first, remote sources second) before we can call it bulletproof.

@Patodo
Patodo force-pushed the feat/remote-schema-sources branch from 90a082d to 29f2a73 Compare July 28, 2026 14:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
test/core/remote-schema/sync-lock.test.ts (1)

163-178: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Tight timing margin may flake under CI load.

Asserting Date.now() - startedAt >= 35 against a 40ms configured timeout leaves only a 5ms buffer for scheduling/polling jitter (retryDelayMs: 5). Consider widening the margin (e.g., lowering the threshold further or increasing timeoutMs) to reduce flakiness on slower CI runners.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/core/remote-schema/sync-lock.test.ts` around lines 163 - 178, The timing
assertion in the “waits the full timeout before reclaiming a fresh unparseable
participant” test is too close to the configured 40ms timeout and may flake
under CI load. Widen the margin by lowering the elapsed-time threshold or
increasing timeoutMs, while preserving verification that the corrupt participant
is reclaimed only after the timeout.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/core/remote-schema/sync-lock.ts`:
- Around line 192-220: The createParticipantFile retry loop can run forever and
retries all ENOENT errors, potentially hanging acquireSchemaSyncLock. Restrict
retries to the directory-deletion race, bound them by the lock acquisition
deadline/timeout, and add an fs.renameSync fallback when fs.linkSync is
unsupported or persistently fails, while preserving staging cleanup.

---

Nitpick comments:
In `@test/core/remote-schema/sync-lock.test.ts`:
- Around line 163-178: The timing assertion in the “waits the full timeout
before reclaiming a fresh unparseable participant” test is too close to the
configured 40ms timeout and may flake under CI load. Widen the margin by
lowering the elapsed-time threshold or increasing timeoutMs, while preserving
verification that the corrupt participant is reclaimed only after the timeout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: c785b9d0-9a5e-43c7-b6f9-33f3edb115c8

📥 Commits

Reviewing files that changed from the base of the PR and between 521dce0 and 29f2a73.

📒 Files selected for processing (17)
  • docs/cli.md
  • docs/customization.md
  • openspec/changes/add-remote-schema-sources/design.md
  • openspec/changes/add-remote-schema-sources/proposal.md
  • openspec/changes/add-remote-schema-sources/specs/remote-schema-sources/spec.md
  • openspec/changes/add-remote-schema-sources/specs/schema-resolution/spec.md
  • openspec/changes/add-remote-schema-sources/tasks.md
  • src/commands/workflow/templates.ts
  • src/core/remote-schema/git.ts
  • src/core/remote-schema/sync-lock.ts
  • src/utils/change-utils.ts
  • test/cli-e2e/remote-schema-concurrency.test.ts
  • test/commands/schema-sync.test.ts
  • test/core/artifact-graph/schema-directory.test.ts
  • test/core/remote-schema/git.test.ts
  • test/core/remote-schema/sync-lock.test.ts
  • test/utils/change-utils.test.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • openspec/changes/add-remote-schema-sources/design.md
  • openspec/changes/add-remote-schema-sources/specs/schema-resolution/spec.md
  • docs/cli.md
  • docs/customization.md
  • test/core/artifact-graph/schema-directory.test.ts
  • openspec/changes/add-remote-schema-sources/specs/remote-schema-sources/spec.md
  • openspec/changes/add-remote-schema-sources/proposal.md

Comment on lines +192 to +220
function createParticipantFile(
lockPath: string,
fileName: string,
participant: SchemaSyncParticipant
): string {
const filePath = path.join(lockPath, fileName);
while (true) {
ensureSelfIgnoredLockDirectory(lockPath);
const stagingPath = path.join(
lockPath,
`.participant-${randomUUID()}.tmp`
);
try {
fs.writeFileSync(stagingPath, JSON.stringify(participant), {
encoding: 'utf8',
flag: 'wx',
});
fs.linkSync(stagingPath, filePath);
return filePath;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
continue;
}
throw error;
} finally {
fs.rmSync(stagingPath, { force: true });
}
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== locate file =="
git ls-files | grep -F 'src/core/remote-schema/sync-lock.ts' || true

echo "== relevant source =="
if [ -f src/core/remote-schema/sync-lock.ts ]; then
  wc -l src/core/remote-schema/sync-lock.ts
  sed -n '1,340p' src/core/remote-schema/sync-lock.ts | cat -n
fi

echo "== timeout/error uses =="
rg -n "SchemaSyncLockError|timeoutMs|createParticipantFile|linkSync|renameSync|retry" src/core/remote-schema/sync-lock.ts src -g '*.ts' || true

echo "== test files around sync lock =="
git ls-files | grep -i 'sync-lock\|sync.*lock\|remote-schema' | head -100

Repository: Fission-AI/OpenSpec

Length of output: 17972


🌐 Web query:

Node.js fs.linkSync ENOENT Container devcontainer overlay filesystem persistent ENOENT

💡 Result:

The ENOENT error during fs.linkSync in a Dev Container or Docker environment—especially when using overlay filesystems or bind mounts—typically occurs because the operation encounters filesystem limitations or kernel behavior that Node.js does not natively handle as a graceful fallback [1][2]. Even when the source file and destination directory exist, fs.linkSync may fail with ENOENT in containerized environments due to the following reasons: 1. Cross-Layer/Cross-Device Limitations: In OverlayFS (often used by Docker), hard linking between the read-only lower layer and the writable upper layer is restricted [1]. While this is technically a cross-device constraint (which should trigger EXDEV), some containerized filesystem configurations or kernel versions may return ENOENT instead, which many Node.js applications or libraries are not programmed to handle as a signal to switch strategies [1][3]. 2. Filesystem Synchronization/Visibility: When bind-mounting directories (common in VS Code Dev Containers), there can be latency or consistency issues between the host and container filesystem drivers (e.g., virtiofs on macOS/Windows Docker Desktop) [4][5][2]. Tools like pnpm or yarn that rely on hard links often assume atomic filesystem behavior that may not be fully supported by the underlying mount, leading to "file not found" errors during linking operations [1][4][2]. Recommended Solutions: - Configure Package Manager Fallbacks: If you are using pnpm or yarn, you can often bypass this by changing the linking method. For pnpm, setting package-import-method=clone-or-copy in your.npmrc file forces the use of copies instead of hard links, which is more resilient to container filesystem constraints [2]. - Adjust Store Locations: If using a persistent store, ensure the pnpm/yarn store is located on the same filesystem mount as your project, or specifically place it in a location (such as the container's home directory) that avoids cross-device or permission issues [2]. - Implement Robust File Operations: If you are writing custom Node.js code that uses fs.linkSync, wrap the call in a try/catch block [1][3]. If the operation catches an error (such as ENOENT or EXDEV), implement a fallback that uses fs.copyFileSync to replicate the file instead of attempting to link it [1][3]. These patterns ensure that your application remains functional even when the underlying container filesystem does not permit standard hard-link operations [1][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== inspect relevant tests for timeout/failure behavior =="
wc -l test/core/remote-schema/sync-lock.test.ts test/cli-e2e/remote-schema-concurrency.test.ts || true
sed -n '1,260p' test/core/remote-schema/sync-lock.test.ts | cat -n
echo "== timeout/deadline references in tests =="
rg -n "timeoutMs|SchemaSyncLockError|acquireSchemaSyncLock|withSchemaSyncLock|createParticipantFile|ENOENT|linkSync" test src/core/remote-schema/sync.ts src/commands/schema.ts || true

Repository: Fission-AI/OpenSpec

Length of output: 16678


Bound the participant publish retry and handle unsupported link errors.

In the unbounded while (true) loop, an ENOENT from fs.linkSync causes every later retry to recreate .schemas.lock/.gitignore as empty and continue forever, so acquireSchemaSyncLock can hang before its timeoutMs deadline. Treat only the directory-deletion race as retryable, bound the retry window by the lock deadline, and consider a fs.renameSync(stagingPath, filePath) fallback where fs.linkSync is unsupported or persistently fails.

🧰 Tools
🪛 ast-grep (0.44.1)

[warning] 204-207: Filesystem path is not a string literal; a request-/variable-derived path can enable path traversal. Validate and normalize the path before use.
Context: fs.writeFileSync(stagingPath, JSON.stringify(participant), {
encoding: 'utf8',
flag: 'wx',
})
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(detect-non-literal-fs-filename-typescript)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/remote-schema/sync-lock.ts` around lines 192 - 220, The
createParticipantFile retry loop can run forever and retries all ENOENT errors,
potentially hanging acquireSchemaSyncLock. Restrict retries to the
directory-deletion race, bound them by the lock acquisition deadline/timeout,
and add an fs.renameSync fallback when fs.linkSync is unsupported or
persistently fails, while preserving staging cleanup.

@Patodo

Patodo commented Jul 28, 2026

Copy link
Copy Markdown
Author

Thanks @clay-good. After stepping back and reconsidering the architecture, I think this review identified the key issue correctly.

I focused too quickly on schema distribution, reproducible pinning, and offline resolution, and overlooked that OpenSpec already has a Git-backed sharing abstraction: Stores. As a result, #1444 introduced a parallel distribution system—sync commands, lockfiles, caches, and Git/network handling—when the existing Store model can cover the main collaboration use case more naturally.

A Store-based model treats planning and schemas as two independent roles. For example, a department could maintain:

  • department-planning: owns shared changes and specs;
  • department-schemas: owns reusable schemas and templates.

A consumer team could configure:

store: department-planning
schema: qeda-sdd
schemaStore:
  id: department-schemas
  schemas:
    - qeda-sdd
    - frontend-sdd

Each developer clones and registers those Stores once:

git clone git@github.com:acme/department-planning.git
openspec store register ./department-planning --id department-planning

git clone git@github.com:acme/department-schemas.git
openspec store register ./department-schemas --id department-schemas

Git remains responsible for collaboration and synchronization. Teams update the registered checkout with normal git pull/git push workflows, while OpenSpec only resolves schemas from the local registered Store. There is no separate schema sync command, lockfile, cache, or additional Git/network trust boundary inside OpenSpec.

The same schema Store can serve multiple teams while each consumer controls visibility. A frontend team can expose only frontend-sdd, a backend team can expose only backend-sdd, and schemaStore: department-schemas can mean all schemas are visible by default. A project can also keep changes and specs locally and use only the shared schema Store by omitting store.

This does give up per-project commit pinning, but I now think that should be a separate product decision rather than something bundled into the initial schema-sharing feature.

I’ve opened a narrower Store-based exploration in #1473. I consider that direction a better fit with OpenSpec’s existing model, and #1444’s remote sync/cache/lockfile approach should be treated as superseded rather than merged as-is.

@Patodo Patodo closed this Jul 28, 2026
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.

3 participants