Explicit create-repo placeholder with push docs #210

Closed
opened 2026-09-08 19:13:09 +00:00 by crueber · 6 comments
Owner

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.

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.
Author
Owner

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 CLI walhub repo create (cmd/walhub/main.go:64) — both undiscoverable (no UI, no SDK
method: web/sdk/src/repo.js has no create) — or (ii) implicitly via
auto-create-on-push (internal/server/smart.go:143-157,419). There is no
UI 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)

  • Placeholder: a repo whose bucket state is indistinguishable from a
    freshly PUT-created repo
    — manifest HeadSeq: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
    .
  • Real: a repo with ≥1 ref (HeadSeq>0 or branches/tags>0). Transition
    placeholder→real happens exactly once, atomically, on the first landed
    push (see §4). The marker clears; the repo is thereafter ordinary.
  • DesignPlaceholder (name for the mechanism): the receive-pack /
    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).

  • Recommended: 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 health projection read it; the push path MUST NOT read it
    on the hot path, see §4).
  • Alternative (if reviewers prefer zero new keys): reuse access.json.
    Repos created via placeholder get access.json materialized at create
    time (instead of lazily synthesized per 01 §10) with an additive optional
    field, e.g. "placeholder": {"created_by":…, "created_at":…} (14 §14.12
    field 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.
  • Frozen-list accounting: sidecar option adds
    repos/<o>/<r>/meta/placeholder.json (Create-then-delete, immutable
    while 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_format is 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-level
create twin under /api/v1 + /api-browser/v1 per the lane-segment rule;
discovery endpoints[] entries (additive — the import feature set the
precedent with api.RegisterExposed, 14-decisions Feature 10).

Method + path Auth (P6 → capability matrix, 01 §5) Behavior
PUT /{o}/{r} / PUT …/api (EXISTING, extended) require_write (unchanged) + optional ?placeholder=true With flag: create manifest (as today) + Create placeholder sidecar (or materialized access field). Returns 201 {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) Body {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_admin per 01 §5 matrix (admin binding or host admin) Unchanged semantics (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.
First push (git receive-pack, both HTTP smart.go:404-440 and SSH bind_ssh.go:104) require_write (existing) §4 adoption. No new status codes.

Who may create where (org/owner rules):

  • Personal namespace (owner == principal-derived name / any owner string
    today — there is NO owner-existence check on the create path today):
    any writer (require_write) may create under any owner name that passes
    naming validation. This matches current repoPut (no org gate) and the
    zero-config default (auto_create_on_push=true, auth none).
  • Org namespace (orgs/<org>/ exists with members.json): creation ALSO
    requires org membership (member+) — checked by one exact-key GET of
    orgs/<org>/members.json (same cost class as the P6 team expansion
    probes, 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.json at placeholder creation: materialize the 01 §10 synthesized
    default 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 = someone
    raced us — adopt, don't overwrite).
  • Policy/templates: none evaluated at create (policy gates pushes, 14 §14.4
    honesty rule). Owner-scoped policy templates at create time are a
    documented future (14 §14.10.1: repo create --policy-from), not this
    ticket.

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/conflict
instead of landing. The design makes this impossible by construction:

  1. The push path never branches on placeholder-ness for the ref write.
    receivePackLocal (smart.go:419): engine.Repo(ctx, id, create=true, …) → Registry.Open succeeds (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:384 shape). There is no 409 anywhere on the push path
    today
    — 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.
  2. Marker clearing (the only new write): after the first successful
    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 Delete of meta/placeholder.json (or CAS-clear of the
    access field). Ordering rules (law 4/6):
    • The ref CAS is the commit point; marker deletion is post-commit
      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).
    • NEVER gate the push response on the marker delete (no extra sequential
      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).
    • NEVER read the marker on the push hot path (no extra GET per push).
  3. Auto-create interplay: if auto_create_on_push=true and the target
    is 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).
  4. Manifest/ref states, enumerated:
    • placeholder: HeadSeq:0, refs:0, marker present → push lands → real.
    • PUT-created (no flag): 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).
    • real: HeadSeq>0, refs>0, marker absent-or-stale → push normal;
      stale marker ignored + opportunistically deleted.
    • create-twice on placeholder: 409 (see §6) — theosecond creator does NOT
      steal; first push wins reality.

5. Naming / validation

  • git.ParseRepoId is the single validator (contract.go:32-47): two
    segments, each [A-Za-z0-9._-]{1,100}, no leading ., not .., optional
    .git suffix 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
    /:owner UI page only; git/API paths unaffected — creation under them is
    allowed 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|sha256 only (400 otherwise — existing
    ObjectFormatFrom behavior). Default sha1 (matches bind_wal.go engine
    default in smart.go:420 git.Sha1).
  • Case: owner/name preserved as given for display; storage prefix uses the
    given spelling (no silent lowercasing — key layout is byte-compat, law 5;
    document that Acme/X and acme/X are distinct prefixes, same as today).

6. Idempotency (create-twice)

  • Same principal, same name, placeholder still unborn: idempotent success
    — 200 {…, placeholder:true, already:true} (not 201), no state change
    (sidecar Create 412 → treat as done, exactly the P3 event-path rule).
    Rationale: double-click / retry-safe UI.
  • Different principal, same name (born or unborn): 409 with
    {error, html_url} so the loser can navigate to the winner (mirrors the
    fork-target contention rule, 03 §8: "one Create wins, the other reports
    409 with the winner's URL"). No content merge, no ownership transfer.
  • Same principal re-create AFTER first push (now real): 409 (it is a repo).
  • PUT without 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

  • Entry: /:owner (Repos page) and / (Owners) gain a "New repository"
    button (writers only — hide for anonymous without write; gate mirrors
    require_write so 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: current
    owner 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); 400
    renders field errors inline (never tray).
  • Placeholder repo view (/{o}/{r} when health:"empty" + marker present):
    Ticket 1's EmptyRepoGuide PLUS placeholder extras: creator + created-at
    line, expiry note if set (§8), admin-only Delete button (existing
    repo.delete() SDK + danger zone), and the clone/push commands
    (verbatim server clone_url, copyText parity with CloneMenu).
  • SDK (web/sdk/src/, esbuild bundle, JSDoc typedefs per 01 §9 pattern):
    new repos.js/create.js submodule: repos.create({owner,name, object_format,visibility}), repo.remove() already exists (delete).
    Static enumeration consistent with 08_ui_sdk.
  • No tray on expected outcomes: 409-exists and validation 400s render
    inline (the Ticket-1 tolerateMissing discipline extended: expected
    control flow ≠ reportError).

8. Risks

  • Placeholder squatting (names). Reserving names without pushing is
    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 is
    re-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 sidecar
    probes 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.
  • Expiry deleting "real work". Expiry/sweep predicate includes
    refs==0 read from a FRESH manifest GET inside the sweep CAS window; a
    repo 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).
  • Stale marker confusion. Crash-between-CAS-and-delete leaves marker on
    real repo → UI must key placeholder affordances on refs==0 && marker,
    never marker alone (stated in §4.2; test it).
  • Access-field alternative contention (if sidecar rejected): admin edit
    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).
  • Auto-create-off admins may read placeholder creation as bypass: it is
    not — create still requires require_write; the flag only reserves names.
  • No silent waiting (law 7): create is synchronous (2 PUTs: manifest
    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-recoverable
    shape as 01-decision org creation #75).

9. EVIDENCE / perf notes

  • Create cost: 2 PUTs (parallel) + 1 access.json Create (parallel) = 1
    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".
  • Summary cost: +0/+1 GET (sidecar probe only when manifest shows
    HeadSeq:0 && refs:0; real repos pay nothing — branch on data in hand).
    State the branch explicitly so reviewers can verify law 6.
  • EVIDENCE.md entries: create latency (memory + filesystem backends),
    first-push adoption success, summary overhead on real repos (expect ~0).
  • Coverage: table-driven httptest for PUT-flag/POST/create-twice/409-shape/
    org-gate/naming-400 (≥95%, -race); node tests for create form +
    placeholder view predicates; browser drive: create → placeholder view →
    git push -u origin main from a real clone → guide clears, no 409.

10. Acceptance criteria

  1. Authed writer creates acme/newthing via UI → 201 → placeholder view
    with verbatim clone URL + git push -u origin main + creator line.
  2. Fresh git init + git push -u origin main (HTTP AND SSH) lands first
    try — no 409, no manual step; repo becomes real (guide clears on next
    summary load; health flips empty→healthy).
  3. Create-twice same principal → idempotent 200 (already:true); different
    principal → 409 with navigable html_url.
  4. Non-member creating under an org prefix → 403; under unclaimed prefix →
    allowed (legacy parity).
  5. Naming violations → inline 400s (UI) / plain-text 400 (API); .git
    suffix accepted and stripped.
  6. Delete of placeholder (admin) → 204; re-create after delete → 201.
  7. Expiry (if configured): expired-unborn sweep deletes; expired-then-pushed
    (real) untouched; stale-marker-on-real renders as real.
  8. make cover gate holds; sim budgets unchanged-or-better; docs updated
    same 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 + eager
    access default), Decisions appends (sidecar classification, org gate,
    expiry defaults, RegisterExposed discovery following Feature 10).
  9. Real Chromium proof: create → push → real, dark + light, console clean.

11. Work breakdown

  1. Backend: ?placeholder=true on repoPut + sidecar writer + org gate +
    eager access + adoption delete (post-commit) + tests. 2. API: POST /api/v1/repos twin + discovery + SDK submodule. 3. Web: /new form +
    placeholder view + delete affordance + node tests. 4. Sweep/expiry (ONLY
    if placeholder_ttl adopted — else document off) + EVIDENCE + browser
    proof. Ship 1+2+3 without 4 by defaulting TTL off.
# 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 CLI `walhub repo create` (`cmd/walhub/main.go:64`) — both undiscoverable (no UI, no SDK method: `web/sdk/src/repo.js` has no `create`) — or (ii) implicitly via auto-create-on-push (`internal/server/smart.go:143-157,419`). There is no UI 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) - **Placeholder**: a repo whose bucket state is *indistinguishable from a freshly `PUT`-created repo* — manifest `HeadSeq: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**. - **Real**: a repo with ≥1 ref (HeadSeq>0 or branches/tags>0). Transition placeholder→real happens exactly once, atomically, on the first landed push (see §4). The marker clears; the repo is thereafter ordinary. - **DesignPlaceholder** (name for the mechanism): the receive-pack / 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). - **Recommended: `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 `health` projection read it; the push path MUST NOT read it on the hot path, see §4). - **Alternative (if reviewers prefer zero new keys): reuse `access.json`.** Repos created via placeholder get `access.json` materialized at create time (instead of lazily synthesized per 01 §10) with an additive optional field, e.g. `"placeholder": {"created_by":…, "created_at":…}` (14 §14.12 field 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. - **Frozen-list accounting**: sidecar option adds `repos/<o>/<r>/meta/placeholder.json` (Create-then-delete, immutable while 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_format` is 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-level create twin under `/api/v1` + `/api-browser/v1` per the lane-segment rule; discovery `endpoints[]` entries (additive — the import feature set the precedent with `api.RegisterExposed`, 14-decisions Feature 10). | Method + path | Auth (P6 → capability matrix, 01 §5) | Behavior | |---|---|---| | `PUT /{o}/{r}` / `PUT …/api` (EXISTING, extended) | `require_write` (unchanged) + optional `?placeholder=true` | With flag: create manifest (as today) + `Create` placeholder sidecar (or materialized access field). Returns `201 {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) | Body `{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_admin` per 01 §5 matrix (admin binding or host admin) | Unchanged semantics (`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. | | First push (git `receive-pack`, both HTTP `smart.go:404-440` and SSH `bind_ssh.go:104`) | `require_write` (existing) | §4 adoption. No new status codes. | **Who may create where (org/owner rules):** - Personal namespace (`owner == principal-derived name` / any owner string today — there is NO owner-existence check on the create path today): any writer (`require_write`) may create under any owner name that passes naming validation. This matches current `repoPut` (no org gate) and the zero-config default (`auto_create_on_push=true`, auth `none`). - Org namespace (`orgs/<org>/` exists with `members.json`): creation ALSO requires org membership (member+) — checked by one exact-key GET of `orgs/<org>/members.json` (same cost class as the P6 team expansion probes, 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.json` at placeholder creation: materialize the 01 §10 synthesized default 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 = someone raced us — adopt, don't overwrite). - Policy/templates: none evaluated at create (policy gates pushes, 14 §14.4 honesty rule). Owner-scoped policy templates at create time are a documented future (`14 §14.10.1`: `repo create --policy-from`), not this ticket. ## 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/conflict instead of landing. The design makes this impossible by construction: 1. **The push path never branches on placeholder-ness for the ref write.** `receivePackLocal` (`smart.go:419`): `engine.Repo(ctx, id, create=true, …)` → `Registry.Open` succeeds (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:384` shape). **There is no 409 anywhere on the push path today** — 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. 2. **Marker clearing (the only new write):** after the first successful 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 `Delete` of `meta/placeholder.json` (or CAS-clear of the access field). Ordering rules (law 4/6): - The ref CAS is the commit point; marker deletion is post-commit 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). - NEVER gate the push response on the marker delete (no extra sequential 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). - NEVER read the marker on the push hot path (no extra GET per push). 3. **Auto-create interplay:** if `auto_create_on_push=true` and the target is 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). 4. **Manifest/ref states, enumerated:** - placeholder: `HeadSeq:0, refs:0, marker present` → push lands → real. - PUT-created (no flag): `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). - real: `HeadSeq>0, refs>0, marker absent-or-stale` → push normal; stale marker ignored + opportunistically deleted. - create-twice on placeholder: 409 (see §6) — theosecond creator does NOT steal; first push wins reality. ## 5. Naming / validation - `git.ParseRepoId` is the single validator (`contract.go:32-47`): two segments, each `[A-Za-z0-9._-]{1,100}`, no leading `.`, not `..`, optional `.git` suffix 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 `/:owner` UI page only; git/API paths unaffected — creation under them is allowed 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|sha256` only (400 otherwise — existing `ObjectFormatFrom` behavior). Default sha1 (matches `bind_wal.go` engine default in `smart.go:420` `git.Sha1`). - Case: owner/name preserved as given for display; storage prefix uses the given spelling (no silent lowercasing — key layout is byte-compat, law 5; document that `Acme/X` and `acme/X` are distinct prefixes, same as today). ## 6. Idempotency (create-twice) - Same principal, same name, placeholder still unborn: **idempotent success** — `200 {…, placeholder:true, already:true}` (not 201), no state change (sidecar `Create` 412 → treat as done, exactly the P3 event-path rule). Rationale: double-click / retry-safe UI. - Different principal, same name (born or unborn): **409** with `{error, html_url}` so the loser can navigate to the winner (mirrors the fork-target contention rule, 03 §8: "one Create wins, the other reports 409 with the winner's URL"). No content merge, no ownership transfer. - Same principal re-create AFTER first push (now real): 409 (it is a repo). - `PUT` without 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 - Entry: `/:owner` (Repos page) and `/` (Owners) gain a "New repository" button (writers only — hide for anonymous without write; gate mirrors `require_write` so 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: current owner 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`); 400 renders field errors inline (never tray). - Placeholder repo view (`/{o}/{r}` when `health:"empty"` + marker present): Ticket 1's `EmptyRepoGuide` PLUS placeholder extras: creator + created-at line, expiry note if set (§8), admin-only Delete button (existing `repo.delete()` SDK + danger zone), and the clone/push commands (verbatim server `clone_url`, `copyText` parity with CloneMenu). - SDK (`web/sdk/src/`, esbuild bundle, JSDoc typedefs per 01 §9 pattern): new `repos.js`/`create.js` submodule: `repos.create({owner,name, object_format,visibility})`, `repo.remove()` already exists (delete). Static enumeration consistent with 08_ui_sdk. - No tray on expected outcomes: 409-exists and validation 400s render inline (the Ticket-1 `tolerateMissing` discipline extended: expected control flow ≠ `reportError`). ## 8. Risks - **Placeholder squatting (names).** Reserving names without pushing is 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 is re-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 sidecar probes 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. - **Expiry deleting "real work".** Expiry/sweep predicate includes `refs==0` read from a FRESH manifest GET inside the sweep CAS window; a repo 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`). - **Stale marker confusion.** Crash-between-CAS-and-delete leaves marker on real repo → UI must key placeholder affordances on `refs==0 && marker`, never marker alone (stated in §4.2; test it). - **Access-field alternative contention** (if sidecar rejected): admin edit 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). - **Auto-create-off admins** may read placeholder creation as bypass: it is not — create still requires `require_write`; the flag only reserves names. - **No silent waiting** (law 7): create is synchronous (2 PUTs: manifest 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-`Create`s the sidecar idempotently. Same atomic-or-recoverable shape as 01-decision org creation #75). ## 9. EVIDENCE / perf notes - Create cost: 2 PUTs (parallel) + 1 access.json Create (parallel) = 1 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". - Summary cost: +0/+1 GET (sidecar probe only when manifest shows `HeadSeq:0 && refs:0`; real repos pay nothing — branch on data in hand). State the branch explicitly so reviewers can verify law 6. - EVIDENCE.md entries: create latency (memory + filesystem backends), first-push adoption success, summary overhead on real repos (expect ~0). - Coverage: table-driven httptest for PUT-flag/POST/create-twice/409-shape/ org-gate/naming-400 (≥95%, `-race`); node tests for create form + placeholder view predicates; browser drive: create → placeholder view → `git push -u origin main` from a real clone → guide clears, no 409. ## 10. Acceptance criteria 1. Authed writer creates `acme/newthing` via UI → 201 → placeholder view with verbatim clone URL + `git push -u origin main` + creator line. 2. Fresh `git init` + `git push -u origin main` (HTTP AND SSH) lands first try — no 409, no manual step; repo becomes real (guide clears on next summary load; `health` flips `empty→healthy`). 3. Create-twice same principal → idempotent 200 (`already:true`); different principal → 409 with navigable `html_url`. 4. Non-member creating under an org prefix → 403; under unclaimed prefix → allowed (legacy parity). 5. Naming violations → inline 400s (UI) / plain-text 400 (API); `.git` suffix accepted and stripped. 6. Delete of placeholder (admin) → 204; re-create after delete → 201. 7. Expiry (if configured): expired-unborn sweep deletes; expired-then-pushed (real) untouched; stale-marker-on-real renders as real. 8. `make cover` gate holds; sim budgets unchanged-or-better; docs updated same 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 + eager access default), Decisions appends (sidecar classification, org gate, expiry defaults, `RegisterExposed` discovery following Feature 10). 9. Real Chromium proof: create → push → real, dark + light, console clean. ## 11. Work breakdown 1. Backend: `?placeholder=true` on `repoPut` + sidecar writer + org gate + eager access + adoption delete (post-commit) + tests. 2. API: `POST /api/v1/repos` twin + discovery + SDK submodule. 3. Web: `/new` form + placeholder view + delete affordance + node tests. 4. Sweep/expiry (ONLY if `placeholder_ttl` adopted — else document off) + EVIDENCE + browser proof. Ship 1+2+3 without 4 by defaulting TTL off.
Author
Owner

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):

  • Create paths: repoPut (api/summary.go:54-85, AuthWrite, 201/409 plain-text) + CLI walhub repo create (cmd/walhub/main.go:64, repo.go:41). Confirmed.
  • "No 409 on the push path": 409 surfaces only from repoPut/createSlow (registry.go:241 ErrExists); receive-pack maps not-found to 404/plain or wire ng (smart.go:419-428). pushPipeline is shared HTTP (smart.go:439) + SSH (bind_ssh.go:181). So first-push-to-placeholder lands by construction — "guarantee + contract test" framing is correct, no new push branch needed.
  • Naming validator: git/contract.go:32-47 (two segments, [A-Za-z0-9._-]{1,100}, no leading dot, .git stripped). Storage prefix uses given spelling verbatim (StorePrefix). Case statement ("Acme/X vs acme/X distinct") accurate.
  • Discovery precedent: api.RegisterExposed exists (discovery.go:67), called from composition (cmd/walhub/repoimport.go:29-30). Cited correctly.
  • access.json synthesis on read: identity/access.go:90-109 + gate.go:29-30; non-email owners synthesize EMPTY bindings (access_test.go:26). Material for B5 below.
  • Reserved-name mechanics: 06:216 list confirmed; /:owner catch confirmed (web/src/index.jsx:60); adding /new needs an explicit route + reserved-row update. Plan notes this — good.
  • Managed-ref interplay: IsManagedRef covers refs/pull/** only (git/managed.go:24-30) — placeholder pushes of refs/heads/* unaffected. "3 PUTs in one window, parallelizable" (manifest+sidecar+access, independent keys) is law-6-correct; "manifest-without-sidecar IS valid empty" matches #209 Empty. Good.

BLOCKING:

  • [B1] Joint summary shape with #209 is undefined — the placeholder view has no data source. #209 owns summary 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.
  • [B2] POST /api/v1/repos seam placement unspecified. Top-level NonRepo routes live in the CORE table (api/routes.go:41-52); the feature-correct path per precedent (Feature 10) is the server.ExtraRoutes chain + api.RegisterExposed (NOT a core-table edit — law 8: a core-table edit is a core revision, not a registration). State the seam + both-lane twins explicitly (note: POST ssh-keys at routes.go:43 is api-lane-only — twins are not automatic; enumerate them).
  • [B3] PUT ?placeholder=true vs pre-1.0 law + frozen 409. Duplicate PUT -> 409 plain-text today (summary.go:74, gaps5_test.go:387-388). The flag builds a 6-cell matrix (flag x same/diff principal x born/unborn) with 200-already:true vs 409 — a second shape behind a query flag, which the AGENTS pre-1.0 rule ("no aliases, shims, deprecated flags") presumptively rejects, and a status-mapping change per 14 §14.12. Either justify the flag AS the shape with a Decisions entry, or simplify to uniform idempotent PUT (same-principal re-PUT -> 200). Related: 409-with-html_url body — 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).
  • [B4] Sidecar lifecycle wording contradicts its own classification. §8: "re-creatable (Create of sidecar CAS-flips version)". If placeholder.json is Create-only/delete-on-transition (invitation class — correctly cited Wave-A precedent, no §14.11 change), there is NO CAS-flip: lifecycle is Delete-then-Create only, never Update. Pin that; any Update path forces a frozen-list amendment.
  • [B5] auth-none eager access.json binding is unwriteable as spec'd. Materializing {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:

  • [S1] SDK: admin.js ALREADY exposes repo.create (PUT) (admin.js:20) — "no SDK method" is true only of repo.js. Use the existing surface (+flag) or justify the new repos.js/create.js submodule; don't ship two create paths (same "one writer implementation" discipline the plan itself cites).
  • [S2] Org-gate TOCTOU: membership GET precedes manifest Create; org created/deleted in between -> state the fail-open/closed rule (recommend: 403 only on proven non-membership; absent-org = legacy-open; probe errors = 503, never 403-as-404).
  • [S3] Name the marker-clear call site: server.pushPipeline post-CAS, post-response (fire-and-forget, control-plane transport) — keeps law-8 layering auditable (server orchestrates the store Delete; wal/git untouched).
  • [S4] Doc list: add 06 reserved-names row for /new (plan lists §3 table + PUT row only), 11_config_cli.md + setup-schema/env-overlay note for server.placeholder_ttl.
  • [S5] Expiry-sweep bound: LIST + per-candidate probes per pass needs an explicit bound (only when TTL>0, paged, maintainer role, off hot path). Keep placeholders_per_principal cut (already cut — hold the line, no counter objects).
  • [S6] Decisions entries required: sidecar classification (Create-only, no §14.11 change), org create-gate (01 §5 matrix amendment), PUT flag justification (pre-1.0 rule), discovery via RegisterExposed (Feature-10 exception — note 01/02/03/C2/05/06 routes are NOT in discovery).

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.

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): - Create paths: repoPut (api/summary.go:54-85, AuthWrite, 201/409 plain-text) + CLI `walhub repo create` (cmd/walhub/main.go:64, repo.go:41). Confirmed. - "No 409 on the push path": 409 surfaces only from repoPut/createSlow (registry.go:241 ErrExists); receive-pack maps not-found to 404/plain or wire ng (smart.go:419-428). pushPipeline is shared HTTP (smart.go:439) + SSH (bind_ssh.go:181). So first-push-to-placeholder lands by construction — "guarantee + contract test" framing is correct, no new push branch needed. - Naming validator: git/contract.go:32-47 (two segments, [A-Za-z0-9._-]{1,100}, no leading dot, .git stripped). Storage prefix uses given spelling verbatim (StorePrefix). Case statement ("Acme/X vs acme/X distinct") accurate. - Discovery precedent: api.RegisterExposed exists (discovery.go:67), called from composition (cmd/walhub/repoimport.go:29-30). Cited correctly. - access.json synthesis on read: identity/access.go:90-109 + gate.go:29-30; non-email owners synthesize EMPTY bindings (access_test.go:26). Material for B5 below. - Reserved-name mechanics: 06:216 list confirmed; /:owner catch confirmed (web/src/index.jsx:60); adding /new needs an explicit route + reserved-row update. Plan notes this — good. - Managed-ref interplay: IsManagedRef covers refs/pull/** only (git/managed.go:24-30) — placeholder pushes of refs/heads/* unaffected. "3 PUTs in one window, parallelizable" (manifest+sidecar+access, independent keys) is law-6-correct; "manifest-without-sidecar IS valid empty" matches #209 Empty. Good. BLOCKING: - [B1] Joint summary shape with #209 is undefined — the placeholder view has no data source. #209 owns summary `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. - [B2] POST /api/v1/repos seam placement unspecified. Top-level NonRepo routes live in the CORE table (api/routes.go:41-52); the feature-correct path per precedent (Feature 10) is the server.ExtraRoutes chain + api.RegisterExposed (NOT a core-table edit — law 8: a core-table edit is a core revision, not a registration). State the seam + both-lane twins explicitly (note: POST ssh-keys at routes.go:43 is api-lane-only — twins are not automatic; enumerate them). - [B3] PUT ?placeholder=true vs pre-1.0 law + frozen 409. Duplicate PUT -> 409 plain-text today (summary.go:74, gaps5_test.go:387-388). The flag builds a 6-cell matrix (flag x same/diff principal x born/unborn) with 200-already:true vs 409 — a second shape behind a query flag, which the AGENTS pre-1.0 rule ("no aliases, shims, deprecated flags") presumptively rejects, and a status-mapping change per 14 §14.12. Either justify the flag AS the shape with a Decisions entry, or simplify to uniform idempotent PUT (same-principal re-PUT -> 200). Related: 409-with-`html_url` body — 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). - [B4] Sidecar lifecycle wording contradicts its own classification. §8: "re-creatable (Create of sidecar CAS-flips version)". If placeholder.json is Create-only/delete-on-transition (invitation class — correctly cited Wave-A precedent, no §14.11 change), there is NO CAS-flip: lifecycle is Delete-then-Create only, never Update. Pin that; any Update path forces a frozen-list amendment. - [B5] auth-none eager access.json binding is unwriteable as spec'd. Materializing `{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: - [S1] SDK: admin.js ALREADY exposes repo.create (PUT) (admin.js:20) — "no SDK method" is true only of repo.js. Use the existing surface (+flag) or justify the new repos.js/create.js submodule; don't ship two create paths (same "one writer implementation" discipline the plan itself cites). - [S2] Org-gate TOCTOU: membership GET precedes manifest Create; org created/deleted in between -> state the fail-open/closed rule (recommend: 403 only on proven non-membership; absent-org = legacy-open; probe errors = 503, never 403-as-404). - [S3] Name the marker-clear call site: server.pushPipeline post-CAS, post-response (fire-and-forget, control-plane transport) — keeps law-8 layering auditable (server orchestrates the store Delete; wal/git untouched). - [S4] Doc list: add 06 reserved-names row for /new (plan lists §3 table + PUT row only), 11_config_cli.md + setup-schema/env-overlay note for server.placeholder_ttl. - [S5] Expiry-sweep bound: LIST + per-candidate probes per pass needs an explicit bound (only when TTL>0, paged, maintainer role, off hot path). Keep placeholders_per_principal cut (already cut — hold the line, no counter objects). - [S6] Decisions entries required: sidecar classification (Create-only, no §14.11 change), org create-gate (01 §5 matrix amendment), PUT flag justification (pre-1.0 rule), discovery via RegisterExposed (Feature-10 exception — note 01/02/03/C2/05/06 routes are NOT in discovery). 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.
Author
Owner

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

Blocking resolutions (normative)

  • B1 (joint) — summary placeholder projection defined HERE: additive placeholder: {created_by, created_at, expires_at} | null, probed from the sidecar ONLY when HeadSeq==0 && refs==0 (real repos +0 round trips). #209 lands health first; this ticket adds the projection on top.
  • B2 — POST /api/v1/repos via server.ExtraRoutes + api.RegisterExposed (Feature 10 precedent), both-lane twins explicit. No core-table edit (law 8).
  • B3 — ?placeholder=true justified AS the shape in Decisions (not a shim): it selects create-semantics on the existing PUT; frozen 409-with-html_url stays plain-text (writePlain); all added response fields enumerated in 07_api.md.
  • B4 — sidecar lifecycle is Delete-then-Create ONLY, never Update (no frozen-list change). Expiry re-create = fresh Create.
  • B5 — auth-none behavior specified: no eager user:anonymous binding (fails subject validation); none-mode relies on existing flag-driven grants; adopt rule vs access-bootstrap race 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 in pushPipeline post-CAS post-response; /new reserved-names row + CLI/setup-schema docs; sweep LIST bound; placeholders_per_principal cut (rate-limit + docs only); six Decisions entries.

# Plan revision R1 (review findings — R1 wins on conflict) ## Blocking resolutions (normative) - **B1 (joint) — summary placeholder projection defined HERE:** additive `placeholder: {created_by, created_at, expires_at} | null`, probed from the sidecar ONLY when `HeadSeq==0 && refs==0` (real repos +0 round trips). #209 lands `health` first; this ticket adds the projection on top. - **B2 — `POST /api/v1/repos` via `server.ExtraRoutes` + `api.RegisterExposed`** (Feature 10 precedent), both-lane twins explicit. No core-table edit (law 8). - **B3 — `?placeholder=true` justified AS the shape** in Decisions (not a shim): it selects create-semantics on the existing PUT; frozen 409-with-`html_url` stays plain-text (`writePlain`); all added response fields enumerated in `07_api.md`. - **B4 — sidecar lifecycle is Delete-then-Create ONLY**, never Update (no frozen-list change). Expiry re-create = fresh Create. - **B5 — auth-none behavior specified:** no eager `user:anonymous` binding (fails subject validation); none-mode relies on existing flag-driven grants; adopt rule vs `access-bootstrap` race 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 in `pushPipeline` post-CAS post-response; `/new` reserved-names row + CLI/setup-schema docs; sweep LIST bound; `placeholders_per_principal` cut (rate-limit + docs only); six Decisions entries.
Author
Owner

PR ready for review (do NOT merge): #218 — branch feat/issue-210 off 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/repos twin 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.create stays 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).

PR ready for review (do NOT merge): https://git.packden.us/crueber/walhub/pulls/218 — branch `feat/issue-210` off 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/repos` twin 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.create` stays 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).
Author
Owner

Review: PR #218 (feat/issue-210 → main) — create-repo placeholder

Verified in scratch worktree at 5dcd7f7 (PR head 444f98a + 2 review-fix commits below). Main worktree untouched (still clean on 48001e8). No browser drive (tests + reasoning only, as instructed); no docker/compose/system-package changes.

R1 compliance — all five blocking rulings hold

  • B1 projection shape + empty-only probe ✓ — PlaceholderInfo{created_by,created_at,expires_at} (internal/api/placeholder.go:64), probed by exact key ONLY when in-hand data says HeadSeq==0 && refs==0 (internal/api/summary.go:54); real repos +0 (pinned by the extended TestSummaryOverviewRoundTrips, cumulative sidecar probes stay 1 after the healthy summary). Absent-projection renders as omitted key, and 07_api.md:409 documents "| null, omitted when null" — doc and code agree.
  • B2 ExtraRoutes + twins, no core edit ✓ — CreateHandler chained via srv.ChainExtra (cmd/walhub/collab.go:216), discovery via api.RegisterExposed (collab.go:75); both lanes served + tested (placeholder_test.go:230); internal/api/routes.go, smart.go, internal/wal/ untouched.
  • B3 flag-as-shape + plain-text 409 + enumerated fields ✓ — ?placeholder=true justified in Decisions (07_api.md:783) and code (summary.go:93); conflicts stay writePlain with the winner URL in-text (placeholder.go:253); all added fields enumerated in 07 §9.1.1. Same-principal unborn re-create → 200 already:true; different-principal/post-push/flag-less-PUT → legacy 409 shapes.
  • B4 Delete-then-Create only ✓ — the marker key sees exactly one PutCreate (placeholder.go:216) and one post-push Delete (bind_ssh.go:323); zero Update paths (grepped). Sidecar classification (invitation class, no §14.11 change) is sound — agree, no frozen-list change needed.
  • B5 none-mode specified ✓ — ValidPrincipal requires 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.create stays flag-less + createPlaceholder/repos.create, justified in Decisions), S2 (403 only on proven non-membership; probe error → 503 + Retry-After, tested), S3 (clear in pushPipeline post-CAS post-response — shared by HTTP smart.go:439 and SSH bind_ssh.go:182, so both transports adopt), S4 (/new reserved row in 06:217,643; no placeholder_ttl key exists so no CLI/setup-schema surface to document — TTL-off stated in 07 §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 of pushPipeline). Hint-gating means unhinted pushes issue zero marker ops (pinned by placeholder_adopt_test.go); the push-budget test (cmd/walhub/push_budget_test.go:163) is genuinely unmodified (empty diff). health209_test.go was 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)

  1. [FIXED 7c59fa1] POST visibility:private silently materialized a public repo. createRequest.Visibility was validated (placeholder.go:372) but never passed to EnsureRepoAccess, which hardcoded VisibilityPublic — the /new visibility toggle was dead and 07 §9.1.1 documented a lie. Fix: AccessBootstrap.EnsureRepoAccess takes 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 in TestEnsureRepoAccess. Coverage unchanged (api 95.4 / identity 97.2).
  2. [FIXED 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)

  • No in-tree test pushes through a placeholder end-to-end (adopt tests are unit-level; idempotency simulates post-push via the view). Acceptable: the manifest is byte-identical to a PUT-created empty repo (the tested gaps5 shape) 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.
  • uiRouteCollisionWarning lists notifications, which 06 §3's reserved-names list omits — the code is right (index.jsx:61 has the route); the doc list predates it. Trivial future tidy, not this PR's debt.
  • TestUIAssetConcepts fails without a fresh make web (needs concepts/push.gif) — environmental, pre-existing, unrelated; green after make web in 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 (after make web for the embed); server cover 95.6 ✓ (make cover gate covers only internal/...; cmd exempt by the Makefile)
  • node --test web/test/unit/*.test.js → 417/417 ✓
  • go test ./internal/e2e/... (real git binary) → ok ✓
  • gofmt -l clean, go vet clean on all touched packages ✓
  • No 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.)

# Review: PR #218 (feat/issue-210 → main) — create-repo placeholder Verified in scratch worktree at `5dcd7f7` (PR head `444f98a` + 2 review-fix commits below). Main worktree untouched (still clean on `48001e8`). No browser drive (tests + reasoning only, as instructed); no docker/compose/system-package changes. ## R1 compliance — all five blocking rulings hold - **B1 projection shape + empty-only probe ✓** — `PlaceholderInfo{created_by,created_at,expires_at}` (`internal/api/placeholder.go:64`), probed by exact key ONLY when in-hand data says `HeadSeq==0 && refs==0` (`internal/api/summary.go:54`); real repos +0 (pinned by the extended `TestSummaryOverviewRoundTrips`, cumulative sidecar probes stay 1 after the healthy summary). Absent-projection renders as omitted key, and `07_api.md:409` documents "`| null`, omitted when null" — doc and code agree. - **B2 ExtraRoutes + twins, no core edit ✓** — `CreateHandler` chained via `srv.ChainExtra` (`cmd/walhub/collab.go:216`), discovery via `api.RegisterExposed` (`collab.go:75`); both lanes served + tested (`placeholder_test.go:230`); `internal/api/routes.go`, `smart.go`, `internal/wal/` untouched. - **B3 flag-as-shape + plain-text 409 + enumerated fields ✓** — `?placeholder=true` justified in Decisions (`07_api.md:783`) and code (`summary.go:93`); conflicts stay `writePlain` with the winner URL in-text (`placeholder.go:253`); all added fields enumerated in `07 §9.1.1`. Same-principal unborn re-create → 200 `already:true`; different-principal/post-push/flag-less-PUT → legacy 409 shapes. - **B4 Delete-then-Create only ✓** — the marker key sees exactly one `PutCreate` (`placeholder.go:216`) and one post-push `Delete` (`bind_ssh.go:323`); zero Update paths (grepped). Sidecar classification (invitation class, no §14.11 change) is sound — agree, no frozen-list change needed. - **B5 none-mode specified ✓** — `ValidPrincipal` requires 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.create` stays flag-less + `createPlaceholder`/`repos.create`, justified in Decisions), S2 (403 only on proven non-membership; probe error → 503 + Retry-After, tested), S3 (clear in `pushPipeline` post-CAS post-response — shared by HTTP `smart.go:439` and SSH `bind_ssh.go:182`, so both transports adopt), S4 (`/new` reserved row in `06:217,643`; no `placeholder_ttl` key exists so no CLI/setup-schema surface to document — TTL-off stated in `07 §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 of `pushPipeline`). Hint-gating means unhinted pushes issue **zero** marker ops (pinned by `placeholder_adopt_test.go`); the push-budget test (`cmd/walhub/push_budget_test.go:163`) is genuinely unmodified (empty diff). `health209_test.go` was 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`) 1. **[FIXED `7c59fa1`] POST `visibility:private` silently materialized a public repo.** `createRequest.Visibility` was validated (`placeholder.go:372`) but never passed to `EnsureRepoAccess`, which hardcoded `VisibilityPublic` — the `/new` visibility toggle was dead and `07 §9.1.1` documented a lie. Fix: `AccessBootstrap.EnsureRepoAccess` takes 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 in `TestEnsureRepoAccess`. Coverage unchanged (api 95.4 / identity 97.2). 2. **[FIXED `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) - No in-tree test pushes *through* a placeholder end-to-end (adopt tests are unit-level; idempotency simulates post-push via the view). Acceptable: the manifest is byte-identical to a PUT-created empty repo (the tested `gaps5` shape) 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. - `uiRouteCollisionWarning` lists `notifications`, which `06 §3`'s reserved-names list omits — the code is right (`index.jsx:61` has the route); the doc list predates it. Trivial future tidy, not this PR's debt. - `TestUIAssetConcepts` fails without a fresh `make web` (needs `concepts/push.gif`) — environmental, pre-existing, unrelated; green after `make web` in 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 (after `make web` for the embed); server cover **95.6** ✓ (`make cover` gate covers only `internal/...`; cmd exempt by the Makefile) - `node --test web/test/unit/*.test.js` → **417/417** ✓ - `go test ./internal/e2e/...` (real git binary) → ok ✓ - `gofmt -l` clean, `go vet` clean on all touched packages ✓ - No `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.)
Author
Owner

Implemented in PR #218 incl. review private-visibility fix (all R1 rulings verified; 95.4/97.2/95.6% + 417/417 + e2e), merged. Closing.

Implemented in PR #218 incl. review private-visibility fix (all R1 rulings verified; 95.4/97.2/95.6% + 417/417 + e2e), merged. Closing.
crueber added this to the v1 milestone 2026-09-10 22:27:13 +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#210
No description provided.