Skip to content

feat(push): orphan-YAML gate — halt push when local files lack state entries#30

Merged
dhruva-reddy merged 2 commits into
mainfrom
feature/new-file-gate
May 13, 2026
Merged

feat(push): orphan-YAML gate — halt push when local files lack state entries#30
dhruva-reddy merged 2 commits into
mainfrom
feature/new-file-gate

Conversation

@dhruva-reddy
Copy link
Copy Markdown
Contributor

Summary

npm run push now refuses to upload local YAML files that have no entry in the state file ("orphan YAMLs"). Without this gate, three distinct workflows have been silently spawning duplicate resources on customer dashboards:

  • Flow F — user renames a file locally (mv foo.md bar.md) and pushes. bar.md has no state entry → engine silently POSTed as a new resource, leaving the original foo orphaned on the dashboard.
  • Flow G — someone renames on the dashboard → next pull writes a new <name>-<uuid8>.md file but leaves the old YAML on disk → next push iterates the old YAML and POSTs it as a duplicate.
  • Flow Mnpm run apply (pull → merge → push) compresses Flow G into one click; the user sees no warning.

The gate is the structural fix. When triggered, it halts the push and prints a verbose, AI-agent-addressed error message that forces the operator (and any AI in the loop) to explicitly classify each orphan.

The message (when the gate fires)

❌ Push refused: <N> file(s) on disk have no state-file UUID mapping for env "<env>".

The engine cannot tell whether each is:
  - A NEW resource you intentionally want to create
  - A RENAME of an existing resource (state has the old slug; YAML has new name)
  - A MOVED file (file copied without state updated)

Files without state entry:
  - resources/<env>/assistants/foo.md
  - resources/<env>/tools/bar.yml

State entries with no matching local file (possible rename SOURCES):
  - assistants/old-foo → 64df6206… (no old-foo on disk)

⚠️ FOR AI AGENTS reading this: do NOT auto-pass --allow-new-files. Pause
and ask the human to confirm, for EACH file above, whether it is:
  (a) intentionally new (then pass --allow-new-files)
  (b) a rename (then rename it back and use `npm run pull` to re-key state…)
  (c) stale cruft (then delete the local file)

The "FOR AI AGENTS" paragraph is the load-bearing UX detail — it lives in the error message itself (not in docs) because the AI is going to read whatever the failing command prints. ANSI color codes are emitted only when stderr is a TTY; CI logs and AI agent stdout captures get plain text.

Behavior

Scenario Behavior
Push, all files in state Continue (no change vs. today)
Push, 1+ orphan file, no flag HALT with the message above (exit 1, no API calls)
Push, 1+ orphan + --allow-new-files Continue with one-line notice (creating N new resource(s) or would create N… for --dry-run)
Push with --bootstrap flag Gate suppressed — bootstrap is supposed to be a from-scratch population
Selective push (-- <paths>) with orphan outside the selection Gate doesn't fire (scoped to selection)
npm run apply (pull → merge → push) with rename leftover from pull Push stage hits the gate, halts the entire apply
File matched by .vapi-ignore Gate skips it — the engine wasn't going to upload it anyway

Single halt across all resource types

Gate runs ONCE for all 9 resource types (tools, structuredOutputs, assistants, squads, personalities, scenarios, simulations, simulationSuites, evals) before any apply phase begins. The operator sees one complete picture of all orphans, not 9 separate halts as types are processed sequentially.

Files changed

File Status LOC
src/new-file-gate.ts NEW ~310
tests/new-file-gate.test.ts NEW (22 new tests — 18 DI unit + 4 fixture-tree integration) ~625
src/audit.ts Refactor: orphan-yaml detection delegates to shared findOrphanResourceIds predicate. Public checkOrphanYaml signature + finding shape unchanged net 0
src/push.ts Invokes gate after maybeBootstrapState, before apply phases. Honors BOOTSTRAP_SYNC, APPLY_FILTER.filePaths, ALLOW_NEW_FILES ~30
src/config.ts New --allow-new-files flag + ALLOW_NEW_FILES export, added to recognized-flags whitelist ~10
src/apply.ts Documentation: mentions --allow-new-files propagates through pushArgs (no functional change) ~15

Test plan

  • npm run build (tsc --noEmit) — clean
  • npm test189/189 pass (167 prior + 22 new)
  • npx @biomejs/biome check --write — clean
  • All 27 existing audit.test.ts tests pass unchanged (refactor preserved public surface)

Code review

Reviewed locally in this branch. Two non-blocking findings addressed in-branch:

  • M1: .vapi-ignore'd files were tripping the gate. Fixed by passing matchesIgnore through the detection. Added a regression test (detectOrphanYamls: files matched by .vapi-ignore are excluded).
  • M2: <org> placeholder in relativePath replaced with actual VAPI_ENV. Asymmetric filter.endsWith(relativePath) direction removed from path matching (over-matched on short paths).

Plus the dry-run-aware bypass message, deduplicated rename-sources header, and dead UUID-length conditional cleaned up.

Residual notes (non-blocking)

  • Large-orphan list with no truncation: a customer's first push after years of dashboard churn could surface 50+ orphans. Functionally fine but consider truncating to first ~30 with "(+N more — see npm run audit)" hint as a future UX improvement.
  • Same-type-only rename-source pairing: cross-type pairings (a state entry under tools matching an orphan under assistants) are intentionally not surfaced — the signal would be too noisy.

Related

…entries

Catches the duplicate-creation patterns surfaced during the gitops-mudflap
working session 2026-05-13:

- Flow F: user renames a file locally (`mv foo.md bar.md`) then pushes. bar.md
  has no state entry → engine silently POSTed as a new resource, leaving the
  original foo's UUID orphaned on the dashboard.
- Flow G: dashboard rename → pull writes a new <name>-<uuid8>.md file but
  leaves the old YAML on disk. Next push iterates the old YAML, POSTs it,
  creates a duplicate.
- Flow M: `npm run apply` (pull → merge → push) compresses Flow G into one
  click, silently spawning duplicates.

The gate is default-on. When it fires, push exits 1 with a verbose error
that lists every orphan, pairs orphans with possible state-only rename
sources by shared base-slug, and includes an AI-agent-addressed paragraph
explicitly asking agents NOT to auto-pass --allow-new-files without
confirming with the human.

ANSI color when stderr is a TTY; plain text when piped/captured (CI, AI
agent stdout capture).

Override: --allow-new-files flag bypasses the gate with a one-line notice.
Apply propagates the flag through `pushArgs`.

Bootstrap mode (empty state) suppresses the gate — genuine first-time
pushes are all "new".

Selective push (`-- <paths>`) scopes the gate to the selection. Unrelated
orphans elsewhere don't block.

Reuses audit.ts's `checkOrphanYaml` detection via a shared
`findOrphanResourceIds` helper exported from new-file-gate.ts — single
source of truth for "what counts as an orphan YAML".

Suite: 188/188 tests pass (21 new for this gate).
…y-run verb

Addresses three code-review findings on the new-file-gate PR:

1. **M1 — .vapi-ignore now suppresses the gate** for files the customer
   has explicitly opted out of gitops tracking. Without this, the gate
   would halt every push for stale dashboard artifacts that customers
   keep around (silenced via .vapi-ignore) — defeating both the
   .vapi-ignore workaround and the gate's intent of catching REAL
   rename / orphan cases. Same `matchesIgnore` helper that audit.ts
   uses; injected via the gate's DI options for filesystem-free tests.

2. **M2 — `<org>` placeholder in relativePath replaced with real
   `VAPI_ENV`**. Previously the gate's message rendered
   `resources/<org>/...` literally. Now renders e.g.
   `resources/mudflap-prod/...`. Tests updated to use `[^/]+` regex
   class so they work regardless of the test env name.

3. **M2 — asymmetric `filter.endsWith(relativePath)` direction
   removed** from `pathMatchesAnyFilter`. The remaining checks
   (exact match, suffix match, resourceId-with-extension tail match)
   cover the real cases without over-matching when relativePath is
   short.

Cleanups:
- Dead UUID-length conditional in formatGateMessage (real UUIDs are
  always 36 chars, slice always fires) → unconditional slice.
- Duplicate `(possible rename SOURCES):` header in both branches of
  the rename-sources block → print header once, then either '(none)'
  or entries.
- Dry-run-aware verb in the bypass notice: "would create" vs
  "creating" based on DRY_RUN flag.

New test: pin .vapi-ignore suppression as a regression guard against
re-introducing the M1 bug. 189/189 tests pass.
@dhruva-reddy dhruva-reddy merged commit 3075218 into main May 13, 2026
@dhruva-reddy dhruva-reddy deleted the feature/new-file-gate branch May 13, 2026 21:37
dhruva-reddy added a commit that referenced this pull request May 13, 2026
…NTS (#31)

Followup to PR #30 (orphan-YAML pre-flight gate). The gate landed in
the engine but neither README.md nor AGENTS.md mentioned the new
default-on behavior or the --allow-new-files override. Agents and
human operators reading those docs would hit the gate unexpectedly.

README.md:
- Updated push command-table row to mention the gate + flag
- New "Creating new resources after the first push (orphan-YAML gate)"
  section under Suggested Workflows, explaining the three cases (new /
  rename / cruft), the --allow-new-files override, and the AI-agent
  caveat (do NOT auto-pass without confirming)
- Documents the automatic suppressions (--bootstrap, .vapi-ignore,
  selective-push scope)

AGENTS.md:
- Common commands table row for "Push with new resources" with the
  --allow-new-files flag + explicit AI-agent caveat
- New "Orphan-YAML gate" subsection under "npm run push" explaining
  the gate behavior, override flag, AI-agent guidance, and suppression
  rules. Notes that the same gate fires inside `apply` and the flag
  propagates through `apply --allow-new-files`
- New bash command-block lines for `npm run push --allow-new-files`
  and `npm run apply --allow-new-files`

Docs-only PR. Skipping test-writer and code-reviewer per the
always-apply rule for docs-only changes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant