Vetrix Docs

Repository Transfer (AccountTransfer) — operator runbook

This is the operator reference for the AccountTransfer admin tool: moving a repository from one user to another outside the regular owner-initiated transfer flow. It describes every phase, every documented failure class, and the recovery procedure for the one-way verify_failed terminus.

For the developer-facing fixture / reset / failure-injection guide see ../../system-docs/testing/repo-transfer-fixtures.md.


Overview

AccountTransfer is the admin tool for forcibly reassigning ownership of a repository. The four-phase orchestrator drives a repo_transfers row through queuedpreflight_passedexecutingverifyingcompleted. Each phase emits a hash-chained audit record and produces a tar.gz manifest archive containing manifest.json + repo.bundle + manifest.sig for forensic / recovery use.

Blast radius:

  • Filesystem. The bare repo is renamed from <repo_root>/<old_owner>/<repo>.git to <repo_root>/<new_owner>/<repo>.git via git.Manager.MoveOwner.
  • Database. repositories.owner_id and repositories.disk_path flip in a single transaction. page_spaces.owner_id for spaces hosted by the moved repo follows. Under the clean-slate ACL model, repo_collaborators and group_repo_grants are dropped for the moved repo — the new owner sets up access from scratch.
  • Audit. Four repo_transfer.* events land in audit_log: preflight, executing, then either completed (success) or failed (any terminal failure).
  • Backup. A manifest archive is written to ${BackupDir}/transfers/<transfer_id>/<transfer_id>.tar.gz.

One-way semantics: a transfer that fails verification after a successful EXECUTE TX is NOT rolled back FS-side. The repo lives under the target owner's namespace; the operator reconciles manually using the manifest archive captured during PREPARE. See verify_failed below for the recovery procedure.


Prerequisites

Requirement Source of truth
transfer.enabled = true /api/v1/admin/settings/transfer.enabled
Configured backup destination (at least one enabled backup_configs row) /admin/backups UI / backup_configs table
AdminRepoTransfer admin scope on the calling user (delegable; super-admins implicitly hold it) acl.AdminRepoTransfer (admin:repo_transfer)
transfer.lock_timeout_seconds set to a sane bound (default 30s) /api/v1/admin/settings/transfer.lock_timeout_seconds
transfer.retention.age_days reviewed (default 0 = forever) /api/v1/admin/settings/transfer.retention.age_days

When transfer.enabled = false (the default), the AccountTransfer endpoints return 404 as if the feature did not exist — the master switch deliberately mimics an unavailable feature rather than returning 403, because 403 would imply the surface exists but is denied. Flip this switch only after the failure-state matrix and integration tests have run green on gitvetrix.test.


Phase 1 — PREPARE (queuedpreflight_passed)

The orchestrator's runPrepare step. No FS or DB mutation has happened yet at the end of this phase.

  1. Confirmation gate. Two distinct confirm-name checks exist under this name; only one produces an HTTP response, and neither produces the 400 + left-in-queued pairing this step used to attribute to a single gate.

    The synchronous gate lives in Service.Begin (internal/admin/repo_transfer/service_begin.go). It resolves the source repository's current name via loadRepoNameAndOwner and compares it against the caller's confirm_repo_name (case-sensitive) before s.store.Create inserts the repo_transfers row, so a mismatch returns errors.Join(ErrInvalidArgument, ...) with no row created. ToHTTPResponse (internal/admin/repo_transfer/http_status.go) maps that to 400 invalid_argument in the same request/response cycle — like the 404s and 409 in_flight_transfer step 2 traces below, this 400 is raised synchronously, before the 202.

    The orchestrator's own confirm-name check lives in runPrepare (internal/admin/repo_transfer/orchestrator.go) and runs against an already-inserted, still-queued row. On mismatch it returns the row untouched — state still queued — joined with ErrInvalidArgument, without calling markPrepareFailed. This gate really does leave the row in queued, but it executes inside the detached goroutine Begin dispatches, whose return values are discarded (_, _ = s.Execute(ctx, id, confirm)), so its error never reaches an HTTP response — it is reachable only on a re-Execute of an already-queued row.

  2. Preflight validator. Checks the source repo exists, the target user exists, and there is no name collision at the target. This step has no HTTP status of its own. Service.Begin (internal/admin/repo_transfer/service_begin.go) answers POST .../transfer with 202 and only then dispatches the orchestrator into a detached goroutine — Execute's sole production call site, with both of its return values discarded — so nothing this step decides can reach the originating response. Its outcome is observable only as a terminal state. The blocking errors these three checks raise are ErrSourceRepoNotFound, ErrTargetUserNotFound, and *NameCollisionError, all returned by DefaultService.Preflight in internal/admin/repo_transfer/validator.go. Each aborts the prepare; markPrepareFailed then stamps the row preflight_failed (terminal) and emits a repo_transfer.failed audit record with class pg_dump_failed. Read the outcome from GET /api/v1/admin/transfers/{id} — its state and error fields. The statuses the POST .../transfer row lists are raised earlier and synchronously, before the 202: the handler's own owner / repo / target-username resolution plus Begin's loadRepoNameAndOwner and loadUsernameByID produce the 404s, and Begin's own in-flight lookup produces 409 in_flight_transfer — that 409 is not this step's name-collision test. The 412 codes cannot arise here either: this step runs the same validator the preflight endpoint calls (note [1] under the REST API surface table).

  3. No backup trigger runs here today. Despite the pg_dump_failed audit class named below, no synchronous pg_dump executes in this phase — or anywhere in the production runPrepare path — as things currently stand. The only synchronous-backup precondition that exists in code is the same dead Preflight.Run / NewPreflight path note [1] under the REST API surface table traces for the 412 branch; as with that branch, this becomes observable here too as soon as the precondition is wired into runPrepare, with no change to this step's shape. pg_dump_failed is still genuinely reachable today, just from different causes: runPrepare attributes pg_dump_failed to a failed manifest build (step 4) or to a failed transfer-directory / archive-path computation, and attributes disk_full to a failed transfer-directory MkdirAll or to a failed archive write (step 5).

  4. Manifest builder. Builds a deterministic snapshot:

    • COUNT(*) per child table, across the 48 FK-bound child tables enumerated in internal/admin/repo_transfer/manifest.go's fkChildTables (analytics, boards, components, environments, issues, merge_requests, milestones, pages, pipelines, registry_, repo_collaborators, repo_transfers, scan_, webhooks, ...).
    • SELECT digest FROM registry_blobs WHERE repo_id=$1 ORDER BY digest ASC for the OCI/registry blob fingerprint.
    • SHA-256 over the concatenated bytes of every file in the bare repo's objects/pack/ directory (GitPackSHA256).
  5. Archive writer. Packs the manifest plus the live repo bundle into ${BackupDir}/transfers/<transfer_id>/<transfer_id>.tar.gz:

    • manifest.json — JSON-encoded *Manifest.
    • repo.bundlegit bundle create <bundle> --all over the bare repo (every ref).
    • manifest.sig — HMAC-SHA256 of the manifest.json bytes keyed by a SECRET_ENC_KEY-derived secret. The hex digest is also persisted on the repo_transfers row so audit repo_transfer.completed events can carry it.

    Tar headers are stamped to a fixed mode/mtime/uname so the manifest.json + HMAC pair is byte-stable across runs.

  6. Persist archive_path and manifest_summary (envelope of {manifest, digest}) on the row, advance state to preflight_passed, emit repo_transfer.preflight.


Phase 2 — EXECUTE (preflight_passedverifying)

The orchestrator's runExecute step. Mutations to the FS and DB happen here. The state machine first transitions the row to executing so any crash mid-phase lands the row in the crash-recovery quadrant.

  1. Pre-rename pack hash. git.Manager.PackHash over the source repo's objects/pack/ directory.

  2. Lock acquisition. A single TX takes:

    • SELECT set_config('lock_timeout', '<N>s', true) from the live transfer.lock_timeout_seconds setting.
    • pg_advisory_xact_lock(hashtext(<src_repo_id>::text)) — TX-scoped, auto-released on COMMIT/ROLLBACK so a panic cannot strand it.
    • SELECT id FROM repositories WHERE id=$1 FOR UPDATE — row-level lock that participates in normal MVCC row-locking so a manual UPDATE repositories ... waits behind the EXECUTE TX.

    On lock_not_available (SQLSTATE 55P03) the helper translates the error to the typed ErrLockTimeout sentinel and the orchestrator marks the row failed with class lock_timeout.

  3. FS rename. git.Manager.MoveOwner(oldOwner, newOwner, repoName). Renames the bare repo directory atomically via os.Rename on the same filesystem.

  4. DB transaction. Four statements in order, all in one TX, with the orchestrator-supplied fsRevert closure ready to undo the FS rename if any statement fails:

    1. UPDATE repositories SET owner_id=?, disk_path=? WHERE id=?
    2. UPDATE page_spaces SET owner_id=? WHERE repo_id=?
    3. DELETE FROM repo_collaborators WHERE repo_id=? (clean-slate ACL)
    4. DELETE FROM group_repo_grants WHERE repo_id=? (clean-slate ACL)

    On 23505 (unique violation on (owner_id, name)) the TX rolls back, fsRevert runs, and a typed *NameCollisionError carrying existing_repo_id is returned. The orchestrator marks the row failed with class db_tx_failed and 409 name_collision_at_target is surfaced to the caller.

  5. Pack-hash equality gate. Re-read the pack hash at the new disk path and call VerifyPackHashEqual(pre, post). Inequality → state failed, audit class verification_drift.

  6. State → verifying, emit repo_transfer.executing.

Crash-recovery quadrants

If the orchestrator crashes between FS rename and TX commit, the next Execute call against the same row enters recoverExecuting, which inspects the four FS×DB quadrants:

FS done DB done Disposition
yes yes Advance to verifying (both halves committed).
yes no Re-enter EXECUTE TX directly. 23505 → failed/db_tx_failed with fsRevert already run inside ExecuteTransferTX; any other DB error → failed/crash_after_fs_rename for operator inspection.
no yes Cannot occur via the legitimate orchestrator path. State → failed/crash_after_fs_rename; manual reconciliation required.
no no Roll back to preflight_passed; the next Execute call retries EXECUTE from scratch.

Phase 3 — VERIFY (verifyingcompleted | failed)

The orchestrator's runVerify step. Re-builds the manifest against the post-EXECUTE state and runs the comparator.

  1. Re-build the manifest using the new owner's directory and re-read row counts for every child table.
  2. Decode the pre-EXECUTE manifest from repo_transfers.manifest_summary.
  3. Compare(pre, post) produces a *Drift describing per-table count deltas and registry-blob additions/removals.
  4. If drift.IsEmpty() → state completed, emit repo_transfer.completed with the manifest digest captured in PREPARE. Done.
  5. Otherwise → state failed, audit class verification_drift, and a *VerificationDriftError is returned with the structured *Drift and the manifest_archive_path. The HTTP layer renders this as 422 verification_drift with a diff field and a manifest_archive_path field. The FS and DB are NOT rolled back.

Phase 4 — FINALIZE

For successful transfers the FINALIZE work is folded into VERIFY's terminal pivot:

  • repo_transfers.state = 'completed' and completed_at = now().
  • repo_transfer.completed audit row written (hash-chained against the preceding executing and preflight rows).
  • The manifest archive on disk is retained per transfer.retention.age_days (default 0 = forever).

For failed transfers FINALIZE is the matching state pivot to failed or preflight_failed, plus the repo_transfer.failed audit row carrying the error_class token.


Failure classes

The HTTP surface stamps a failure_class field on every classified non-2xx response body. Six values; the operator action differs per class.

backup_failed

Not currently reachable in production. ClassifyError in internal/admin/repo_transfer/failure_class.go reaches FailureClassBackupFailed from exactly one predicate — priority step 6, a structured preflight *Error. That value is returned only by Preflight.Run, the same dead path note [1] under the REST API surface table traces for the 412 branch and step 3 of Phase 1 — PREPARE (above) traces for pg_dump_failed's current causes: NewPreflight has no production call site, and runPrepare calls the DefaultService.Preflight validator instead, which never returns a *Error. As with the other two notes, backup_failed becomes observable here too as soon as the backup precondition is wired into runPrepare, with no change to this class's shape.

If encountered. No traced production error chain currently produces this class, so its appearance means either the precondition above has since been wired in, or an untraced path is populating the preflight *Error. Treat it as unknown until that is confirmed: do not auto-retry, and file a ticket citing the wrapped error chain and the repo_transfers row so this entry can be corrected with a verified cause.

lock_timeout

Cause. EXECUTE could not acquire the per-repo advisory + row-level lock within transfer.lock_timeout_seconds. Typical trigger: a concurrent push, a long-running write, or a stuck BEGIN; SELECT FOR UPDATE; ... from another admin job. No FS or DB state changed.

Operator action.

  1. Inspect locks: SELECT pid, locktype, mode, granted FROM pg_locks WHERE relation = (SELECT oid FROM pg_class WHERE relname='repositories');
  2. Wait for the contending session to finish, or coordinate with the user holding the long-running session.
  3. Re-run the transfer.
  4. If contention is structural (always-on background replication, very active repo), increase transfer.lock_timeout_seconds via the admin settings panel — the change is live without restart.

fs_rename_failed

Cause. The FS-side MoveOwner failed before the EXECUTE TX committed. Canonical triggers: ENOSPC (disk full), EROFS (read-only mount), EXDEV (cross-device — the new owner's directory lives on a different mount), EACCES/EPERM (permission denied), pre-existing destination directory.

The orchestrator rolled the row back to preflight_passed and the on-disk repo is still under the source owner.

Operator action.

  1. Inspect the repo volume: free space (df -h), permissions (the server process's UID must own the repo root), filesystem read/write status.
  2. If the new owner's directory is on a different mount, fix the mount layout — the orchestrator does not handle cross-device renames.
  3. Re-run the transfer.

db_tx_failed

Cause. The EXECUTE TX failed after the FS rename. Canonical triggers:

  • 23505 unique violation on repositories(owner_id, name): the target user already owns a repository with the same name.
  • SERIALIZABLE retry exhausted.
  • Connection drop mid-COMMIT.

ExecuteTransferTX has already rolled back the TX and invoked fsRevert to undo the FS rename. The repo is back at the source path.

Operator action.

  • Name collision branch. The 409 response body carries existing_repo_id. Coordinate with the target user to rename the colliding repository first; then re-run the transfer.
  • Transport / retry branch. Re-run the transfer.

verify_failed

Cause. VERIFY found drift between the pre-PREPARE manifest and the post-EXECUTE manifest, OR the pack-hash equality gate failed.

CRITICAL — DO NOT REVERT. The transfer is one-way at this point. The FS and DB are NOT rolled back. The repo lives under the target owner. Reverting manually risks a worse split: half the child tables already mutated under the new owner, half still referencing the old.

Operator action.

  1. Pull the manifest archive. The 422 response body carries manifest_archive_path, e.g. ${BackupDir}/transfers/<transfer_id>/<transfer_id>.tar.gz. The same path is in the repo_transfers.backup_manifest_path column on the failed row.
  2. Extract the archive to a recovery directory:
    mkdir -p /var/recovery/<transfer_id>
    tar -xzf <transfer_id>.tar.gz -C /var/recovery/<transfer_id>/
    
    This produces three files: manifest.json, repo.bundle, manifest.sig.
  3. Verify the HMAC. Re-derive the HMAC key from SECRET_ENC_KEY using the same KDF the engine used at PREPARE time, then check:
    openssl dgst -sha256 -mac HMAC -macopt hexkey:<derived_key> \
      /var/recovery/<transfer_id>/manifest.json
    
    The output must match the bytes of manifest.sig (raw 32 bytes, hex-encoded). If it does not match, the archive is tampered — stop and engage the security team.
  4. Diagnose the drift. The 422 response body's diff field already names the divergent tables / registry blobs. Cross-check against the live state under the new owner to identify what mutated mid-flight.
  5. Recover the bundle if needed. If the on-disk repo is also corrupt, clone from the bundle into a recovery dir:
    git clone --bare /var/recovery/<transfer_id>/repo.bundle \
      /var/recovery/<transfer_id>/repo.git
    
  6. Reconcile the DB manually. Use the per-table row counts in manifest.tables as the ground truth for what each child table should hold. There is no pg_dump artifact to restore rows from. repo_transfers.backup_pg_dump_path is a real column (db/migrations/000145_repo_transfers.up.sql) and is still listed on GET /api/v1/admin/transfers/{id} above, but the only production call site that writes it passes an empty string — the same dead synchronous-pg_dump path traced by note [1], step 3 of Phase 1 — PREPARE (above), and backup_failed. Treat the column as always empty until one of those three notes' precondition gets wired in. Reconcile divergent rows from a source outside the AccountTransfer engine — the operator's regular database backup / WAL retention path — since the manifest itself (step 4 of Phase 1 — PREPARE, above) records only counts and digests, never row content.
  7. Close the audit trail. Once reconciliation is complete, write a manual audit_log annotation referencing the transfer_id so the chain check (vetrix-cli audit verify) shows the disposition.
  8. Engage the AccountTransfer team if the drift is unexplained — it may indicate a concurrent admin mutation that slipped past the row-level lock and warrants a fix in the orchestrator.

unknown

Cause. The classifier could not map the error chain to one of the five recovery buckets. Either a genuinely-novel failure mode or a wrapping bug.

Operator action.

  1. Do NOT auto-retry — retrying an unknown failure can compound state corruption.
  2. Pull the repo_transfers row and the matching audit_log rows by transfer_id. The audit row's error_class (one of db_tx_failed, verification_drift, disk_full, pg_dump_failed, lock_timeout, crash_after_fs_rename) is the closest internal hint.
  3. File a ticket including the wrapped error chain and the repo_transfers row so the classifier can grow a new branch in the next AccountTransfer release.

REST API surface

The HTTP handlers live in internal/api/adminapi/admin_repo_transfer_handler.go and are mounted by mountRepoTransfer in internal/api/adminapi/mount.go. Every endpoint requires the AdminRepoTransfer admin scope (admin:repo_transfer, or super-admin) and is guarded by transfer.enabled. With the master switch off, every endpoint returns 404 as if it did not exist.

Method Path Description
POST /api/v1/admin/repos/{owner}/{repo}/transfer/preflight Dry-run validation only — no mutation. Body: {"target_username": "<string>"}. On success, 200 with the structured preflight result: target_exists, source_exists, name_collision_at_target, existing_colliding_repo_id (omitted when absent), in_flight_transfer (omitted when absent), collaborators_to_drop, group_grants_to_drop. On error: 404 source_repo_not_found, 404 target_user_not_found, 409 name_collision_at_target, or 400 invalid_argument. The handler's error funnel also maps a 412 (no_backup_destination or preflight_failed), but no request to this endpoint currently reaches it — see note [1] below the table.
POST /api/v1/admin/repos/{owner}/{repo}/transfer Create + execute a transfer. Body: {"target_username": "<string>", "confirm_repo_name": "<string>"}. Returns 202 with {transfer_id, state} on success. On error: 400 invalid_argument (missing/invalid body fields, including a confirm_repo_name mismatch), 404 source_repo_not_found / 404 target_user_not_found, 409 in_flight_transfer (a non-terminal transfer already exists for this repo — the body carries the existing transfer_id + state), or 500 unclassified.
GET /api/v1/admin/transfers List transfer records (history), paginated (page, default 1; per_page, default 25, max 100) and filterable by target_user (username, case-insensitive) and state. Returns {items, total, page, per_page}.
GET /api/v1/admin/transfers/{id} Fetch one transfer record by id — the full row shape, including backup_pg_dump_path, backup_manifest_path, and manifest_summary. 400 on a malformed id; 404 on an unknown id.

[1] The 412 on POST .../transfer/preflight is handler-contract only — not currently reachable. mountRepoTransfer in internal/api/adminapi/mount.go builds the handler with NewAdminRepoTransferHandler(d.RepoTransfer, d.RepoStore, d.Pool), and AdminRepoTransferHandler.Preflight calls only Svc.Preflight — in production the DefaultService.Preflight validator in internal/admin/repo_transfer/validator.go. That validator returns only ErrInvalidArgument, ErrTargetUserNotFound, ErrSourceRepoNotFound, a *NameCollisionError, or a wrapped pgx error; it never returns the structured *Error value that carries the no_backup_destination and preflight_failed codes. That value is constructed only by Preflight.Run in internal/admin/repo_transfer/preflight.go — the backup-destination and pg_dump prerequisite check — and no production call site invokes it, so the 412 branch stays dark at runtime.

The mapping itself is real and still part of the endpoint's contract: the handler passes every error through repo_transfer.ToHTTPResponse, which classifies that structured *Error as 412, and the handler's unit tests exercise the branch through a stubbed Service. Treat 412 as a response an API client should still handle defensively — it becomes observable as soon as the backup prerequisite is wired into the production preflight path, with no change to this endpoint's shape.

All non-2xx response bodies carry the operator-facing failure_class field plus a stable error code (e.g. name_collision_at_target, pack_hash_mismatch, verification_drift).

verification_drift body shape

{
  "error": "verification_drift",
  "message": "verification drift detected at <table>: count mismatch ...",
  "diff": {
    "table_drift": [{ "table": "issues", "pre": 12, "post": 11 }],
    "registry_blobs_added": [],
    "registry_blobs_removed": []
  },
  "manifest_archive_path": "/var/lib/vetrix-backups/transfers/<id>/<id>.tar.gz",
  "failure_class": "verify_failed"
}

name_collision_at_target body shape

{
  "error": "name_collision_at_target",
  "existing_repo_id": "8fbe...",
  "repo_name": "myrepo",
  "target_user_id": "0acd...",
  "failure_class": "db_tx_failed"
}

Audit log

Four event types per the catalog, all hash-chained into the audit_log table:

Event Phase Required Details
repo_transfer.preflight end of PREPARE actor_user_id, source_repo_id, target_user_id
repo_transfer.executing start of EXECUTE as above + transfer_id
repo_transfer.completed end of VERIFY (success) as executing + manifest_digest (64 lowercase hex chars)
repo_transfer.failed terminal failure (any phase) as executing + error_class + error

Closed error_class set on repo_transfer.failed:

  • pg_dump_failed
  • verification_drift
  • disk_full
  • lock_timeout
  • db_tx_failed
  • crash_after_fs_rename

The audit chain integrity check is part of vetrix-cli audit verify. After a manual reconciliation under verify_failed, re-run the check:

vetrix-cli audit verify --since "<transfer.created_at>"

The check fails loudly if any row has been edited / inserted out of order; this is the canary that the verify_failed recovery dance did not corrupt the chain.


Fixture seeder reference

The deterministic fixture set used by the end-to-end runs lives in scripts/transfer-fixtures/. See ../../system-docs/testing/repo-transfer-fixtures.md for the seeder's pinned manifest digest, the reset script contract, and the failure-injection harness flags (-tags inject_failures + the XFER_INJECT_* env vars) used to drive each named failure class on demand.


Rollout

transfer.enabled is a master switch:

  1. The flag defaults to false. New installs cannot reach the AccountTransfer endpoints; the surface is invisible (404).
  2. The flag flips to true ONLY after the failure-state matrix and integration tests have run green on gitvetrix.test AND every documented failure class has been exercised end-to-end against the fixture set.
  3. Per release, the CHANGELOG entry notes whether transfer.enabled is intended to be flipped on at that point. Production operators should keep the flag false until the release notes explicitly clear it.

The flag is intentionally stored as a runtime setting (not an app.toml toggle) so a botched flip can be reverted live without a server restart.


Pre-merge lint gate

AccountTransfer's frontend surface ships a scoped lint script (web/package.json) that runs eslint with --max-warnings=0 against the AccountTransfer files only:

cd web
npm run lint:strict

What it covers:

  • web/src/app/admin/repos/transfer/ — transfer page.
  • web/src/app/admin/transfers/ — history list + detail.
  • web/src/components/admin/<TransferConfirmModal /> and any other admin components touched by the transfer feature.
  • web/src/__tests__/pages/admin/ — Vitest page tests for the surfaces above.
  • web/src/__tests__/components/admin/ — Vitest component tests.
  • web/src/__tests__/a11y/ — unit-level axe-core tests for the AccountTransfer surfaces.

What it does NOT cover:

  • web/src/lib/api/admin.ts — the admin API client. ESLint follows the imports from the page, so any unused export or unused symbol on a type the AccountTransfer files reference will still be flagged on the page surface even though lib/ is not in the --dir list.
  • Pre-existing warnings on unrelated files (e.g. TopNav.test.tsx, login-mfa-a11y.spec.ts). The umbrella npm run lint continues to report those.

When to run:

  • Before opening any PR that touches an AccountTransfer file — the PR review template asks for the npm run lint:strict exit-0 line in the test plan.
  • After rebasing onto a new rc — a sibling ticket can introduce a warning that lands inside the strict scope without your branch changing it directly.
  • Before flipping transfer.enabled = true — the rollout checklist treats a non-zero npm run lint:strict as a release-blocker the same way it treats a failing integration run.

Extending the scope:

The --dir list is intentionally narrow, covering the new surfaces without inheriting the umbrella project's 4 pre-existing warnings on unrelated files. Operators may extend the list as additional surfaces come online — append --dir <path> entries to the lint:strict script in web/package.json. The umbrella npm run lint already covers the entire tree at warn-only; promoting a warning to a hard fail is just a question of widening the strict scope.

CI invocation:

lint:strict is a separate npm script (not pretest) so a stray new warning on an unrelated file does not block AccountTransfer changes. The release pipeline runs npm run lint:strict as a distinct gate alongside npm run lint and npm run build. A non-zero exit fails the pipeline; the failure log is the canonical record of which file / rule introduced the regression.