Mirror repos (pull-only) with scheduled upstream syncs: create-from-URL flow, cron-ish schedule presets, next-sync display, and push rejection #240

Closed
opened 2026-09-09 15:55:23 +00:00 by crueber · 5 comments
Owner

What's requested

A repo can be created as a mirror of an external git repository: walhub pulls from the upstream on a schedule, the repo is marked read-only (pull-only), pushes to it fail, and the UI shows the mirror badge plus the next scheduled sync. Schedules are presets: every hour, every 8 hours, every day (default), every week, every month. No achievements-style extras — sync, show next fire time, reject pushes.

Current state (what exists / what doesn't)

  • Import ≠ mirror. internal/repoimport is a one-shot clone-and-land (Feature 10): Begin → task → runImport → terminal. There is no recurring sync, no upstream credential retention for re-clone, and no mirror flag on the manifest.
  • Manifest has no mirror concept. proto.Manifest (internal/store/proto/types.go:71) carries format/repo/seq/packs/settings only; RepoSettings is inline TOML. A mirror flag + schedule + upstream pointer needs a home (see notes).
  • Push gating is per-principal only. internal/server/smart.go:92 (gitInfoRefs) and the receive-pack path gate on requireWrite(principal) — there is no repo-level read-only concept anywhere (grep for read-only/readonly/RO finds nothing repo-scoped).
  • Scheduling primitives exist. internal/bundle/cron.go has a parsed 6-field UTC cron with Next/PreviousFire and the bundle strategies pattern (Planner in internal/maintain/bundles.go, settings-tab rendering of schedule + next fire in web/src/pages/Settings.jsx ScheduledTab). The maintain package runs loops (maintain.go interval goroutine + follow.go separate-cadence pattern) — mirror sync should follow the follow.go shape: its own cadence, never blocking maintenance.
  • Task system exists for narrated long work: internal/wal/tasks.go (repo, kind) single-flight — a sync run should be a task (repo, "mirror-sync") so joins/dedup come free.
  • Repo creation surface: HTTP PUT (§9.1, repoPut in internal/api/summary.go) and CLI. The mirror flow likely extends the import flow rather than replacing it (see notes).

Proposed design (for planner/code review to pressure-test)

  1. Mirror state on the repo. Extend the repo's settings/meta document (the settings TOML path from #235 is one candidate; a sidecar repos/<o>/<r>/mirror.json following the access.json/meta/placeholder.json sidecar precedent is another — planner's call) with: mirror: true, upstream_url (canonical, from repoimport's NormalizeSource — same SSRF gate), schedule (one of hourly|8h|daily|weekly|monthly), last_synced_at, next_sync_at, last_result. Canonical-schedule mapping to the existing 6-field cron so bundle.Cron.Next computes fire times.
  2. Sync engine. A mirror-sync task kind over internal/repoimport's existing clone/ingest machinery — a re-import into the same repo (the task system's (repo,kind) single-flight prevents overlap; a sync landing on an in-flight previous sync joins or skips). Driver: a new follow-style loop goroutine (mirror cadence, separate from maintenance interval) that scans mirror repos and fires those due — same shape as maintain.RunFollow. Scale check: daily-default schedules mean the scanner is cheap.
  3. Push rejection. Repo-level read-only check at both receive-pack gates (internal/server/smart.go gitInfoRefs :32 and the post-auth receive path, plus internal/server/bind_ssh.go:91 SSHReceivePack): mirror → refuse with a clear plain-text error ("this repository is a read-only mirror; pushes are rejected") — 403 on info/refs service discovery is the right code; pushes must fail for admins too, not just non-admins.
  4. UI.
    • Repo header (Repo.jsx): a mirror badge + "pull-only" indicator next to the title (same slot as the #235 description).
    • Next sync display: header badge tooltip or the repo landing — next_sync_at from the summary API (mirror repos only).
    • Settings: a "Mirror" tab (settingsNav.js pattern) with upstream URL, schedule preset picker (hourly/8h/daily/weekly/monthly; daily default), last-sync time/result, and a manual "Sync now" button (admin-gated).
    • New repo flow: a "mirror from URL" option that creates the repo + mirror config + runs the first sync immediately.
  5. API surface. Mirror config rides repo settings or its own endpoint (…/api/mirror GET/PUT, admin-write). Summary (summaryBody) gains mirror: {upstream_url, schedule, next_sync_at, last_synced_at, last_result} — ETag caveat: any cached summary change needs ETag coverage per the #235 precedent. Manual sync POST spawns the task (202, same shape as imports).

Acceptance criteria

  • A repo can be created as a mirror of an https upstream (with token support) via UI and API; the first sync runs at creation.
  • Schedule presets hourly/8h/daily(default)/weekly/monthly are selectable; schedule changes take effect for the next fire (next_sync_at recomputed).
  • Syncs run on schedule (hourly preset fires within the hour, etc.), each narrated as a (repo, mirror-sync) task; concurrent syncs of the same repo are joined/skipped, never overlapped.
  • next_sync_at is visible on the repo (header or settings) and updates after each sync.
  • Mirror repos show a mirror + pull-only badge in the header.
  • Pushes (HTTP and SSH) to a mirror are rejected with a clear message, regardless of principal privileges; fetches/clone continue to work.
  • Upstream URL passes the same SSRF gate as imports (NormalizeSource + CheckSSRF — including the #237 canonicalization fixes once landed); upstream credentials are stored in a way that never leaks to logs/import.json-adjacent surfaces (scrubURL/scrubError precedent in repoimport/url.go).
  • Sync failures are visible (last_result in UI + task error narration); a failed sync doesn't clear next_sync_at.
  • Deleting the mirror config stops the loop for that repo (no orphaned syncs after config removal).
  • Tests: schedule-mapping unit tests (preset → cron → next fire), sync-task single-flight test, push-rejection test for HTTP+SSH on a mirror repo, and a loop test proving a due repo fires and an overdue one catches up after restart.
## What's requested A repo can be created as a **mirror** of an external git repository: walhub pulls from the upstream on a schedule, the repo is marked read-only (pull-only), pushes to it fail, and the UI shows the mirror badge plus the next scheduled sync. Schedules are presets: **every hour, every 8 hours, every day (default), every week, every month**. No achievements-style extras — sync, show next fire time, reject pushes. ## Current state (what exists / what doesn't) - **Import ≠ mirror.** `internal/repoimport` is a one-shot clone-and-land (Feature 10): `Begin` → task → `runImport` → terminal. There is no recurring sync, no upstream credential retention for re-clone, and no mirror flag on the manifest. - **Manifest has no mirror concept.** `proto.Manifest` (`internal/store/proto/types.go:71`) carries format/repo/seq/packs/settings only; `RepoSettings` is inline TOML. A mirror flag + schedule + upstream pointer needs a home (see notes). - **Push gating is per-principal only.** `internal/server/smart.go:92` (`gitInfoRefs`) and the receive-pack path gate on `requireWrite(principal)` — there is no repo-level read-only concept anywhere (`grep` for read-only/readonly/RO finds nothing repo-scoped). - **Scheduling primitives exist.** `internal/bundle/cron.go` has a parsed 6-field UTC cron with `Next`/`PreviousFire` and the bundle strategies pattern (`Planner` in `internal/maintain/bundles.go`, settings-tab rendering of schedule + next fire in `web/src/pages/Settings.jsx` ScheduledTab). The maintain package runs loops (`maintain.go` interval goroutine + `follow.go` separate-cadence pattern) — mirror sync should follow the follow.go shape: its own cadence, never blocking maintenance. - **Task system exists** for narrated long work: `internal/wal/tasks.go` `(repo, kind)` single-flight — a sync run should be a task (`repo, "mirror-sync"`) so joins/dedup come free. - **Repo creation surface:** HTTP PUT (§9.1, `repoPut` in `internal/api/summary.go`) and CLI. The mirror flow likely extends the import flow rather than replacing it (see notes). ## Proposed design (for planner/code review to pressure-test) 1. **Mirror state on the repo.** Extend the repo's settings/meta document (the settings TOML path from #235 is one candidate; a sidecar `repos/<o>/<r>/mirror.json` following the `access.json`/`meta/placeholder.json` sidecar precedent is another — planner's call) with: `mirror: true`, `upstream_url` (canonical, from repoimport's `NormalizeSource` — same SSRF gate), `schedule` (one of `hourly|8h|daily|weekly|monthly`), `last_synced_at`, `next_sync_at`, `last_result`. Canonical-schedule mapping to the existing 6-field cron so `bundle.Cron.Next` computes fire times. 2. **Sync engine.** A `mirror-sync` task kind over `internal/repoimport`'s existing clone/ingest machinery — a re-import into the same repo (the task system's `(repo,kind)` single-flight prevents overlap; a sync landing on an in-flight previous sync joins or skips). Driver: a new follow-style loop goroutine (mirror cadence, separate from maintenance interval) that scans mirror repos and fires those due — same shape as `maintain.RunFollow`. Scale check: daily-default schedules mean the scanner is cheap. 3. **Push rejection.** Repo-level read-only check at both receive-pack gates (`internal/server/smart.go` gitInfoRefs :32 and the post-auth receive path, plus `internal/server/bind_ssh.go:91` SSHReceivePack): mirror → refuse with a clear plain-text error ("this repository is a read-only mirror; pushes are rejected") — 403 on info/refs service discovery is the right code; pushes must fail for admins too, not just non-admins. 4. **UI.** - Repo header (Repo.jsx): a `mirror` badge + "pull-only" indicator next to the title (same slot as the #235 description). - Next sync display: header badge tooltip or the repo landing — `next_sync_at` from the summary API (mirror repos only). - Settings: a "Mirror" tab (settingsNav.js pattern) with upstream URL, schedule preset picker (hourly/8h/daily/weekly/monthly; daily default), last-sync time/result, and a manual "Sync now" button (admin-gated). - New repo flow: a "mirror from URL" option that creates the repo + mirror config + runs the first sync immediately. 5. **API surface.** Mirror config rides repo settings or its own endpoint (`…/api/mirror` GET/PUT, admin-write). Summary (`summaryBody`) gains `mirror: {upstream_url, schedule, next_sync_at, last_synced_at, last_result}` — ETag caveat: any cached summary change needs ETag coverage per the #235 precedent. Manual sync POST spawns the task (202, same shape as imports). ## Acceptance criteria - [ ] A repo can be created as a mirror of an https upstream (with token support) via UI and API; the first sync runs at creation. - [ ] Schedule presets hourly/8h/daily(default)/weekly/monthly are selectable; schedule changes take effect for the next fire (next_sync_at recomputed). - [ ] Syncs run on schedule (hourly preset fires within the hour, etc.), each narrated as a `(repo, mirror-sync)` task; concurrent syncs of the same repo are joined/skipped, never overlapped. - [ ] `next_sync_at` is visible on the repo (header or settings) and updates after each sync. - [ ] Mirror repos show a mirror + pull-only badge in the header. - [ ] Pushes (HTTP and SSH) to a mirror are rejected with a clear message, regardless of principal privileges; fetches/clone continue to work. - [ ] Upstream URL passes the same SSRF gate as imports (NormalizeSource + CheckSSRF — including the #237 canonicalization fixes once landed); upstream credentials are stored in a way that never leaks to logs/import.json-adjacent surfaces (scrubURL/scrubError precedent in repoimport/url.go). - [ ] Sync failures are visible (last_result in UI + task error narration); a failed sync doesn't clear next_sync_at. - [ ] Deleting the mirror config stops the loop for that repo (no orphaned syncs after config removal). - [ ] Tests: schedule-mapping unit tests (preset → cron → next fire), sync-task single-flight test, push-rejection test for HTTP+SSH on a mirror repo, and a loop test proving a due repo fires and an overdue one catches up after restart.
Author
Owner

Review: #240 Mirror repos with scheduled upstream syncs (PLAN review, no code)

Reviewer verdict: proceed-with-fixes. The plan's current-state analysis verified accurate against code on every load-bearing claim I checked. The seam mapping (Seam 5 task kind, follow-style loop, bundle cron reuse, repoimport reuse) is sound. But item (a) — upstream credentials — is an unresolved architectural decision that blocks implementation start, and (c)/(d)/(e) each contain one blocking sub-point. Details below with severity tags.

Verified claims (all check out)

  • No mirror flag on manifest: confirmed. Manifest (internal/store/proto/types.go) carries format/repo/seq/packs/settings only; RepoSettings is inline TOML. No repo-level read-only concept exists (gates are requireWrite(principal)-only; the sole namespace-level refusal precedent is git.IsManagedRef for refs/pull/**).
  • Scheduling primitives: bundle.Cron 6-field UTC + Next/PreviousFire confirmed (internal/bundle/cron.go); Settings.jsx ScheduledTab schedule+next rendering pattern confirmed (line ~132); maintain.RunFollow separate-cadence loop confirmed (internal/maintain/follow.go).
  • wal.TaskTable.Run (repo,kind) single-flight join confirmed (internal/wal/tasks.go:180).
  • repoimport one-shot with never-stored per-request tokens (task memory only, scrubbed params/records, host-pinned credential helper) confirmed (url.go, 10_git_import.md §7).
  • NIT: plan's line citations are slightly stale (smart.go gitInfoRefs gate is ~:92–126, not :92/:32 as cited twice). Re-verify lines at implementation time.

(a) UPSTREAM CREDENTIALS — BLOCKING, the biggest decision

The plan's acceptance criteria require "token support" and say credentials "are stored in a way that never leaks" — but never say where the token lives between scheduled syncs. This directly contradicts the #10 deliberate decision (never-stored import tokens; import.json carries no secret, params carry secret_set:bool only). A scheduled sync with no human present cannot use an ephemeral token. Three options:

  1. Public-upstreams-only v1 (recommended). No stored secrets at all. Token field accepted only for the creation-time first sync (memory-only, import S2 discipline) and dropped after; scheduled fires use anonymous fetch. Matches the zero-secret posture, zero new attack surface.
  2. Per-request token on manual "Sync now" only. Works (human present, same as import Begin), but does not cover scheduled fires — so it composes with option 1, it is not an alternative.
  3. Stored-secret design (new bucket family + envelope encryption + rotation/audit). This is a whole feature, not a paragraph: key management, CAS'd secret records, scrub discipline, setup UI, threat model. Must NOT be smuggled into the mirror change; needs its own issue.

Rule: v1 = option 1 + option 2. Scheduled syncs fetch public upstreams only; "Sync now" MAY accept a memory-only token in the POST body (never persisted, never logged, import S2 scrub rules apply). Any persisted-credential design is explicitly out of scope and needs its own issue + Decisions entry before code. (Weak fourth option, noted only: a single operator-scoped env token à la WALGIT_UPSTREAM_TOKEN — shares one credential across all mirrors, no per-repo scoping, blast radius unjustifiable. Do not take it.)

(b) Mirror flag home — SHOULD-FIX (blocking-adjacent)

  • Rule: sidecar repos/<o>/<r>/meta/mirror.json, Create-once-then-CAS'd (same family as import.json/fork.json), amended into the frozen overwritable list per 14 §14.11 rule 2 in the same change. NOT a manifest proto field (frozen contract #2: new field number, Rust interop, golden fixtures — heavy, and sync state churn does not belong on the git linearization point), NOT settings TOML (any settings writer could flip read-only off; settings is the wrong trust domain for an enforcement flag).
  • Store last_synced_at + last_result (+ consecutive-failure count, see (f)); DERIVE next_sync_at at read time (bundle.Cron.Next(last_synced)) — never store it. Stored next-fire invites writer skew and clock bugs; compute-on-read is one pure function the summary handler already has.
  • Store the preset name (hourly|8h|daily|weekly|monthly), derive the cron string server-side from a fixed map. Do not accept freeform cron from the API (no cron-injection surface; unknown preset fails closed).

(c) Push rejection points — BLOCKING sub-point: placement + sync self-refusal

Write paths that must refuse, enumerated:

  1. HTTP gitInfoRefs receive-pack advertisement (internal/server/smart.go ~:126) → 403 plain-text. Plan is right that this is the discovery code.
  2. pushPipeline (internal/server/bind_ssh.go:214) — ONE check at its top covers BOTH transports' pack flow, since HTTP receivePackLocal and SSH both funnel through it (verified). The plan's three-point enumeration (info/refs + post-auth receive path + SSHReceivePack) is redundant if placed here — prefer the funnel. Precedent: the IsManagedRef refusal at the same site.
  3. SSH advertisement in SSHReceivePack (it writes a v0 advertisement before reading the client — refusal must precede it, else the client hangs).
  4. Mid-push refusal is git-wire, not HTTP status: in-pipeline refusal must be per-ref ng / rejected… report lines (managed-ref precedent), since the HTTP body is a git stream by then. The plan's "403" covers discovery only — state both codes.
  5. The sync engine must bypass its own refusal. Server-side publishes bypass pushPipeline/policy by construction (internal/git/managed.go header comment; follow §8.4 "configuration, not a principal"). Mirror sync MUST publish via Publish/PublishRefs directly, never through pushPipeline, or it refuses itself. State this explicitly in the plan.
  6. Audit, don't forget: PUT …/settings (admin — the mirror-flag flip path; admin-only is correct, say so), POST …/ops/{op} (do any ops write refs? repair? — needs a one-line ruling each), and auto-create-on-push (a push to an unborn mirror name must not create a writable repo that then… actually creating the repo is fine, it just must be born mirrored or born refused — rule: mirror targets are created only through the mirror flow, never via auto-create).

(d) Schedule storage + next-fire + ticker — SHOULD-FIX (two blocking sub-points)

  • Preset→cron mapping confirmed expressible in the existing 6-field cron (hourly=@hourly, 8h=0 0 */8 * * *, daily=@daily, weekly=@weekly, monthly=@monthly). Reuse is ParseSchedule+Next only — the bundle Planner (slots/strategies) is NOT reusable here, and the plan's wording ("Canonical-schedule mapping to the existing 6-field cron so bundle.Cron.Next computes fire times") is correct as long as nobody tries to reuse Plan/Build.
  • BLOCKING sub-point 1 — cross-instance exclusion. follow gets away with no lease because compare-then-atomic-PublishRefs converges (second publisher sees in-sync). A clone-based sync does NOT converge cheaply — two instances firing the same due mirror = two full clones + racing publishes. Instance-memory (repo,kind) single-flight does not span instances. Rule: the sync body takes a bucket lease (leases/mirror-<repo>.pb, CAS+TTL, 14.7 avoidance pattern) before cloning, or the loop is placement-gated to exactly one writer. Say which in the plan.
  • BLOCKING sub-point 2 — repo enumeration without LIST. "Scans mirror repos" via what? Rule: iterate the in-memory repo registry (the followRoundAll shape: m.eng.Repos() + placement check) and probe meta/mirror.json per repo (conditional GET; cheap at daily-default cadence). Never bucket LIST (law 4). Due = Next(last_synced) <= now; overdue-after-restart fires ONCE (not N catch-ups) — state this.
  • Manual "Sync now" = direct task spawn (202, join-or-run by (repo,mirror-sync)); deleting mirror.json stops the loop for that repo (probe-absent → skip — the plan's criterion is satisfiable exactly this way, no extra machinery).

(e) Interactions — one BLOCKING sub-point

  • Import (#10): reuse clone/enumerate/refmap/scrub/SSRF, but NOT completeBody converge. Import converge is create-only-by-design (a ref pointing elsewhere aborts loud 409 — task.go ~:306-311). Sync's entire job is fast-forwarding refs that moved — the opposite semantics. Rule: write a sync converge modeled on followOnce (§8.3 compare + ff-only check + atomic PublishRefs txn), reusing repoimport's clone + for-each-ref + refmap + scrub layers. "Re-import into the same repo" as literally stated will 409 on the first sync that moves anything.
  • BLOCKING: import-vs-sync overlap is NOT excluded by (repo,kind) single-flight — repo-import and mirror-sync are different kinds. Rule: Begin (import) on a target with a live mirror.json → 409; sync fire on a target with a running repo-import (or an in-progress import.json claim) → skip + narrate. The #79 claim protocol otherwise collides with the sync writer.
  • Forks (03 §7 GC): a mirror as fork-parent is fine IF/WHEN children register in the mirror's meta/forks.json (existing removeSuperseded consults children's manifests — state that sync-replaced packs flow through the same gate; no new GC rule needed). Mirror-as-child-of-external-upstream is out of scope by definition. A mirror must not itself be forkable-into-writeability confusion: forks of mirrors are born writable normal repos (fork gets own namespace/policy — 03 §7) — say so, or someone will file it as a bypass.
  • #79 claim protocol: covered above (Begin-gate + skip rule).
  • #63 userspace: no impact — mirror.json lives under the repos/ prefix so Registry.Delete sweeps it like import.json; no userspace records reference mirrors. Summary ETag caveat in the plan is correct (per #235 precedent) — just do it.

(f) Failures, backoff, rewind — SHOULD-FIX

  • With v1 public-only, "auth fails" ≈ unreachable/timeouts/4xx. Rule: record consecutive_failures in the sidecar; backoff (skip next fire or capped delay — pick one, state it); every failure narrates via the task error terminal + last_result; a failed sync never clears or moves the computed next fire (plan's criterion stands).
  • Missing from the plan: upstream rewind policy. Mirrors classically force-sync, but silent history rewrites deserve a rule. Rule: ff-only default (follow §8.3 precedent — ref comparison via merge-base --is-ancestor, refused + narrated, not sticky-silenced); a manual "force resync" op is the escape hatch. One line in the plan closes a real argument.
  • Schedule changes recompute next fire trivially under compute-on-read (no migration, no stored field to update) — the plan's criterion gets simpler.

Cross-cutting (law compliance — all SHOULD-FIX, all cheap)

  • Law 1: no new deps (cron reuse, hand-rolled loop — plan already complies; keep it that way, no scheduler library).
  • Law 2: sync clone uses the pinned CloneMirror argv (04 §12); any fetch variant needs a doc'd argv line.
  • Law 8: new task kind via RegisterKind + route via ExtraRoutes chain; core packages learn nothing named "mirror".
  • Law 11: new package (or repoimport extension) held to ≥95% + -race + the plan's listed tests (add: lease-contention test, import-vs-sync 409 test, rewind-refusal test).
  • Law 12: Decisions entries for (a) public-only v1, (b) sidecar family + frozen-list amendment, (c) funnel placement, (f) ff-only — in the same change as code.

Acceptance-criteria deltas (concrete edits to the issue)

  1. "(with token support)" → "public upstreams for scheduled syncs; optional memory-only token on manual Sync now".
  2. Add: sync converge is ff-only followOnce-shaped, not completeBody reuse; import-vs-sync mutual exclusion (409/skip); bucket lease for cross-instance exclusion; enumeration via registry + probe (no LIST); rewind = refuse + narrate; backoff counter.
  3. next_sync_at is computed at read, not stored.
  4. Fix stale line citations at implementation time.
# Review: #240 Mirror repos with scheduled upstream syncs (PLAN review, no code) **Reviewer verdict: proceed-with-fixes.** The plan's current-state analysis verified accurate against code on every load-bearing claim I checked. The seam mapping (Seam 5 task kind, follow-style loop, bundle cron reuse, repoimport reuse) is sound. But item (a) — upstream credentials — is an unresolved architectural decision that blocks implementation start, and (c)/(d)/(e) each contain one blocking sub-point. Details below with severity tags. ## Verified claims (all check out) - No mirror flag on manifest: confirmed. `Manifest` (`internal/store/proto/types.go`) carries format/repo/seq/packs/settings only; `RepoSettings` is inline TOML. No repo-level read-only concept exists (gates are `requireWrite(principal)`-only; the sole namespace-level refusal precedent is `git.IsManagedRef` for `refs/pull/**`). - Scheduling primitives: `bundle.Cron` 6-field UTC + `Next`/`PreviousFire` confirmed (`internal/bundle/cron.go`); `Settings.jsx` `ScheduledTab` schedule+next rendering pattern confirmed (line ~132); `maintain.RunFollow` separate-cadence loop confirmed (`internal/maintain/follow.go`). - `wal.TaskTable.Run` `(repo,kind)` single-flight join confirmed (`internal/wal/tasks.go:180`). - `repoimport` one-shot with never-stored per-request tokens (task memory only, scrubbed params/records, host-pinned credential helper) confirmed (`url.go`, `10_git_import.md` §7). - NIT: plan's line citations are slightly stale (`smart.go` gitInfoRefs gate is ~:92–126, not :92/:32 as cited twice). Re-verify lines at implementation time. ## (a) UPSTREAM CREDENTIALS — BLOCKING, the biggest decision The plan's acceptance criteria require "token support" and say credentials "are stored in a way that never leaks" — but never say **where the token lives between scheduled syncs**. This directly contradicts the #10 deliberate decision (never-stored import tokens; `import.json` carries no secret, params carry `secret_set:bool` only). A scheduled sync with no human present cannot use an ephemeral token. Three options: 1. **Public-upstreams-only v1 (recommended).** No stored secrets at all. Token field accepted only for the creation-time first sync (memory-only, import S2 discipline) and dropped after; scheduled fires use anonymous fetch. Matches the zero-secret posture, zero new attack surface. 2. **Per-request token on manual "Sync now" only.** Works (human present, same as import `Begin`), but does not cover scheduled fires — so it composes with option 1, it is not an alternative. 3. **Stored-secret design (new bucket family + envelope encryption + rotation/audit).** This is a whole feature, not a paragraph: key management, CAS'd secret records, scrub discipline, setup UI, threat model. Must NOT be smuggled into the mirror change; needs its own issue. **Rule:** v1 = option 1 + option 2. Scheduled syncs fetch public upstreams only; "Sync now" MAY accept a memory-only token in the POST body (never persisted, never logged, import S2 scrub rules apply). Any persisted-credential design is explicitly out of scope and needs its own issue + Decisions entry before code. (Weak fourth option, noted only: a single operator-scoped env token à la `WALGIT_UPSTREAM_TOKEN` — shares one credential across all mirrors, no per-repo scoping, blast radius unjustifiable. Do not take it.) ## (b) Mirror flag home — SHOULD-FIX (blocking-adjacent) - **Rule: sidecar `repos/<o>/<r>/meta/mirror.json`, Create-once-then-CAS'd** (same family as `import.json`/`fork.json`), amended into the frozen overwritable list per 14 §14.11 rule 2 in the same change. NOT a manifest proto field (frozen contract #2: new field number, Rust interop, golden fixtures — heavy, and sync state churn does not belong on the git linearization point), NOT settings TOML (any settings writer could flip read-only off; settings is the wrong trust domain for an enforcement flag). - **Store `last_synced_at` + `last_result` (+ consecutive-failure count, see (f)); DERIVE `next_sync_at` at read time** (`bundle.Cron.Next(last_synced)`) — never store it. Stored next-fire invites writer skew and clock bugs; compute-on-read is one pure function the summary handler already has. - Store the **preset name** (`hourly|8h|daily|weekly|monthly`), derive the cron string server-side from a fixed map. Do not accept freeform cron from the API (no cron-injection surface; unknown preset fails closed). ## (c) Push rejection points — BLOCKING sub-point: placement + sync self-refusal Write paths that must refuse, enumerated: 1. **HTTP `gitInfoRefs` receive-pack advertisement** (`internal/server/smart.go` ~:126) → 403 plain-text. Plan is right that this is the discovery code. 2. **`pushPipeline` (`internal/server/bind_ssh.go:214`) — ONE check at its top covers BOTH transports' pack flow**, since HTTP `receivePackLocal` and SSH both funnel through it (verified). The plan's three-point enumeration (info/refs + post-auth receive path + `SSHReceivePack`) is redundant if placed here — prefer the funnel. Precedent: the `IsManagedRef` refusal at the same site. 3. **SSH advertisement** in `SSHReceivePack` (it writes a v0 advertisement before reading the client — refusal must precede it, else the client hangs). 4. **Mid-push refusal is git-wire, not HTTP status**: in-pipeline refusal must be per-ref `ng` / `rejected…` report lines (managed-ref precedent), since the HTTP body is a git stream by then. The plan's "403" covers discovery only — state both codes. 5. **The sync engine must bypass its own refusal.** Server-side publishes bypass pushPipeline/policy by construction (`internal/git/managed.go` header comment; follow §8.4 "configuration, not a principal"). Mirror sync MUST publish via `Publish`/`PublishRefs` directly, never through pushPipeline, or it refuses itself. State this explicitly in the plan. 6. **Audit, don't forget:** `PUT …/settings` (admin — the mirror-flag flip path; admin-only is correct, say so), `POST …/ops/{op}` (do any ops write refs? `repair`? — needs a one-line ruling each), and auto-create-on-push (a push to an unborn mirror name must not create a writable repo that then… actually creating the repo is fine, it just must be born mirrored or born refused — rule: mirror targets are created only through the mirror flow, never via auto-create). ## (d) Schedule storage + next-fire + ticker — SHOULD-FIX (two blocking sub-points) - Preset→cron mapping confirmed expressible in the existing 6-field cron (`hourly`=@hourly, `8h`=`0 0 */8 * * *`, `daily`=@daily, `weekly`=@weekly, `monthly`=@monthly). Reuse is `ParseSchedule`+`Next` only — the bundle `Planner` (slots/strategies) is NOT reusable here, and the plan's wording ("Canonical-schedule mapping to the existing 6-field cron so bundle.Cron.Next computes fire times") is correct as long as nobody tries to reuse `Plan`/`Build`. - **BLOCKING sub-point 1 — cross-instance exclusion.** `follow` gets away with no lease because compare-then-atomic-`PublishRefs` converges (second publisher sees in-sync). A clone-based sync does NOT converge cheaply — two instances firing the same due mirror = two full clones + racing publishes. Instance-memory `(repo,kind)` single-flight does not span instances. **Rule: the sync body takes a bucket lease** (`leases/mirror-<repo>.pb`, CAS+TTL, 14.7 avoidance pattern) **before cloning**, or the loop is placement-gated to exactly one writer. Say which in the plan. - **BLOCKING sub-point 2 — repo enumeration without LIST.** "Scans mirror repos" via what? **Rule: iterate the in-memory repo registry** (the `followRoundAll` shape: `m.eng.Repos()` + placement check) and probe `meta/mirror.json` per repo (conditional GET; cheap at daily-default cadence). Never bucket LIST (law 4). Due = `Next(last_synced) <= now`; overdue-after-restart fires ONCE (not N catch-ups) — state this. - Manual "Sync now" = direct task spawn (202, join-or-run by `(repo,mirror-sync)`); deleting `mirror.json` stops the loop for that repo (probe-absent → skip — the plan's criterion is satisfiable exactly this way, no extra machinery). ## (e) Interactions — one BLOCKING sub-point - **Import (#10): reuse clone/enumerate/refmap/scrub/SSRF, but NOT `completeBody` converge.** Import converge is create-only-by-design (a ref pointing elsewhere aborts loud 409 — `task.go` ~:306-311). Sync's entire job is fast-forwarding refs that moved — the opposite semantics. **Rule: write a sync converge modeled on `followOnce` (§8.3 compare + ff-only check + atomic `PublishRefs` txn), reusing repoimport's clone + `for-each-ref` + refmap + scrub layers.** "Re-import into the same repo" as literally stated will 409 on the first sync that moves anything. - **BLOCKING: import-vs-sync overlap is NOT excluded by `(repo,kind)` single-flight** — `repo-import` and `mirror-sync` are different kinds. **Rule: `Begin` (import) on a target with a live `mirror.json` → 409; sync fire on a target with a running `repo-import` (or an in-progress `import.json` claim) → skip + narrate.** The #79 claim protocol otherwise collides with the sync writer. - **Forks (03 §7 GC):** a mirror as fork-parent is fine IF/WHEN children register in the mirror's `meta/forks.json` (existing `removeSuperseded` consults children's manifests — state that sync-replaced packs flow through the same gate; no new GC rule needed). Mirror-as-child-of-external-upstream is out of scope by definition. A mirror must not itself be forkable-into-writeability confusion: forks of mirrors are born writable normal repos (fork gets own namespace/policy — 03 §7) — say so, or someone will file it as a bypass. - **#79 claim protocol:** covered above (Begin-gate + skip rule). - **#63 userspace:** no impact — `mirror.json` lives under the `repos/` prefix so `Registry.Delete` sweeps it like `import.json`; no userspace records reference mirrors. Summary ETag caveat in the plan is correct (per #235 precedent) — just do it. ## (f) Failures, backoff, rewind — SHOULD-FIX - With v1 public-only, "auth fails" ≈ unreachable/timeouts/4xx. **Rule: record `consecutive_failures` in the sidecar; backoff (skip next fire or capped delay — pick one, state it); every failure narrates via the task `error` terminal + `last_result`; a failed sync never clears or moves the computed next fire** (plan's criterion stands). - **Missing from the plan: upstream rewind policy.** Mirrors classically force-sync, but silent history rewrites deserve a rule. **Rule: ff-only default (follow §8.3 precedent — ref comparison via `merge-base --is-ancestor`, refused + narrated, not sticky-silenced); a manual "force resync" op is the escape hatch.** One line in the plan closes a real argument. - Schedule changes recompute next fire trivially under compute-on-read (no migration, no stored field to update) — the plan's criterion gets simpler. ## Cross-cutting (law compliance — all SHOULD-FIX, all cheap) - Law 1: no new deps (cron reuse, hand-rolled loop — plan already complies; keep it that way, no scheduler library). - Law 2: sync clone uses the pinned `CloneMirror` argv (04 §12); any fetch variant needs a doc'd argv line. - Law 8: new task kind via `RegisterKind` + route via `ExtraRoutes` chain; core packages learn nothing named "mirror". - Law 11: new package (or `repoimport` extension) held to ≥95% + `-race` + the plan's listed tests (add: lease-contention test, import-vs-sync 409 test, rewind-refusal test). - Law 12: Decisions entries for (a) public-only v1, (b) sidecar family + frozen-list amendment, (c) funnel placement, (f) ff-only — in the same change as code. ## Acceptance-criteria deltas (concrete edits to the issue) 1. "(with token support)" → "public upstreams for scheduled syncs; optional memory-only token on manual Sync now". 2. Add: sync converge is ff-only `followOnce`-shaped, not `completeBody` reuse; import-vs-sync mutual exclusion (409/skip); bucket lease for cross-instance exclusion; enumeration via registry + probe (no LIST); rewind = refuse + narrate; backoff counter. 3. `next_sync_at` is computed at read, not stored. 4. Fix stale line citations at implementation time.
Author
Owner

Plan revision R1 (review findings — R1 wins on conflict)

Blocking resolutions (normative)

  • (a) Credentials: public-upstreams-only v1. No stored secrets. Token accepted ONLY for creation-time first sync (memory-only, import S2 discipline) and manual "Sync now" POST body (never persisted/logged); scheduled fires fetch anonymously. Stored-secret design explicitly out of scope (own issue). Acceptance "(with token support)" means exactly this.
  • (b) Mirror state: sidecar repos/<o>/<r>/meta/mirror.json, Create-once-then-CAS'd, frozen-list amendment in the same change. NOT manifest proto, NOT settings TOML. Stores preset name + last_synced_at + last_result + consecutive_failures; next_sync_at COMPUTED at read via the preset→cron map (hourly/8h/daily/weekly/monthly → 6-field cron, no freeform cron).
  • (c) Push refusal at the funnel: pushPipeline top (covers HTTP+SSH pack flow) + SSH advertisement refusal before client read; discovery 403, in-pipeline per-ref ng lines. Sync publishes via Publish/PublishRefs directly (bypasses its own refusal). Audit: settings PUT (admin-only, correct), ops ref-writers ruled, auto-create never creates mirror targets.
  • (d) Cross-instance exclusion: bucket lease (leases/mirror-<repo>.pb, CAS+TTL) before cloning. Enumeration via in-memory registry + mirror.json probe (no LIST); overdue-after-restart fires ONCE. Manual Sync-now = direct task spawn; deleting mirror.json stops the loop.
  • (e) Sync converge is followOnce-shaped (compare + ff-only + atomic PublishRefs), NOT completeBody reuse; import-vs-sync mutual exclusion (Begin on mirrored target → 409; sync fire during repo-import/claim → skip + narrate); forks-of-mirrors are born writable; #63 no impact.
  • (f) Failures: consecutive-failure counter + backoff; failed sync never moves computed next fire; upstream rewind = refuse + narrate (ff-only default) + manual force-resync escape hatch.

Standing decisions kept

Task kind via RegisterKind, ExtraRoutes, no new deps, pinned git argv, ≥95% + -race, Decisions entries for (a)(b)(c)(f), EVIDENCE entry, browser-proof UI (badge + next-sync display).

# Plan revision R1 (review findings — R1 wins on conflict) ## Blocking resolutions (normative) - **(a) Credentials: public-upstreams-only v1.** No stored secrets. Token accepted ONLY for creation-time first sync (memory-only, import S2 discipline) and manual "Sync now" POST body (never persisted/logged); scheduled fires fetch anonymously. Stored-secret design explicitly out of scope (own issue). Acceptance "(with token support)" means exactly this. - **(b) Mirror state: sidecar `repos/<o>/<r>/meta/mirror.json`**, Create-once-then-CAS'd, frozen-list amendment in the same change. NOT manifest proto, NOT settings TOML. Stores preset name + `last_synced_at` + `last_result` + `consecutive_failures`; `next_sync_at` COMPUTED at read via the preset→cron map (hourly/8h/daily/weekly/monthly → 6-field cron, no freeform cron). - **(c) Push refusal at the funnel:** `pushPipeline` top (covers HTTP+SSH pack flow) + SSH advertisement refusal before client read; discovery 403, in-pipeline per-ref `ng` lines. Sync publishes via `Publish`/`PublishRefs` directly (bypasses its own refusal). Audit: settings PUT (admin-only, correct), ops ref-writers ruled, auto-create never creates mirror targets. - **(d) Cross-instance exclusion: bucket lease** (`leases/mirror-<repo>.pb`, CAS+TTL) before cloning. Enumeration via in-memory registry + `mirror.json` probe (no LIST); overdue-after-restart fires ONCE. Manual Sync-now = direct task spawn; deleting `mirror.json` stops the loop. - **(e) Sync converge is `followOnce`-shaped** (compare + ff-only + atomic PublishRefs), NOT `completeBody` reuse; import-vs-sync mutual exclusion (`Begin` on mirrored target → 409; sync fire during `repo-import`/claim → skip + narrate); forks-of-mirrors are born writable; #63 no impact. - **(f) Failures:** consecutive-failure counter + backoff; failed sync never moves computed next fire; upstream rewind = refuse + narrate (ff-only default) + manual force-resync escape hatch. ## Standing decisions kept Task kind via RegisterKind, ExtraRoutes, no new deps, pinned git argv, ≥95% + -race, Decisions entries for (a)(b)(c)(f), EVIDENCE entry, browser-proof UI (badge + next-sync display).
Author
Owner

Implementation ready for review: #250 (branch feat/issue-240, one commit). Per plan R1: sidecar + frozen-list amendment, create-from-URL (public-only v1, memory-only token), mirror-sync task + lease + loop, followOnce converge (ff-only + force escape), funnel refusal (HTTP+SSH), import exclusion, backoff, computed next-fire, badge/UI/presets, EVIDENCE E15. Gates: mirror 97.0%, server/api/repoimport/store >=95%, -race green, node 471 green, vet/contract/e2e/full-short green. Live proof: create -> sync -> scheduled fire -> push refused -> summary data. One open item: in-browser render blocked (shared Chrome daemon refuses private/loopback targets, private daemon forbidden) — recorded in 11_mirror.md/E15, not claimed. Not merging.

Implementation ready for review: https://git.packden.us/crueber/walhub/pulls/250 (branch feat/issue-240, one commit). Per plan R1: sidecar + frozen-list amendment, create-from-URL (public-only v1, memory-only token), mirror-sync task + lease + loop, followOnce converge (ff-only + force escape), funnel refusal (HTTP+SSH), import exclusion, backoff, computed next-fire, badge/UI/presets, EVIDENCE E15. Gates: mirror 97.0%, server/api/repoimport/store >=95%, -race green, node 471 green, vet/contract/e2e/full-short green. Live proof: create -> sync -> scheduled fire -> push refused -> summary data. One open item: in-browser render blocked (shared Chrome daemon refuses private/loopback targets, private daemon forbidden) — recorded in 11_mirror.md/E15, not claimed. Not merging.
Author
Owner

REVIEW: PR #250 (feat/issue-240) — R1 compliance verified point-by-point, 2 fixes pushed, recommendation at the end.

R1 RULINGS (all hold in code + docs):

(a) Credentials, public-only v1 — PASS. MirrorDoc (internal/mirror/mirror.go:68) has no secret field. Token path is POST body -> SyncAsync goroutine closure -> runSync -> child env only (git.go:139-141, host-pinned helper, never argv/bucket/logs). Task params carry secret_set presence-only (sync.go:143-145). Scheduled loop fires with token="" (sync.go:756). Errors scrubbed (scrubText, git.go:326). Grep confirms no token persistence; TestRunnerTokenClone + TestScrubText pin it.

(b) Sidecar + frozen list — PASS. repos/\u003co\u003e/\u003cr\u003e/meta/mirror.json, Create-once-then-CAS'd (mirror.go:194,214); 14_extensibility.md overwritable list amended + Feature-11 Decisions entry in the same change. Stores preset + last_synced/attempt_at + last_result + consecutive_failures; next_sync_at COMPUTED at read (NextFire/ViewOf, mirror.go:82,303), failures never move it (anchored at last SUCCESS). Preset-only, fail-closed both endpoints (ValidPreset/CronFor).

(c) Funnel refusal — PASS. pushPipeline top = per-ref ng lines (bind_ssh.go:228-235, covers HTTP+SSH pack flow); gitInfoRefs discovery 403 (smart.go:134-137); SSH pre-advertisement refusal (bind_ssh.go:124-126). Fetches/clones untouched. Sync publishes via h.Publish directly (sync.go:401, never enters the funnel — cannot refuse itself). Audit in 11_mirror.md §4: settings PUT stays admin-only, ops ref-writers bypass by construction, auto-create births normal repos only. Core never imports the feature (injected MirrorGuard, server.go:61, nil-safe).

(d) Lease + enumeration — PASS. leases/mirror-\u003cowner\u003e-\u003cname\u003e.pb, CAS+TTL 10m skew 0, taken BEFORE cloning (sync.go:256,625); contention = skip + narrate, never wait. Registry + mirror.json probe enumeration (sync.go:738-752), no LIST; overdue fires once; delete stops loop (probe-absent skip). Loop is its own 1-min goroutine on maintain-role hosts (serve.go:193-201, follow.go shape). Manual sync = async task spawn (202).

(e) Converge + exclusion — PASS. followOnce-shaped: compare + merge-base --is-ancestor ff-only + atomic PublishRefs (sync.go:344-406); no completeBody reuse. Begin on mirrored target -> 409 via key probe, no import-\u003emirror import (service.go:216-220). Sync under live repo-import claim -> skip + narrate (importClaimLive, sync.go:460). Rewind = refuse + narrate, counter untouched (recordRefused); force escape bypasses ff-only AND backoff. Forks-of-mirrors born writable stated (11_mirror.md:77).

(f) Failures/backoff — PASS. consecutive_failures + 15m x 2^(n-1), 24h cap, attempt-anchored (mirror.go:120-150); failed sync never moves next fire.

Plus: push budget +2 exact-key probes documented (E15) and pinned (push-budget test: mirror probes ≤6 per 2 pushes, every other collab family zero); summary +1 probe only when hook set, ETag ~m suffix (summary.go:117-123) + test; UI badge/tab//new mode/next-sync all from shared summary, no extra fetch, no new npm deps; go.mod/go.sum untouched; Decisions entries for (a)(b)(c)(f) + E15 present; law-8 seams respected (RegisterKind/ExtraRoutes/RegisterExposed/Env-hook).

FIXES PUSHED to origin/feat/issue-240 (commit 60fe26d, reviewed + tested):

  1. PUT-create on unborn repo created an orphan sidecar (future pushes 403'd, syncs failed at Open) — despite the test-stated contract 'repo must exist first' (http_test.go:149). PUT-create now 404s when the manifest is absent (http.go); doc §5 updated. Test: TestPutUnbornRepo404 (404 + no sidecar left behind).
  2. Rewind-refusal paths interpolated upstream ref names unscrubbed into task logs + sidecar LastResult ('=' is legal in refnames). Both sites now pass through scrubText (sync.go). Test: TestSyncRewindRefusalScrubbed (hostile branch 'leak-token=hunter2' — sidecar redacted, task log tail clean).

TEST RESULTS (scratch worktree, PR branch + fixup): gofmt clean; go vet clean (mirror/server/api/repoimport/store/cmd); go build ./... ok; -race: internal/mirror ok (96.9% cover), internal/server ok (95.6%), internal/api ok (95.4%), internal/repoimport ok (95.9%), internal/store ok (95.1%), cmd/walhub push-budget + forward-identity tests ok. JS: all 468 unit tests pass (incl. new mirror.test.js + sdk-mirror.test.js); smoke.test.js passes alone but hangs in any multi-file run — reproduced identically on main WITHOUT the PR (pre-existing environmental issue: loopback fetch never settles under this workspace's network guard), not a PR defect. Browser render remains open per 11_mirror.md/E15 (shared daemon blocks loopback; no private daemon per workspace rules) — not attempted, not claimed.

MERGE RECOMMENDATION: ready to merge (CI to confirm the fixup commit; no structural blockers).

REVIEW: PR #250 (feat/issue-240) — R1 compliance verified point-by-point, 2 fixes pushed, recommendation at the end. R1 RULINGS (all hold in code + docs): (a) Credentials, public-only v1 — PASS. MirrorDoc (internal/mirror/mirror.go:68) has no secret field. Token path is POST body -&gt; SyncAsync goroutine closure -&gt; runSync -&gt; child env only (git.go:139-141, host-pinned helper, never argv/bucket/logs). Task params carry secret_set presence-only (sync.go:143-145). Scheduled loop fires with token="" (sync.go:756). Errors scrubbed (scrubText, git.go:326). Grep confirms no token persistence; TestRunnerTokenClone + TestScrubText pin it. (b) Sidecar + frozen list — PASS. repos/\u003co\u003e/\u003cr\u003e/meta/mirror.json, Create-once-then-CAS'd (mirror.go:194,214); 14_extensibility.md overwritable list amended + Feature-11 Decisions entry in the same change. Stores preset + last_synced/attempt_at + last_result + consecutive_failures; next_sync_at COMPUTED at read (NextFire/ViewOf, mirror.go:82,303), failures never move it (anchored at last SUCCESS). Preset-only, fail-closed both endpoints (ValidPreset/CronFor). (c) Funnel refusal — PASS. pushPipeline top = per-ref ng lines (bind_ssh.go:228-235, covers HTTP+SSH pack flow); gitInfoRefs discovery 403 (smart.go:134-137); SSH pre-advertisement refusal (bind_ssh.go:124-126). Fetches/clones untouched. Sync publishes via h.Publish directly (sync.go:401, never enters the funnel — cannot refuse itself). Audit in 11_mirror.md §4: settings PUT stays admin-only, ops ref-writers bypass by construction, auto-create births normal repos only. Core never imports the feature (injected MirrorGuard, server.go:61, nil-safe). (d) Lease + enumeration — PASS. leases/mirror-\u003cowner\u003e-\u003cname\u003e.pb, CAS+TTL 10m skew 0, taken BEFORE cloning (sync.go:256,625); contention = skip + narrate, never wait. Registry + mirror.json probe enumeration (sync.go:738-752), no LIST; overdue fires once; delete stops loop (probe-absent skip). Loop is its own 1-min goroutine on maintain-role hosts (serve.go:193-201, follow.go shape). Manual sync = async task spawn (202). (e) Converge + exclusion — PASS. followOnce-shaped: compare + merge-base --is-ancestor ff-only + atomic PublishRefs (sync.go:344-406); no completeBody reuse. Begin on mirrored target -&gt; 409 via key probe, no import-\u003emirror import (service.go:216-220). Sync under live repo-import claim -&gt; skip + narrate (importClaimLive, sync.go:460). Rewind = refuse + narrate, counter untouched (recordRefused); force escape bypasses ff-only AND backoff. Forks-of-mirrors born writable stated (11_mirror.md:77). (f) Failures/backoff — PASS. consecutive_failures + 15m x 2^(n-1), 24h cap, attempt-anchored (mirror.go:120-150); failed sync never moves next fire. Plus: push budget +2 exact-key probes documented (E15) and pinned (push-budget test: mirror probes ≤6 per 2 pushes, every other collab family zero); summary +1 probe only when hook set, ETag ~m suffix (summary.go:117-123) + test; UI badge/tab//new mode/next-sync all from shared summary, no extra fetch, no new npm deps; go.mod/go.sum untouched; Decisions entries for (a)(b)(c)(f) + E15 present; law-8 seams respected (RegisterKind/ExtraRoutes/RegisterExposed/Env-hook). FIXES PUSHED to origin/feat/issue-240 (commit 60fe26d, reviewed + tested): 1. PUT-create on unborn repo created an orphan sidecar (future pushes 403'd, syncs failed at Open) — despite the test-stated contract 'repo must exist first' (http_test.go:149). PUT-create now 404s when the manifest is absent (http.go); doc §5 updated. Test: TestPutUnbornRepo404 (404 + no sidecar left behind). 2. Rewind-refusal paths interpolated upstream ref names unscrubbed into task logs + sidecar LastResult ('=' is legal in refnames). Both sites now pass through scrubText (sync.go). Test: TestSyncRewindRefusalScrubbed (hostile branch 'leak-token=hunter2' — sidecar redacted, task log tail clean). TEST RESULTS (scratch worktree, PR branch + fixup): gofmt clean; go vet clean (mirror/server/api/repoimport/store/cmd); go build ./... ok; -race: internal/mirror ok (96.9% cover), internal/server ok (95.6%), internal/api ok (95.4%), internal/repoimport ok (95.9%), internal/store ok (95.1%), cmd/walhub push-budget + forward-identity tests ok. JS: all 468 unit tests pass (incl. new mirror.test.js + sdk-mirror.test.js); smoke.test.js passes alone but hangs in any multi-file run — reproduced identically on main WITHOUT the PR (pre-existing environmental issue: loopback fetch never settles under this workspace's network guard), not a PR defect. Browser render remains open per 11_mirror.md/E15 (shared daemon blocks loopback; no private daemon per workspace rules) — not attempted, not claimed. MERGE RECOMMENDATION: ready to merge (CI to confirm the fixup commit; no structural blockers).
Author
Owner

Implemented in PR #250 incl. review PUT-404 + scrub fixes (all R1 rulings verified; coverage gates hold), merged. Closing.

Implemented in PR #250 incl. review PUT-404 + scrub fixes (all R1 rulings verified; coverage gates hold), merged. Closing.
crueber added this to the v1 milestone 2026-09-10 22:27:10 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#240
No description provided.