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 queued → preflight_passed → executing
→ verifying → completed. 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>.gitto<repo_root>/<new_owner>/<repo>.gitviagit.Manager.MoveOwner. - Database.
repositories.owner_idandrepositories.disk_pathflip in a single transaction.page_spaces.owner_idfor spaces hosted by the moved repo follows. Under the clean-slate ACL model,repo_collaboratorsandgroup_repo_grantsare dropped for the moved repo — the new owner sets up access from scratch. - Audit. Four
repo_transfer.*events land inaudit_log:preflight,executing, then eithercompleted(success) orfailed(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 (queued → preflight_passed)
The orchestrator's runPrepare step. No FS or DB mutation has happened
yet at the end of this phase.
-
Confirmation gate. Two distinct confirm-name checks exist under this name; only one produces an HTTP response, and neither produces the
400+ left-in-queuedpairing 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 currentnamevialoadRepoNameAndOwnerand compares it against the caller'sconfirm_repo_name(case-sensitive) befores.store.Createinserts therepo_transfersrow, so a mismatch returnserrors.Join(ErrInvalidArgument, ...)with no row created.ToHTTPResponse(internal/admin/repo_transfer/http_status.go) maps that to400 invalid_argumentin the same request/response cycle — like the404s and409 in_flight_transferstep 2 traces below, this400is raised synchronously, before the202.The orchestrator's own confirm-name check lives in
runPrepare(internal/admin/repo_transfer/orchestrator.go) and runs against an already-inserted, still-queuedrow. On mismatch it returns the row untouched — state stillqueued— joined withErrInvalidArgument, without callingmarkPrepareFailed. This gate really does leave the row inqueued, but it executes inside the detached goroutineBegindispatches, 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-Executeof an already-queuedrow. -
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) answersPOST .../transferwith202and 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 areErrSourceRepoNotFound,ErrTargetUserNotFound, and*NameCollisionError, all returned byDefaultService.Preflightininternal/admin/repo_transfer/validator.go. Each aborts the prepare;markPrepareFailedthen stamps the rowpreflight_failed(terminal) and emits arepo_transfer.failedaudit record with classpg_dump_failed. Read the outcome fromGET /api/v1/admin/transfers/{id}— itsstateanderrorfields. The statuses thePOST .../transferrow lists are raised earlier and synchronously, before the202: the handler's own owner / repo / target-username resolution plusBegin'sloadRepoNameAndOwnerandloadUsernameByIDproduce the404s, andBegin's own in-flight lookup produces409 in_flight_transfer— that409is not this step's name-collision test. The412codes cannot arise here either: this step runs the same validator the preflight endpoint calls (note [1] under the REST API surface table). -
No backup trigger runs here today. Despite the
pg_dump_failedaudit class named below, no synchronouspg_dumpexecutes in this phase — or anywhere in the productionrunPreparepath — as things currently stand. The only synchronous-backup precondition that exists in code is the same deadPreflight.Run/NewPreflightpath note [1] under the REST API surface table traces for the412branch; as with that branch, this becomes observable here too as soon as the precondition is wired intorunPrepare, with no change to this step's shape.pg_dump_failedis still genuinely reachable today, just from different causes:runPrepareattributespg_dump_failedto a failed manifest build (step 4) or to a failed transfer-directory / archive-path computation, and attributesdisk_fullto a failed transfer-directoryMkdirAllor to a failed archive write (step 5). -
Manifest builder. Builds a deterministic snapshot:
COUNT(*)per child table, across the 48 FK-bound child tables enumerated ininternal/admin/repo_transfer/manifest.go'sfkChildTables(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 ASCfor the OCI/registry blob fingerprint.- SHA-256 over the concatenated bytes of every file in the bare
repo's
objects/pack/directory (GitPackSHA256).
-
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.bundle—git bundle create <bundle> --allover the bare repo (every ref).manifest.sig— HMAC-SHA256 of themanifest.jsonbytes keyed by aSECRET_ENC_KEY-derived secret. The hex digest is also persisted on therepo_transfersrow so auditrepo_transfer.completedevents can carry it.
Tar headers are stamped to a fixed mode/mtime/uname so the
manifest.json+ HMAC pair is byte-stable across runs. -
Persist
archive_pathandmanifest_summary(envelope of{manifest, digest}) on the row, advance state topreflight_passed, emitrepo_transfer.preflight.
Phase 2 — EXECUTE (preflight_passed → verifying)
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.
-
Pre-rename pack hash.
git.Manager.PackHashover the source repo'sobjects/pack/directory. -
Lock acquisition. A single TX takes:
SELECT set_config('lock_timeout', '<N>s', true)from the livetransfer.lock_timeout_secondssetting.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 manualUPDATE repositories ...waits behind the EXECUTE TX.
On
lock_not_available(SQLSTATE 55P03) the helper translates the error to the typedErrLockTimeoutsentinel and the orchestrator marks the row failed with classlock_timeout. -
FS rename.
git.Manager.MoveOwner(oldOwner, newOwner, repoName). Renames the bare repo directory atomically viaos.Renameon the same filesystem. -
DB transaction. Four statements in order, all in one TX, with the orchestrator-supplied
fsRevertclosure ready to undo the FS rename if any statement fails:UPDATE repositories SET owner_id=?, disk_path=? WHERE id=?UPDATE page_spaces SET owner_id=? WHERE repo_id=?DELETE FROM repo_collaborators WHERE repo_id=?(clean-slate ACL)DELETE FROM group_repo_grants WHERE repo_id=?(clean-slate ACL)
On 23505 (unique violation on
(owner_id, name)) the TX rolls back,fsRevertruns, and a typed*NameCollisionErrorcarryingexisting_repo_idis returned. The orchestrator marks the row failed with classdb_tx_failedand409 name_collision_at_targetis surfaced to the caller. -
Pack-hash equality gate. Re-read the pack hash at the new disk path and call
VerifyPackHashEqual(pre, post). Inequality → statefailed, audit classverification_drift. -
State →
verifying, emitrepo_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 (verifying → completed | failed)
The orchestrator's runVerify step. Re-builds the manifest against the
post-EXECUTE state and runs the comparator.
- Re-build the manifest using the new owner's directory and re-read row counts for every child table.
- Decode the pre-EXECUTE manifest from
repo_transfers.manifest_summary. Compare(pre, post)produces a*Driftdescribing per-table count deltas and registry-blob additions/removals.- If
drift.IsEmpty()→ statecompleted, emitrepo_transfer.completedwith the manifest digest captured in PREPARE. Done. - Otherwise → state
failed, audit classverification_drift, and a*VerificationDriftErroris returned with the structured*Driftand themanifest_archive_path. The HTTP layer renders this as422 verification_driftwith adifffield and amanifest_archive_pathfield. 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'andcompleted_at = now().repo_transfer.completedaudit row written (hash-chained against the precedingexecutingandpreflightrows).- The manifest archive on disk is retained per
transfer.retention.age_days(default0= 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.
- Inspect locks:
SELECT pid, locktype, mode, granted FROM pg_locks WHERE relation = (SELECT oid FROM pg_class WHERE relname='repositories'); - Wait for the contending session to finish, or coordinate with the user holding the long-running session.
- Re-run the transfer.
- If contention is structural (always-on background replication, very
active repo), increase
transfer.lock_timeout_secondsvia 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.
- Inspect the repo volume: free space (
df -h), permissions (the server process's UID must own the repo root), filesystem read/write status. - If the new owner's directory is on a different mount, fix the mount layout — the orchestrator does not handle cross-device renames.
- 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.
- 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 therepo_transfers.backup_manifest_pathcolumn on the failed row. - Extract the archive to a recovery directory:
This produces three files:mkdir -p /var/recovery/<transfer_id> tar -xzf <transfer_id>.tar.gz -C /var/recovery/<transfer_id>/manifest.json,repo.bundle,manifest.sig. - Verify the HMAC. Re-derive the HMAC key from
SECRET_ENC_KEYusing the same KDF the engine used at PREPARE time, then check:
The output must match the bytes ofopenssl dgst -sha256 -mac HMAC -macopt hexkey:<derived_key> \ /var/recovery/<transfer_id>/manifest.jsonmanifest.sig(raw 32 bytes, hex-encoded). If it does not match, the archive is tampered — stop and engage the security team. - Diagnose the drift. The 422 response body's
difffield already names the divergent tables / registry blobs. Cross-check against the live state under the new owner to identify what mutated mid-flight. - 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 - Reconcile the DB manually. Use the per-table row counts in
manifest.tablesas the ground truth for what each child table should hold. There is nopg_dumpartifact to restore rows from.repo_transfers.backup_pg_dump_pathis a real column (db/migrations/000145_repo_transfers.up.sql) and is still listed onGET /api/v1/admin/transfers/{id}above, but the only production call site that writes it passes an empty string — the same dead synchronous-pg_dumppath traced by note [1], step 3 of Phase 1 — PREPARE (above), andbackup_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. - Close the audit trail. Once reconciliation is complete, write a
manual
audit_logannotation referencing thetransfer_idso the chain check (vetrix-cli audit verify) shows the disposition. - 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.
- Do NOT auto-retry — retrying an unknown failure can compound state corruption.
- Pull the
repo_transfersrow and the matchingaudit_logrows bytransfer_id. The audit row'serror_class(one ofdb_tx_failed,verification_drift,disk_full,pg_dump_failed,lock_timeout,crash_after_fs_rename) is the closest internal hint. - File a ticket including the wrapped error chain and the
repo_transfersrow 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_failedverification_driftdisk_fulllock_timeoutdb_tx_failedcrash_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:
- The flag defaults to
false. New installs cannot reach the AccountTransfer endpoints; the surface is invisible (404). - The flag flips to
trueONLY after the failure-state matrix and integration tests have run green ongitvetrix.testAND every documented failure class has been exercised end-to-end against the fixture set. - Per release, the CHANGELOG entry notes whether
transfer.enabledis intended to be flipped on at that point. Production operators should keep the flagfalseuntil 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 thoughlib/is not in the--dirlist.- Pre-existing warnings on unrelated files (e.g.
TopNav.test.tsx,login-mfa-a11y.spec.ts). The umbrellanpm run lintcontinues 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:strictexit-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-zeronpm run lint:strictas 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.