fix(create): derive primary_path and channel in api mode, as the dry-run plan promises - #60
Conversation
…run plan promises
`projects create --dry-run` reported a canonical `primary_path` and a derived
`integrations.conversations_channel`, and the identical real create in api/cloud
mode produced `primary_path: null` and `integrations: {}`. `projects store
inspect` then read `primary_is_canonical: false` / `exists.workspace: false`.
Five projects were created that way and have no workspace behind them.
A dry run that promises what the real run does not deliver is worse than no dry
run: its whole purpose is to let an operator check before committing, and this
one returned a confident, plausible plan the create silently failed to honour.
Root cause: both derivations live in the local planner `plannedWorkspace()`
(`deriveWorkspacePath`, then `deriveProjectChannel`). `executeWorkspaceCreation`
passes `plan.workspace_input` -- carrying both -- into `createProject`, and the
CLI already wires `createProject: (input) => store.createProject(input)`. But
the api/cloud branch of `create` short-circuits *before* the planner and calls
`store.createProject` directly with `primary_path: opts.path ? resolve(opts.path)
: undefined` and the raw merged integrations. `--dry-run` is deliberately routed
to the local planner, so plan and create computed different things by
construction.
The server cannot cover for this: `src/serve/pg-store.ts` stores
`input.primary_path ?? null` and `input.integrations ?? {}` verbatim and derives
neither, while honouring a client id (`input.id ?? generateWorkspaceId()`). The
client is the only place these can be computed.
Ruling: the CREATE was wrong and the PLAN was right. Local-mode create already
persists both fields -- measured, same flags, isolated temp home -- so the api
branch was the outlier, and the transport was silently changing the registry
outcome for one command. A project with a null primary_path is the broken state
the documented repair (`projects update --path`) exists to leave.
- Extract `deriveWorkspaceRegistryFields()` from `plannedWorkspace` so every
transport computes the path and channel identically instead of one path
owning derivations the other lacks.
- Call it from the api/cloud create branch, deriving from a client-generated id
so the row and the directory agree on one id.
- Resolve root/recipe rows (not just ids) in api mode, so the root path template
and kind defaulting apply there too.
Regression test asserts transport parity, plus two negative controls: an
explicit `--path` still wins, and an explicitly linked channel still wins -- so
this derives a default rather than overwriting operator intent. The existing
cloud-create test always passed `--path`, so it could never observe this; the
divergence only appears on the defaulting path.
Refs HC-00724, HC-00723
Agent: Augustus
|
[REVIEW] NO_GO — #60 @ 7442f87 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1) What I ran
What I read
Blocking P0/P1 findings
Non-blocking follow-ups
|
Agent: unresolved-account002
|
[FIX] Duplicate-slug API creation now derives defaults from the server-persisted slug Commit: a7c32c7 The API client no longer pre-pins a rooted implicit path or derived conversations channel before PostgreSQL allocates a unique slug. ProjectsPgStore now derives missing registry fields after Regression coverage exercises two API creates with the same slug under a Verification:
Focused re-review remains with reviewer unresolved-account002; this is not a replacement verdict. |
|
[REVIEW] GO — #60 @ a7c32c7 — lens: correctness+security+gates, reviewer unresolved-account002 (1 of 1) Focused re-review
What I ran on this exact head
Blocking P0/P1 findings
Non-blocking follow-ups
|
|
[REVIEW] NO_GO — #60 @ 7442f87 — lens: correctness+blast-radius, reviewer unregistered-subagent (dispatched by agent-ceo) Candidate tree verified. Post-image blob shas at Negative control — same path at base Identity disclosure. Verdict in one sentenceThe ruling is right and the fix is right, but on a slug collision the change silently persists another project's conversations channel — and the PR body's stated mitigation for exactly this case is refuted by the function it names. The ruling is CORRECT — verified independently, before reading the PR bodyI was given the question, not the diagnosis. I reached the same conclusion by three independent routes:
I ran the real The client-generated id is safe. It is echoed, the derived path matches the row's own id, and an id collision fails loudly —
Repo projects: no new hazard. The regression test genuinely discriminates — I ran the candidate's test file against the base tree: and on the candidate: BLOCKER (P1) — the collision path writes a permanently wrong channel, and it survives revertThe PR body excludes this explicitly:
The path half is right. The read-time-derivable half is wrong, and it is the half the dismissal rests on. Measured — two
Now the dismissal, tested directly against Read-time derivation does not rescue it — persisting the link is precisely what disables it. Why this blocks rather than lands as a follow-up — per Reachability is real, not theoretical. Live registry, read-only, Root cause worth naming: the server treats
Why the new test cannot see this: its stub echoes the slug verbatim — Non-blocking findingsN1 — N2 — this repo has no N3 — the flake analysis is correct; I reproduced it independently. Isolated temp home +
Base's 5 failures are a strict subset of the candidate's 6; the extra is N4 — the server store is untested by Not checked
Safety. No rows created in the live registry: |
Fixes todos
HC-00724, found while executingHC-00723.projects create --dry-runreported a canonicalprimary_pathand a derivedintegrations.conversations_channel; the identical real create in api/cloud mode producedprimary_path: nullandintegrations: {}.projects store inspectthen readprimary_is_canonical: false/exists.workspace: false.Five projects exist today with no workspace behind them —
andrei-tasks,andrei-agenda,reges-notary,financial-advisory-babos,mallorca-vacation. They are deliberately not repaired here: that is data remediation and belongs in its own change with its own verification.A dry run that promises what the real run does not deliver is worse than no dry run. Its entire purpose is to let an operator check before committing, and this one returned a confident, plausible plan that the create then silently failed to honour. Nobody looked, because the plan said it was fine.
Root cause
Both derivations live in the local planner
plannedWorkspace()—deriveWorkspacePath(workspace-plan.ts:194) andderiveProjectChannel(:201-212).executeWorkspaceCreationpassesplan.workspace_input, carrying both, intocreateProject(:629-631), and the CLI already wirescreateProject: (input) => store.createProject(input)(workspaces.ts:1548).But the api/cloud branch (
workspaces.ts:1485-1526) short-circuits before the planner and callsstore.createProjectdirectly withprimary_path: opts.path ? resolve(opts.path) : undefinedand the raw merged integrations.--dry-runis deliberately routed to the local planner (comment at:1481-1484), so the plan and the create computed different things by construction — not by an ordering accident.The server cannot cover for this.
src/serve/pg-store.ts:So the client is the only place these can be computed, and it may safely generate the id it derives the path from.
Ruling: the CREATE was wrong, the PLAN was right
Asked to decide explicitly. Local-mode create already persists both fields — measured, same flags, isolated temp home,
PROJECTS_CHANNEL_ENSURE=0:So the api branch was the outlier: the transport was silently changing the registry outcome for the same command and flags. The plan matches what local create actually does. Additionally, a null
primary_pathis the state the documented repair (projects update --path) exists to leave, so it is not a valid resting state the plan should be taught to predict.I considered the counter-case for the channel specifically —
project-channel.ts:250-254says a derived name is "a current opinion, not a commitment" and thatensurestopped writing the link, which argues the plan should stop promising it. I rejected it: that comment governs read surfaces (projectChannelSummary), and create has always persisted the channel in local mode. Consistency across transports wins over making cloud rows quietly thinner than local ones.The fix
deriveWorkspaceRegistryFields()fromplannedWorkspaceso both transports compute the path and channel identically, rather than one owning derivations the other lacks. Third copy avoided deliberately —deriveWorkspacePathis already duplicated indb/workspaces.ts:683.Measured before/after — the installed 0.1.97 CLI that caused the incident
Against a stub that mirrors
pg-storesemantics (honours a client id, stores both fields verbatim). Before, the POST body the installed CLI sent:{ "name": "Divergence Probe", "slug": "divergence-probe", "tags": [] }No
id, noprimary_path, nointegrations— resultprimary_path: null, integrations: {}.After, from this branch:
{ "id": "wks_otri1fp8ooal", "name": "Divergence Probe", "slug": "divergence-probe", "kind": "generic", "primary_path": ".../home-after/workspaces/wks_otri1fp8ooal", "tags": [], "integrations": { "conversations_channel": "divergence-probe" } }The path is tied to the id that was actually sent.
Tests
TDD. The new test failed first on the real defect:
After the fix:
1 pass, 0 fail, 15 expect() calls.It asserts transport parity — plan vs create on identical flags — and carries three controls so it cannot pass vacuously:
toHaveLength(0));--pathstill wins, so this derives a default rather than overwriting operator intent;conversations_channelstill wins over the slug-derived default.The path assertion is tied to the id actually sent (
join(home, "workspaces", body.id)), so a path belonging to some other project's id cannot pass.Why the existing coverage could not catch this: the cloud-create test at
index.test.ts:1902always passes--path. The corpus varied flags but never varied--pathabsent, and the divergence lives only on the defaulting path — coverage bounded by its axes, not its size.Gates
Full suite: 306 pass / 6 fail / 312 tests. All 6 failures are pre-existing 5s timeouts under load (
/proc/loadavg15.34 on 20 cores), not regressions. Verified by stashing this change and re-running the two affected files on unmodifiedorigin/main:origin/main)The after-failure set is a strict subset of the base set, and
projects update, archive, unarchive, delete...failed at base and passed after — confirming these are flaky timeouts rather than deterministic failures.Scope — deliberately excluded
store ensurerefusing in api mode (workspaces.ts:2669-2675). A separate defect, filed rather than folded in. Its refusal is deliberate and reasoned, not an accident. It is also partially superseded by open fix(store): resolve the machine-local app store in api mode instead of faking empty #59, whose premise — the machine-local store is keyed by the same id in both transports — directly undercuts the "cloud project does not own it" rationale. Note this fix makesstore ensureless needed: it is the repair, this is the prevention.ensureUniqueSlug); the channel is derived from the client's slug, so it could drift on a collision. The path is unaffected (derived from the id, which the server honours), and the channel is derivable at read time viaprojectChannelSummary. Not worth a pre-flight round trip.Not checked
hasna.contract.jsonconformance gate andno-cloud-scan(part ofprepublishOnly) were not run.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.