Explicit create-repo placeholder with push docs #210
Labels
No labels
actions
bug
cli
duplicate
enhancement
fork
forum
git storage
help wanted
insights
invalid
issues
moderation
oidc
ownership transfer
packages
pr/merge protection rules
projects
pull requests
question
releases
sponsorships
tags
webhooks
wiki
wontfix
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
crueber/walhub#210
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Full plan in comments below (planning subagent output). Summary: explicit create action (UI /new + POST /api/v1/repos) producing a placeholder (same empty manifest + marker sidecar, non-real until first push); first push adopts without 409; org-membership gate for org prefixes; idempotent re-create; delete unchanged.
TICKET 2 — Explicit "create repo" placeholder
0. Problem statement
Today a repo comes into existence only via (i)
PUT /{o}/{r}/PUT …/api(
internal/api/summary.go:53-85, AuthWrite, 201/409) or CLIwalhub repo create(cmd/walhub/main.go:64) — both undiscoverable (no UI, no SDKmethod:
web/sdk/src/repo.jshas nocreate) — or (ii) implicitly viaauto-create-on-push (
internal/server/smart.go:143-157,419). There is noUI affordance to reserve a name, show where to push, or land a first push
without tripping over the thing you just created. This ticket adds an
explicit create action producing a placeholder: a non-real repo entry
that renders push documentation and is adopted (not rejected) by the
first push.
1. Definitions (normative for this ticket)
freshly
PUT-created repo — manifestHeadSeq:0, MinSeq:0, Revision:1,no refs — PLUS a marker recording that it was created through the
placeholder flow and has never received a push. "Non-real" is a UI/UX
designation, not a second storage state: no new manifest state, no new
WAL kind, no new bucket family for the repo itself.
placeholder→real happens exactly once, atomically, on the first landed
push (see §4). The marker clears; the repo is thereafter ordinary.
auto-create path treats a placeholder target as creatable, never as a
conflict. Concretely: first push to a placeholder MUST NOT 409; it lands
through the normal publish/CAS path and clears the marker in the same
commit window.
Why "same manifest, plus marker" and not a reservation table: AGENTS law 4
(bucket-is-repo — a reservation anywhere but the bucket is lost on wipe),
law 5 (byte-compat key layout; a new top-level prefix would need a frozen
rewrite), and 14 §14.11 (no unlisted mutable keys). The marker MUST live in
an already-frozen or explicitly-amended family (options in §3).
2. Where the marker lives (decision with options)
The marker needs: Create-once, CAS-updatable (clear on first push),
delete-with-repo (prefix sweep covers it), probe-by-exact-key (no LIST).
repos/<o>/<r>/meta/placeholder.json— Create-once(
PutCreate; 412 = already a placeholder — idempotent, see §6), body{"version":1,"created_by":"<principal>","created_at":"RFC3339", "object_format":"sha1|sha256","expires_at":"RFC3339|null"}.Cleared by DELETE on first-push adoption (delete-on-transition, same class
as invitation objects —
01_identity_permissions.md §7: Create-only,delete-on-terminal, NOT overwritable → no frozen-list change needed).
Reads: exact-key probe (1 GET, off hot path — only the create flow and
the summary's
healthprojection read it; the push path MUST NOT read iton the hot path, see §4).
access.json.Repos created via placeholder get
access.jsonmaterialized at createtime (instead of lazily synthesized per 01 §10) with an additive optional
field, e.g.
"placeholder": {"created_by":…, "created_at":…}(14 §14.12field rule — readers ignore unknown fields). Cleared by full-doc CAS PUT
on first push. Downside: couples placeholder lifecycle to the access CAS
loop (contended admin edits could 412 against the clearer — bounded retry
≤5 per 01 §4 handles it, but it is a real interaction). The plan
recommends the sidecar; records this alternative in Decisions if rejected.
repos/<o>/<r>/meta/placeholder.json(Create-then-delete, immutablewhile present) — same class as
meta/import.json/fork.json(Create-once-then-CAS'd provenance) but delete-on-transition makes it
less than overwritable: no §14.11 amendment required; the adopting doc
states the classification explicitly (as Feature 10 did for its family).
object_formatis frozen at placeholder creation (manifest's format;push cannot change it — ingest would fail on hash mismatch, existing
behavior).
3. Endpoints + P6 gates
All repo-scoped → both lanes (
api.Lanes, 14 §14.12 two-lane rule); top-levelcreate twin under
/api/v1+/api-browser/v1per the lane-segment rule;discovery
endpoints[]entries (additive — the import feature set theprecedent with
api.RegisterExposed, 14-decisions Feature 10).PUT /{o}/{r}/PUT …/api(EXISTING, extended)require_write(unchanged) + optional?placeholder=trueCreateplaceholder sidecar (or materialized access field). Returns201 {owner,name,full_name,placeholder:true,clone_url,expires_at?}. Without flag: byte-identical to today. Idempotency §6.POST /api/v1/repos(+ browser twin) (NEW convenience)require_write(any authed writer;none-mode anonymous inherits existing write){owner, name, object_format?, placeholder?}(default placeholder=true for UI use). Validates naming (§5) → same create path as PUT →201(+Location:repo URL) /409 {exists, html_url}(so the UI can link the squatter — see risks). Thin wrapper, no second writer implementation (Seam 7 rule: one publish path).DELETE /{o}/{r}/DELETE …/api(EXISTING)require_adminper 01 §5 matrix (admin binding or host admin)summary.go:87-101): manifest-first linearization, prefix sweep (takes the sidecar with it), idempotent 204. Placeholder needs NO special delete — but the UI exposes Delete on the placeholder page (owner typo? reclaim), gated admin as today.receive-pack, both HTTPsmart.go:404-440and SSHbind_ssh.go:104)require_write(existing)Who may create where (org/owner rules):
owner == principal-derived name/ any owner stringtoday — there is NO owner-existence check on the create path today):
any writer (
require_write) may create under any owner name that passesnaming validation. This matches current
repoPut(no org gate) and thezero-config default (
auto_create_on_push=true, authnone).orgs/<org>/exists withmembers.json): creation ALSOrequires org membership (member+) — checked by one exact-key GET of
orgs/<org>/members.json(same cost class as the P6 team expansionprobes, 01 §6; human-rate path, never hot). Non-member → 403. Rationale:
without this, placeholder creation becomes name-squatting inside someone
else's org (risk §8). Where no org object exists, the owner prefix is
unclaimed and today's open behavior persists (back-compat; org-claim of a
populated prefix is out of scope — mirrors 01 §3 "409 with the count"
conservatism, but creation≠deletion so the gate is membership, not
ownership).
access.jsonat placeholder creation: materialize the 01 §10 synthesizeddefault eagerly (
{visibility:"public", role_bindings:[{subject: "user:<creator>", role:"admin"}]}; for org targets the org-owner rule(P6 step 2) covers governance, creator binding still recorded).
Rationale: the placeholder page needs a deterministic visibility + an
admin for the Danger-Zone delete; lazy synthesis would leave the creator
without an admin handle under future private defaults. Uses the SAME
Create-with-synthesis writer shape as 01 §10 Concurrency (412 = someoneraced us — adopt, don't overwrite).
honesty rule). Owner-scoped policy templates at create time are a
documented future (
14 §14.10.1:repo create --policy-from), not thisticket.
4. DesignPlaceholder: first-push adoption (exactly how, no 409)
The failure mode to kill: UI creates placeholder (manifest exists) →
git push -u origin main→ server sees existing manifest → 409/conflictinstead of landing. The design makes this impossible by construction:
receivePackLocal(smart.go:419):engine.Repo(ctx, id, create=true, …)→Registry.Opensucceeds (manifest EXISTS — no create attempted,hence no
ErrExists, hence no 409 surface). Parse → ingest → policy →publish/CAS proceeds EXACTLY as a push to a PUT-created empty repo does
today (that flow already works: PUT-create then push is the tested
gaps5_test.go:384shape). There is no 409 anywhere on the push pathtoday — the 409 lives only in
repoPut/createSlow(registry.go:241)and fork-target Create contention. So "designPlaceholder" is mostly a
guarantee + test, not a new branch: assert by contract test that push
to a placeholder-identical manifest lands normally.
manifest CAS that carries ≥1 ref (i.e. the publish that makes
HeadSeq>0 — detected in the push pipeline post-CAS, same goroutine that
just CAS'd, NOT a second CAS on the manifest), the pipeline issues one
best-effort
Deleteofmeta/placeholder.json(or CAS-clear of theaccess field). Ordering rules (law 4/6):
cleanup (same class as P8 fan-out: crash between CAS and delete leaves
a stale marker on a REAL repo — harmless: any read with refs>0 treats
marker as stale and hides it; a sweeper/opportunistic delete on next
push clears it — the orphan philosophy of §6.4 / 14 §14.10.2, reused).
round trip on the hot path — law 6: push budget ≤5 unchanged; the
delete is fire-and-forget post-response or piggybacked on the existing
post-publish bookkeeping).
auto_create_on_push=trueand the targetis a placeholder, step 1 already handles it (Open wins, no create).
If auto-create is OFF and target is placeholder: Open still succeeds
(repo EXISTS) — push proceeds. Placeholders are thus more pushable
than unborn names under auto-create-off, which is exactly the point
(explicit create is the fallback when auto-create is disabled).
HeadSeq:0, refs:0, marker present→ push lands → real.HeadSeq:0, refs:0, marker absent→ push lands(unchanged); UI shows the SAME empty guide (Ticket 1 health:"empty")
but without "placeholder" affordances (no expiry note, no creator line).
HeadSeq>0, refs>0, marker absent-or-stale→ push normal;stale marker ignored + opportunistically deleted.
steal; first push wins reality.
5. Naming / validation
git.ParseRepoIdis the single validator (contract.go:32-47): twosegments, each
[A-Za-z0-9._-]{1,100}, no leading., not.., optional.gitsuffix stripped. Create path returns 400 plain-text on violation(same as today). Reserved single-segment UI names (
import, api, keys, setup, explore, how-it-works—06_server_http.md:216) shadow the/:ownerUI page only; git/API paths unaffected — creation under them isallowed but the UI links will misroute: surface a non-blocking warning in
the create response (
"warning":"owner name collides with a UI route"),additive field.
object_format:sha1|sha256only (400 otherwise — existingObjectFormatFrombehavior). Default sha1 (matchesbind_wal.goenginedefault in
smart.go:420git.Sha1).given spelling (no silent lowercasing — key layout is byte-compat, law 5;
document that
Acme/Xandacme/Xare distinct prefixes, same as today).6. Idempotency (create-twice)
—
200 {…, placeholder:true, already:true}(not 201), no state change(sidecar
Create412 → treat as done, exactly the P3 event-path rule).Rationale: double-click / retry-safe UI.
{error, html_url}so the loser can navigate to the winner (mirrors thefork-target contention rule, 03 §8: "one Create wins, the other reports
409 with the winner's URL"). No content merge, no ownership transfer.
PUTwithout flag on an existing placeholder: 409 "already exists"(unchanged legacy semantic — the flag is what opts into idempotent
re-affirm; document the difference).
7. UI flow
/:owner(Repos page) and/(Owners) gain a "New repository"button (writers only — hide for anonymous without write; gate mirrors
require_writeso the button never promises what the POST refuses).Route:
/new(top-level; add to reserved-name awareness — NOT under/:owner/:repo, it creates them) with fields owner (prefilled: currentowner page / own name), name, object_format (advanced, default sha1),
visibility toggle (writes the materialized access.json), submit →
POST /api/v1/repos→ 201 navigates to/{o}/{r}(placeholder view);409 renders "already exists — take me there" link (uses
html_url); 400renders field errors inline (never tray).
/{o}/{r}whenhealth:"empty"+ marker present):Ticket 1's
EmptyRepoGuidePLUS placeholder extras: creator + created-atline, expiry note if set (§8), admin-only Delete button (existing
repo.delete()SDK + danger zone), and the clone/push commands(verbatim server
clone_url,copyTextparity with CloneMenu).web/sdk/src/, esbuild bundle, JSDoc typedefs per 01 §9 pattern):new
repos.js/create.jssubmodule:repos.create({owner,name, object_format,visibility}),repo.remove()already exists (delete).Static enumeration consistent with 08_ui_sdk.
inline (the Ticket-1
tolerateMissingdiscipline extended: expectedcontrol flow ≠
reportError).8. Risks
cheaper than pushing; a hostile writer could park
acme/*. Mitigations(normative): (a) org-namespace membership gate (§3) — the valuable
prefixes are org-owned; (b) optional expiry:
expires_at(default: none;config
server.placeholder_ttl,0=off) — an expired placeholder isre-creatable (Create of sidecar CAS-flips version; the manifest is
untouched) and a maintainer unit MAY delete expired-unborn placeholders
(manifest + sidecar, same linearization as Delete; never touches real
repos — predicate
refs==0 && expired); (c) per-principal cap(
server.placeholders_per_principal, default e.g. 20 — exact-key sidecarprobes per candidate? NO — cap enforced at create by a per-user counter
object? REJECT counters (new mutable family); instead enforce rate-limit
(existing middleware shape) and document the cap as future. Do NOT invent
a counter object in this ticket — say so explicitly.
refs==0read from a FRESH manifest GET inside the sweep CAS window; arepo that gained a ref between scan and delete is skipped (re-check, same
discipline as 01 §3 org-delete re-check). Sweep is a Seam-5 unit reusing
the listing cache path (off hot path, LIST acceptable for maintenance —
Delete itself LISTs,
registry.go:293-312).real repo → UI must key placeholder affordances on
refs==0 && marker,never marker alone (stated in §4.2; test it).
racing first-push clear 412-loops; bounded retry (01 §4, ≤5 then 409 to
the loser — the push already landed, so the loser is just the cleanup;
cleanup failure = stale marker, covered above).
not — create still requires
require_write; the flag only reserves names.Create + sidecar Create, parallelizable — independent keys, law 6:
parallelize; second's 412 after first's success → rollback first?
NO — manifest-without-sidecar IS a valid empty repo (Ticket 1 state);
the retry re-
Creates the sidecar idempotently. Same atomic-or-recoverableshape as 01-decision org creation #75).
9. EVIDENCE / perf notes
round-trip window; first push: identical to push-to-PUT-created-empty
today (+0 hot-path requests: no marker read on push, delete post-commit
off-response). Assert in sim: "placeholder create ≤1 window; first push
budget == baseline push ≤5".
HeadSeq:0 && refs:0; real repos pay nothing — branch on data in hand).State the branch explicitly so reviewers can verify law 6.
first-push adoption success, summary overhead on real repos (expect ~0).
org-gate/naming-400 (≥95%,
-race); node tests for create form +placeholder view predicates; browser drive: create → placeholder view →
git push -u origin mainfrom a real clone → guide clears, no 409.10. Acceptance criteria
acme/newthingvia UI → 201 → placeholder viewwith verbatim clone URL +
git push -u origin main+ creator line.git init+git push -u origin main(HTTP AND SSH) lands firsttry — no 409, no manual step; repo becomes real (guide clears on next
summary load;
healthflipsempty→healthy).already:true); differentprincipal → 409 with navigable
html_url.allowed (legacy parity).
.gitsuffix accepted and stripped.
(real) untouched; stale-marker-on-real renders as real.
make covergate holds; sim budgets unchanged-or-better; docs updatedsame change:
docs/go/06_server_http.md(§3 table + PUT row),docs/go/07_api.md(POST /repos + 201/409 shapes + discovery),docs/go/12_web_ui.md(/new+ placeholder view),docs/features/01_identity_permissions.md(org create gate + eageraccess default), Decisions appends (sidecar classification, org gate,
expiry defaults,
RegisterExposeddiscovery following Feature 10).11. Work breakdown
?placeholder=trueonrepoPut+ sidecar writer + org gate +eager access + adoption delete (post-commit) + tests. 2. API:
POST /api/v1/repostwin + discovery + SDK submodule. 3. Web:/newform +placeholder view + delete affordance + node tests. 4. Sweep/expiry (ONLY
if
placeholder_ttladopted — else document off) + EVIDENCE + browserproof. Ship 1+2+3 without 4 by defaulting TTL off.
REVIEW — Ticket 2 placeholder plan (against CODE, not docs)
Verdict: PROCEED-WITH-FIXES (design is sound — "same manifest + sidecar, adoption is guarantee+test" is the right call — but 5 blocking spec gaps).
VERIFIED CORRECT (checked in tree):
walhub repo create(cmd/walhub/main.go:64, repo.go:41). Confirmed.BLOCKING:
health; this plan's view keys on "health:empty + marker present" (§7) but never defines the SUMMARY projection of the marker (field name?placeholder:{created_by,created_at,expires_at}? boolean?). Define it: additive summary field, sidecar probe ONLY when HeadSeq==0 && refs==0 (branch on data in hand — real repos pay +0, consistent with both plans' law-6 claims). Wave order: #209 health first, this ticket adds the projection.html_urlbody — errors are plain-text by frozen convention (env.go writePlain/mapViewErr); a JSON 409 needs a convention waiver + Decisions entry, else render the URL inside plain text. Enumerate ALL added response fields (placeholder/clone_url/expires_at/warning/already) in 07_api.md (additive = fine, just list them).{subject:"user:<creator>"}with an anonymous creator yields "user:anonymous", which fails subject validation (user: subjects are emails; non-email owners synthesize empty bindings — access_test.go:26). Specify none-mode behavior (skip creator binding / visibility-only doc / skip eager materialization and rely on synthesis). Also state the adopt rule vs the access-bootstrap Create race (both Create-412-adopt — fine, just say so).SHOULD-FIX:
NIT: "01 §4 handles bounded retry ≤5" cite for access CAS loop — the loop bound lives in access.go:174-185 ("changed under you" 409); cite the file, not just the doc.
CROSS-PLAN: empty predicate (HeadSeq==0 && refs==0) and stale-marker rule (affordances key on refs==0 && marker, never marker alone) compose correctly with #209 health-from-refs. First-push adoption (no push-path 409, verified) is compatible with #209's suppressed-fetch UI. Only gap is B1 (joint summary shape) — fix there fixes both.
Plan revision R1 (review findings — R1 wins on conflict)
Blocking resolutions (normative)
placeholder: {created_by, created_at, expires_at} | null, probed from the sidecar ONLY whenHeadSeq==0 && refs==0(real repos +0 round trips). #209 landshealthfirst; this ticket adds the projection on top.POST /api/v1/reposviaserver.ExtraRoutes+api.RegisterExposed(Feature 10 precedent), both-lane twins explicit. No core-table edit (law 8).?placeholder=truejustified AS the shape in Decisions (not a shim): it selects create-semantics on the existing PUT; frozen 409-with-html_urlstays plain-text (writePlain); all added response fields enumerated in07_api.md.user:anonymousbinding (fails subject validation); none-mode relies on existing flag-driven grants; adopt rule vsaccess-bootstraprace documented (Create-wins, adopt-don't-overwrite).Should-fix adoptions
Reuse-or-justify
admin.js repo.create; org-gate 403 only on proven non-membership (probe errors → 503); marker clear inpushPipelinepost-CAS post-response;/newreserved-names row + CLI/setup-schema docs; sweep LIST bound;placeholders_per_principalcut (rate-limit + docs only); six Decisions entries.PR ready for review (do NOT merge): #218 — branch
feat/issue-210off origin/main (@48001e8, #209 already in).Waves 1–4 per R1 (TTL sweep off by default, as directed): backend (
?placeholder=true+ sidecar + org gate + eager access + hint-gated adoption + idempotency), API (POST /api/v1/repostwin via ExtraRoutes + RegisterExposed + discovery + SDK), web (/new+ placeholder view + delete + node tests), docs (06/07/12/01 + Decisions + EVIDENCE E14).Notable resolutions: B1 projection is empty-only (real +0, empty ≤1 — E13 row amended); B2 no core-table edit; B3 flag-as-shape + plain-text 409; B4 Delete-then-Create only; B5 none-mode skips creator binding; S1
repo.createstays flag-less +createPlaceholder/repos.create; same-principal re-create after push → 409 via unborn re-check; adoption hint set keeps unhinted pushes at zero marker ops (push-budget test unmodified).Proof: race-green api/identity/server/cmd; cover 95.4/97.2/95.6; node 417/417; e2e green; live HTTP+SSH push proof; Chromium 24/24 both themes console-clean. Deviations: none from R1 (409-inline browser path asserted via unit tests — auth-none mode is single-principal; browser asserted the idempotent re-affirm instead).
Review: PR #218 (feat/issue-210 → main) — create-repo placeholder
Verified in scratch worktree at
5dcd7f7(PR head444f98a+ 2 review-fix commits below). Main worktree untouched (still clean on48001e8). No browser drive (tests + reasoning only, as instructed); no docker/compose/system-package changes.R1 compliance — all five blocking rulings hold
PlaceholderInfo{created_by,created_at,expires_at}(internal/api/placeholder.go:64), probed by exact key ONLY when in-hand data saysHeadSeq==0 && refs==0(internal/api/summary.go:54); real repos +0 (pinned by the extendedTestSummaryOverviewRoundTrips, cumulative sidecar probes stay 1 after the healthy summary). Absent-projection renders as omitted key, and07_api.md:409documents "| null, omitted when null" — doc and code agree.CreateHandlerchained viasrv.ChainExtra(cmd/walhub/collab.go:216), discovery viaapi.RegisterExposed(collab.go:75); both lanes served + tested (placeholder_test.go:230);internal/api/routes.go,smart.go,internal/wal/untouched.?placeholder=truejustified in Decisions (07_api.md:783) and code (summary.go:93); conflicts staywritePlainwith the winner URL in-text (placeholder.go:253); all added fields enumerated in07 §9.1.1. Same-principal unborn re-create → 200already:true; different-principal/post-push/flag-less-PUT → legacy 409 shapes.PutCreate(placeholder.go:216) and one post-pushDelete(bind_ssh.go:323); zero Update paths (grepped). Sidecar classification (invitation class, no §14.11 change) is sound — agree, no frozen-list change needed.ValidPrincipalrequires an email (identity.go:121), so anon creators get a visibility-only doc (creategate.go:60); Create-wins/adopt-don't-overwrite documented and tested.Should-fix all adopted: S1 (
repo.createstays flag-less +createPlaceholder/repos.create, justified in Decisions), S2 (403 only on proven non-membership; probe error → 503 + Retry-After, tested), S3 (clear inpushPipelinepost-CAS post-response — shared by HTTPsmart.go:439and SSHbind_ssh.go:182, so both transports adopt), S4 (/newreserved row in06:217,643; noplaceholder_ttlkey exists so no CLI/setup-schema surface to document — TTL-off stated in07 §9.1.1+ E14), S5 (no sweep, no counters), S6 (eight Decisions bullets covering the required six).Adoption correctness ✓
Marker delete is post-CAS, post-response, fire-and-forget on the control-plane transport; the push path never reads the marker and never branches on placeholder-ness (Open wins → no
ErrExists/409 by construction — the core promise holds by inspection ofpushPipeline). Hint-gating means unhinted pushes issue zero marker ops (pinned byplaceholder_adopt_test.go); the push-budget test (cmd/walhub/push_budget_test.go:163) is genuinely unmodified (empty diff).health209_test.gowas modified but only the summary round-trip bound (0 → ≤1 GET on the empty path, fsck probe still forbidden) — disclosed and correct.Findings fixed directly (pushed to
origin/feat/issue-210)7c59fa1] POSTvisibility:privatesilently materialized a public repo.createRequest.Visibilitywas validated (placeholder.go:372) but never passed toEnsureRepoAccess, which hardcodedVisibilityPublic— the/newvisibility toggle was dead and07 §9.1.1documented a lie. Fix:AccessBootstrap.EnsureRepoAccesstakes the request visibility (""from the PUT-flag path = public default; plain string, no api→identity import, law 8 intact); identity honors"private", unknown falls back to public. Tests:TestPostReposVisibilityThreaded(threading) + private-doc case inTestEnsureRepoAccess. Coverage unchanged (api 95.4 / identity 97.2).5dcd7f7] E14 transposed the budget numbers ("cold 9 / warm 8" vs E10's "cold 8 / warm 9"). One-line doc fix.Non-blocking notes (no action required)
gaps5shape) and the push path never reads the marker — plus the E14-claimed live HTTP+SSH proof. Consider a create→push→adopted e2e if a sweep ever lands.uiRouteCollisionWarninglistsnotifications, which06 §3's reserved-names list omits — the code is right (index.jsx:61has the route); the doc list predates it. Trivial future tidy, not this PR's debt.TestUIAssetConceptsfails without a freshmake web(needsconcepts/push.gif) — environmental, pre-existing, unrelated; green aftermake webin the scratch worktree.Test results (scratch worktree, PR head + fixes)
go test -race ./internal/api/ ./internal/identity/→ ok; cover 95.4 / 97.2 ✓go test -race ./internal/server/... ./cmd/...→ ok (aftermake webfor the embed); server cover 95.6 ✓ (make covergate covers onlyinternal/...; cmd exempt by the Makefile)node --test web/test/unit/*.test.js→ 417/417 ✓go test ./internal/e2e/...(real git binary) → ok ✓gofmt -lclean,go vetclean on all touched packages ✓go.mod/go.sum/npm-manifest diffs → no new deps ✓MERGE RECOMMENDATION: ready to merge
R1 B1–B5 + should-fix all verified, the one real bug found (visibility) is fixed with tests, budgets/coverage/gates hold. (Not merging per instructions.)
Implemented in PR #218 incl. review private-visibility fix (all R1 rulings verified; 95.4/97.2/95.6% + 417/417 + e2e), merged. Closing.